Compare commits

..
54 Commits
Author SHA1 Message Date
jiang b04378397c test: type logout audit fetch mock
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-09-09 15:07:45 +08:00
jiang bad8769000 fix(auth): redirect expired sessions before permission checks 2026-09-09 15:07:25 +08:00
jiang 29b8babd68 fix(api): align frontend with project-scoped backend
Use project-scoped endpoints and run IDs for current project, SCADA metadata, historical time series, migrated DMA results, and sensor placement runs.

The regressions recurred because tests mocked pre-interceptor and new-only payload shapes, while history queries relied on implicit global scheme state and unbounded request fan-out. Cover post-interceptor pages, migrated result_rows, explicit run_id routing, multi-element requests, request limits, and cancellation.
2026-09-01 14:37:51 +08:00
jiang 0dad61ff1f feat(chat): 优化分析过程与结果展示
Generic Container CI/CD / test-build-publish (push) Successful in 2m25s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m25s
2026-08-26 18:05:03 +08:00
jiang fee4fc6ce1 refactor(frontend): align TJWater Next integrations 2026-08-26 12:26:29 +08:00
jiang 6623f4f7fb merge: sync Server storage cleanup contract
Merge PR #2 after API contract, lint, test, and production build verification.
2026-08-18 18:35:23 +08:00
jiang d2ce1687f2 chore(api): sync storage cleanup contract 2026-08-18 18:29:09 +08:00
jiang dca2817320 merge: integrate agent-mvp into master
Merge PR #1 after frontend, cross-repository contract, and container gates passed.
2026-08-18 17:56:45 +08:00
jiang 41bf7d71e3 chore(api): sync backend contract mirror 2026-08-18 17:56:11 +08:00
jiang 5bea86dbf6 fix(deps): update XML parser and nanoid 2026-08-18 16:50:46 +08:00
jiang 78a470e89e merge(frontend): integrate master into agent-mvp 2026-08-18 16:41:52 +08:00
jiang 6d505ca461 fix(flushing): 收紧空阀门区域高度
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-08-17 19:44:11 +08:00
jiang f34d81c933 fix(flushing): 对齐选择项空状态
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-08-17 19:36:33 +08:00
jiang 5053ddcd1f fix(map): 修正属性查询数据源与模拟属性
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-08-17 19:31:54 +08:00
jiang 45def5bba3 fix(analysis): 统一方案名与接口错误提示
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s
2026-08-17 18:54:29 +08:00
jiang 9e79d52dc8 feat(flushing): 添加阀门状态与设置值控制 2026-08-17 18:29:00 +08:00
jiang 4a978de905 ci(frontend): inject public build configuration 2026-08-11 10:44:56 +08:00
TJWater CI f5bcf28f7e fix(map): preserve hook order while data loads
Generic Container CI/CD / test-build-publish (push) Failing after 1m50s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m50s
2026-08-11 10:32:18 +08:00
jiang 1ac7372585 ci: replace webhook deployment with v2 workflow
Generic Container CI/CD / test-build-publish (push) Successful in 4m7s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m7s
2026-08-11 10:17:03 +08:00
TJWater CI ab45c8da8e ci: add frontend v2 deployment workflow
Generic Container CI/CD / test-build-publish (push) Failing after 1m13s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m14s
2026-08-11 09:51:13 +08:00
jiang 5d730ba1b8 fix(auth): ensure Keycloak logout clears secure session 2026-08-07 14:47:20 +08:00
jiang caf18f706d fix(auth): prevent logout audit from blocking redirect 2026-08-07 12:01:39 +08:00
jiang 0dd521d8c9 fix(chat): distinguish auth outages from model failures
Runtime model checks previously mapped every authenticated endpoint failure to models_unavailable. Treat request failures as service outages and retry failed checks after five seconds; empty model configurations remain distinct.
2026-08-06 19:35:24 +08:00
jiang 0a47534ddb fix(chat): restore windowed turn alignment
Keep the virtual-window measurement wrapper as a flex column so AgentTurn alignSelf rules remain active. Add regression coverage for the alignment context.
2026-08-06 19:07:40 +08:00
jiang 9e75e2df8a feat(chat): 完善运行状态与权限交互 2026-08-06 18:41:53 +08:00
jiang 8d7c947897 fix(auth): keep expiry dialog above agent UI 2026-08-06 16:09:23 +08:00
jiang 71bde7d9e4 refactor(chat): centralize agent session URLs 2026-08-06 10:40:13 +08:00
jiang 5037089057 feat(chat): refresh agent credentials during tool calls 2026-08-06 10:16:50 +08:00
jiang b4c96f8524 fix(auth): set local logout redirect example 2026-08-05 17:57:49 +08:00
jiang e496fbe4b7 fix(auth): align session lifecycle with Keycloak 2026-08-05 17:10:59 +08:00
jiang 5d9c40e454 feat(sensor): 完善监测点结果编辑与图层切换 2026-08-03 19:00:16 +08:00
jiang 5592c27386 chore(api): sync internal server contract 2026-08-03 11:12:54 +08:00
jiang 94142d7031 fix(auth): complete Keycloak logout flow 2026-08-03 10:40:21 +08:00
jiang 0e638046ed fix(frontend): update production dependency security patches
Build Push and Deploy / docker-image (push) Successful in 2m7s
Build Push and Deploy / deploy-fallback-log (push) Skipped
2026-07-30 22:41:30 +08:00
jiang 65ed1fd212 fix(sensor-placement): refine drawing export and editor density
Build Push and Deploy / docker-image (push) Failing after 23s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-07-30 21:39:27 +08:00
jiang 81cc5dcae1 fix(burst-location): clarify simulation baseline requirement 2026-07-30 20:55:01 +08:00
jiang 782363cfb6 fix(api): align frontend with REST contracts 2026-07-30 20:38:52 +08:00
jiang b57e58ff87 fix(chat): 调整输入区操作按钮布局 2026-07-30 19:07:17 +08:00
jiang 38d246eb7b fix(dma): 放宽总漏损流量下限 2026-07-30 18:15:36 +08:00
jiang 723931b6ae feat(frontend): enforce RBAC and refine burst analysis 2026-07-30 16:45:10 +08:00
jiang f355ddd002 fix(sensor-placement): smooth result editor transitions 2026-07-30 16:38:06 +08:00
jiang 6114e76735 fix(sensor-placement): stabilize engineering drawing export
Render the OpenLayers basemap at the final A3 map-box resolution with pixelRatio 1, carry provider attribution into the drawing, and keep the linework/basemap export path covered by regression tests.
2026-07-30 16:21:30 +08:00
jiang d643f09fcf feat(sensor-placement): add scheme engineering editor 2026-07-30 16:16:51 +08:00
jiang 04557b9363 feat(ui): 统一面板空状态引导 2026-07-30 14:34:43 +08:00
jiang 8836549697 fix(auth): unify scheme creator display 2026-07-30 14:21:20 +08:00
jiang 1d7e07174f fix(map): use component-level loading skeletons 2026-07-30 13:21:55 +08:00
jiang c4246cf25f feat(burst): add analysis report and valve binding
Replace the obsolete location result with a printable analysis report, bind valve analysis to the selected scheme, and cache report diameter lookups. Scheme identity is tracked with valve results so reports cannot reuse results from another scheme that shares the same pipe.
2026-07-30 13:16:05 +08:00
jiang 1b2a7f4fb8 perf(map): reduce tile snapshot work during zoom 2026-07-30 12:50:33 +08:00
jiang 82e75c03d0 feat(health-risk): add printable assessment report 2026-07-30 11:52:43 +08:00
jiang 5894ee277a fix(frontend): use success style for empty scheme queries 2026-07-30 11:04:31 +08:00
jiang a3331691de test(frontend): strengthen default time assertion 2026-07-30 11:04:15 +08:00
jiang 7745333a58 fix(frontend): query schemes by type 2026-07-30 11:01:45 +08:00
jiang 4596af7c12 docs: 编写中文 README 2026-07-22 11:26:06 +08:00
jiang 5332f8f0c5 fix(layout): prevent map page overflow
Build Push and Deploy / docker-image (push) Successful in 7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-20 13:12:19 +08:00
202 changed files with 91922 additions and 4041 deletions
+3 -3
View File
@@ -7,8 +7,8 @@ NEXTAUTH_URL="https://frontend.example.com/"
BACKEND_URL="https://server.example.com"
AGENT_URL="https://agent.example.com"
MAP_URL="https://geoserver.example.com/geoserver"
MAP_WORKSPACE="tjwater"
MAP_EXTENT="13490131,3630016,13525879,3666968.25"
NETWORK_NAME="tjwater"
MAP_WORKSPACE="tjwater_next"
MAP_EXTENT="13508801.93,3608163.35,13555650.64,3633685.14"
NETWORK_NAME="tjwater_next"
MAPBOX_TOKEN="replace-with-public-mapbox-token"
TIANDITU_TOKEN="replace-with-public-tianditu-token"
+14 -152
View File
@@ -1,159 +1,21 @@
name: Build Push and Deploy
name: Frontend CI/CD v2
on:
push:
tags:
- "v*"
- "latest"
workflow_dispatch: {}
jobs:
docker-image:
runs-on: ubuntu-22.04
permissions:
contents: read
defaults:
run:
shell: sh
steps:
- name: Checkout code
env:
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
GIT_USERNAME: ${{ github.actor }}
GIT_TOKEN: ${{ github.token }}
run: |
case "$SERVER_URL" in
http://*)
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
;;
https://*)
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
;;
*)
AUTH_SERVER_URL="$SERVER_URL"
;;
esac
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
else
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
fi
git fetch --depth=1 origin "$COMMIT_SHA"
git checkout --force --detach FETCH_HEAD
git clean -ffdx
- name: Normalize image metadata
env:
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
RAW_REPOSITORY: ${{ github.repository }}
IMAGE_TAG: ${{ github.ref_name }}
run: |
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
} >> "$GITHUB_ENV"
- name: Login to Gitea Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \
--username "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build and Push Image
run: |
push_with_retry() {
image_ref="$1"
attempt=1
max_attempts=3
while [ "$attempt" -le "$max_attempts" ]; do
if docker push "$image_ref"; then
return 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
return 1
fi
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
attempt=$((attempt + 1))
sleep 10
done
}
docker build \
--network=host \
-f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
--build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \
.
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest"
- name: Notify Deploy Server
run: |
post_deploy_webhook() {
label="$1"
payload="$2"
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
-d "$payload")
echo "[$label] webhook HTTP status: ${http_code}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
return 0
fi
echo "[$label] response headers:"
cat /tmp/deploy_headers.txt
echo "[$label] response body:"
cat /tmp/deploy_response.txt
return 1
}
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
exit 0
fi
echo "Primary webhook request failed, retrying with lowercase repo path..."
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
exit 0
fi
echo "Deploy webhook failed after primary and fallback attempts."
exit 1
deploy-fallback-log:
runs-on: ubuntu-22.04
needs: docker-image
if: failure()
steps:
- name: Deployment not triggered
run: echo "Image build/push failed, deployment webhook was not called."
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
with:
image_name: gitea.waternetwork.cn/orgtjwater/tjwaterfrontend_refine
dockerfile: Dockerfile
build_context: .
deploy_service: frontend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
+1
View File
@@ -19,6 +19,7 @@
# misc
.DS_Store
*.pem
/public/runtime-config.js
# debug
npm-debug.log*
+5
View File
@@ -22,6 +22,11 @@ npm run start
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
When the Server API changes, sync `contracts/server-v1.openapi.json` from the
backend source of truth, update its SHA-256 in `contracts/manifest.json`, then
run `npm run api:generate` and `npm run api:check` so the generated types and
contract mirror remain aligned.
## Coding Style & Naming Conventions
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
+72 -29
View File
@@ -1,48 +1,91 @@
# my-refine-app
# TJWaterFrontend_Refine 内部前端
<div align="center" style="margin: 30px;">
<a href="https://refine.dev">
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
</a>
</div>
<br/>
`TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app).
## 技术栈
## Getting Started
- Next.js 16
- React 19
- Refine 5
- MUI 6 / MUI X
- OpenLayers、deck.gl、Turf
- Zustand、NextAuth、Jest
A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility ✨
## 目录结构
Refine's hooks and components simplifies the development process and eliminates the repetitive tasks by providing industry-standard solutions for crucial aspects of a project, including authentication, access control, routing, networking, state management, and i18n.
## Available Scripts
### Running the development server.
```bash
npm run dev
```text
src/app/ Next.js App Router 页面
src/components/ 复用 UI 组件
src/providers/ Refine、认证、数据和主题 provider
src/hooks/ 业务 hooks
src/utils/ 通用工具
public/ 静态资源
scripts/ 运行时配置和辅助脚本
Dockerfile 镜像构建文件
docker-compose.yml 本地编排参考
```
### Building for production.
新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
## 本地开发
要求 Node.js 20 或更高版本:
```bash
npm run build
npm install
npm run dev
```
### Running the production server.
`npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
## 常用命令
```bash
npm run start
npm run lint
npm test
npm run test:coverage
npm run build
npm run start
docker build -t tjwater-frontend:local .
```
## Learn More
- `npm run lint`:运行 ESLint。
- `npm test`:运行 Jest。
- `npm run test:coverage`:生成测试覆盖率。
- `npm run build`:生成生产构建。
- `npm run start`:启动生产模式服务。
To learn more about **Refine**, please check out the [Documentation](https://refine.dev/docs)
## 配置说明
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview)
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
## License
只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。
MIT
## 开发规范
- React 组件文件使用 `PascalCase.tsx`。
- 普通 TypeScript 模块、hooks、store、provider 和工具使用 `camelCase.ts`。
- `src/app` 路由目录使用 `kebab-case`,保留 Next.js 路由组和动态段语法。
- UI 优先沿用 MUI、Refine 和既有地图/聊天界面模式。
- 与后端或 Agent 通信的字段保持接口原始格式,通常为 `snake_case`。
## 测试与发布
提交前建议运行:
```bash
npm run lint
npm test
```
发布镜像前运行:
```bash
npm run build
```
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
## 安全规则
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"contract_version": "1.0.0",
"contracts": {
"agent": {
"file": "agent-v1.openapi.json",
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
}
}
}
File diff suppressed because it is too large Load Diff
+827 -416
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -6,13 +6,16 @@
"node": ">=20"
},
"scripts": {
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
"dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
"runtime:config": "node scripts/generate-runtime-config.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
"api:check": "node scripts/check-api-contracts.mjs",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
},
"dependencies": {
@@ -43,7 +46,8 @@
"next": "^16.1.6",
"next-auth": "^4.24.5",
"ol": "^10.7.0",
"postcss": "^8.5.6",
"openapi-fetch": "^0.17.0",
"postcss": "8.5.25",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-draggable": "^4.5.0",
@@ -55,7 +59,9 @@
"zustand": "^5.0.11"
},
"overrides": {
"fast-xml-parser": "5.5.9"
"fast-xml-parser": "5.7.0",
"postcss": "8.5.25",
"sharp": "0.35.3"
},
"devDependencies": {
"@svgr/webpack": "^8.1.0",
@@ -74,6 +80,7 @@
"eslint-config-next": "^16.1.6",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"openapi-typescript": "^7.13.0",
"ts-jest": "^29.4.6",
"typescript": "^5.8.3"
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
<title id="title">TJWater</title>
<rect width="72" height="72" rx="18" fill="#1478d4"/>
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
<rect width="1440" height="900" fill="#edf5f5"/>
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
</g>
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
<circle cx="178" cy="608" r="10"/><circle cx="203" cy="386" r="9"/>
<circle cx="354" cy="294" r="9"/><circle cx="475" cy="469" r="11"/>
<circle cx="659" cy="536" r="10"/><circle cx="747" cy="652" r="9"/>
<circle cx="320" cy="659" r="8"/>
</g>
<g fill="#0b8f82">
<circle cx="111" cy="291" r="6"/><circle cx="166" cy="130" r="6"/>
<circle cx="446" cy="153" r="7"/><circle cx="598" cy="211" r="6"/>
<circle cx="696" cy="105" r="6"/><circle cx="316" cy="791" r="6"/>
<circle cx="487" cy="872" r="6"/><circle cx="835" cy="331" r="7"/>
</g>
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
<circle cx="615" cy="448" r="222"/><circle cx="615" cy="448" r="276"/>
<circle cx="615" cy="448" r="334"/>
</g>
<g fill="#1478d4" opacity=".08">
<rect x="40" y="40" width="118" height="10" rx="5"/>
<rect x="40" y="62" width="72" height="6" rx="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+57
View File
@@ -0,0 +1,57 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const manifest = JSON.parse(
await readFile(new URL("../contracts/manifest.json", import.meta.url), "utf8"),
);
const run = promisify(execFile);
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
const generator = join(
projectRoot,
"node_modules",
"openapi-typescript",
"bin",
"cli.js",
);
const temporaryDirectory = await mkdtemp(
join(tmpdir(), "tjwater-api-contracts-"),
);
try {
for (const [name, contract] of Object.entries(manifest.contracts)) {
const contractPath = fileURLToPath(
new URL(`../contracts/${contract.file}`, import.meta.url),
);
const payload = await readFile(contractPath);
const actual = createHash("sha256").update(payload).digest("hex");
if (actual !== contract.sha256) {
throw new Error(
`${name} contract hash mismatch: expected ${contract.sha256}, got ${actual}`,
);
}
const generatedName = `${name}Api.ts`;
const temporaryOutput = join(temporaryDirectory, generatedName);
await run(process.execPath, [generator, contractPath, "-o", temporaryOutput]);
const expectedOutput = await readFile(
new URL(`../src/generated/${generatedName}`, import.meta.url),
);
const generatedOutput = await readFile(temporaryOutput);
if (!expectedOutput.equals(generatedOutput)) {
throw new Error(
`${basename(contract.file)} generated type is stale: run \`npm run api:generate\``,
);
}
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
console.log(
`validated API contract mirrors and generated types for ${manifest.contract_version}`,
);
+39
View File
@@ -0,0 +1,39 @@
import nextEnv from "@next/env";
import fs from "node:fs";
import path from "node:path";
const projectDir = process.cwd();
const { loadEnvConfig } = nextEnv;
loadEnvConfig(projectDir, process.env.NODE_ENV !== "production");
const parseExtent = (value) => {
if (!value) {
return [13508801.93, 3608163.35, 13555650.64, 3633685.14];
}
const extent = value.split(",").map(Number);
return extent.length === 4 && extent.every(Number.isFinite)
? extent
: [13508801.93, 3608163.35, 13555650.64, 3633685.14];
};
const config = {
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater_next",
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater_next",
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
};
const outputPath = path.join(projectDir, "public", "runtime-config.js");
fs.writeFileSync(
outputPath,
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
);
console.log(`Generated ${path.relative(projectDir, outputPath)}`);
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,10 +1,10 @@
"use client";
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
import dynamic from "next/dynamic";
import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons";
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 "@components/olmap/core/Controls/StyleLegend";
import {
RAINBOW_COLORS,
@@ -12,6 +12,26 @@ import {
} from "@components/olmap/HealthRiskAnalysis/types";
import { Box } from "@mui/material";
const Timeline = dynamic(
() => import("@components/olmap/HealthRiskAnalysis/Timeline"),
{
loading: () => <MapTimelineSkeleton />,
},
);
const HealthRiskStatistics = dynamic(
() =>
import("@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"),
{
loading: () => <MapPanelSkeleton variant="health-risk-analysis" />,
},
);
const PredictDataPanel = dynamic(
() => import("@components/olmap/HealthRiskAnalysis/PredictDataPanel"),
{
loading: () => null,
},
);
export default function Home() {
return (
<HealthRiskProvider>
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
const BurstDetectionPanel = dynamic(
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-detection" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
const BurstLocationPanel = dynamic(
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-location" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
const BurstPipeAnalysisPanel = dynamic(
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
const WaterQualityPanel = dynamic(
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
{
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
const DMALeakDetectionPanel = dynamic(
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
const FlushingAnalysisPanel = dynamic(
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,21 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
const MonitoringPlaceOptimizationPanel = dynamic(
() =>
import(
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
),
{
loading: () => (
<MapPanelSkeleton variant="monitoring-place-optimization" />
),
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,11 +1,32 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import Timeline from "@components/olmap/core/Controls/Timeline";
import {
MapPanelSkeleton,
MapTimelineSkeleton,
} from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
const Timeline = dynamic(
() => import("@components/olmap/core/Controls/Timeline"),
{
loading: () => <MapTimelineSkeleton />,
},
);
const SCADADeviceList = dynamic(
() => import("@components/olmap/SCADA/SCADADeviceList"),
{
loading: () => <MapPanelSkeleton variant="network-simulation" />,
},
);
const SCADADataPanel = dynamic(
() => import("@components/olmap/SCADA/SCADADataPanel"),
{
loading: () => null,
},
);
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,10 +1,23 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
const SCADADeviceList = dynamic(
() => import("@components/olmap/SCADA/SCADADeviceList"),
{
loading: () => <MapPanelSkeleton variant="scada-data-cleaning" />,
},
);
const SCADADataPanel = dynamic(
() => import("@components/olmap/SCADA/SCADADataPanel"),
{
loading: () => null,
},
);
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
+10 -8
View File
@@ -1,11 +1,10 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import React, { Suspense } from "react";
import type { ReactNode } from "react";
import authOptions from "@app/api/auth/[...nextauth]/options";
import { Header } from "@components/header";
import { Title } from "@components/title";
import { MapSkeleton } from "@components/loading/MapSkeleton";
import { AppSider } from "@components/sider/AppSider";
import { ThemedLayout } from "@refinedev/mui";
import { getServerSession } from "next-auth/next";
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
export default async function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme");
@@ -38,15 +37,18 @@ export default async function MainLayout({
Title={Title}
Sider={AppSider}
childrenBoxProps={{
sx: { height: "100vh", p: 0 },
sx: {
flex: 1,
minHeight: 0,
overflow: "auto",
p: 0,
},
}}
containerBoxProps={{
sx: { height: "100%" },
sx: { height: "100vh", overflow: "hidden" },
}}
>
<Suspense fallback={<MapSkeleton />}>
{children}
</Suspense>
{children}
</ThemedLayout>
);
}
+278 -197
View File
@@ -1,32 +1,41 @@
"use client";
import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {
RefineSnackbarProvider,
} from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
Refine,
type AccessControlProvider,
type AuthProvider,
} from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import React, { useEffect, useState } 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 { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
import {
ManageAccounts as ManageAccountsIcon,
FactCheck as FactCheckIcon,
ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
@@ -36,16 +45,12 @@ type RefineContextProps = {
};
export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps>
) => {
return (
<SessionProvider>
<ProjectProvider>
<App {...props} />
</ProjectProvider>
</SessionProvider>
);
};
props: React.PropsWithChildren<RefineContextProps>,
) => (
<SessionProvider>
<App {...props} />
</SessionProvider>
);
type AppProps = {
defaultMode?: string;
@@ -55,40 +60,87 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession();
const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset);
const can = (permission: string) => permissions.includes(permission);
useEffect(() => {
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
setAccessToken(
typeof data?.accessToken === "string" ? data.accessToken : null,
);
}, [data?.accessToken, setAccessToken]);
useEffect(() => {
if (data?.error === "SessionExpired") {
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
markSessionExpired("refresh_failed");
return;
}
if (status === "authenticated") {
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
useEffect(() => {
if (status !== "authenticated") {
setIsMetadataAdmin(false);
resetAccess();
return;
}
let cancelled = false;
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
projectHeaderMode: "omit",
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
skipAuthRedirect: true,
})
.then(async (response) => {
if (cancelled) return;
if (!response.ok) {
setIsMetadataAdmin(false);
resetAccess();
return;
}
const payload = await response.json();
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
setAccessContext(await response.json());
})
.catch(() => {
if (!cancelled) setIsMetadataAdmin(false);
if (!cancelled) resetAccess();
});
return () => {
cancelled = true;
};
}, [status]);
}, [
currentProjectId,
resetAccess,
setAccessContext,
setAccessLoading,
status,
]);
useEffect(() => {
if (status !== "authenticated" || !data?.user?.id) return;
const auditKey = `tjwater-login-audit:${data.user.id}`;
if (sessionStorage.getItem(auditKey)) return;
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "login" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
})
.then((response) => {
if (response.ok) sessionStorage.setItem(auditKey, "1");
})
.catch(() => undefined);
}, [data?.user?.id, status]);
if (status === "loading") {
return <span>loading...</span>;
@@ -100,199 +152,228 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
callbackUrl: to ? to.toString() : "/",
redirect: true,
});
return {
success: true,
};
},
logout: async () => {
signOut({
redirect: true,
callbackUrl: "/login",
});
return {
success: true,
};
return { success: true };
},
logout: () =>
completeLogout({
reportAudit: () =>
reportLogoutAudit({
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
accessToken:
typeof data?.accessToken === "string"
? data.accessToken
: useAuthStore.getState().accessToken,
}),
clearLocalState: () => {
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
}),
onError: async (error) => {
if (error.response?.status === 401) {
return {
logout: true,
};
}
return {
error,
};
},
check: async () => {
if (status === "unauthenticated") {
return {
authenticated: false,
redirectTo: "/login",
};
}
return {
authenticated: true,
};
},
getPermissions: async () => {
return null;
return { error };
},
check: async () =>
status === "unauthenticated"
? { authenticated: false, redirectTo: "/login" }
: { authenticated: true },
getPermissions: async () => permissions,
getIdentity: async () => {
if (data?.user) {
const { user } = data;
return {
id: user.id,
name: user.name,
avatar: user.image,
};
}
return null;
if (!data?.user) return null;
return {
id: data.user.id,
username: data.user.username,
name: data.user.name,
avatar: data.user.image,
};
},
};
const defaultMode = props?.defaultMode;
const accessControlProvider: AccessControlProvider = {
can: async ({ resource }) => {
const requiredPermission = resource
? resourcePermissions[resource]
: undefined;
return {
can: !requiredPermission || permissions.includes(requiredPermission),
reason: requiredPermission
? `需要权限:${requiredPermission}`
: undefined,
};
},
};
const resources = [
...(can(permissionCodes.simulationView)
? [
{
name: "管网在线模拟",
list: "/network-simulation",
meta: {
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
label: "管网在线模拟",
},
},
]
: []),
...(can(permissionCodes.scadaClean)
? [
{
name: "SCADA 数据清洗",
list: "/scada-data-cleaning",
meta: {
icon: <TbDatabaseEdit className="w-6 h-6" />,
label: "SCADA 数据清洗",
},
},
]
: []),
...(can(permissionCodes.optimizationRun)
? [
{
name: "监测点优化布置",
list: "/monitoring-place-optimization",
meta: {
icon: <LuReplace className="w-6 h-6" />,
label: "监测点优化布置",
},
},
]
: []),
...(can(permissionCodes.riskRun)
? [
{
name: "健康风险分析",
list: "/health-risk-analysis",
meta: {
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
label: "健康风险分析",
},
},
]
: []),
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
? [
{
name: "Hydraulic Simulation",
meta: { label: "事件模拟" },
},
]
: []),
...(can(permissionCodes.burstRun)
? [
{
name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation",
meta: {
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 漏损识别",
},
},
]
: []),
...(can(permissionCodes.simulationRun)
? [
{
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: "管道冲洗",
},
},
]
: []),
...(can(permissionCodes.environmentManage)
? [
{
name: "系统管理",
list: "/system-admin",
meta: {
icon: <ManageAccountsIcon className="w-6 h-6" />,
label: "系统管理",
},
},
]
: []),
...(can(permissionCodes.auditView)
? [
{
name: "审计日志",
list: "/audit-logs",
meta: {
icon: <FactCheckIcon className="w-6 h-6" />,
label: "审计日志",
},
},
]
: []),
];
return (
<>
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={defaultMode}>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
resources={[
{
name: "管网在线模拟",
list: "/network-simulation",
meta: {
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
label: "管网在线模拟",
},
},
{
name: "SCADA 数据清洗",
list: "/scada-data-cleaning",
meta: {
icon: <TbDatabaseEdit className="w-6 h-6" />,
label: "SCADA 数据清洗",
},
},
{
name: "监测点优化布置",
list: "/monitoring-place-optimization",
meta: {
icon: <LuReplace className="w-6 h-6" />,
label: "监测点优化布置",
},
},
{
name: "健康风险分析",
list: "/health-risk-analysis",
meta: {
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
label: "健康风险分析",
},
},
{
name: "Hydraulic Simulation",
meta: {
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
},
{
name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <TbLocationPin className="w-6 h-6" />,
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: "管道冲洗",
},
},
...(isMetadataAdmin
? [
{
name: "系统管理",
list: "/system-admin",
meta: {
icon: <ManageAccountsIcon className="w-6 h-6" />,
label: "系统管理",
},
},
{
name: "审计日志",
list: "/audit-logs",
meta: {
icon: <FactCheckIcon className="w-6 h-6" />,
label: "审计日志",
},
},
]
: []),
]}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
{props.children}
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</>
</ProjectProvider>
);
};
@@ -0,0 +1,111 @@
import authOptions from "./options";
describe("NextAuth access token refresh", () => {
it("forces a Keycloak refresh for an agent credential request", async () => {
const previousEnv = {
issuer: process.env.KEYCLOAK_ISSUER,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
};
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
process.env.KEYCLOAK_CLIENT_ID = "frontend";
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
const originalFetch = globalThis.fetch;
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "fresh-access-token",
expires_in: 900,
refresh_token: "rotated-refresh-token",
}),
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
accessToken?: string;
refreshToken?: string;
}>;
const result = await jwt({
token: {
accessToken: "still-fresh-access-token",
accessTokenExpires: Date.now() + 600_000,
accessTokenIssuedAt: Date.now(),
refreshToken: "refresh-token",
},
trigger: "update",
session: { forceRefresh: true },
});
expect(result.accessToken).toBe("fresh-access-token");
expect(result.refreshToken).toBe("rotated-refresh-token");
expect(fetchMock).toHaveBeenCalledWith(
"https://keycloak.example/realms/tjwater/protocol/openid-connect/token",
expect.objectContaining({ method: "POST" }),
);
} finally {
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
}
});
it.each([
["network failure", () => Promise.reject(new Error("connection refused"))],
[
"non-JSON response",
() =>
Promise.resolve({
ok: false,
json: async () => {
throw new SyntaxError("Unexpected token");
},
}),
],
[
"invalid token payload",
() =>
Promise.resolve({
ok: true,
json: async () => ({ access_token: " ", expires_in: 0 }),
}),
],
])("returns a session error for %s", async (_label, fetchResult) => {
const previousEnv = {
issuer: process.env.KEYCLOAK_ISSUER,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
};
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
process.env.KEYCLOAK_CLIENT_ID = "frontend";
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
const originalFetch = globalThis.fetch;
globalThis.fetch = jest.fn(fetchResult) as unknown as typeof fetch;
try {
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
error?: string;
}>;
await expect(
jwt({
token: { refreshToken: "refresh-token" },
trigger: "update",
session: { forceRefresh: true },
}),
).resolves.toMatchObject({ error: "RefreshAccessTokenError" });
} finally {
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
}
});
});
const restoreEnv = (key: string, value: string | undefined) => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
};
+83 -17
View File
@@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
type KeycloakTokenResponse = {
access_token: string;
expires_in: number;
@@ -36,24 +39,43 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
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;
try {
const response = await fetch(keycloakTokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const refreshed = (await response.json()) as Partial<KeycloakTokenResponse> | null;
if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") {
if (
!response.ok ||
!refreshed ||
typeof refreshed.access_token !== "string" ||
!refreshed.access_token.trim() ||
typeof refreshed.expires_in !== "number" ||
!Number.isFinite(refreshed.expires_in) ||
refreshed.expires_in <= 0
) {
return { ...token, error: "RefreshAccessTokenError" };
}
const rotatedRefreshToken =
typeof refreshed.refresh_token === "string" &&
refreshed.refresh_token.trim()
? refreshed.refresh_token.trim()
: token.refreshToken;
return {
...token,
accessToken: refreshed.access_token.trim(),
accessTokenIssuedAt: Date.now(),
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
refreshToken: rotatedRefreshToken,
error: undefined,
};
} catch {
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 = {
@@ -66,6 +88,7 @@ const authOptions: NextAuthOptions = {
profile(profile) {
return {
id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: Avatar.src,
@@ -75,18 +98,29 @@ const authOptions: NextAuthOptions = {
],
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
jwt: async ({ token, profile, account }) => {
jwt: async ({ token, profile, account, trigger, session }) => {
if (profile?.sub) {
token.sub = profile.sub;
}
const preferredUsername = (
profile as { preferred_username?: unknown } | undefined
)?.preferred_username;
if (typeof preferredUsername === "string") {
token.username = preferredUsername;
}
if (account) {
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
if (account.access_token) {
token.accessToken = account.access_token;
token.accessTokenIssuedAt = Date.now();
}
if (account.refresh_token) {
token.refreshToken = account.refresh_token;
}
if (account.id_token) {
token.idToken = account.id_token;
}
if (typeof account.expires_at === "number") {
token.accessTokenExpires = account.expires_at * 1000;
}
@@ -94,7 +128,26 @@ const authOptions: NextAuthOptions = {
return token;
}
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) {
if (
typeof token.sessionExpiresAt === "number" &&
Date.now() >= token.sessionExpiresAt
) {
return { ...token, error: "SessionExpired" };
}
if (
trigger === "update" &&
(session as { forceRefresh?: unknown } | undefined)?.forceRefresh === true
) {
return refreshAccessToken(token);
}
const accessTokenIsFresh =
typeof token.accessTokenExpires === "number" &&
typeof token.accessTokenIssuedAt === "number" &&
Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS;
if (accessTokenIsFresh) {
return token;
}
@@ -104,15 +157,28 @@ const authOptions: NextAuthOptions = {
if (session.user && token.sub) {
session.user.id = token.sub;
}
if (session.user && token.username) {
session.user.username = token.username;
}
if (token.accessToken) {
session.accessToken = token.accessToken;
}
if (token.error) {
session.error = token.error;
}
if (typeof token.sessionExpiresAt === "number") {
session.sessionExpiresAt = token.sessionExpiresAt;
}
return session;
},
},
session: {
strategy: "jwt",
maxAge: SESSION_MAX_AGE_SECONDS,
},
jwt: {
maxAge: SESSION_MAX_AGE_SECONDS,
},
};
export default authOptions;
@@ -0,0 +1,97 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
import { GET } from "./route";
jest.mock("next-auth/jwt", () => ({
getToken: jest.fn(),
}));
const getTokenMock = getToken as jest.MockedFunction<typeof getToken>;
describe("GET /api/auth/keycloak-logout", () => {
const originalIssuer = process.env.KEYCLOAK_ISSUER;
const originalClientId = process.env.KEYCLOAK_CLIENT_ID;
const originalNextAuthUrl = process.env.NEXTAUTH_URL;
const originalPostLogoutRedirectUri =
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
beforeEach(() => {
process.env.KEYCLOAK_ISSUER = "https://keycloak.example.com/realms/tjwater";
process.env.KEYCLOAK_CLIENT_ID = "tjwater";
process.env.NEXTAUTH_URL = "https://frontend.example.com";
delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
getTokenMock.mockReset();
});
afterAll(() => {
process.env.KEYCLOAK_ISSUER = originalIssuer;
process.env.KEYCLOAK_CLIENT_ID = originalClientId;
process.env.NEXTAUTH_URL = originalNextAuthUrl;
if (originalPostLogoutRedirectUri) {
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI = originalPostLogoutRedirectUri;
} else {
delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
}
});
it("clears the local session and redirects the browser to Keycloak logout", async () => {
getTokenMock.mockResolvedValue({
idToken: "header.payload.signature",
});
const request = new NextRequest(
"https://frontend.example.com/api/auth/keycloak-logout",
{
headers: {
cookie:
"__Secure-next-auth.session-token.0=first; __Secure-next-auth.session-token.1=second; next-auth.session-token=local",
},
},
);
const response = await GET(request);
const logoutUrl = new URL(response.headers.get("location") ?? "");
expect(logoutUrl.origin).toBe("https://keycloak.example.com");
expect(logoutUrl.pathname).toBe(
"/realms/tjwater/protocol/openid-connect/logout",
);
expect(logoutUrl.searchParams.get("id_token_hint")).toBe(
"header.payload.signature",
);
expect(logoutUrl.searchParams.get("client_id")).toBe("tjwater");
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
expect(
response.cookies.get("__Secure-next-auth.session-token.0"),
).toMatchObject({ value: "", path: "/", secure: true });
expect(
response.cookies.get("__Secure-next-auth.session-token.1"),
).toMatchObject({ value: "", path: "/", secure: true });
expect(response.cookies.get("next-auth.session-token")).toMatchObject({
value: "",
path: "/",
});
expect(
response.cookies.get("next-auth.session-token")?.secure,
).toBeFalsy();
});
it("ignores a legacy automatic post-logout redirect setting", async () => {
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI =
"https://frontend.example.com/login";
getTokenMock.mockResolvedValue({ idToken: "header.payload.signature" });
const response = await GET(
new NextRequest(
"https://frontend.example.com/api/auth/keycloak-logout",
),
);
const logoutUrl = new URL(response.headers.get("location") ?? "");
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
type KeycloakToken = {
idToken?: string;
};
const sessionCookiePrefixes = [
"next-auth.session-token",
"__Secure-next-auth.session-token",
];
const isSessionCookie = (name: string) =>
sessionCookiePrefixes.some((prefix) => name.startsWith(prefix));
export const GET = async (request: NextRequest) => {
const localLoginUrl = new URL(
"/login",
process.env.NEXTAUTH_URL ?? request.nextUrl.origin,
);
const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, "");
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const token = (await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
})) as KeycloakToken | null;
const logoutUrl = issuer
? new URL(`${issuer}/protocol/openid-connect/logout`)
: localLoginUrl;
if (issuer) {
if (clientId) logoutUrl.searchParams.set("client_id", clientId);
if (token?.idToken) logoutUrl.searchParams.set("id_token_hint", token.idToken);
}
const response = NextResponse.redirect(logoutUrl);
for (const { name } of request.cookies.getAll()) {
if (isSessionCookie(name)) {
response.cookies.delete({
name,
path: "/",
secure: name.startsWith("__Secure-"),
});
}
}
return response;
};
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function RootLayout({
const defaultMode = theme?.value === "dark" ? "dark" : "light";
return (
<html lang="en">
<html lang="zh-CN">
<body>
<Script src="/runtime-config.js" strategy="beforeInteractive" />
<Suspense>
+45
View File
@@ -0,0 +1,45 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import Login from "./page";
const mockLogin = jest.fn();
jest.mock("@refinedev/core", () => ({
useLogin: () => ({ mutate: mockLogin }),
}));
describe("Login", () => {
beforeEach(() => {
jest.useFakeTimers();
mockLogin.mockClear();
});
afterEach(() => {
jest.useRealTimers();
});
it("starts Keycloak login once when the page opens", () => {
const { rerender } = render(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
expect(mockLogin).toHaveBeenCalledWith({});
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
rerender(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
});
it("shows a manual fallback when automatic redirect does not complete", () => {
render(<Login />);
expect(
screen.queryByRole("button", { name: "继续登录" }),
).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(4000);
});
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
expect(mockLogin).toHaveBeenCalledTimes(2);
});
});
+239 -34
View File
@@ -1,54 +1,259 @@
"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 CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useLogin } from "@refinedev/core";
import { Title } from "@components/title";
import { PROJECT_TITLE } from "@config/config";
import { useEffect, useRef, useState } from "react";
const FALLBACK_DELAY_MS = 4000;
export default function Login() {
const { mutate: login } = useLogin();
const loginStartedRef = useRef(false);
const [showFallback, setShowFallback] = useState(false);
useEffect(() => {
if (!loginStartedRef.current) {
loginStartedRef.current = true;
login({});
}
const fallbackTimer = window.setTimeout(
() => setShowFallback(true),
FALLBACK_DELAY_MS,
);
return () => window.clearTimeout(fallbackTimer);
}, [login]);
return (
<Container
style={{
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
<Box
component="main"
sx={{
minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
color: "oklch(0.3 0.055 215)",
bgcolor: "oklch(0.965 0.014 205)",
backgroundImage: `
linear-gradient(
90deg,
transparent 0%,
transparent 50%,
oklch(0.975 0.01 205 / 62%) 68%,
oklch(0.975 0.01 205 / 82%) 100%
),
url("/login-network-blueprint.svg")
`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
"@keyframes tjwaterEnter": {
from: { opacity: 0, transform: "translateY(12px)" },
to: { opacity: 1, transform: "translateY(0)" },
},
}}
>
<Box
display="flex"
gap="36px"
justifyContent="center"
flexDirection="column"
sx={{
width: "100%",
maxWidth: 1720,
minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
mx: "auto",
px: {
xs: 1.75,
sm: 3,
md: "clamp(40px, 5vw, 88px)",
},
py: {
xs: "max(28px, env(safe-area-inset-top))",
md: "clamp(32px, 4.5vw, 76px)",
},
display: "grid",
gridTemplateColumns: {
xs: "minmax(0, 1fr)",
md: "minmax(360px, 1fr) minmax(400px, 460px)",
},
gridTemplateAreas: {
xs: '"brand" "status"',
md: '"brand status"',
},
alignContent: { xs: "center", md: "stretch" },
alignItems: "center",
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
}}
>
<Box display="flex" justifyContent="center">
<Title collapsed={false} />
</Box>
<Button
style={{ width: "240px" }}
size="large"
variant="contained"
onClick={() => login({})}
<Stack
spacing={{ xs: 0, md: 3 }}
direction={{ xs: "row", md: "column" }}
alignItems={{ xs: "center", md: "flex-start" }}
sx={{
position: "relative",
isolation: "isolate",
gridArea: "brand",
width: "fit-content",
maxWidth: "100%",
animation:
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
"&::before": {
position: "absolute",
zIndex: -1,
inset: { xs: "-24px -20px", md: "-54px -72px" },
background: {
xs: `radial-gradient(
ellipse at center,
oklch(0.965 0.014 205 / 98%) 0%,
oklch(0.965 0.014 205 / 88%) 58%,
transparent 84%
)`,
md: `radial-gradient(
ellipse at center,
oklch(0.925 0.018 205 / 98%) 0%,
oklch(0.925 0.018 205 / 94%) 48%,
oklch(0.925 0.018 205 / 62%) 65%,
transparent 82%
)`,
},
pointerEvents: "none",
content: '""',
},
}}
>
Sign in
</Button>
<Typography align="center" color={"text.secondary"} fontSize="12px">
Powered by
<Image
style={{ padding: "0 5px" }}
alt="Keycloak"
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
width={18}
height={18}
<Box
component="img"
src="/login-logo-mark.svg"
alt=""
width={56}
height={56}
sx={{
width: { xs: 42, md: 56 },
height: { xs: 42, md: 56 },
flex: { xs: "0 0 42px", md: "0 0 auto" },
mr: { xs: 1.5, md: 0 },
}}
/>
Keycloak
</Typography>
<Typography
component="h1"
sx={{
maxWidth: 680,
m: 0,
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
fontWeight: 720,
lineHeight: { xs: 1.4, md: 1.28 },
textWrap: "balance",
}}
>
{PROJECT_TITLE}
</Typography>
<Box
aria-hidden="true"
sx={{
display: { xs: "none", md: "block" },
width: 64,
height: 3,
borderRadius: 999,
bgcolor: "oklch(0.58 0.12 180)",
}}
/>
</Stack>
<Box
aria-live="polite"
sx={{
gridArea: "status",
width: "100%",
maxWidth: 460,
justifySelf: "stretch",
p: { xs: 2.75, sm: 4.5 },
overflow: "hidden",
borderRadius: { xs: 4, sm: 4.5 },
bgcolor: "oklch(0.995 0.004 205 / 97%)",
boxShadow:
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
animation:
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
}}
>
<Stack spacing={3}>
<Stack direction="row" spacing={2} alignItems="center">
<CircularProgress
size={30}
thickness={4.5}
aria-label="正在连接统一身份认证"
sx={{
flex: "0 0 auto",
color: "oklch(0.57 0.16 242)",
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
"& .MuiCircularProgress-circle": {
animation: "none",
},
},
}}
/>
<Stack spacing={0.5}>
<Typography
component="h2"
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
>
正在进入账号登录
</Typography>
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 14, sm: 15 },
lineHeight: 1.7,
}}
>
正在连接统一身份认证服务,请稍候。
</Typography>
</Stack>
</Stack>
{showFallback ? (
<Button
fullWidth
size="large"
variant="contained"
onClick={() => login({})}
sx={{
minHeight: 48,
borderRadius: 3,
fontSize: 16,
fontWeight: 700,
textTransform: "none",
boxShadow:
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
transitionProperty:
"transform, background-color, box-shadow",
"&:active": { transform: "scale(0.96)" },
}}
>
继续登录
</Button>
) : null}
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 12, sm: 13 },
lineHeight: 1.7,
textAlign: "center",
}}
>
账号与访问权限由统一身份认证服务管理
</Typography>
</Stack>
</Box>
</Box>
</Container>
</Box>
);
}
+73 -50
View File
@@ -57,6 +57,7 @@ import {
import { useNotification } from "@refinedev/core";
import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { useProjectStore } from "@/store/projectStore";
type MetadataUser = {
@@ -106,24 +107,30 @@ type DatabaseHealth = {
const businessRoleOptions = [
{ value: "admin", label: "系统管理员" },
{ value: "operator", label: "运行人员" },
{ value: "user", label: "普通用户" },
{ value: "viewer", label: "只读用户" },
];
const projectRoleLabels: Record<string, string> = {
owner: "项目负责人",
admin: "项目管理员",
member: "项目成员",
viewer: "只读成员",
};
const projectRoleOptions = [
{ value: "admin", label: projectRoleLabels.admin },
{ value: "member", label: projectRoleLabels.member },
{ value: "viewer", label: projectRoleLabels.viewer },
];
const projectRolePermissionSummary = [
{
role: "项目成员",
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
},
{
role: "只读成员",
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
},
];
const projectStatusOptions = [
{ value: "active", label: "启用" },
{ value: "inactive", label: "停用" },
@@ -473,6 +480,20 @@ export const SystemAdminPanel = () => {
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth);
const recoveryDraft = useMemo(
() => ({ tab, projectId, memberForm, projectForm, createProjectOpen, createProjectForm }),
[createProjectForm, createProjectOpen, memberForm, projectForm, projectId, tab],
);
const restoreRecoveryDraft = useCallback((draft: typeof recoveryDraft) => {
setTab(draft.tab);
setProjectId(draft.projectId);
setMemberForm(draft.memberForm);
setProjectForm(draft.projectForm);
setCreateProjectOpen(draft.createProjectOpen);
setCreateProjectForm(draft.createProjectForm);
}, []);
useSessionRecoveryDraft("system-admin", recoveryDraft, restoreRecoveryDraft);
useEffect(() => {
openNotificationRef.current = openNotification;
}, [openNotification]);
@@ -634,7 +655,7 @@ export const SystemAdminPanel = () => {
const loadInitialState = async () => {
try {
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
@@ -941,7 +962,7 @@ export const SystemAdminPanel = () => {
const form = databaseForms[role];
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`,
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health-checks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1340,7 +1361,7 @@ export const SystemAdminPanel = () => {
<Stack spacing={2}>
<SectionHeader
title="项目成员"
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
action={
<Button
variant="outlined"
@@ -1352,6 +1373,25 @@ export const SystemAdminPanel = () => {
</Button>
}
/>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1 }}>
固定项目权限
</Typography>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={1}
useFlexGap
flexWrap="wrap"
>
{projectRolePermissionSummary.map((item) => (
<Chip
key={item.role}
label={`${item.role}:${item.permissions}`}
variant="outlined"
/>
))}
</Stack>
</Paper>
{!hasProjectId && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
当前未选择管理项目。
@@ -1467,7 +1507,6 @@ export const SystemAdminPanel = () => {
)}
{members.map((member) => {
const isSelf = member.user_id === currentAdmin?.id;
const isOwner = member.project_role === "owner";
return (
<TableRow key={member.id} hover>
@@ -1486,54 +1525,38 @@ export const SystemAdminPanel = () => {
</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
{isOwner ? (
<Tooltip title="项目负责人不在系统管理页维护">
<Chip
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
<span>
<FormControl
size="small"
color="warning"
label={getProjectRoleLabel(member.project_role)}
/>
</Tooltip>
) : (
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
<span>
<FormControl
size="small"
sx={{ minWidth: 140 }}
disabled={isSelf}
sx={{ minWidth: 140 }}
disabled={isSelf}
>
<Select
value={member.project_role}
onChange={(e) =>
updateMemberRole(member, e.target.value)
}
renderValue={(value) =>
getProjectRoleLabel(String(value))
}
>
<Select
value={member.project_role}
onChange={(e) =>
updateMemberRole(member, e.target.value)
}
renderValue={(value) =>
getProjectRoleLabel(String(value))
}
>
{projectRoleOptions.map((role) => (
<MenuItem key={role.value} value={role.value}>
{role.label}
</MenuItem>
))}
</Select>
</FormControl>
</span>
</Tooltip>
)}
{projectRoleOptions.map((role) => (
<MenuItem key={role.value} value={role.value}>
{role.label}
</MenuItem>
))}
</Select>
</FormControl>
</span>
</Tooltip>
</TableCell>
<TableCell>
<StatusChip active={member.is_active} />
</TableCell>
<TableCell align="right">
<Tooltip
title={
isSelf
? "不能操作当前登录用户"
: isOwner
? "项目负责人不在系统管理页维护"
: ""
}
title={isSelf ? "不能操作当前登录用户" : ""}
>
<span>
<Button
@@ -1542,7 +1565,7 @@ export const SystemAdminPanel = () => {
variant="outlined"
startIcon={<RemoveCircleOutlineIcon />}
onClick={() => removeMember(member)}
disabled={isSelf || isOwner}
disabled={isSelf}
>
移除
</Button>
+20 -43
View File
@@ -49,6 +49,8 @@ import {
} from "@mui/icons-material";
import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
type AuditLog = {
id: string;
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
end_time: "",
};
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
{ value: "all", label: "全部状态" },
{ value: "success", label: "成功 2xx" },
@@ -338,8 +342,14 @@ const DetailSection = ({
);
export const AuditLogPanel = () => {
const [adminChecked, setAdminChecked] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const accessLoading = useAccessStore((state) => state.loading);
const permissions = useAccessStore((state) => state.permissions);
const isSystemAdmin = useAccessStore(
(state) => state.context?.is_system_admin === true,
);
const canViewAudit = permissions.includes(permissionCodes.auditView);
const adminChecked = !accessLoading;
const isAuthorized = adminChecked && canViewAudit && isSystemAdmin;
const [logs, setLogs] = useState<AuditLog[]>([]);
const [users, setUsers] = useState<MetadataUser[]>([]);
const [projects, setProjects] = useState<AdminProject[]>([]);
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
const countParams = buildCountParams(appliedFilters);
const [logsResponse, countResponse] = await Promise.all([
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`),
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
]);
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
setTotalCount(Number(countPayload.count ?? 0));
} else {
const params = buildServerParams(appliedFilters, 0, 1000);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
if (!response.ok) throw new Error(await readErrorText(response));
const payload = (await response.json()) as AuditLog[];
setLogs(payload);
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
}, [appliedFilters, page, rowsPerPage]);
useEffect(() => {
let cancelled = false;
const checkAdmin = async () => {
try {
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
if (cancelled) return;
if (!response.ok) {
setIsAuthorized(false);
setAdminChecked(true);
return;
}
const payload = await response.json();
setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin"));
setAdminChecked(true);
} catch (err) {
if (!cancelled) {
setIsAuthorized(false);
setAdminChecked(true);
setError(String(err));
}
}
};
void checkAdmin();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!adminChecked || !isAuthorized) return;
if (!isAuthorized) return;
void loadOptions();
}, [adminChecked, isAuthorized, loadOptions]);
}, [isAuthorized, loadOptions]);
useEffect(() => {
if (!adminChecked || !isAuthorized) return;
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
setError(null);
try {
const params = buildServerParams(appliedFilters, 0, 1000);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
if (!response.ok) throw new Error(await readErrorText(response));
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
matchesStatusFilter(log, appliedFilters.status),
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
审计日志
</Typography>
<Typography variant="body2" color="text.secondary">
管理员审计查询
全局审计查询
</Typography>
</Box>
</Stack>
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
<Chip
color="success"
icon={<AdminPanelSettingsIcon />}
label="管理员权限已验证"
label="系统管理员权限已验证"
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
/>
)}
@@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard";
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
}));
describe("RoutePermissionGuard", () => {
beforeEach(() => {
useAccessStore.setState({
context: null,
permissions: [],
loading: false,
});
useAuthStore.setState({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("prioritizes an expired session over a missing route permission", () => {
useAuthStore.setState({
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
render(
<RoutePermissionGuard>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.getByText("正在跳转到登录页面…")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
expect(screen.queryByText(/simulation\.view/)).not.toBeInTheDocument();
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("shows the permission error when the session is still valid", () => {
render(
<RoutePermissionGuard>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
});
@@ -0,0 +1,62 @@
"use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
}: {
children: ReactNode;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname);
if (sessionExpired) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
<Stack spacing={0.5}>
<Typography variant="body1">登录状态已失效</Typography>
<Typography variant="body2">
正在跳转到登录页面…
</Typography>
</Stack>
</Alert>
</Box>
);
}
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
<CircularProgress size={28} />
</Box>
);
}
if (requiredPermission && !permissions.includes(requiredPermission)) {
return (
<Box sx={{ p: { xs: 2, md: 4 } }}>
<Alert severity="error" icon={<LockOutlinedIcon />}>
<Stack spacing={0.5}>
<Typography fontWeight={700}>无权访问此功能</Typography>
<Typography variant="body2">
当前项目角色缺少权限:{requiredPermission}
</Typography>
</Stack>
</Alert>
</Box>
);
}
return children;
};
@@ -0,0 +1,79 @@
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { useAuthStore } from "@/store/authStore";
import { SessionExpiryDialog } from "./SessionExpiryDialog";
jest.mock("next-auth/react", () => ({
signIn: jest.fn(),
}));
describe("SessionExpiryDialog", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(signIn).mockReset().mockResolvedValue(undefined);
useAuthStore.setState({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
afterEach(() => {
act(() => useAuthStore.getState().clearSessionExpired());
jest.useRealTimers();
});
it("renders above every regular application overlay", () => {
const theme = createTheme();
render(
<ThemeProvider theme={theme}>
<SessionExpiryDialog />
</ThemeProvider>,
);
const dialogRoot = screen.getByRole("dialog").closest(".MuiModal-root");
expect(dialogRoot).not.toBeNull();
expect(dialogRoot).toHaveStyle({
zIndex: theme.zIndex.tooltip + 1,
});
});
it("shows the expired state before automatically starting login", () => {
window.history.replaceState({}, "", "/network-simulation?tab=history");
render(<SessionExpiryDialog />);
expect(screen.getByText("登录已过期")).toBeInTheDocument();
expect(screen.getByText("即将自动跳转到登录页面。"))
.toBeInTheDocument();
expect(signIn).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(2_000);
});
expect(signIn).toHaveBeenCalledTimes(1);
expect(signIn).toHaveBeenCalledWith("keycloak", {
callbackUrl: "/network-simulation?tab=history",
redirect: true,
});
});
it("allows a retry when starting automatic login fails", async () => {
jest.mocked(signIn)
.mockRejectedValueOnce(new Error("network unavailable"))
.mockResolvedValueOnce(undefined);
render(<SessionExpiryDialog />);
await act(async () => {
jest.advanceTimersByTime(2_000);
});
fireEvent.click(screen.getByRole("button", { name: "重新认证" }));
expect(signIn).toHaveBeenCalledTimes(2);
});
});
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { signIn } from "next-auth/react";
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useAuthStore } from "@/store/authStore";
const WARNING_WINDOW_MS = 15 * 60 * 1000;
const EXPIRED_REDIRECT_DELAY_MS = 2_000;
type SessionExpiryDialogProps = {
expiresAt?: number;
};
export const SessionExpiryDialog = ({
expiresAt,
}: SessionExpiryDialogProps) => {
const theme = useTheme();
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const reason = useAuthStore((state) => state.sessionExpiryReason);
const [now, setNow] = useState(() => Date.now());
const [warningDismissed, setWarningDismissed] = useState(false);
const redirectStartedRef = useRef(false);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(timer);
}, []);
const isExpiringSoon = useMemo(
() =>
!sessionExpired &&
!warningDismissed &&
typeof expiresAt === "number" &&
expiresAt > now &&
expiresAt - now <= WARNING_WINDOW_MS,
[expiresAt, now, sessionExpired, warningDismissed],
);
const handleReauthenticate = useCallback(() => {
if (redirectStartedRef.current) return;
redirectStartedRef.current = true;
const callbackUrl = `${window.location.pathname}${window.location.search}`;
void signIn("keycloak", { callbackUrl, redirect: true }).catch(() => {
redirectStartedRef.current = false;
});
}, []);
useEffect(() => {
if (!sessionExpired) {
redirectStartedRef.current = false;
return;
}
const timer = window.setTimeout(
handleReauthenticate,
EXPIRED_REDIRECT_DELAY_MS,
);
return () => window.clearTimeout(timer);
}, [handleReauthenticate, sessionExpired]);
const isOpen = sessionExpired || isExpiringSoon;
const title = sessionExpired ? "登录已过期" : "登录即将到期";
const detail = sessionExpired
? reason === "session_max_age"
? "已达到 12 小时的最长连续登录时间。请重新认证后继续。"
: "无法续期当前登录。请重新认证后继续。"
: "当前登录将在 15 分钟内到期。请先保存正在编辑的内容。";
return (
<Dialog
open={isOpen}
style={{ zIndex: theme.zIndex.tooltip + 1 }}
disableEscapeKeyDown={sessionExpired}
onClose={sessionExpired ? undefined : () => setWarningDismissed(true)}
aria-labelledby="session-expiry-dialog-title"
>
<DialogTitle id="session-expiry-dialog-title">{title}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 0.5 }}>
<Alert icon={<AccessTimeOutlinedIcon />} severity={sessionExpired ? "warning" : "info"}>
{detail}
</Alert>
<Typography variant="body2" color="text.secondary">
重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。
</Typography>
{sessionExpired && (
<Typography variant="body2" color="text.secondary">
即将自动跳转到登录页面。
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions>
{!sessionExpired && (
<Button onClick={() => setWarningDismissed(true)}>稍后处理</Button>
)}
<Button variant="contained" onClick={handleReauthenticate}>
重新认证
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,384 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Collapse,
IconButton,
LinearProgress,
Stack,
Typography,
alpha,
useMediaQuery,
useTheme,
} from "@mui/material";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import StopCircleRounded from "@mui/icons-material/StopCircleRounded";
import type { AgentActivity, AgentActivityAction } from "@/lib/chatStream";
const activityAccent = "#0097a7";
type TimedActivityItem = {
status: "running" | "completed" | "error" | "cancelled";
startedAt: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
const formatDuration = (durationMs: number | undefined) => {
if (durationMs === undefined || !Number.isFinite(durationMs)) return undefined;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
const seconds = Math.round(durationMs / 1000);
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
const getElapsedMs = (
item: TimedActivityItem,
now: number,
) => {
if (item.durationMs !== undefined) return item.durationMs;
if (item.status === "running") {
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
return Math.max(0, item.elapsedMs + now - item.elapsedSnapshotAt);
}
return Math.max(0, now - item.startedAt);
}
return item.endedAt ? Math.max(0, item.endedAt - item.startedAt) : undefined;
};
const StatusIcon = ({
status,
size = 18,
}: {
status: AgentActivity["status"];
size?: number;
}) => {
if (status === "completed") {
return <CheckCircleRounded color="success" sx={{ fontSize: size }} />;
}
if (status === "error") {
return <ErrorOutlineRounded color="error" sx={{ fontSize: size }} />;
}
if (status === "cancelled") {
return <StopCircleRounded color="disabled" sx={{ fontSize: size }} />;
}
return <AutoAwesomeRounded sx={{ fontSize: size, color: activityAccent }} />;
};
const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] }) => {
if (status === "error") {
return <ErrorOutlineRounded sx={{ mt: "2px", fontSize: 14, color: "error.main" }} />;
}
if (status === "completed") {
return <CheckCircleRounded sx={{ mt: "2px", fontSize: 14, color: "success.main" }} />;
}
return (
<RadioButtonUncheckedRounded
sx={{ mt: "2px", fontSize: 14, color: activityAccent }}
/>
);
};
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
const elapsed = getElapsedMs(action, now);
return (
<Stack
direction="row"
spacing={1}
alignItems="flex-start"
sx={{ minWidth: 0, py: 0.55 }}
>
<ActionStatusIcon status={action.status} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={{ xs: 0.2, sm: 1 }}
justifyContent="space-between"
>
<Typography variant="body2" fontWeight={650} sx={{ lineHeight: 1.45 }}>
{action.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{formatDuration(elapsed)}
</Typography>
</Stack>
{action.target ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
mt: 0.2,
fontFamily: "monospace",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{action.target}
</Typography>
) : null}
{action.error ? (
<Typography
variant="caption"
color="error.main"
sx={{ display: "block", mt: 0.2, wordBreak: "break-word" }}
>
{action.error}
</Typography>
) : null}
</Box>
</Stack>
);
};
export const AgentActivityTimeline = ({
activities,
}: {
activities: AgentActivity[];
}) => {
const theme = useTheme();
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const hasRunning = activities.some((activity) => activity.status === "running");
const hasError = activities.some((activity) => activity.status === "error");
const hasCancelled = activities.some((activity) => activity.status === "cancelled");
const [expanded, setExpanded] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!hasRunning) return;
const timer = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(timer);
}, [hasRunning]);
const current = [...activities]
.reverse()
.find((activity) => activity.status === "running") ?? activities.at(-1);
const totalDuration = useMemo(() => {
if (!activities.length) return undefined;
const start = Math.min(...activities.map((activity) => activity.startedAt));
const end = hasRunning
? now
: Math.max(
...activities.map((activity) => activity.endedAt ?? activity.startedAt),
);
return formatDuration(Math.max(0, end - start));
}, [activities, hasRunning, now]);
const overallStatus: AgentActivity["status"] = hasRunning
? "running"
: hasError
? "error"
: hasCancelled
? "cancelled"
: "completed";
const statusLabel = {
running: "进行中",
completed: "已完成",
error: "失败",
cancelled: "已停止",
}[overallStatus];
const statusColor = {
running: activityAccent,
completed: theme.palette.success.main,
error: theme.palette.error.main,
cancelled: theme.palette.text.secondary,
}[overallStatus];
const summary = hasRunning
? (current?.title ?? "正在分析")
: hasError
? "分析未完成"
: hasCancelled
? "分析已停止"
: `已完成 ${activities.length} 个阶段`;
return (
<Box
sx={{
overflow: "hidden",
borderRadius: 3,
border: `1px solid ${alpha(activityAccent, 0.16)}`,
bgcolor: alpha(activityAccent, 0.035),
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ px: 1.4, py: 1.05 }}
>
<Box
sx={{
width: 28,
height: 28,
flex: "0 0 auto",
display: "grid",
placeItems: "center",
borderRadius: 2,
color: activityAccent,
bgcolor: alpha(activityAccent, 0.1),
}}
>
<AutoAwesomeRounded sx={{ fontSize: 17 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={0.75} alignItems="center">
<Typography variant="body2" fontWeight={750}>
分析过程
</Typography>
<Stack
component="span"
direction="row"
spacing={0.45}
alignItems="center"
sx={{ color: statusColor }}
>
<Box
component="span"
sx={{
width: 5,
height: 5,
borderRadius: "50%",
bgcolor: "currentColor",
}}
/>
<Typography
component="span"
variant="caption"
fontWeight={700}
color="inherit"
>
{statusLabel}
</Typography>
</Stack>
</Stack>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: "block", mt: 0.1 }}
>
{summary}
{totalDuration ? ` · ${totalDuration}` : ""}
</Typography>
</Box>
<IconButton
size="small"
aria-label={expanded ? "收起分析过程" : "展开分析过程"}
onClick={() => setExpanded((current) => !current)}
sx={{
width: 28,
height: 28,
flex: "0 0 auto",
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
</Stack>
{hasRunning ? (
<LinearProgress
sx={{
height: 2,
bgcolor: alpha(activityAccent, 0.08),
"& .MuiLinearProgress-bar": { bgcolor: activityAccent },
}}
/>
) : null}
<Collapse in={expanded} timeout={reduceMotion ? 0 : 180}>
<Stack
spacing={1.1}
sx={{
px: 1.4,
py: 1.15,
}}
>
{activities.map((activity, index) => {
const elapsed = formatDuration(getElapsedMs(activity, now));
return (
<Stack
key={activity.id}
direction="row"
spacing={1}
sx={{
position: "relative",
minWidth: 0,
}}
>
<Box
sx={{
width: 18,
flex: "0 0 18px",
position: "relative",
pt: "2px",
}}
>
{index < activities.length - 1 ? (
<Box
sx={{
position: "absolute",
top: 19,
bottom: -14,
left: 8.5,
width: "1px",
bgcolor: alpha(activityAccent, 0.18),
}}
/>
) : null}
<StatusIcon status={activity.status} size={17} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={1} justifyContent="space-between">
<Typography variant="body2" fontWeight={700} sx={{ lineHeight: 1.45 }}>
{activity.title}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{elapsed}
</Typography>
</Stack>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.25, lineHeight: 1.5 }}
>
{activity.reason}
</Typography>
{activity.actions.length ? (
<Stack
spacing={0}
sx={{
mt: 0.6,
pl: 1,
borderLeft: `1px solid ${alpha(activityAccent, 0.2)}`,
}}
>
{activity.actions.map((action) => (
<ActionRow key={action.id} action={action} now={now} />
))}
</Stack>
) : null}
</Box>
</Stack>
);
})}
</Stack>
</Collapse>
</Box>
);
};
+111
View File
@@ -0,0 +1,111 @@
/* eslint-disable @next/next/no-img-element */
import React from "react";
import { render, screen } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { AgentComposer } from "./AgentComposer";
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} alt={props.alt ?? ""} />
),
}));
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({
children,
animate: _animate,
exit: _exit,
initial: _initial,
transition: _transition,
...props
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
},
}));
describe("AgentComposer", () => {
it("places voice input immediately before send without an attachment action", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
isListening={false}
isSttSupported
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[{ id: "test-model", label: "测试模型" }]}
selectedModel="test-model"
onModelChange={jest.fn()}
approvalMode="auto"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
const voiceButton = screen.getByRole("button", { name: "语音输入" });
const sendButton = screen.getByRole("button", { name: "发送" });
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
expect(screen.getByText("自动批准")).toBeInTheDocument();
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
});
it("disables input while the Agent runtime is unavailable", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
runtimeState="unavailable"
isListening={false}
isSttSupported={false}
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[]}
onModelChange={jest.fn()}
approvalMode="auto"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByPlaceholderText("Agent 服务未就绪,暂时无法发送消息")).toBeDisabled();
expect(screen.getByRole("button", { name: "发送" })).toBeDisabled();
});
it("renders the always-allow mode as an explicit warning state", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
isListening={false}
isSttSupported={false}
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[]}
onModelChange={jest.fn()}
approvalMode="always"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByText("始终允许")).toBeInTheDocument();
expect(screen.getByTestId("WarningAmberRoundedIcon")).toBeInTheDocument();
});
});
+151 -80
View File
@@ -23,11 +23,12 @@ import StopRounded from "@mui/icons-material/StopRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
import WarningAmberRounded from "@mui/icons-material/WarningAmberRounded";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
import type { AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
@@ -41,6 +42,7 @@ export type AgentComposerHandle = {
type AgentComposerProps = {
isHydrating?: boolean;
runtimeState?: AgentRuntimeState;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
@@ -56,6 +58,35 @@ type AgentComposerProps = {
onApprovalModeChange: (mode: AgentApprovalMode) => void;
};
const approvalModeOptions: ReadonlyArray<{
value: AgentApprovalMode;
label: string;
description: string;
icon: React.ElementType;
}> = [
{
value: "request",
label: "请求批准",
description: "白名单外的工具权限逐次确认",
icon: VerifiedUserRounded,
},
{
value: "auto",
label: "自动批准",
description: "低风险自动批准,其余仍需确认",
icon: AdminPanelSettingsRounded,
},
{
value: "always",
label: "始终允许",
description: "除明确禁止项外自动放行",
icon: WarningAmberRounded,
},
];
const getApprovalModeOption = (value: AgentApprovalMode) =>
approvalModeOptions.find((option) => option.value === value) ?? approvalModeOptions[0];
const renderModelIcon = (
icon: AgentModelOption["icon"] | undefined,
props?: React.ComponentProps<typeof BoltRounded>,
@@ -68,6 +99,7 @@ const renderModelIcon = (
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false,
runtimeState = "ready",
isStreaming,
isListening,
isSttSupported,
@@ -86,8 +118,19 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
const isRuntimeReady = runtimeState === "ready";
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating && isRuntimeReady;
const placeholder = isHydrating
? "正在加载对话记录..."
: runtimeState === "checking"
? "正在连接 Agent 服务..."
: runtimeState === "models_unavailable"
? "模型尚未加载,暂时无法发送消息"
: runtimeState === "unavailable"
? "Agent 服务未就绪,暂时无法发送消息"
: "描述你的分析目标,或点击上方指令库...";
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
const selectedApprovalModeOption = getApprovalModeOption(approvalMode);
React.useImperativeHandle(
ref,
@@ -103,10 +146,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const handleSend = React.useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming || isHydrating) return;
if (!prompt || isStreaming || isHydrating || !isRuntimeReady) return;
setInput("");
onSend(prompt);
}, [input, isHydrating, isStreaming, onSend]);
}, [input, isHydrating, isRuntimeReady, isStreaming, onSend]);
return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
@@ -125,16 +168,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{
objectFit: "contain",
flexShrink: 0,
}}
/>
<AutoAwesomeRounded
titleAccess="快捷指令图标"
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
/>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
管网分析快捷指令
</Typography>
@@ -161,6 +198,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
label={prompt.replace(/[。.]$/, "")}
size="medium"
clickable
disabled={!isRuntimeReady || isHydrating || isStreaming}
onClick={() => {
setInput(prompt);
setIsPresetOpen(false);
@@ -216,12 +254,12 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
handleSend();
}
}}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
placeholder={placeholder}
fullWidth
multiline
maxRows={5}
variant="standard"
disabled={isHydrating}
disabled={isHydrating || !isRuntimeReady}
InputProps={{
disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
@@ -230,61 +268,33 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
<AttachFileRounded fontSize="small" />
</IconButton>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<FormControl size="small" sx={{ minWidth: 96 }}>
<FormControl size="small" sx={{ minWidth: 128 }}>
<Select
value={approvalMode}
onChange={(event) =>
onApprovalModeChange(event.target.value as AgentApprovalMode)
}
disabled={isHydrating || isStreaming}
disabled={isHydrating || isStreaming || !isRuntimeReady}
aria-label="权限批准模式"
renderValue={(val) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
{val === "always" ? (
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
) : (
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
)}
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{val === "always" ? "始终允许" : "请求批准"}
</Typography>
</Box>
)}
renderValue={() => {
const SelectedApprovalIcon = selectedApprovalModeOption.icon;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
<SelectedApprovalIcon
sx={{
fontSize: 18,
color:
selectedApprovalModeOption.value === "always"
? "warning.main"
: "inherit",
}}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{selectedApprovalModeOption.label}
</Typography>
</Box>
);
}}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "left" },
transformOrigin: { vertical: "bottom", horizontal: "left" },
@@ -292,7 +302,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
PaperProps: {
sx: {
mb: 1.5,
width: 210,
width: 248,
borderRadius: 4,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(24px)",
@@ -311,6 +321,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
"& .always-icon": { color: "warning.main" },
},
},
},
@@ -340,20 +351,47 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
},
}}
>
<MenuItem value="request">
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>请求批准</Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>工具权限逐次确认</Typography>
</Box>
</MenuItem>
<MenuItem value="always">
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>始终允许</Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>自动允许本轮权限请求</Typography>
</Box>
</MenuItem>
{approvalModeOptions.map((option) => {
const ApprovalIcon = option.icon;
const isAlways = option.value === "always";
return (
<MenuItem key={option.value} value={option.value}>
<ApprovalIcon
className={isAlways ? "icon always-icon" : "icon"}
sx={{
mr: 1.5,
mt: 0.15,
fontSize: 18,
color: isAlways ? "warning.main" : "text.secondary",
}}
/>
<Box>
<Typography
className="title"
sx={{
mb: 0.2,
color: "text.primary",
fontSize: "0.85rem",
fontWeight: 700,
}}
>
{option.label}
</Typography>
<Typography
sx={{
color: "text.secondary",
fontSize: "0.7rem",
fontWeight: 500,
lineHeight: 1.3,
whiteSpace: "nowrap",
}}
>
{option.description}
</Typography>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
</Stack>
@@ -471,6 +509,39 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
</Select>
</FormControl>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating || !isRuntimeReady}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
+16 -3
View File
@@ -19,12 +19,14 @@ import CloseRounded from "@mui/icons-material/CloseRounded";
import EditRounded from "@mui/icons-material/EditRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
type AgentHeaderProps = {
sessionTitle?: string;
canRenameSessionTitle?: boolean;
isHydrating?: boolean;
isStreaming: boolean;
runtimeState?: AgentRuntimeState;
isHistoryOpen: boolean;
onHistoryToggle: () => void;
onRenameSessionTitle?: (title: string) => void;
@@ -37,6 +39,7 @@ export const AgentHeader = ({
canRenameSessionTitle = false,
isHydrating = false,
isStreaming,
runtimeState = "ready",
isHistoryOpen,
onHistoryToggle,
onRenameSessionTitle,
@@ -47,6 +50,14 @@ export const AgentHeader = ({
const displayTitle = sessionTitle?.trim() || "新对话";
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
const runtimeStatus =
runtimeState === "unavailable" || runtimeState === "models_unavailable"
? { color: "#e53935", label: "Agent 服务未就绪" }
: runtimeState === "checking"
? { color: "#ffb300", label: "正在连接 Agent 服务" }
: isStreaming
? { color: "#ff9800", label: "Agent 正在生成" }
: { color: "#00e676", label: "Agent 已就绪" };
React.useEffect(() => {
if (!isEditingTitle) {
@@ -109,17 +120,19 @@ export const AgentHeader = ({
/>
</Avatar>
<Box
role="status"
aria-label={runtimeStatus.label}
sx={{
position: "absolute",
bottom: -2,
right: -2,
width: 14,
height: 14,
bgcolor: isStreaming ? "#ff9800" : "#00e676",
bgcolor: runtimeStatus.color,
borderRadius: "50%",
border: "2.5px solid #fff",
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
animation: isStreaming ? "pulse 1.5s infinite" : "none",
boxShadow: `0 0 10px ${runtimeStatus.color}`,
animation: isStreaming && runtimeState === "ready" ? "pulse 1.5s infinite" : "none",
"@keyframes pulse": {
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
+129 -96
View File
@@ -19,12 +19,12 @@ import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import GppGoodRounded from "@mui/icons-material/GppGoodRounded";
import type { PermissionReply } from "@/lib/chatStream";
import type { PermissionDecision } from "@/lib/chatStream";
import type { Message } from "./GlobalChatbox.types";
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
@@ -58,7 +58,7 @@ const PermissionIcon = ({
};
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
if (status === "approved_always") return "已始终允许";
if (status === "approved_always") return "已保存授权";
if (status === "approved_once") return "已允许一次";
if (status === "rejected") return "已拒绝";
if (status === "aborted") return "已中断";
@@ -99,7 +99,7 @@ const PermissionRequestCard = ({
}: {
permission: NonNullable<Message["permissions"]>[number];
isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void;
onReply: (requestId: string, reply: PermissionDecision) => void;
}) => {
const theme = useTheme();
const isPending =
@@ -109,25 +109,19 @@ const PermissionRequestCard = ({
const accentColor = getPermissionStatusColor(permission.status, theme);
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
const statusLabel = getPermissionStatusLabel(permission.status);
const persistentScope = permission.always.length > 0
? permission.always
: permission.patterns;
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.5),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
backdropFilter: "blur(20px)",
border: `1px solid ${alpha(accentColor, 0.18)}`,
bgcolor: alpha(accentColor, 0.035),
boxShadow: `0 6px 18px ${alpha("#000", 0.04)}`,
position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: accentColor,
},
}}
>
<Stack
@@ -177,6 +171,22 @@ const PermissionRequestCard = ({
</Stack>
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
<Box
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha(accentColor, 0.055),
border: `1px solid ${alpha(accentColor, 0.12)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
执行目的
</Typography>
<Typography variant="body2" sx={{ mt: 0.25, lineHeight: 1.55 }}>
{permission.reason?.trim() || "Agent 未提供执行目的"}
</Typography>
</Box>
<Box
sx={{
px: 1.25,
@@ -203,6 +213,29 @@ const PermissionRequestCard = ({
{primaryValue}
</Typography>
</Box>
{isPending || isSubmitting ? (
<Box
sx={{
px: 1.25,
py: 0.85,
borderRadius: 2.5,
bgcolor: alpha(theme.palette.success.main, 0.055),
border: `1px solid ${alpha(theme.palette.success.main, 0.11)}`,
}}
>
<Typography variant="caption" color="success.dark" fontWeight={800}>
保存授权范围
</Typography>
<Typography
variant="caption"
color="text.secondary"
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
sx={{ display: "block", mt: 0.2, lineHeight: 1.45, wordBreak: "break-word", whiteSpace: "pre-wrap" }}
>
{persistentScope.join("\n")}
</Typography>
</Box>
) : null}
</Stack>
{permission.error ? (
@@ -232,84 +265,84 @@ const PermissionRequestCard = ({
useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
>
<Button
size="small"
variant="contained"
disableElevation
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "once")}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 94,
height: 34,
borderRadius: "17px",
bgcolor: "#00838f",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
"&:hover": {
bgcolor: "#006c78",
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
},
}}
>
允许一次
</Button>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "always")}
startIcon={<PushPinRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "#00838f",
borderColor: alpha("#00838f", 0.24),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha("#00838f", 0.36),
bgcolor: alpha("#00838f", 0.08),
},
}}
>
始终允许
</Button>
<Button
size="small"
color="error"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "reject")}
startIcon={<BlockRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
borderColor: alpha(theme.palette.error.main, 0.22),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha(theme.palette.error.main, 0.34),
bgcolor: alpha(theme.palette.error.main, 0.07),
},
}}
>
拒绝
</Button>
<Button
size="small"
variant="contained"
disableElevation
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "once")}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 94,
height: 34,
borderRadius: "17px",
bgcolor: "#00838f",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
"&:hover": {
bgcolor: "#006c78",
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
},
}}
>
允许一次
</Button>
<Button
size="small"
color="success"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "always")}
startIcon={<GppGoodRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
borderColor: alpha(theme.palette.success.main, 0.28),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha(theme.palette.success.main, 0.42),
bgcolor: alpha(theme.palette.success.main, 0.08),
},
}}
>
保存授权
</Button>
<Button
size="small"
color="error"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "reject")}
startIcon={<BlockRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
borderColor: alpha(theme.palette.error.main, 0.22),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha(theme.palette.error.main, 0.34),
bgcolor: alpha(theme.palette.error.main, 0.07),
},
}}
>
拒绝
</Button>
</Stack>
) : null}
</Box>
@@ -323,7 +356,7 @@ export const PermissionRequestGroup = ({
}: {
permissions: NonNullable<Message["permissions"]>;
isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void;
onReply: (requestId: string, reply: PermissionDecision) => void;
}) => {
const theme = useTheme();
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
@@ -348,7 +381,7 @@ export const PermissionRequestGroup = ({
const summaryItems = [
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
{ label: "保存授权", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
];
+7 -6
View File
@@ -29,7 +29,9 @@ export const TodoPlanCard = ({
const theme = useTheme();
const total = todoUpdate.todos.length;
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
const runningCount = todoUpdate.todos.filter(
(todo) => todo.status === "in_progress",
).length;
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -77,7 +79,7 @@ export const TodoPlanCard = ({
? `${completed} 完成 / ${cancelled} 中止`
: [
completed ? `${completed} 完成` : null,
running ? "1 进行中" : null,
runningCount ? `${runningCount} 进行中` : null,
pending ? `${pending} 待办` : null,
cancelled ? `${cancelled} 中止` : null,
].filter(Boolean).join(" / ") || "等待任务";
@@ -221,14 +223,14 @@ export const TodoPlanCard = ({
</Typography>
<Chip
size="small"
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
label={runningCount ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
sx={{
height: 20,
borderRadius: "10px",
fontSize: "0.66rem",
fontWeight: 800,
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
color: runningCount ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
bgcolor: alpha(runningCount ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
"& .MuiChip-label": { px: 0.75 },
}}
/>
@@ -305,4 +307,3 @@ export const TodoPlanCard = ({
);
};
+236
View File
@@ -14,6 +14,7 @@ jest.mock("next/image", () => ({
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useReducedMotion: () => false,
motion: {
div: ({
children,
@@ -44,6 +45,47 @@ jest.mock("./AgentMarkdownBlock", () => ({
}));
describe("AgentTurn speech selection", () => {
it("mounts the answer only after the complete response is available", () => {
const sharedProps = {
messageSpeechState: "idle" as const,
onSpeak: jest.fn(),
onPause: jest.fn(),
onResume: jest.fn(),
onStopSpeech: jest.fn(),
isTtsSupported: true,
onCreateBranch: jest.fn(),
onReplyPermission: jest.fn(),
onReplyQuestion: jest.fn(),
onRejectQuestion: jest.fn(),
};
const { rerender } = render(
<AgentTurn
{...sharedProps}
message={{ id: "assistant-buffered", role: "assistant", content: "" }}
isStreaming
/>,
);
expect(screen.queryByTestId("agent-answer-content")).not.toBeInTheDocument();
expect(screen.getByText("正在生成")).toBeInTheDocument();
rerender(
<AgentTurn
{...sharedProps}
message={{
id: "assistant-buffered",
role: "assistant",
content: "完整分析结果已生成。",
}}
isStreaming
/>,
);
expect(screen.getByTestId("agent-answer-content")).toHaveTextContent(
"完整分析结果已生成。",
);
});
it("shows a floating action and reads from the selected text", async () => {
const content = "第一段内容。\n\n第二段内容。";
const speechText = "第一段内容。\n第二段内容。";
@@ -112,4 +154,198 @@ describe("AgentTurn speech selection", () => {
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
});
});
it("offers one-time and saved permission approval with distinct actions", () => {
const onReplyPermission = jest.fn();
render(
<AgentTurn
message={{
id: "assistant-permission",
role: "assistant",
content: "",
progress: [
{
id: "permission-progress",
phase: "permission",
status: "running",
title: "等待权限确认",
},
],
permissions: [
{
requestId: "permission-1",
sessionId: "session-1",
permission: "bash",
patterns: ["npm test"],
target: "npm test",
reason: "需要运行测试确认本次改动没有引入回归。",
always: ["npm test"],
createdAt: 1,
status: "pending",
},
],
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={onReplyPermission}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
expect(screen.getByText("执行目的")).toBeInTheDocument();
expect(screen.getByText("需要运行测试确认本次改动没有引入回归。")).toBeInTheDocument();
expect(screen.getAllByText("npm test")).toHaveLength(2);
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
});
it("groups concrete actions under a business activity", () => {
render(
<AgentTurn
message={{
id: "assistant-activity",
role: "assistant",
content: "",
activities: [
{
id: "activity-1",
title: "准备供水分区数据",
reason: "需要确认拓扑与水库属性完整,才能计算服务范围。",
status: "running",
startedAt: Date.now(),
actions: [
{
id: "action-1",
tool: "tjwater_cli",
title: "查询后端数据",
status: "completed",
target: "network get-all-reservoirs-properties",
startedAt: Date.now() - 100,
endedAt: Date.now(),
durationMs: 100,
},
],
},
],
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText("分析过程")).toBeInTheDocument();
expect(screen.getByText("进行中")).toBeInTheDocument();
expect(screen.getAllByText("准备供水分区数据").length).toBeGreaterThan(0);
expect(screen.getByTestId("KeyboardArrowDownRoundedIcon")).toBeInTheDocument();
expect(
screen.queryByText("需要确认拓扑与水库属性完整,才能计算服务范围。"),
).not.toBeVisible();
expect(screen.queryByText("查询后端数据")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
expect(screen.getByTestId("KeyboardArrowUpRoundedIcon")).toBeInTheDocument();
expect(screen.getByText("需要确认拓扑与水库属性完整,才能计算服务范围。")).toBeVisible();
expect(screen.getByText("查询后端数据")).toBeVisible();
});
it("shows the actual number of in-progress session tasks", () => {
render(
<AgentTurn
message={{
id: "assistant-todos",
role: "assistant",
content: "",
todos: {
sessionId: "session-1",
createdAt: 1,
todos: [
{ id: "todo-1", content: "准备数据", status: "completed" },
{ id: "todo-2", content: "分析结果", status: "completed" },
{ id: "todo-3", content: "生成建议", status: "in_progress" },
{ id: "todo-4", content: "生成图表", status: "in_progress" },
],
},
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText(/2 完成 \/ 2 进行中/u)).toBeInTheDocument();
});
it("keeps a failed activity compact until the user expands it", () => {
render(
<AgentTurn
message={{
id: "assistant-activity-error",
role: "assistant",
content: "⚠️ **错误:** 模型请求失败",
activities: [
{
id: "activity-error",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
status: "error",
startedAt: Date.now() - 4100,
endedAt: Date.now(),
durationMs: 4100,
actions: [],
},
],
}}
isStreaming={false}
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText("失败")).toBeInTheDocument();
expect(screen.getByText("分析未完成 · 4.1s")).toBeInTheDocument();
expect(
screen.queryByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
expect(
screen.getByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
).toBeInTheDocument();
});
});
+98 -112
View File
@@ -2,7 +2,7 @@
import Image from "next/image";
import React, { useMemo } from "react";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import {
Avatar,
Box,
@@ -20,7 +20,7 @@ import {
} from "@mui/material";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import { TbArrowsSplit2 } from "react-icons/tb";
import type { PermissionReply } from "@/lib/chatStream";
import type { PermissionDecision } from "@/lib/chatStream";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
@@ -33,6 +33,7 @@ import type {
import { stripMarkdown } from "./globalChatboxUtils";
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { AgentActivityTimeline } from "./AgentActivityTimeline";
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
@@ -100,7 +101,7 @@ type AgentTurnProps = {
onStopSpeech: () => void;
isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
@@ -149,61 +150,6 @@ const StreamingStatus = () => {
);
};
const StreamingMarkdownBlock = ({
text,
isStreaming,
segmentKey,
}: {
text: string;
isStreaming: boolean;
segmentKey: string;
}) => {
const [streamTextState, setStreamTextState] = React.useState<{
displayText: string;
animatedTailLength: number;
}>({
displayText: text,
animatedTailLength: 0,
});
React.useLayoutEffect(() => {
setStreamTextState((current) => {
if (current.displayText === text) {
return current;
}
if (!isStreaming) {
return {
displayText: text,
animatedTailLength: 0,
};
}
if (current.displayText === text) {
return current;
}
return {
displayText: text,
animatedTailLength:
text.length > current.displayText.length &&
text.startsWith(current.displayText)
? Math.min(48, text.length - current.displayText.length)
: 0,
};
});
}, [isStreaming, text]);
return (
<MarkdownBlock
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
streamFadeLength={streamTextState.animatedTailLength}
>
{streamTextState.displayText}
</MarkdownBlock>
);
};
export const AgentTurn = React.memo(
({
message,
@@ -220,9 +166,11 @@ export const AgentTurn = React.memo(
onRejectQuestion,
}: AgentTurnProps) => {
const theme = useTheme();
const reduceMotion = useReducedMotion();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
const hasFinalAnswer = message.content.trim().length > 0;
const [isHovered, setIsHovered] = React.useState(false);
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
@@ -230,7 +178,8 @@ export const AgentTurn = React.memo(
(item) => item.phase === "complete" && item.status === "completed",
) ?? false;
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
message.progress?.some((item) => item.status === "running") ?? false
(message.activities?.some((item) => item.status === "running") ?? false) ||
(message.progress?.some((item) => item.status === "running") ?? false)
);
const parsedAssistantSections = useMemo(
@@ -456,7 +405,9 @@ export const AgentTurn = React.memo(
}}
>
<Stack spacing={1.5}>
{message.progress?.length ? (
{message.activities?.length ? (
<AgentActivityTimeline activities={message.activities} />
) : message.progress?.length ? (
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null}
@@ -493,63 +444,98 @@ export const AgentTurn = React.memo(
}}
>
<Stack spacing={1.2}>
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
>
<Typography
variant="caption"
color="text.secondary"
fontWeight={800}
sx={{ letterSpacing: 0.5 }}
>
分析结果
</Typography>
{isStreamingAssistant ? <StreamingStatus /> : null}
</Stack>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<StreamingMarkdownBlock
key={segIdx}
text={text || "..."}
isStreaming={isStreamingAssistant}
segmentKey={`${message.id}-${segIdx}`}
/>
);
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories}
series={p.series}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
isStreaming={isStreamingAssistant}
/>
);
{hasFinalAnswer || !isStreamingAssistant ? (
<motion.div
data-testid="agent-answer-content"
initial={
reduceMotion
? false
: { opacity: 0, y: 6, filter: "blur(2px)" }
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<ChartGenerationSkeleton
key="tool-pending"
status={<StreamingStatus />}
/>
);
}
return null;
})}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
duration: reduceMotion ? 0 : 0.24,
ease: [0.16, 1, 0.3, 1],
}}
>
<Stack spacing={1.2}>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<MarkdownBlock key={segIdx}>
{text || "..."}
</MarkdownBlock>
);
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ??
"line"
}
x_data={
p.x_data ??
p.xData ??
p.labels ??
p.categories
}
series={p.series}
x_axis_name={
(p.x_axis_name as string) ?? undefined
}
y_axis_name={
(p.y_axis_name as string) ?? undefined
}
isStreaming={isStreamingAssistant}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<ChartGenerationSkeleton
key="tool-pending"
status={<StreamingStatus />}
/>
);
}
return null;
})}
</Stack>
</motion.div>
) : null}
</Stack>
</Box>
+99 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom";
import React from "react";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types";
@@ -85,6 +85,38 @@ describe("AgentWorkspace", () => {
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
});
it("shows runtime startup failures in the existing empty state and retries there", () => {
const onRetryRuntime = jest.fn();
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
runtimeState="unavailable"
onRetryRuntime={onRetryRuntime}
messages={[]}
/>,
);
expect(screen.getByText("Agent 服务未就绪")).toBeInTheDocument();
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "重新检测" }));
expect(onRetryRuntime).toHaveBeenCalledTimes(1);
});
it("distinguishes model loading failures from backend startup failures", () => {
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
runtimeState="models_unavailable"
messages={[]}
/>,
);
expect(screen.getByText("模型未加载")).toBeInTheDocument();
expect(screen.getByText(/模型配置加载失败/)).toBeInTheDocument();
});
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
const userMessage: Message = {
id: "user-1",
@@ -164,4 +196,70 @@ describe("AgentWorkspace", () => {
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
expect(streamingFlags.get("assistant-1")).toBe(false);
});
it("windows long conversations instead of mounting every turn", () => {
const messages = Array.from({ length: 200 }, (_, index): Message => ({
id: `message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
}));
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
expect(renderCounts.size).toBeGreaterThan(0);
expect(renderCounts.size).toBeLessThan(40);
});
it("preserves the flex alignment context around windowed turns", () => {
const messages = Array.from({ length: 41 }, (_, index): Message => ({
id: `message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
}));
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
expect(screen.getByTestId("turn-message-0").parentElement).toHaveStyle({
display: "flex",
flexDirection: "column",
});
});
it("keeps visible turns mounted when crossing the windowing threshold", () => {
const messages = Array.from({ length: 41 }, (_, index): Message => ({
id: `message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
}));
const { rerender } = render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages.slice(0, 40)}
/>,
);
rerender(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
expect(mountCounts.get("message-0")).toBe(1);
expect(unmountCounts.get("message-0") ?? 0).toBe(0);
});
});
+300 -41
View File
@@ -3,14 +3,16 @@
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
import { Box, Button, CircularProgress, Paper, Skeleton, Stack, Typography, alpha, Grid } from "@mui/material";
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
import MapRounded from "@mui/icons-material/MapRounded";
import ReplayRounded from "@mui/icons-material/ReplayRounded";
import { AgentTurn } from "./AgentTurn";
import type { PermissionReply } from "@/lib/chatStream";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
import type { PermissionDecision } from "@/lib/chatStream";
import type {
Message,
SpeechState,
@@ -19,6 +21,8 @@ import type {
type AgentWorkspaceProps = {
messages: Message[];
isStreaming: boolean;
runtimeState?: AgentRuntimeState;
onRetryRuntime?: () => void;
isLoadingSession?: boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>;
@@ -35,13 +39,15 @@ type AgentWorkspaceProps = {
onStopSpeech: () => void;
isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
type TurnListProps = {
messages: Message[];
scrollTop: number;
viewportHeight: number;
isAssistantStreaming: boolean;
streamingMessageId: string | null;
speakingMessageId: string | null;
@@ -56,13 +62,18 @@ type TurnListProps = {
onStopSpeech: () => void;
isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
const TURN_WINDOW_THRESHOLD = 40;
const TURN_ESTIMATED_HEIGHT_PX = 220;
const TURN_GAP_PX = 16;
const TURN_OVERSCAN_PX = 600;
const DEFAULT_VIEWPORT_HEIGHT_PX = 720;
const sameMessages = (left: Message[], right: Message[]) =>
left.length === right.length &&
@@ -72,6 +83,8 @@ const TurnItem = React.memo(AgentTurn);
const TurnListInner = ({
messages,
scrollTop,
viewportHeight,
isAssistantStreaming,
streamingMessageId,
speakingMessageId,
@@ -86,33 +99,179 @@ const TurnListInner = ({
onReplyQuestion,
onRejectQuestion,
}: TurnListProps) => {
const [measuredHeights, setMeasuredHeights] = React.useState(
() => new Map<string, number>(),
);
const isWindowed = messages.length > TURN_WINDOW_THRESHOLD;
React.useEffect(() => {
const activeIds = new Set(messages.map((message) => message.id));
setMeasuredHeights((current) => {
if ([...current.keys()].every((messageId) => activeIds.has(messageId))) {
return current;
}
return new Map(
[...current].filter(([messageId]) => activeIds.has(messageId)),
);
});
}, [messages]);
const updateMeasuredHeight = React.useCallback(
(messageId: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) return;
const roundedHeight = Math.ceil(height);
setMeasuredHeights((current) => {
if (current.get(messageId) === roundedHeight) return current;
const next = new Map(current);
next.set(messageId, roundedHeight);
return next;
});
},
[],
);
const turnOffsets = React.useMemo(() => {
const offsets = new Array<number>(messages.length + 1).fill(0);
if (!isWindowed) return offsets;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
const size =
(measuredHeights.get(message.id) ?? TURN_ESTIMATED_HEIGHT_PX) +
TURN_GAP_PX;
offsets[index + 1] = offsets[index] + size;
}
return offsets;
}, [isWindowed, measuredHeights, messages]);
const windowState = React.useMemo(() => {
if (!isWindowed) {
return {
endIndex: messages.length,
startIndex: 0,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
}
const visibleStart = Math.max(0, scrollTop - TURN_OVERSCAN_PX);
const visibleEnd = scrollTop + viewportHeight + TURN_OVERSCAN_PX;
const startIndex = Math.max(
0,
lowerBound(turnOffsets, visibleStart, 1) - 1,
);
const endIndex = lowerBound(turnOffsets, visibleEnd, startIndex);
const boundedEndIndex = Math.min(
messages.length,
Math.max(startIndex + 1, endIndex),
);
return {
startIndex,
endIndex: boundedEndIndex,
topSpacerHeight: turnOffsets[startIndex],
bottomSpacerHeight:
turnOffsets[messages.length] - turnOffsets[boundedEndIndex],
};
}, [isWindowed, messages.length, scrollTop, turnOffsets, viewportHeight]);
const visibleMessages = isWindowed
? messages.slice(windowState.startIndex, windowState.endIndex)
: messages;
return (
<>
{messages.map((message) => (
<TurnItem
{windowState.topSpacerHeight > 0 ? (
<Box aria-hidden sx={{ height: windowState.topSpacerHeight, flexShrink: 0 }} />
) : null}
{visibleMessages.map((message) => (
<MeasuredTurn
key={message.id}
message={message}
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
measure={isWindowed}
onHeightChange={updateMeasuredHeight}
>
<TurnItem
message={message}
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</MeasuredTurn>
))}
{windowState.bottomSpacerHeight > 0 ? (
<Box aria-hidden sx={{ height: windowState.bottomSpacerHeight, flexShrink: 0 }} />
) : null}
</>
);
};
const lowerBound = (values: number[], target: number, fromIndex: number) => {
let low = Math.max(0, fromIndex);
let high = values.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if (values[middle] < target) low = middle + 1;
else high = middle;
}
return low;
};
type MeasuredTurnProps = {
children: React.ReactNode;
measure: boolean;
message: Message;
onHeightChange: (messageId: string, height: number) => void;
};
const MeasuredTurn = ({
children,
measure,
message,
onHeightChange,
}: MeasuredTurnProps) => {
const rowRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const row = rowRef.current;
if (!measure || !row) return;
const measureRow = () =>
onHeightChange(message.id, row.getBoundingClientRect().height);
measureRow();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measureRow);
observer.observe(row);
return () => observer.disconnect();
}, [measure, message.id, onHeightChange]);
return (
<Box
ref={rowRef}
sx={{
display: "flex",
flexDirection: "column",
flexShrink: 0,
mb: measure ? `${TURN_GAP_PX}px` : 0,
}}
>
{children}
</Box>
);
};
const TurnList = React.memo(
TurnListInner,
(prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.scrollTop === nextProps.scrollTop &&
prevProps.viewportHeight === nextProps.viewportHeight &&
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
prevProps.streamingMessageId === nextProps.streamingMessageId &&
prevProps.speakingMessageId === nextProps.speakingMessageId &&
@@ -130,8 +289,34 @@ const TurnList = React.memo(
TurnList.displayName = "TurnList";
const EmptyState = () => {
const theme = useTheme();
const EmptyState = ({
runtimeState,
onRetryRuntime,
}: {
runtimeState: AgentRuntimeState;
onRetryRuntime?: () => void;
}) => {
const isReady = runtimeState === "ready";
const isChecking = runtimeState === "checking";
const statusCopy = isChecking
? {
title: "正在连接 Agent 服务",
detail: "正在确认后端运行时和模型加载状态,请稍候。",
}
: runtimeState === "models_unavailable"
? {
title: "模型未加载",
detail: "Agent 服务已启动,但模型配置加载失败。请检查模型配置后重新检测。",
}
: runtimeState === "unavailable"
? {
title: "Agent 服务未就绪",
detail: "无法连接 Agent 后端,或运行时启动失败。请检查服务后重新检测。",
}
: {
title: "我已就绪,请描述任务",
detail: "你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。",
};
const capabilities = [
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
@@ -147,6 +332,8 @@ const EmptyState = () => {
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
>
<Paper
role={isReady || isChecking ? "status" : "alert"}
aria-live="polite"
elevation={0}
sx={{
p: 4,
@@ -170,9 +357,9 @@ const EmptyState = () => {
}} />
<motion.div
animate={{
y: [-6, 4, -6],
scale: [1, 1.04, 1],
rotate: [-3, 3, -3],
y: isReady ? [-6, 4, -6] : 0,
scale: isReady ? [1, 1.04, 1] : 1,
rotate: isReady ? [-3, 3, -3] : 0,
}}
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
style={{
@@ -194,22 +381,37 @@ const EmptyState = () => {
height={54}
style={{
objectFit: "contain",
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
filter: isReady
? "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))"
: "grayscale(0.65) opacity(0.72)",
}}
/>
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
我已就绪,请描述任务
{statusCopy.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: isReady ? 3 : 2 }}>
{statusCopy.detail}
</Typography>
<Grid container spacing={1.5}>
{capabilities.map((item) => (
<Grid item xs={6} key={item.label}>
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
<Stack
{isChecking ? (
<CircularProgress size={28} thickness={4} aria-label="正在检测 Agent 服务" />
) : !isReady ? (
<Button
variant="contained"
size="small"
startIcon={<ReplayRounded />}
onClick={onRetryRuntime}
sx={{ borderRadius: 999, px: 2, boxShadow: "none" }}
>
重新检测
</Button>
) : (
<Grid container spacing={1.5}>
{capabilities.map((item) => (
<Grid item xs={6} key={item.label}>
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
@@ -234,11 +436,12 @@ const EmptyState = () => {
<Typography variant="caption" fontWeight={700}>
{item.label}
</Typography>
</Stack>
</motion.div>
</Grid>
))}
</Grid>
</Stack>
</motion.div>
</Grid>
))}
</Grid>
)}
</Paper>
</motion.div>
);
@@ -309,6 +512,8 @@ const SessionLoadingSkeleton = () => (
export const AgentWorkspace = ({
messages,
isStreaming,
runtimeState = "ready",
onRetryRuntime,
isLoadingSession = false,
scrollContainerRef,
bottomRef,
@@ -325,14 +530,23 @@ export const AgentWorkspace = ({
onReplyQuestion,
onRejectQuestion,
}: AgentWorkspaceProps) => {
const localScrollContainerRef = React.useRef<HTMLDivElement>(null);
const [scrollMetrics, setScrollMetrics] = React.useState({
scrollTop: 0,
viewportHeight: DEFAULT_VIEWPORT_HEIGHT_PX,
});
const streamingMessageId =
isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1)?.id ?? null
: null;
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!onScrollStateChange) return;
const target = event.currentTarget;
setScrollMetrics({
scrollTop: target.scrollTop,
viewportHeight: target.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX,
});
if (!onScrollStateChange) return;
const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
onScrollStateChange(
@@ -343,9 +557,37 @@ export const AgentWorkspace = ({
[isStreaming, onScrollStateChange],
);
const setScrollContainer = React.useCallback(
(node: HTMLDivElement | null) => {
localScrollContainerRef.current = node;
if (scrollContainerRef) {
scrollContainerRef.current = node;
}
},
[scrollContainerRef],
);
React.useEffect(() => {
const container = localScrollContainerRef.current;
if (!container || typeof ResizeObserver === "undefined") return;
const updateViewportHeight = () => {
const viewportHeight =
container.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX;
setScrollMetrics((current) =>
current.viewportHeight === viewportHeight
? current
: { ...current, viewportHeight },
);
};
updateViewportHeight();
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(container);
return () => observer.disconnect();
}, []);
return (
<Box
ref={scrollContainerRef}
ref={setScrollContainer}
onScroll={handleScroll}
sx={{
flex: 1,
@@ -363,13 +605,30 @@ export const AgentWorkspace = ({
) : (
<>
<AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null}
{messages.length === 0 ? (
<EmptyState
runtimeState={runtimeState}
onRetryRuntime={onRetryRuntime}
/>
) : null}
</AnimatePresence>
{messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: messages.length > TURN_WINDOW_THRESHOLD ? 0 : 2,
}}
>
<TurnList
messages={messages}
scrollTop={
messages.length > TURN_WINDOW_THRESHOLD
? scrollMetrics.scrollTop
: 0
}
viewportHeight={scrollMetrics.viewportHeight}
isAssistantStreaming={isStreaming}
streamingMessageId={streamingMessageId}
speakingMessageId={speakingMessageId}
+14 -23
View File
@@ -30,6 +30,7 @@ import {
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "./toolCallStyleHelpers";
import { buildViewHistoryAction } from "./historyToolAction";
/* ------------------------------------------------------------------ */
/* Interactive card rendered inside a chat bubble for tool actions */
@@ -45,12 +46,12 @@ type ToolMeta = {
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",
locate_junctions: "junctions",
locate_pipes: "pipes",
locate_valves: "valves",
locate_reservoirs: "reservoirs",
locate_pumps: "pumps",
locate_tanks: "tanks",
};
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
@@ -428,17 +429,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
};
}
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,
};
return buildViewHistoryAction(params);
}
case "view_scada": {
const scadaRange = resolveTimeRange();
@@ -672,22 +663,22 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
switch (featureType) {
case "junction":
case "junctions":
return { layer: "geo_junctions_mat", geometryKind: "point" };
return { layer: "junctions", geometryKind: "point" };
case "pipe":
case "pipes":
return { layer: "geo_pipes_mat", geometryKind: "line" };
return { layer: "pipes", geometryKind: "line" };
case "valve":
case "valves":
return { layer: "geo_valves", geometryKind: "point" };
return { layer: "valves", geometryKind: "point" };
case "reservoir":
case "reservoirs":
return { layer: "geo_reservoirs", geometryKind: "point" };
return { layer: "reservoirs", geometryKind: "point" };
case "pump":
case "pumps":
return { layer: "geo_pumps", geometryKind: "point" };
return { layer: "pumps", geometryKind: "point" };
case "tank":
case "tanks":
return { layer: "geo_tanks", geometryKind: "point" };
return { layer: "tanks", geometryKind: "point" };
default:
return null;
}
+74 -4
View File
@@ -5,6 +5,8 @@ import { act, render, screen } from "@testing-library/react";
import { GlobalChatbox } from "./GlobalChatbox";
const createSession = jest.fn();
const mockFetchAgentRuntimeHealth = jest.fn();
const mockFetchAgentModels = jest.fn();
let mockCurrentProjectId = "project-1";
jest.mock("@refinedev/core", () => ({
@@ -12,7 +14,12 @@ jest.mock("@refinedev/core", () => ({
}));
jest.mock("@/lib/chatModels", () => ({
fetchAgentModels: jest.fn(() => new Promise(() => {})),
fetchAgentModels: (...args: unknown[]) => mockFetchAgentModels(...args),
}));
jest.mock("@/lib/agentRuntime", () => ({
fetchAgentRuntimeHealth: (...args: unknown[]) =>
mockFetchAgentRuntimeHealth(...args),
}));
jest.mock("@/store/projectStore", () => ({
@@ -73,12 +80,14 @@ jest.mock("./AgentHistoryPanel", () => ({
}));
jest.mock("./AgentWorkspace", () => ({
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
AgentWorkspace: ({ runtimeState }: { runtimeState: string }) => (
<div data-testid="agent-workspace">Workspace state: {runtimeState}</div>
),
}));
jest.mock("./AgentComposer", () => ({
AgentComposer: React.forwardRef(function MockAgentComposer() {
return <div>Composer</div>;
AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) {
return <div>Composer mode: {props.approvalMode}</div>;
}),
}));
@@ -90,12 +99,17 @@ describe("GlobalChatbox lifecycle", () => {
beforeEach(() => {
jest.useFakeTimers();
createSession.mockClear();
mockFetchAgentRuntimeHealth.mockReset();
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
mockFetchAgentModels.mockReset();
mockFetchAgentModels.mockImplementation(() => new Promise(() => {}));
mockCurrentProjectId = "project-1";
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it("keeps content mounted and preserves the session across close and reopen", async () => {
@@ -121,4 +135,60 @@ describe("GlobalChatbox lifecycle", () => {
expect(createSession).toHaveBeenCalledTimes(2);
});
it("defaults the composer to automatic permission approval", () => {
render(<GlobalChatbox open onClose={jest.fn()} />);
expect(screen.getByText("Composer mode: auto")).toBeInTheDocument();
});
it("passes backend startup failures into the existing workspace empty state", async () => {
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(false);
render(<GlobalChatbox open onClose={jest.fn()} />);
await act(async () => Promise.resolve());
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
});
it("reserves models unavailable for an empty model configuration", async () => {
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(true);
mockFetchAgentModels.mockResolvedValueOnce({ models: [] });
render(<GlobalChatbox open onClose={jest.fn()} />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Workspace state: models_unavailable")).toBeInTheDocument();
});
it("reports an auth dependency failure as unavailable and retries quickly", async () => {
jest.spyOn(console, "error").mockImplementation(() => undefined);
mockFetchAgentRuntimeHealth.mockResolvedValue(true);
mockFetchAgentModels
.mockRejectedValueOnce(new Error("authentication service unavailable"))
.mockResolvedValueOnce({
defaultModel: "provider/model",
models: [{ id: "provider/model", label: "Model" }],
});
render(<GlobalChatbox open onClose={jest.fn()} />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
expect(mockFetchAgentModels).toHaveBeenCalledTimes(1);
await act(async () => {
jest.advanceTimersByTime(5_000);
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText("Workspace state: ready")).toBeInTheDocument();
expect(mockFetchAgentModels).toHaveBeenCalledTimes(2);
});
});
+97 -26
View File
@@ -10,6 +10,10 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import { useNotification } from "@refinedev/core";
import { getAccessToken } from "@/lib/authToken";
import {
fetchAgentRuntimeHealth,
type AgentRuntimeState,
} from "@/lib/agentRuntime";
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore";
@@ -26,6 +30,9 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions";
const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
const AGENT_RUNTIME_POLL_MS = 30_000;
const AGENT_RUNTIME_RETRY_MS = 5_000;
const AGENT_RUNTIME_TIMEOUT_MS = 8_000;
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520);
@@ -34,8 +41,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
const [runtimeState, setRuntimeState] = useState<AgentRuntimeState>("checking");
const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request");
useState<AgentApprovalMode>("auto");
const bottomRef = useRef<HTMLDivElement>(null);
const workspaceScrollRef = useRef<HTMLDivElement>(null);
@@ -43,6 +51,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null);
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
const runtimeRequestIdRef = useRef(0);
const runtimeAbortRef = useRef<AbortController | null>(null);
const theme = useTheme();
const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -68,35 +78,87 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isSupported: isSttSupported,
} = useSpeechRecognition(handleSpeechResult);
useEffect(() => {
let cancelled = false;
const refreshAgentRuntime = useCallback(async (showChecking = true) => {
const requestId = ++runtimeRequestIdRef.current;
runtimeAbortRef.current?.abort();
const controller = new AbortController();
runtimeAbortRef.current = controller;
const timeoutId = window.setTimeout(
() => controller.abort(),
AGENT_RUNTIME_TIMEOUT_MS,
);
let runtimeHealthy = false;
const loadModels = async () => {
try {
const modelConfig = await fetchAgentModels();
if (cancelled) return;
setModelOptions(modelConfig.models);
setSelectedModel((current) => {
if (current && modelConfig.models.some((model) => model.id === current)) {
return current;
}
return modelConfig.defaultModel;
});
} catch (error) {
console.error("[GlobalChatbox] Failed to load agent models:", error);
if (!cancelled) {
setModelOptions([]);
setSelectedModel(undefined);
}
if (showChecking) setRuntimeState("checking");
try {
runtimeHealthy = await fetchAgentRuntimeHealth(controller.signal);
if (requestId !== runtimeRequestIdRef.current) return;
if (!runtimeHealthy) {
setRuntimeState("unavailable");
setModelOptions([]);
setSelectedModel(undefined);
return "unavailable" as const;
}
const modelConfig = await fetchAgentModels(controller.signal);
if (requestId !== runtimeRequestIdRef.current) return;
if (modelConfig.models.length === 0) {
setRuntimeState("models_unavailable");
setModelOptions([]);
setSelectedModel(undefined);
return "models_unavailable" as const;
}
setModelOptions(modelConfig.models);
setSelectedModel((current) => {
if (current && modelConfig.models.some((model) => model.id === current)) {
return current;
}
return modelConfig.defaultModel;
});
setRuntimeState("ready");
return "ready" as const;
} catch (error) {
if (requestId !== runtimeRequestIdRef.current) return;
console.error("[GlobalChatbox] Failed to check agent runtime:", error);
setRuntimeState("unavailable");
setModelOptions([]);
setSelectedModel(undefined);
return "unavailable" as const;
} finally {
window.clearTimeout(timeoutId);
if (runtimeAbortRef.current === controller) {
runtimeAbortRef.current = null;
}
}
}, []);
useEffect(() => {
if (!open) return;
let cancelled = false;
let pollTimerId: number | undefined;
const pollRuntime = async (showChecking: boolean) => {
const nextState = await refreshAgentRuntime(showChecking);
if (cancelled || !nextState) return;
pollTimerId = window.setTimeout(
() => void pollRuntime(false),
nextState === "ready" ? AGENT_RUNTIME_POLL_MS : AGENT_RUNTIME_RETRY_MS,
);
};
void loadModels();
void pollRuntime(true);
return () => {
cancelled = true;
if (pollTimerId !== undefined) window.clearTimeout(pollTimerId);
runtimeRequestIdRef.current += 1;
runtimeAbortRef.current?.abort();
runtimeAbortRef.current = null;
};
}, []);
}, [open, refreshAgentRuntime]);
const handleToolCall = useAgentToolActions();
const {
@@ -167,18 +229,23 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
useEffect(() => {
if (isStreaming) {
const latestAssistant = [...messages]
.reverse()
.find((message) => message.role === "assistant");
if (latestAssistant?.content.trim()) {
cancelStreamingScroll();
return;
}
if (!isNearBottomRef.current) return;
scheduleStreamingScrollToBottom();
return;
}
cancelStreamingScroll();
scrollToBottom("smooth");
}, [
cancelStreamingScroll,
isStreaming,
messages,
scheduleStreamingScrollToBottom,
scrollToBottom,
]);
useEffect(
@@ -203,7 +270,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return;
if (isStreaming || isCheckingAuth || runtimeState !== "ready") return;
setIsCheckingAuth(true);
try {
@@ -229,7 +296,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
} finally {
setIsCheckingAuth(false);
}
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]);
}, [isCheckingAuth, isStreaming, openNotification, runtimeState, sendPrompt]);
const handleNewConversation = useCallback(() => {
handleStopSpeech();
@@ -373,6 +440,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
canRenameSessionTitle={Boolean(activeSessionId)}
isHydrating={isHydrating}
isStreaming={isStreaming}
runtimeState={runtimeState}
isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle}
onRenameSessionTitle={handleRenameActiveSession}
@@ -430,6 +498,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentWorkspace
messages={messages}
isStreaming={isStreaming}
runtimeState={runtimeState}
onRetryRuntime={() => void refreshAgentRuntime(true)}
isLoadingSession={Boolean(loadingSessionId)}
scrollContainerRef={workspaceScrollRef}
bottomRef={bottomRef}
@@ -450,6 +520,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentComposer
ref={composerRef}
isHydrating={isHydrating || isCheckingAuth}
runtimeState={runtimeState}
isStreaming={isStreaming}
isListening={isListening}
isSttSupported={isSttSupported}
@@ -1,4 +1,5 @@
import type {
AgentActivity,
AgentQuestionRequest,
AgentTodoUpdate,
} from "@/lib/chatStream";
@@ -42,6 +43,8 @@ export type AgentPermissionRequest = {
permission: string;
patterns: string[];
target?: string;
activityId?: string;
reason?: string;
always: string[];
tool?: {
messageID: string;
@@ -59,6 +62,7 @@ export type Message = {
content: string;
isError?: boolean;
progress?: ChatProgress[];
activities?: AgentActivity[];
artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
+2 -2
View File
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
sessionId: "session-1",
messages: [{ id: "message-1", role: "user", content: "查压力" }],
});
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
});
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
isTitleManuallyEdited: true,
});
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
title: "新标题",
+4 -4
View File
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
method: "GET",
projectHeaderMode: "include",
skipAuthRedirect: true,
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: "GET",
projectHeaderMode: "include",
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
isTitleManuallyEdited?: boolean,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
headers: {
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
projectHeaderMode: "include",
@@ -0,0 +1,36 @@
import { buildViewHistoryAction } from "./historyToolAction";
describe("buildViewHistoryAction", () => {
it("preserves the scheme run ID from Agent tool parameters", () => {
expect(
buildViewHistoryAction({
feature_infos: [["P-1", "pipe"]],
data_type: "scheme",
run_id: " 99dd4142-368b-54cb-bfca-d59ee48f6298 ",
}),
).toEqual(
expect.objectContaining({
featureInfos: [["P-1", "pipe"]],
dataType: "scheme",
runId: "99dd4142-368b-54cb-bfca-d59ee48f6298",
}),
);
});
it("normalizes invalid history parameters", () => {
expect(
buildViewHistoryAction({
feature_infos: [["", "pipe"], "invalid"],
data_type: "unexpected",
runId: " ",
}),
).toEqual({
type: "view_history",
featureInfos: [],
dataType: "realtime",
runId: undefined,
startTime: undefined,
endTime: undefined,
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { ChatToolAction } from "@/store/chatToolStore";
type ViewHistoryAction = Extract<ChatToolAction, { type: "view_history" }>;
const readOptionalString = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : undefined;
export const buildViewHistoryAction = (
params: Record<string, unknown>,
): ViewHistoryAction => {
const rawFeatureInfos = Array.isArray(params.feature_infos)
? params.feature_infos
: [];
const featureInfos = rawFeatureInfos
.filter(
(item): item is [unknown, unknown] =>
Array.isArray(item) && item.length >= 2,
)
.map(
([id, type]) => [String(id).trim(), String(type).trim()] as [string, string],
)
.filter(([id, type]) => id.length > 0 && type.length > 0);
const rawDataType = params.data_type;
const dataType =
rawDataType === "realtime" ||
rawDataType === "scheme" ||
rawDataType === "none"
? rawDataType
: "realtime";
return {
type: "view_history",
featureInfos,
dataType,
runId: readOptionalString(params.run_id ?? params.runId),
startTime: readOptionalString(
params.start_time ?? params.startTime ?? params.from ?? params.start,
),
endTime: readOptionalString(
params.end_time ?? params.endTime ?? params.to ?? params.end,
),
};
};
@@ -1,4 +1,5 @@
import type {
AgentActivity,
AgentQuestionRequest,
AgentTodoUpdate,
PermissionReply,
@@ -79,6 +80,48 @@ export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
};
});
export const upsertActivity = (
activities: AgentActivity[] | undefined,
event: StreamEvent & { type: "activity_update" },
) => {
const next = [...(activities ?? [])];
const index = next.findIndex((activity) => activity.id === event.activity.id);
if (index >= 0) next[index] = event.activity;
else next.push(event.activity);
return next;
};
export const completeRunningActivities = (
activities: AgentActivity[] | undefined,
status: "completed" | "error" | "cancelled" = "completed",
) => activities?.map((activity) => {
if (activity.status !== "running") return activity;
const endedAt = Date.now();
return {
...activity,
status,
actions: activity.actions.map((action) =>
action.status === "running"
? {
...action,
status: status === "error" ? "error" as const : "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - action.startedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
}
: action,
),
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - activity.startedAt),
};
});
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todoUpdate
? {
@@ -107,6 +150,8 @@ export const upsertPermission = (
permission: event.permission,
patterns: event.patterns,
target: event.target,
activityId: event.activityId,
reason: event.reason,
always: event.always,
tool: event.tool,
createdAt: event.createdAt,
@@ -405,6 +450,7 @@ export const rejectOpenQuestionsAfterAbort = (
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress);
const cancelledActivities = completeRunningActivities(message.activities, "cancelled");
const cancelledTodos = cancelRunningTodos(message.todos);
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
@@ -414,6 +460,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
Boolean(abortedPermissions?.length) ||
Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) ||
Boolean(cancelledActivities?.length) ||
Boolean(cancelledTodos);
if (!hasVisibleOutput) {
@@ -425,6 +472,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
content: message.content || "⚠️ **请求已中断**",
isError: true,
progress: completedProgress,
activities: cancelledActivities,
permissions: abortedPermissions,
questions: rejectedQuestions,
todos: cancelledTodos,
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
import {
abortAgentChat,
forkAgentChat,
replyAgentCredentialRefresh,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentCredentialRefresh: jest.fn(async () => undefined),
replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
const mockUpdateSession = jest.fn();
jest.mock("next-auth/react", () => ({
useSession: () => ({ update: mockUpdateSession }),
}));
const listChatSessions = jest.fn();
const deleteChatSession = jest.fn();
const updateChatSessionTitle = jest.fn();
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentCredentialRefresh).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
@@ -122,6 +132,80 @@ describe("useAgentChatSession actions", () => {
);
});
it("applies an activity phase and todo snapshot atomically before revealing the final answer", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "activity_update",
sessionId: "session-1",
activity: {
id: "activity-analyze",
title: "分析管网数据",
reason: "需要识别影响供水能力的关键管段。",
status: "running",
actions: [],
startedAt: 1000,
},
todos: [
{
id: "todo-data",
content: "准备管网数据",
status: "completed",
priority: "high",
},
{
id: "todo-analysis",
content: "识别瓶颈管段",
status: "in_progress",
priority: "high",
},
],
todosCreatedAt: 1001,
});
onEvent({
type: "final_answer",
sessionId: "session-1",
content: "已识别关键瓶颈管段。",
});
onEvent({ type: "done", sessionId: "session-1" });
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.sendPrompt("分析管网瓶颈");
});
const assistantMessage = result.current.messages.at(-1);
expect(assistantMessage).toMatchObject({
role: "assistant",
content: "已识别关键瓶颈管段。",
todos: {
sessionId: "session-1",
createdAt: 1001,
todos: [
expect.objectContaining({ id: "todo-data", status: "completed" }),
expect.objectContaining({ id: "todo-analysis", status: "completed" }),
],
},
});
expect(
assistantMessage?.activities?.find(
(activity) => activity.id === "activity-analyze",
),
).toMatchObject({
title: "分析管网数据",
status: "completed",
});
});
it("finalizes running progress when aborting an active prompt", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
import {
abortAgentChat,
forkAgentChat,
replyAgentCredentialRefresh,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentCredentialRefresh: jest.fn(async () => undefined),
replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
const mockUpdateSession = jest.fn();
jest.mock("next-auth/react", () => ({
useSession: () => ({ update: mockUpdateSession }),
}));
const listChatSessions = jest.fn();
const deleteChatSession = jest.fn();
const updateChatSessionTitle = jest.fn();
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentCredentialRefresh).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
@@ -330,7 +340,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
}),
expect.objectContaining({
id: "todo-2",
status: "in_progress",
status: "completed",
}),
],
}),
+229 -168
View File
@@ -1,77 +1,70 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
import {
abortAgentChat,
forkAgentChat,
rejectAgentQuestion,
replyAgentCredentialRefresh,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { PermissionDecision, StreamEvent } from "@/lib/chatStream";
import { useAuthStore } from "@/store/authStore";
import type {
AgentArtifact,
ChatSessionSummary,
Message,
} from "../GlobalChatbox.types";
import { cloneMessages } from "../globalChatboxUtils";
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
import {
createEmptyChatState,
deleteChatSession,
listChatSessions,
loadChatSessionById,
updateChatSessionTitle,
} from "../chatStorage";
import {
applyQuestionResponse,
cancelRunningTodos,
completeRunningActivities,
completeRunningProgress,
createAssistantMessage,
createTodoUpdateFromEvent,
createUserMessage,
dedupeQuestionsAcrossMessages,
finalizeAssistantMessageAfterAbort,
normalizeSessionTodos,
toPermissionStatus,
upsertPermission,
upsertActivity,
upsertProgress,
upsertQuestionAcrossMessages,
} from "./agentChatSessionState";
import type {
PromptRunOptions,
UseAgentChatSessionOptions,
} from "./useAgentChatSession.types";
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
const TOKEN_PLAYBACK_BASE_CHARS = 28;
const TOKEN_PLAYBACK_MAX_CHARS = 160;
const sliceCodePoints = (value: string, count: number) =>
Array.from(value).slice(0, count).join("");
let cachedSegmenter: Intl.Segmenter | null | undefined;
const getSegmenter = () => {
if (cachedSegmenter !== undefined) return cachedSegmenter;
cachedSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter("zh", { granularity: "word" })
: null;
return cachedSegmenter;
};
const getPlaybackChunkSize = (bufferLength: number) => {
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
if (bufferLength >= 300) return 112;
if (bufferLength >= 140) return 72;
if (bufferLength >= 64) return 44;
return TOKEN_PLAYBACK_BASE_CHARS;
};
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
if (content.length <= maxChars) return content;
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
const segmenter = getSegmenter();
if (segmenter) {
let chunk = "";
for (const segment of segmenter.segment(content)) {
chunk += segment.segment;
if (
chunk.length >= maxChars ||
(chunk.length >= targetChars &&
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
) {
return chunk;
const completeTodos = (todoUpdate: Message["todos"]) =>
todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "completed" as const,
updatedAt: Date.now(),
}
: todo,
),
}
}
}
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
if (phrase) return phrase;
const cjkChunk = content.match(
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
)?.[0];
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
const wordChunk = content.match(/^\S+\s*/u)?.[0];
if (wordChunk) {
return wordChunk.length <= maxChars
? wordChunk
: sliceCodePoints(wordChunk, maxChars);
}
return sliceCodePoints(content, Math.min(maxChars, 12));
};
: undefined;
export const useAgentChatSession = ({
projectId,
@@ -80,6 +73,7 @@ export const useAgentChatSession = ({
getModel,
getApprovalMode,
}: UseAgentChatSessionOptions) => {
const { update: updateSession } = useSession();
const hydrationNonceRef = useRef(0);
const [messages, setMessages] = useState<Message[]>([]);
@@ -97,11 +91,7 @@ export const useAgentChatSession = ({
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0);
const pendingTokenRef = useRef<{
assistantMessageId: string;
content: string;
} | null>(null);
const tokenPlaybackIntervalRef = useRef<number | null>(null);
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
useEffect(() => {
sessionIdRef.current = sessionId;
@@ -128,83 +118,6 @@ export const useAgentChatSession = ({
});
}, []);
const cancelTokenPlayback = useCallback(() => {
const intervalId = tokenPlaybackIntervalRef.current;
if (intervalId === null) return;
window.clearInterval(intervalId);
tokenPlaybackIntervalRef.current = null;
}, []);
const flushPendingTokens = useCallback(() => {
const pending = pendingTokenRef.current;
pendingTokenRef.current = null;
cancelTokenPlayback();
if (!pending) return;
applyTokenContent(pending.assistantMessageId, pending.content);
}, [applyTokenContent, cancelTokenPlayback]);
const scheduleTokenPlayback = useCallback(() => {
if (tokenPlaybackIntervalRef.current !== null) return;
const id = window.setInterval(() => {
const pending = pendingTokenRef.current;
if (!pending) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
return;
}
const chunk = takeNextTokenPlaybackChunk(
pending.content,
getPlaybackChunkSize(pending.content.length),
);
if (!chunk) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
pendingTokenRef.current = null;
return;
}
const remaining = pending.content.slice(chunk.length);
pendingTokenRef.current = remaining
? { assistantMessageId: pending.assistantMessageId, content: remaining }
: null;
applyTokenContent(pending.assistantMessageId, chunk);
if (!remaining) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
}
}, TOKEN_PLAYBACK_INTERVAL_MS);
tokenPlaybackIntervalRef.current = id;
}, [applyTokenContent]);
const queueTokenContent = useCallback(
(assistantMessageId: string, content: string) => {
const pending = pendingTokenRef.current;
if (pending && pending.assistantMessageId !== assistantMessageId) {
flushPendingTokens();
}
pendingTokenRef.current = {
assistantMessageId,
content:
pending?.assistantMessageId === assistantMessageId
? pending.content + content
: content,
};
scheduleTokenPlayback();
},
[flushPendingTokens, scheduleTokenPlayback],
);
useEffect(
() => () => {
pendingTokenRef.current = null;
cancelTokenPlayback();
},
[cancelTokenPlayback],
);
useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
}, [isSessionTitleManuallyEdited]);
@@ -289,6 +202,61 @@ export const useAgentChatSession = ({
return assistant?.id ?? fallback;
}, []);
const handleCredentialRefresh = useCallback(
async (event: StreamEvent & { type: "credential_refresh_required" }) => {
if (
!event.sessionId ||
!event.requestId ||
credentialRefreshRequestIdsRef.current.has(event.requestId)
) {
return;
}
credentialRefreshRequestIdsRef.current.add(event.requestId);
try {
const refreshedSession = await updateSession({ forceRefresh: true });
if (
refreshedSession?.error ||
typeof refreshedSession?.accessToken !== "string" ||
!refreshedSession.accessToken
) {
throw new Error("登录凭据续期失败");
}
const authStore = useAuthStore.getState();
authStore.setAccessToken(refreshedSession.accessToken);
authStore.clearSessionExpired();
await replyAgentCredentialRefresh(event.sessionId, event.requestId);
} catch (error) {
useAuthStore.getState().markSessionExpired("refresh_failed");
const assistantMessageId = getLastAssistantMessageId();
if (assistantMessageId) {
const message = error instanceof Error ? error.message : String(error);
setMessages((prev) =>
prev.map((item) =>
item.id === assistantMessageId
? {
...item,
content: item.content || `⚠️ **${message}**`,
isError: true,
progress: upsertProgress(item.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "error",
title: "登录凭据续期失败",
detail: message,
}),
}
: item,
),
);
}
setIsStreaming(false);
}
},
[getLastAssistantMessageId, updateSession],
);
const applyStreamEvent = useCallback(
(
event: StreamEvent,
@@ -296,10 +264,6 @@ export const useAgentChatSession = ({
assistantMessageId?: string;
},
) => {
if (event.type !== "token") {
flushPendingTokens();
}
if (
event.type !== "session_title" &&
"sessionId" in event &&
@@ -352,7 +316,36 @@ export const useAgentChatSession = ({
}
if (event.type === "token") {
queueTokenContent(assistantMessageId, event.content);
applyTokenContent(assistantMessageId, event.content);
} else if (event.type === "final_answer") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, content: event.content, isError: false }
: message,
);
messagesRef.current = next;
return next;
});
} else if (event.type === "activity_update") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, activities: upsertActivity(message.activities, event) }
: message,
);
return event.todos
? normalizeSessionTodos(
next,
{
sessionId: event.sessionId,
todos: event.todos,
createdAt: event.todosCreatedAt ?? Date.now(),
},
assistantMessageId,
)
: next;
});
} else if (event.type === "progress") {
setMessages((prev) =>
prev.map((message) =>
@@ -421,11 +414,74 @@ export const useAgentChatSession = ({
assistantMessageId,
),
);
} else if (event.type === "credential_refresh_required") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "running",
title: "正在续期登录凭据",
detail: `当前工具调用保持等待,最长 ${Math.ceil((event.timeoutMs ?? 30_000) / 1000)} 秒`,
startedAt: Date.now(),
}),
}
: message,
),
);
void handleCredentialRefresh(event);
} else if (event.type === "credential_refreshed") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "completed",
title: "登录凭据已续期",
}),
}
: message,
),
);
} else if (event.type === "credential_refresh_failed") {
useAuthStore.getState().markSessionExpired("refresh_failed");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content || `⚠️ **${event.message}**`,
isError: true,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "error",
title: "登录凭据续期失败",
detail: event.message,
}),
}
: message,
),
);
setIsStreaming(false);
} else if (event.type === "done") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantMessageId) return message;
const completedProgress = completeRunningProgress(message.progress);
const completedActivities = completeRunningActivities(message.activities);
if (
message.content.trim().length === 0 &&
!(message.artifacts?.length)
@@ -435,9 +491,16 @@ export const useAgentChatSession = ({
content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}
return { ...message, progress: completedProgress };
return {
...message,
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}),
);
setIsStreaming(false);
@@ -450,6 +513,7 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos),
}
: message,
@@ -457,6 +521,7 @@ export const useAgentChatSession = ({
);
setIsStreaming(false);
} else if (event.type === "auth_required") {
useAuthStore.getState().markSessionExpired("unauthorized");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
@@ -465,6 +530,7 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **${event.message}**`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos),
}
: message,
@@ -474,11 +540,11 @@ export const useAgentChatSession = ({
}
},
[
applyTokenContent,
appendArtifact,
flushPendingTokens,
getLastAssistantMessageId,
handleCredentialRefresh,
onToolCall,
queueTokenContent,
],
);
@@ -495,20 +561,18 @@ export const useAgentChatSession = ({
onEvent: (event) => applyStreamEvent(event),
})
.catch((error) => {
flushPendingTokens();
if (!controller.signal.aborted) {
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
setIsStreaming(false);
}
})
.finally(() => {
flushPendingTokens();
if (abortRef.current === controller) {
abortRef.current = null;
}
});
},
[applyStreamEvent, flushPendingTokens],
[applyStreamEvent],
);
resumeStreamingSessionRef.current = resumeStreamingSession;
@@ -557,7 +621,6 @@ export const useAgentChatSession = ({
}),
});
} catch (error) {
flushPendingTokens();
if (controller.signal.aborted) {
setMessages((prev) =>
prev
@@ -574,6 +637,7 @@ export const useAgentChatSession = ({
message.content.trim().length === 0 &&
!(message.artifacts?.length) &&
!(message.progress?.length) &&
!(message.activities?.length) &&
!message.todos
),
),
@@ -588,20 +652,19 @@ export const useAgentChatSession = ({
content: `⚠️ **错误:** ${String(error)}`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
}
: message,
),
);
setIsStreaming(false);
} finally {
flushPendingTokens();
abortRef.current = null;
setIsStreaming(false);
}
},
[
applyStreamEvent,
flushPendingTokens,
getApprovalMode,
getModel,
isHydrating,
@@ -614,7 +677,6 @@ export const useAgentChatSession = ({
const abort = useCallback(() => {
const controller = abortRef.current;
controller?.abort();
flushPendingTokens();
setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId();
@@ -637,10 +699,10 @@ export const useAgentChatSession = ({
}
});
cancelPromiseRef.current = trackedCancelPromise;
}, [flushPendingTokens, getLastAssistantMessageId]);
}, [getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => {
async (requestId: string, reply: PermissionDecision) => {
const target = messagesRef.current
.flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId);
@@ -850,7 +912,6 @@ export const useAgentChatSession = ({
const createSession = useCallback(() => {
if (isHydrating || isStreaming) return;
flushPendingTokens();
const controller = abortRef.current;
controller?.abort();
hydrationNonceRef.current += 1;
@@ -861,7 +922,7 @@ export const useAgentChatSession = ({
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setIsStreaming(false);
}, [flushPendingTokens, isHydrating, isStreaming]);
}, [isHydrating, isStreaming]);
const switchSession = useCallback(
async (nextSessionId: string, optimisticTitle?: string) => {
@@ -10,6 +10,7 @@ import {
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "../toolCallStyleHelpers";
import { buildViewHistoryAction } from "../historyToolAction";
type ToolCallEvent = StreamEvent & { type: "tool_call" };
@@ -22,30 +23,30 @@ const FEATURE_TYPE_MAP: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
junction: { layer: "junctions", geometryKind: "point", label: "节点" },
junctions: { layer: "junctions", geometryKind: "point", label: "节点" },
pipe: { layer: "pipes", geometryKind: "line", label: "管道" },
pipes: { layer: "pipes", geometryKind: "line", label: "管道" },
valve: { layer: "valves", geometryKind: "point", label: "阀门" },
valves: { layer: "valves", geometryKind: "point", label: "阀门" },
reservoir: { layer: "reservoirs", geometryKind: "point", label: "水源" },
reservoirs: { layer: "reservoirs", geometryKind: "point", label: "水源" },
pump: { layer: "pumps", geometryKind: "point", label: "泵站" },
pumps: { layer: "pumps", geometryKind: "point", label: "泵站" },
tank: { layer: "tanks", geometryKind: "point", label: "水池" },
tanks: { layer: "tanks", geometryKind: "point", label: "水池" },
};
const LOCATE_TOOL_CONFIG: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
locate_pipes: { layer: "pipes", geometryKind: "line", label: "管道" },
locate_junctions: { layer: "junctions", geometryKind: "point", label: "节点" },
locate_valves: { layer: "valves", geometryKind: "point", label: "阀门" },
locate_reservoirs: { layer: "reservoirs", geometryKind: "point", label: "水源" },
locate_pumps: { layer: "pumps", geometryKind: "point", label: "泵站" },
locate_tanks: { layer: "tanks", geometryKind: "point", label: "水池" },
};
const LOCATE_ID_PARAM_KEYS = [
@@ -253,21 +254,12 @@ const buildToolAction = (
}
if (tool === "view_history") {
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
const { startTime, endTime } = resolveTimeRange(params);
const action = buildViewHistoryAction(params);
return {
action: {
type: "view_history",
featureInfos,
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime,
endTime,
},
action,
kind: "panel",
title: "打开计算结果曲线",
description: compactNames(featureInfos.map(([id]) => id)),
description: compactNames(action.featureInfos.map(([id]) => id)),
};
}
@@ -0,0 +1,524 @@
import { Box, Skeleton } from "@mui/material";
import type { MapSkeletonVariant } from "./mapComponentSkeletonConfig";
const skeletonSx = {
transform: "none",
bgcolor: "rgba(37, 125, 212, 0.10)",
"&::after": {
background:
"linear-gradient(90deg, transparent, rgba(37, 125, 212, 0.12), transparent)",
},
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
"&::after": {
animation: "none",
},
},
} as const;
const Line = ({ width = "44%", height = 18 }: { width?: string; height?: number }) => (
<Skeleton
variant="text"
animation="wave"
width={width}
height={height}
sx={skeletonSx}
/>
);
const Field = () => (
<Box sx={{ width: "100%" }}>
<Line width="34%" height={18} />
<Skeleton
variant="rounded"
animation="wave"
height={40}
sx={{ ...skeletonSx, mt: 0.5, borderRadius: 1 }}
/>
</Box>
);
const TwoColumns = ({ children }: { children: React.ReactNode }) => (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 1.5,
}}
>
{children}
</Box>
);
const Action = ({
width = "100%",
height = 40,
testId,
}: {
width?: string;
height?: number;
testId?: string;
}) => (
<Skeleton
data-testid={testId}
variant="rounded"
animation="wave"
width={width}
height={height}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
);
const Notice = ({ testId }: { testId?: string }) => (
<Box
data-testid={testId}
sx={{
display: "grid",
gap: 0.5,
p: 1.5,
bgcolor: "rgba(37, 125, 212, 0.07)",
borderRadius: 1,
}}
>
<Line width="58%" height={18} />
<Line width="88%" height={16} />
</Box>
);
const SelectionHeader = ({ actionWidth = "34%" }: { actionWidth?: string }) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1 }}>
<Line width="44%" height={20} />
</Box>
<Action width={actionWidth} height={32} />
</Box>
);
const EmptySelection = ({ height = 44 }: { height?: number }) => (
<Skeleton
variant="rounded"
animation="wave"
height={height}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
);
const Divider = () => (
<Box sx={{ height: 1, flex: "0 0 auto", bgcolor: "rgba(15, 23, 42, 0.08)" }} />
);
const AdvancedRow = ({ testId }: { testId?: string }) => (
<Box
data-testid={testId}
sx={{
minHeight: 40,
display: "flex",
alignItems: "center",
gap: 1,
px: 1.25,
border: "1px solid",
borderColor: "rgba(15, 23, 42, 0.10)",
borderRadius: 1,
}}
>
<Box sx={{ flex: 1 }}>
<Line width="34%" height={18} />
</Box>
<Skeleton
variant="circular"
animation="wave"
width={24}
height={24}
sx={skeletonSx}
/>
</Box>
);
const SelectionList = ({ rows = 3 }: { rows?: number }) => (
<Box
sx={{
display: "grid",
gap: 1,
p: 1.25,
bgcolor: "rgba(15, 23, 42, 0.025)",
borderRadius: 1,
}}
>
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
sx={{ display: "flex", alignItems: "center", gap: 1 }}
>
<Skeleton
variant="circular"
animation="wave"
width={26}
height={26}
sx={skeletonSx}
/>
<Box sx={{ flex: 1 }}>
<Line width={`${72 - index * 8}%`} height={16} />
</Box>
<Skeleton
variant="rounded"
animation="wave"
width={48}
height={26}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
</Box>
))}
</Box>
);
const AnalysisFormSkeleton = ({ variant }: { variant: MapSkeletonVariant }) => {
switch (variant) {
case "burst-detection":
return (
<>
<Box
data-testid="burst-detection-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
<Field />
</Box>
<Notice />
<Line width="88%" height={16} />
<Box sx={{ display: "flex", gap: 1.5, mt: "auto", pt: 1.5 }}>
<Action />
<Action />
</Box>
</>
);
case "burst-location":
return (
<>
<Box
data-testid="burst-location-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
</Box>
<Notice />
<TwoColumns>
<Field />
<Field />
</TwoColumns>
<Field />
<AdvancedRow />
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "burst-simulation":
return (
<>
<Box
data-testid="burst-simulation-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
</Box>
<Box
data-testid="burst-simulation-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
<Field />
</Box>
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "contaminant-simulation":
return (
<>
<Box
data-testid="contaminant-simulation-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
<EmptySelection />
</Box>
<Box
data-testid="contaminant-simulation-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
{Array.from({ length: 4 }, (_, index) => (
<Field key={index} />
))}
</Box>
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "dma-leak-detection":
return (
<>
<Notice testId="dma-notice-skeleton" />
<Box
data-testid="dma-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
{Array.from({ length: 5 }, (_, index) => (
<Field key={index} />
))}
</Box>
<AdvancedRow testId="dma-advanced-skeleton" />
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action testId="dma-primary-action-skeleton" />
</Box>
</>
);
case "flushing-analysis":
return (
<>
<Box
data-testid="flushing-valve-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
<EmptySelection height={192} />
</Box>
<Divider />
<Box
data-testid="flushing-node-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
<EmptySelection height={48} />
</Box>
<Divider />
<Box
data-testid="flushing-primary-fields"
data-layout="vertical-vertical-two-columns"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
<TwoColumns>
<Field />
<Field />
</TwoColumns>
</Box>
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "monitoring-place-optimization":
return (
<>
<Box
data-testid="monitoring-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 2 }}
>
{Array.from({ length: 5 }, (_, index) => (
<Field key={index} />
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Action width="132px" />
</Box>
</>
);
default:
return null;
}
};
const DeviceListSkeleton = ({ cleaning }: { cleaning: boolean }) => (
<Box
data-testid={cleaning ? "cleaning-device-content" : "network-device-content"}
sx={{ display: "grid", gap: 2 }}
>
<Skeleton
variant="rounded"
animation="wave"
height={40}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
{Array.from({ length: 3 }, (_, index) => (
<Skeleton
key={index}
variant="rounded"
animation="wave"
height={38}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
))}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ flex: 1 }}>
<Line width="58%" height={16} />
</Box>
{cleaning && (
<Skeleton
data-testid="cleaning-action-skeleton"
variant="circular"
animation="wave"
width={32}
height={32}
sx={skeletonSx}
/>
)}
<Skeleton
variant="circular"
animation="wave"
width={32}
height={32}
sx={skeletonSx}
/>
</Box>
<Box sx={{ height: 1, bgcolor: "rgba(15, 23, 42, 0.08)" }} />
<SelectionList rows={6} />
</Box>
);
const RiskChartSkeleton = () => (
<Box
data-testid="health-risk-chart-content"
sx={{ display: "grid", height: "100%", minHeight: 360, gap: 2 }}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Line width="32%" height={18} />
<Box sx={{ flex: 1 }} />
<Action width="22%" />
</Box>
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "flex-end",
justifyContent: "space-around",
gap: 2,
minHeight: 300,
px: 3,
pt: 3,
pb: 2,
bgcolor: "rgba(15, 23, 42, 0.025)",
borderRadius: 1,
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.10)",
}}
>
{[36, 58, 44, 76, 62, 84].map((height, index) => (
<Skeleton
key={index}
variant="rounded"
animation="wave"
width="9%"
height={`${height}%`}
sx={{ ...skeletonSx, borderRadius: "6px 6px 2px 2px" }}
/>
))}
</Box>
</Box>
);
export function MapPageSkeletonContent({
variant,
}: {
variant: MapSkeletonVariant;
}) {
if (variant === "network-simulation") {
return <DeviceListSkeleton cleaning={false} />;
}
if (variant === "scada-data-cleaning") {
return <DeviceListSkeleton cleaning />;
}
if (variant === "health-risk-analysis") {
return <RiskChartSkeleton />;
}
return (
<Box
data-testid={`${variant}-content`}
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
height: "100%",
minHeight: 0,
}}
>
<AnalysisFormSkeleton variant={variant} />
</Box>
);
}
export function MapTimelineSkeleton() {
return (
<Box
role="status"
aria-live="polite"
aria-label="正在加载时间轴"
data-component-skeleton="map-timeline"
data-testid="map-timeline-skeleton"
sx={{
position: "absolute",
left: "50%",
bottom: 16,
width: "min(950px, calc(100% - 32px))",
transform: "translateX(-50%)",
display: "grid",
gap: 1.5,
p: 2,
bgcolor: "rgba(255, 255, 255, 0.96)",
borderRadius: 1.5,
boxShadow:
"0 14px 30px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08)",
pointerEvents: "none",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
{Array.from({ length: 5 }, (_, index) => (
<Skeleton
key={index}
variant="circular"
animation="wave"
width={32}
height={32}
sx={skeletonSx}
/>
))}
<Box sx={{ flex: 1 }} />
<Skeleton
variant="rounded"
animation="wave"
width={132}
height={32}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
</Box>
<Skeleton
variant="rounded"
animation="wave"
height={8}
sx={{ ...skeletonSx, borderRadius: 999 }}
/>
</Box>
);
}
@@ -0,0 +1,149 @@
import { render, screen, within } from "@testing-library/react";
import {
MapPanelSkeleton,
MapTimelineSkeleton,
} from "./MapComponentSkeletons";
import {
MAP_SKELETON_CONFIGS,
MAP_SKELETON_VARIANTS,
type MapSkeletonVariant,
} from "./mapComponentSkeletonConfig";
describe("map component skeletons", () => {
it("keeps the loading state inside the analysis panel geometry", () => {
render(<MapPanelSkeleton variant="burst-simulation" />);
const panel = screen.getByRole("status", {
name: "正在加载爆管分析",
});
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
expect(panel).toHaveAttribute("data-panel-side", "right");
expect(panel).toHaveAttribute("data-panel-width", "520");
expect(panel).toHaveStyle({
position: "absolute",
pointerEvents: "none",
});
expect(screen.getByTestId("burst-simulation-content")).toBeInTheDocument();
});
it("keeps an explicit panel configuration for every heavy map component", () => {
expect(Object.keys(MAP_SKELETON_CONFIGS).sort()).toEqual(
[...MAP_SKELETON_VARIANTS].sort(),
);
});
it("keeps known panel chrome out of the skeleton layer", () => {
render(<MapPanelSkeleton variant="burst-simulation" />);
const header = screen.getByTestId("map-panel-header");
const tabs = screen.getByTestId("map-panel-tabs");
expect(header.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
expect(tabs.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
expect(screen.queryByText("正在加载组件")).not.toBeInTheDocument();
expect(
within(tabs).getByText("分析要件"),
).toBeInTheDocument();
});
it.each(MAP_SKELETON_VARIANTS)(
"renders the component-specific panel for %s",
(variant: MapSkeletonVariant) => {
const config = MAP_SKELETON_CONFIGS[variant];
render(<MapPanelSkeleton variant={variant} />);
const panel = screen.getByRole("status", {
name: `正在加载${config.title}`,
});
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
expect(panel).toHaveAttribute("data-panel-side", config.side);
expect(panel).toHaveAttribute(
"data-panel-width",
String(config.panelWidth),
);
expect(screen.getByText(config.panelTitle)).toBeInTheDocument();
expect(screen.queryAllByTestId("map-panel-tab-label")).toHaveLength(
config.tabLabels.length,
);
},
);
it("distinguishes cleaning controls from the simulation device list", () => {
const { rerender } = render(
<MapPanelSkeleton variant="network-simulation" />,
);
expect(screen.getByTestId("network-device-content")).toBeInTheDocument();
rerender(<MapPanelSkeleton variant="scada-data-cleaning" />);
expect(screen.getByTestId("cleaning-device-content")).toBeInTheDocument();
expect(screen.getByTestId("cleaning-action-skeleton")).toBeInTheDocument();
});
it("matches the DMA parameter panel's vertical control distribution", () => {
render(<MapPanelSkeleton variant="dma-leak-detection" />);
const fields = screen.getByTestId("dma-primary-fields");
expect(screen.getByTestId("dma-notice-skeleton")).toBeInTheDocument();
expect(fields).toHaveAttribute("data-layout", "vertical");
expect(fields.children).toHaveLength(5);
expect(screen.getByTestId("dma-advanced-skeleton")).toBeInTheDocument();
expect(screen.getByTestId("dma-primary-action-skeleton")).toHaveStyle({
width: "100%",
});
});
it.each([
["burst-detection", "burst-detection-primary-fields", "vertical", 3],
["burst-location", "burst-location-primary-fields", "vertical", 2],
["burst-simulation", "burst-simulation-primary-fields", "vertical", 3],
[
"contaminant-simulation",
"contaminant-simulation-primary-fields",
"vertical",
4,
],
[
"flushing-analysis",
"flushing-primary-fields",
"vertical-vertical-two-columns",
3,
],
[
"monitoring-place-optimization",
"monitoring-primary-fields",
"vertical",
5,
],
] as const)(
"matches the primary control distribution for %s",
(variant, testId, layout, childCount) => {
render(<MapPanelSkeleton variant={variant} />);
const fields = screen.getByTestId(testId);
expect(fields).toHaveAttribute("data-layout", layout);
expect(fields.children).toHaveLength(childCount);
},
);
it("keeps the two flushing selection regions separate", () => {
render(<MapPanelSkeleton variant="flushing-analysis" />);
expect(screen.getByTestId("flushing-valve-selection")).toBeInTheDocument();
expect(screen.getByTestId("flushing-node-selection")).toBeInTheDocument();
});
it("renders the timeline as an independent loading boundary", () => {
render(<MapTimelineSkeleton />);
expect(
screen.getByRole("status", { name: "正在加载时间轴" }),
).toHaveAttribute("data-component-skeleton", "map-timeline");
});
});
@@ -0,0 +1,160 @@
"use client";
import { Box, CircularProgress, Typography } from "@mui/material";
import { MapPageSkeletonContent } from "./MapComponentSkeletonLayouts";
import {
MAP_SKELETON_CONFIGS,
type MapSkeletonVariant,
} from "./mapComponentSkeletonConfig";
export { MapTimelineSkeleton } from "./MapComponentSkeletonLayouts";
export interface MapPanelSkeletonProps {
variant: MapSkeletonVariant;
}
/**
* 地图业务面板的组件级加载占位。
* 只占据最终面板区域,不替换地图、工具栏或其他已经完成加载的组件。
*/
export function MapPanelSkeleton({ variant }: MapPanelSkeletonProps) {
const config = MAP_SKELETON_CONFIGS[variant];
const horizontalPosition =
config.side === "left"
? { left: { xs: 12, md: 16 }, right: "auto" }
: { right: { xs: 12, md: 16 }, left: "auto" };
return (
<Box
role="status"
aria-live="polite"
aria-label={`正在加载${config.title}`}
data-component-skeleton="map-panel"
data-testid="map-panel-skeleton"
data-panel-side={config.side}
data-panel-width={config.panelWidth}
sx={{
position: "absolute",
top: { xs: 12, md: config.panelTop },
width: {
xs: "calc(100% - 24px)",
sm: `min(${Math.min(config.panelWidth, 640)}px, calc(100% - 32px))`,
md: config.panelWidth,
},
height: {
xs: "calc(100% - 24px)",
md: `min(${config.panelMaxHeight}px, calc(100% - ${config.panelTop + 16}px))`,
},
zIndex: 1300,
display: "flex",
flexDirection: "column",
overflow: "hidden",
bgcolor: "rgba(255, 255, 255, 0.97)",
borderRadius: 1.5,
boxShadow:
"0 20px 34px rgba(15, 23, 42, 0.18), 0 4px 10px rgba(15, 23, 42, 0.08)",
pointerEvents: "none",
...horizontalPosition,
}}
>
<Box
data-testid="map-panel-header"
sx={{
minHeight: 64,
display: "flex",
alignItems: "center",
gap: 1.25,
px: 2.5,
py: 1.5,
bgcolor: "#257DD4",
color: "#fff",
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
component="span"
lang="zh-CN"
sx={{
display: "block",
fontSize: 18,
fontWeight: 600,
lineHeight: 1.4,
}}
>
{config.panelTitle}
</Typography>
</Box>
<CircularProgress
aria-label={`正在加载${config.title}`}
size={20}
thickness={4}
sx={{ color: "rgba(255, 255, 255, 0.90)" }}
/>
</Box>
{config.tabLabels.length > 0 && (
<Box
data-testid="map-panel-tabs"
sx={{
minHeight: 48,
display: "grid",
gridTemplateColumns: `repeat(${config.tabLabels.length}, minmax(0, 1fr))`,
alignItems: "center",
gap: 1,
px: 2,
bgcolor: "#fff",
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.08)",
}}
>
{config.tabLabels.map((label, index) => (
<Box
key={label}
data-testid="map-panel-tab-label"
sx={{ display: "grid", justifyItems: "center", gap: 0.5 }}
>
<Typography
component="span"
lang="zh-CN"
sx={{
fontSize: 14,
fontWeight: 500,
lineHeight: 1.5,
color: index === 0 ? "#257DD4" : "text.secondary",
}}
>
{label}
</Typography>
{index === 0 && (
<Box
sx={{
width: "78%",
height: 2,
bgcolor: "rgba(37, 125, 212, 0.34)",
borderRadius: 999,
}}
/>
)}
</Box>
))}
</Box>
)}
<Box
sx={{
flex: 1,
minHeight: 0,
overflow: "hidden",
p: variant === "health-risk-analysis" ? 2 : 2.5,
bgcolor:
variant === "network-simulation" ||
variant === "scada-data-cleaning"
? "rgba(248, 250, 252, 0.98)"
: "#fff",
}}
>
<MapPageSkeletonContent variant={variant} />
</Box>
</Box>
);
}
-191
View File
@@ -1,191 +0,0 @@
import { Box, Skeleton, CircularProgress } from "@mui/material";
/**
* 地图页面骨架屏组件
* 提供即时视觉反馈,模拟地图界面布局
*/
export function MapSkeleton() {
return (
<Box
sx={{
width: "100%",
height: "100%",
position: "relative",
bgcolor: "background.default",
overflow: "hidden",
}}
>
{/* 主地图区域骨架 */}
<Skeleton
variant="rectangular"
animation="wave"
sx={{
width: "100%",
height: "100%",
bgcolor: "action.hover",
}}
/>
{/* 中央加载指示器 */}
<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",
top: 20,
left: 20,
display: "flex",
flexDirection: "column",
gap: 1.5,
zIndex: 5,
}}
>
{[1, 2, 3, 4].map((i) => (
<Skeleton
key={i}
variant="circular"
width={40}
height={40}
animation="wave"
sx={{ boxShadow: 1 }}
/>
))}
</Box>
{/* 右侧控制面板骨架 (抽屉式) */}
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
width: { xs: "100%", sm: 360 },
height: "100%",
bgcolor: "background.paper",
borderLeft: 1,
borderColor: "divider",
p: 3,
zIndex: 5,
display: { xs: "none", md: "flex" },
flexDirection: "column",
boxShadow: -2,
}}
>
<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: 30,
left: "50%",
transform: "translateX(-50%)",
width: { xs: "90%", md: "60%" },
height: 64,
bgcolor: "background.paper",
borderRadius: 4,
boxShadow: 3,
p: 2,
display: "flex",
alignItems: "center",
gap: 2,
zIndex: 5,
}}
>
<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: 110,
right: { xs: 20, md: 380 }, // Adjust if drawer is open
display: "flex",
flexDirection: "column",
gap: 1,
zIndex: 4,
}}
>
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
</Box>
</Box>
);
}
/**
* 简化版骨架屏 - 用于非地图页面
*/
export function SimpleSkeleton() {
return (
<Box
sx={{
width: "100%",
height: "100%",
p: 3,
bgcolor: "background.default",
}}
>
<Skeleton width="40%" height={40} animation="wave" sx={{ mb: 3 }} />
<Skeleton width="100%" height={60} animation="wave" sx={{ mb: 2 }} />
<Skeleton width="100%" height={300} animation="wave" sx={{ mb: 2 }} />
<Box sx={{ display: "flex", gap: 2, mb: 2 }}>
<Skeleton
variant="rectangular"
width="30%"
height={150}
animation="wave"
/>
<Skeleton
variant="rectangular"
width="30%"
height={150}
animation="wave"
/>
<Skeleton
variant="rectangular"
width="30%"
height={150}
animation="wave"
/>
</Box>
</Box>
);
}
@@ -0,0 +1,120 @@
export const MAP_SKELETON_VARIANTS = [
"network-simulation",
"scada-data-cleaning",
"health-risk-analysis",
"monitoring-place-optimization",
"burst-detection",
"burst-location",
"burst-simulation",
"contaminant-simulation",
"dma-leak-detection",
"flushing-analysis",
] as const;
export type MapSkeletonVariant = (typeof MAP_SKELETON_VARIANTS)[number];
export interface MapSkeletonConfig {
title: string;
panelTitle: string;
side: "left" | "right";
panelWidth: number;
panelTop: number;
panelMaxHeight: number;
tabLabels: readonly string[];
}
export const MAP_SKELETON_CONFIGS: Record<
MapSkeletonVariant,
MapSkeletonConfig
> = {
"network-simulation": {
title: "管网模拟",
panelTitle: "SCADA 设备列表",
side: "left",
panelWidth: 360,
panelTop: 80,
panelMaxHeight: 860,
tabLabels: [],
},
"scada-data-cleaning": {
title: "数据清洗",
panelTitle: "SCADA 设备列表",
side: "left",
panelWidth: 360,
panelTop: 80,
panelMaxHeight: 860,
tabLabels: [],
},
"health-risk-analysis": {
title: "健康风险分析",
panelTitle: "管道健康风险统计",
side: "right",
panelWidth: 640,
panelTop: 16,
panelMaxHeight: 614,
tabLabels: [],
},
"monitoring-place-optimization": {
title: "监测点优化",
panelTitle: "监测点优化",
side: "right",
panelWidth: 520,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["优化要件", "结果编辑", "方案查询"],
},
"burst-detection": {
title: "爆管侦测",
panelTitle: "爆管侦测",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["侦测参数", "方案查询", "侦测结果"],
},
"burst-location": {
title: "爆管定位",
panelTitle: "爆管定位",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["定位参数", "方案查询", "定位结果"],
},
"burst-simulation": {
title: "爆管分析",
panelTitle: "爆管分析",
side: "right",
panelWidth: 520,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["分析要件", "方案查询", "分析报告", "关阀分析"],
},
"contaminant-simulation": {
title: "水质模拟",
panelTitle: "水质模拟",
side: "right",
panelWidth: 520,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["分析要件", "方案查询", "模拟结果"],
},
"dma-leak-detection": {
title: "DMA 漏损识别",
panelTitle: "DMA 漏损识别",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["识别参数", "方案查询", "识别结果"],
},
"flushing-analysis": {
title: "管道冲洗分析",
panelTitle: "管道冲洗分析",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["分析参数", "方案查询"],
},
};
@@ -1,23 +1,37 @@
import dayjs from "dayjs";
import { api } from "@/lib/api";
import {
buildBurstDetectionRequest,
createBurstDetectionAnalysisParametersState,
fetchPressureSamplingInterval,
parseScadaFrequencyMinutes,
resolvePressureSamplingInterval,
} from "./AnalysisParameters";
jest.mock("@/lib/api", () => ({
api: {
get: jest.fn(),
},
}));
const get = api.get as jest.Mock;
describe("burst detection request", () => {
beforeEach(() => {
get.mockReset();
});
it("requests the latest complete monitoring time by default", () => {
const state = createBurstDetectionAnalysisParametersState();
state.schemeName = " latest-case ";
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
network: "fengyang",
expect(buildBurstDetectionRequest(state)).toEqual({
scheme_name: "latest-case",
sampling_interval_minutes: 15,
});
expect(state.detectionMode).toBe("latest");
expect(state.targetTime?.minute() % 15).toBe(0);
expect(state.targetTime).not.toBeNull();
expect(state.targetTime!.minute() % 15).toBe(0);
});
it("sends one target time for historical replay", () => {
@@ -32,10 +46,8 @@ describe("burst detection request", () => {
samplingIntervalMinutes: 30,
samplingIntervalSource: "manual",
},
"fengyang",
),
).toEqual({
network: "fengyang",
scheme_name: "history-case",
sampling_interval_minutes: 30,
target_time: targetTime.toISOString(),
@@ -47,11 +59,27 @@ describe("burst detection request", () => {
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
expect(
resolvePressureSamplingInterval([
{ type: "pressure", transmission_frequency: "0:15:00" },
{ type: "pressure", transmission_frequency: "0:15:00" },
{ type: "pressure", transmission_frequency: "0:30:00" },
{ type: "pipe_flow", transmission_frequency: "1:00:00" },
{ device_type: "pressure", transmission_frequency: "0:15:00" },
{ device_type: "pressure", transmission_frequency: "0:15:00" },
{ device_type: "pressure", transmission_frequency: "0:30:00" },
{ device_type: "pipe_flow", transmission_frequency: "1:00:00" },
]),
).toBe(15);
});
it("loads the pressure frequency from the pooled SCADA metadata endpoint", async () => {
get.mockResolvedValue({
data: {
success: true,
count: 2,
data: [
{ device_type: "pressure", transmission_frequency: "0:30:00" },
{ device_type: "pipe_flow", transmission_frequency: "0:15:00" },
],
},
});
await expect(fetchPressureSamplingInterval()).resolves.toBe(30);
expect(get).toHaveBeenCalledWith("/api/v1/scada-info/database-view");
});
});
@@ -20,7 +20,16 @@ import "dayjs/locale/zh-cn";
import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { BurstDetectionResult } from "./types";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props {
onResult: (result: BurstDetectionResult) => void;
@@ -37,10 +46,14 @@ export interface BurstDetectionAnalysisParametersState {
}
interface ScadaInfoItem {
type?: string;
device_type?: string;
transmission_frequency?: string | number | null;
}
interface ScadaInfoResponse {
data?: ScadaInfoItem[];
}
const currentQuarterHour = () => {
const now = dayjs().second(0).millisecond(0);
return now.minute(Math.floor(now.minute() / 15) * 15);
@@ -48,7 +61,7 @@ const currentQuarterHour = () => {
export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({
schemeName: `Burst_Detection_${Date.now()}`,
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection),
detectionMode: "latest",
targetTime: currentQuarterHour(),
samplingIntervalMinutes: 15,
@@ -77,7 +90,7 @@ export const parseScadaFrequencyMinutes = (
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
const counts = new Map<number, number>();
items
.filter((item) => item.type?.toLowerCase() === "pressure")
.filter((item) => item.device_type?.toLowerCase() === "pressure")
.forEach((item) => {
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
if (minutes && 1440 % minutes === 0) {
@@ -90,12 +103,19 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
)[0]?.[0] ?? 15;
};
export const fetchPressureSamplingInterval = async () => {
const response = await api.get<ScadaInfoResponse>(
"/api/v1/scada-info/database-view",
);
return resolvePressureSamplingInterval(
Array.isArray(response.data.data) ? response.data.data : [],
);
};
export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState,
network: string,
) => ({
network,
scheme_name: parameters.schemeName.trim(),
scheme_name: normalizeSchemeName(parameters.schemeName),
sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime
? { target_time: parameters.targetTime.toISOString() }
@@ -123,18 +143,20 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState;
const [running, setRunning] = useState(false);
const [frequencyLoading, setFrequencyLoading] = useState(false);
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
setParametersState({
...draft,
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
}),
);
useEffect(() => {
if (samplingIntervalSource !== "metadata") return;
let active = true;
setFrequencyLoading(true);
api
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
.then((response) => {
fetchPressureSamplingInterval()
.then((interval) => {
if (!active) return;
const interval = resolvePressureSamplingInterval(
response.data as ScadaInfoItem[],
);
setParametersState((previous) =>
previous.samplingIntervalSource === "metadata"
? { ...previous, samplingIntervalMinutes: interval }
@@ -159,7 +181,7 @@ const AnalysisParameters: React.FC<Props> = ({
const isValid = useMemo(
() =>
schemeName.trim().length > 0 &&
isSchemeNameValid(schemeName) &&
samplingIntervalValid &&
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
[detectionMode, samplingIntervalValid, schemeName, targetTime],
@@ -186,8 +208,8 @@ const AnalysisParameters: React.FC<Props> = ({
try {
const response = await api.post(
"/api/v1/burst-detection/detect/",
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
"/api/v1/burst-detections",
buildBurstDetectionRequest(parametersState),
);
onResult(response.data as BurstDetectionResult);
open?.({
@@ -198,12 +220,12 @@ const AnalysisParameters: React.FC<Props> = ({
? "目标时刻存在异常信号,请优先复核相关测点。"
: "目标时刻未发现爆管异常。",
});
} catch (error: any) {
} catch (error) {
open?.({
key: "burst-detection-analysis-error",
type: "error",
message: "侦测失败",
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
description: getApiErrorMessage(error, "爆管侦测请求失败"),
});
} finally {
setRunning(false);
@@ -221,6 +243,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName}
onChange={(event) => setFormField("schemeName", event.target.value)}
placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
fullWidth
size="small"
/>
@@ -21,6 +21,7 @@ import SchemeQuery, {
createBurstDetectionSchemeQueryState,
type BurstDetectionSchemeQueryState,
} from "./SchemeQuery";
import { useData } from "@components/olmap/core/MapComponent";
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
const TabPanel = ({
@@ -38,6 +39,7 @@ const TabPanel = ({
);
const BurstDetectionPanel: React.FC = () => {
const setSchemeRunId = useData()?.setSchemeRunId;
const [open, setOpen] = useState(true);
const [tab, setTab] = useState(0);
const [result, setResult] = useState<BurstDetectionResult | null>(null);
@@ -58,10 +60,23 @@ const BurstDetectionPanel: React.FC = () => {
const drawerWidth = 450;
const panelTitle = "爆管侦测";
const handleResult = useCallback((payload: BurstDetectionResult) => {
setResult(payload);
setTab(2);
}, []);
const handleResult = useCallback(
(payload: BurstDetectionResult) => {
setSchemeRunId?.("");
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
const handleViewResult = useCallback(
(payload: BurstDetectionResult, runId: string) => {
setSchemeRunId?.(runId);
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
return (
<>
@@ -166,7 +181,7 @@ const BurstDetectionPanel: React.FC = () => {
</TabPanel>
<TabPanel value={tab} index={1}>
<SchemeQuery
onViewResult={handleResult}
onViewResult={handleViewResult}
schemes={schemes}
onSchemesChange={setSchemes}
state={queryState}
@@ -23,6 +23,7 @@ import { bbox, featureCollection } from "@turf/turf";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface BurstDetectionResultsState {
selectedDay: number | null;
@@ -73,17 +74,11 @@ const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
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">
提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。
</Typography>
</Box>
<PanelEmptyState
icon={<ShowChartIcon />}
title="尚未生成侦测结果"
description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
@@ -184,10 +179,7 @@ const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) =>
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");
}
const features = await queryFeaturesByIds(sensorIds, "junctions");
if (features.length === 0) return;
setHighlightFeatures(features);
const format = new GeoJSON();
@@ -21,9 +21,10 @@ 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 { getAnalysisScheme, listAnalysisSchemes } from "@/lib/analysisRuns";
import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
import {
BurstDetectionResult,
BurstDetectionSchemeDetail,
@@ -31,7 +32,7 @@ import {
} from "./types";
interface Props {
onViewResult: (result: BurstDetectionResult) => void;
onViewResult: (result: BurstDetectionResult, runId: string) => void;
schemes?: BurstDetectionSchemeRecord[];
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
state?: BurstDetectionSchemeQueryState;
@@ -41,7 +42,8 @@ interface Props {
export interface BurstDetectionSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
expandedId: string | null;
hasQueried: boolean;
}
export const createBurstDetectionSchemeQueryState =
@@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState =
queryAll: true,
queryDate: dayjs(),
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -64,7 +67,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createBurstDetectionSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
@@ -127,14 +130,14 @@ const SchemeQuery: React.FC<Props> = ({
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[];
const nextSchemes = (await listAnalysisSchemes({
runType: "burst_detection",
queryDate: !queryAll && queryDate
? queryDate.format("YYYY-MM-DD")
: undefined,
})) as BurstDetectionSchemeRecord[];
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
open?.({
type: "success",
message: "查询成功",
@@ -151,13 +154,9 @@ const SchemeQuery: React.FC<Props> = ({
}
};
const handleViewSchemeResult = async (schemeName: string) => {
const handleViewSchemeResult = async (runId: string) => {
try {
const response = await api.get(
`/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } },
);
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
const schemeRecord = (await getAnalysisScheme(runId)) as BurstDetectionSchemeRecord & {
result_payload?: BurstDetectionResult;
};
const normalizedResult =
@@ -175,11 +174,11 @@ const SchemeQuery: React.FC<Props> = ({
throw new Error("方案详情缺少侦测结果数据");
}
onViewResult(normalizedResult);
onViewResult(normalizedResult, runId);
open?.({
type: "success",
message: "方案加载成功",
description: `已加载方案:${schemeName}`,
description: `已加载方案:${schemeRecord.scheme_name}`,
});
} catch (error: any) {
open?.({
@@ -200,7 +199,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(event) => setQueryField("queryAll", event.target.checked)}
onChange={(event) => {
setQueryField("queryAll", event.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2">查询全部</Typography>}
@@ -209,7 +211,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
@@ -231,12 +236,7 @@ const SchemeQuery: React.FC<Props> = ({
<Box className="flex-1 overflow-auto">
{sortedSchemes.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>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="px-2 text-gray-500">
@@ -391,7 +391,7 @@ const SchemeQuery: React.FC<Props> = ({
size="small"
className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => handleViewSchemeResult(scheme.scheme_name)}
onClick={() => handleViewSchemeResult(scheme.scheme_id)}
>
查看侦测结果
</Button>
+1 -1
View File
@@ -97,7 +97,7 @@ export interface BurstDetectionSchemeDetail {
}
export interface BurstDetectionSchemeRecord {
scheme_id: number;
scheme_id: string;
scheme_name: string;
scheme_type?: string;
create_time: string;
@@ -24,10 +24,21 @@ import { useNotification } from "@refinedev/core";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn";
import { api } from "@/lib/api";
import { listAnalysisSchemes } from "@/lib/analysisRuns";
import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import { BurstLocationResult } from "./types";
import { getBurstLocationErrorNotice } from "./burstLocationError";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props {
onResult: (result: BurstLocationResult) => void;
@@ -36,7 +47,7 @@ interface Props {
}
export interface SchemeItem {
scheme_id: number;
scheme_id: string;
scheme_name: string;
scheme_type: string;
create_time: string;
@@ -53,7 +64,7 @@ export interface BurstLocationAnalysisParametersState {
schemeName: string;
dataSource: DataSource;
schemes: SchemeItem[];
selectedSchemeId: number | "";
selectedSchemeId: string;
burstLeakage: number;
enableFlow: boolean;
burstStartTime: Dayjs | null;
@@ -65,7 +76,7 @@ export interface BurstLocationAnalysisParametersState {
export const createBurstLocationAnalysisParametersState =
(): BurstLocationAnalysisParametersState => ({
schemeName: `Burst_Locate_${Date.now()}`,
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstLocation),
dataSource: "monitoring",
schemes: [],
selectedSchemeId: "",
@@ -104,6 +115,13 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false);
const [running, setRunning] = useState(false);
useSessionRecoveryDraft("burst-location", parametersState, (draft) =>
setParametersState({
...draft,
burstStartTime: draft.burstStartTime ? dayjs(draft.burstStartTime) : null,
burstEndTime: draft.burstEndTime ? dayjs(draft.burstEndTime) : null,
}),
);
const isSimulationMode = dataSource === "simulation";
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
@@ -124,12 +142,9 @@ const AnalysisParameters: React.FC<Props> = ({
setSchemeLoading(true);
try {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME },
});
const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis",
).sort(
const burstSchemes = (await listAnalysisSchemes({
runType: "burst_analysis",
}) as unknown as SchemeItem[]).sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
);
@@ -154,12 +169,14 @@ const AnalysisParameters: React.FC<Props> = ({
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
});
}
} catch (error: any) {
} catch (error) {
open?.({
type: "error",
message: "刷新方案失败",
description:
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
description: getApiErrorMessage(
error,
"无法获取爆管分析方案列表",
),
});
} finally {
setSchemeLoading(false);
@@ -175,7 +192,7 @@ const AnalysisParameters: React.FC<Props> = ({
}
};
const handleSchemeSelect = (schemeId: number) => {
const handleSchemeSelect = (schemeId: string) => {
setFormField("selectedSchemeId", schemeId);
const scheme = schemes.find((item) => item.scheme_id === schemeId);
if (scheme) {
@@ -184,6 +201,7 @@ const AnalysisParameters: React.FC<Props> = ({
};
const isValid = useMemo(() => {
if (!isSchemeNameValid(schemeName)) return false;
if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false;
if (!burstStartTime || !burstEndTime) {
return false;
@@ -195,6 +213,7 @@ const AnalysisParameters: React.FC<Props> = ({
return burstStartTime.isBefore(burstEndTime);
}, [
burstLeakage,
schemeName,
burstStartTime,
burstEndTime,
dataSource,
@@ -222,19 +241,17 @@ const AnalysisParameters: React.FC<Props> = ({
: undefined;
const response = await api.post(
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
`${config.BACKEND_URL}/api/v1/burst-locations`,
{
network: NETWORK_NAME,
data_source: dataSource,
scheme_name: schemeName.trim() || undefined,
scheme_name: normalizeSchemeName(schemeName),
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,
simulation_run_id: selectedScheme?.scheme_id,
},
);
@@ -262,12 +279,13 @@ const AnalysisParameters: React.FC<Props> = ({
message: "爆管定位成功",
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
});
} catch (error: any) {
} catch (error) {
const notice = getBurstLocationErrorNotice(error);
open?.({
key: "burst-location-analysis-error",
type: "error",
message: "提交分析失败",
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
message: notice.message,
description: notice.description,
});
} finally {
setRunning(false);
@@ -285,6 +303,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
fullWidth
size="small"
/>
@@ -304,22 +329,30 @@ const AnalysisParameters: React.FC<Props> = ({
</Select>
</FormControl>
<Alert
severity="info"
severity={isSimulationMode ? "warning" : "info"}
variant="outlined"
sx={{
mt: 1,
alignItems: "flex-start",
borderColor: "info.light",
backgroundColor: "#eff6ff",
borderColor: isSimulationMode ? "warning.light" : "info.light",
backgroundColor: isSimulationMode ? "#fff8e1" : "#eff6ff",
"& .MuiAlert-message": { width: "100%" },
}}
>
<Typography variant="caption" className="block font-semibold text-blue-900">
数据选择规则
<Typography
variant="caption"
className="block font-semibold"
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
>
{isSimulationMode ? "模拟方案分析前置条件" : "数据选择规则"}
</Typography>
<Typography variant="caption" className="block leading-5 text-blue-800">
<Typography
variant="caption"
className="block leading-5"
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
>
{isSimulationMode
? "当前为模拟方案:爆管数据取所选方案模拟结果,正常数据取实时模拟结果,两者使用同一爆管时间窗。"
? "爆管数据取所选方案,正常数据取同一时间窗的实时模拟结果。所选时间窗必须已有实时模拟正常基线,否则无法分析;系统不会自动补数据。"
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
</Typography>
</Alert>
@@ -334,7 +367,7 @@ const AnalysisParameters: React.FC<Props> = ({
<FormControl fullWidth size="small">
<Select
value={selectedSchemeId}
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
onChange={(e) => handleSchemeSelect(String(e.target.value))}
disabled={schemeLoading}
displayEmpty
>
@@ -19,6 +19,7 @@ import SchemeQuery, {
type BurstLocationSchemeQueryState,
} from "./SchemeQuery";
import { BurstLocationResult, BurstSchemeRecord } from "./types";
import { useData } from "@components/olmap/core/MapComponent";
const TabPanel = ({
value,
@@ -35,6 +36,7 @@ const TabPanel = ({
);
const BurstLocationPanel: React.FC = () => {
const setSchemeRunId = useData()?.setSchemeRunId;
const [open, setOpen] = useState(true);
const [tab, setTab] = useState(0);
const [result, setResult] = useState<BurstLocationResult | null>(null);
@@ -51,15 +53,23 @@ const BurstLocationPanel: React.FC = () => {
const drawerWidth = 450;
const panelTitle = "爆管定位";
const handleResult = useCallback((payload: BurstLocationResult) => {
setResult(payload);
setTab(2);
}, []);
const handleResult = useCallback(
(payload: BurstLocationResult) => {
setSchemeRunId?.("");
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
const handleViewResult = useCallback((payload: BurstLocationResult) => {
setResult(payload);
setTab(2);
}, []);
const handleViewResult = useCallback(
(payload: BurstLocationResult, runId: string) => {
setSchemeRunId?.(runId);
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
return (
<>
@@ -31,6 +31,7 @@ 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";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props {
result: BurstLocationResult | null;
@@ -128,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
};
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>
<PanelEmptyState
icon={<MapIcon />}
title="尚未生成定位结果"
description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
const LocationResults: React.FC<Props> = ({ result }) => {
@@ -207,10 +202,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
if (!pipeIds.length || !map) return;
try {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
if (features.length === 0) {
features = await queryFeaturesByIds(pipeIds, "geo_pipes");
}
const features = await queryFeaturesByIds(pipeIds, "pipes");
if (features.length === 0) return;
setHighlightFeatures(features);
@@ -25,8 +25,8 @@ 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 { getAnalysisScheme, listAnalysisSchemes } from "@/lib/analysisRuns";
import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
@@ -42,9 +42,11 @@ import {
BurstSchemeRecord,
} from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props {
onViewResult: (result: BurstLocationResult) => void;
onViewResult: (result: BurstLocationResult, runId: string) => void;
schemes?: BurstSchemeRecord[];
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
state?: BurstLocationSchemeQueryState;
@@ -54,8 +56,9 @@ interface Props {
export interface BurstLocationSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
expandedId: string | null;
simulationBurstIdsByName: Record<string, string[]>;
hasQueried: boolean;
}
export const createBurstLocationSchemeQueryState =
@@ -64,6 +67,7 @@ export const createBurstLocationSchemeQueryState =
queryDate: dayjs(),
expandedId: null,
simulationBurstIdsByName: {},
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -82,10 +86,11 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createBurstLocationSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const creatorName = useSchemeCreatorName();
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
@@ -142,10 +147,7 @@ const SchemeQuery: React.FC<Props> = ({
if (!uniquePipeIds.length || !map) return;
try {
let features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes_mat");
if (features.length === 0) {
features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes");
}
const features = await queryFeaturesByIds(uniquePipeIds, "pipes");
if (features.length === 0) return;
setHighlightFeatures(features);
@@ -222,24 +224,17 @@ const SchemeQuery: React.FC<Props> = ({
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, simulationResponse] = await Promise.all([
api.get(url, { params }),
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME },
const [nextSchemes, simulationSchemes] = await Promise.all([
listAnalysisSchemes({
runType: "burst_location",
queryDate: !queryAll && queryDate
? queryDate.format("YYYY-MM-DD")
: undefined,
}),
listAnalysisSchemes({ runType: "burst_analysis" }),
]);
const nextSchemes = response.data as BurstSchemeRecord[];
const nextSimulationBurstIdsByName = Object.fromEntries(
(simulationResponse.data as BurstSimulationSchemeItem[])
.filter((scheme) => scheme.scheme_type === "burst_analysis")
(simulationSchemes as BurstSimulationSchemeItem[])
.map((scheme) => [
scheme.scheme_name,
normalizeBurstIds(scheme.scheme_detail?.burst_ID),
@@ -249,11 +244,12 @@ const SchemeQuery: React.FC<Props> = ({
setSchemes(
nextSchemes.map((scheme) =>
enrichSchemeWithSimulationBurstIds(
scheme,
scheme as BurstSchemeRecord,
nextSimulationBurstIdsByName,
),
),
);
setQueryField("hasQueried", true);
open?.({
type: "success",
message: "查询成功",
@@ -271,13 +267,9 @@ const SchemeQuery: React.FC<Props> = ({
}
};
const handleViewSchemeResult = async (schemeName: string) => {
const handleViewSchemeResult = async (runId: 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 & {
const schemeRecord = (await getAnalysisScheme(runId)) as BurstSchemeRecord & {
result_payload?: BurstLocationResult;
};
const normalizedResult =
@@ -293,11 +285,11 @@ const SchemeQuery: React.FC<Props> = ({
if (!normalizedResult) {
throw new Error("方案详情缺少定位结果数据");
}
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult), runId);
open?.({
type: "success",
message: "方案加载成功",
description: `已加载方案: ${schemeName}`,
description: `已加载方案: ${schemeRecord.scheme_name}`,
});
} catch (error: any) {
open?.({
@@ -318,7 +310,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2">查询全部</Typography>}
@@ -327,7 +322,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -348,39 +346,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box>
<Box className="flex-1 overflow-auto">
{sortedSchemes.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>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -526,7 +492,7 @@ const SchemeQuery: React.FC<Props> = ({
用户:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.username || "-"}
{creatorName(scheme.username)}
</Typography>
</Box>
</Box>
@@ -537,7 +503,7 @@ const SchemeQuery: React.FC<Props> = ({
size="small"
className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => handleViewSchemeResult(scheme.scheme_name)}
onClick={() => handleViewSchemeResult(scheme.scheme_id)}
>
查看定位结果
</Button>
@@ -0,0 +1,45 @@
import { getBurstLocationErrorNotice } from "./burstLocationError";
describe("getBurstLocationErrorNotice", () => {
it("explains a missing realtime normal-pressure baseline", () => {
const notice = getBurstLocationErrorNotice({
response: {
data: {
detail: "正常压力数据 在时间窗内无有效模拟数据: 10186",
},
},
});
expect(notice.message).toBe("缺少正常模拟基线");
expect(notice.description).toContain("实时模拟正常压力数据");
expect(notice.description).toContain("10186");
expect(notice.description).toContain("系统不会自动补数据");
});
it("distinguishes incomplete burst-scheme data", () => {
const notice = getBurstLocationErrorNotice({
response: {
data: {
detail: "爆管压力数据 在时间窗内无有效模拟数据: 10186",
},
},
});
expect(notice).toEqual({
message: "所选方案数据不完整",
description:
"所选方案时间窗内没有可用的爆管压力数据,缺失点位:10186。请选择数据完整的模拟方案。",
});
});
it("preserves unrelated backend details", () => {
expect(
getBurstLocationErrorNotice({
response: { data: { detail: "分析参数不合法" } },
}),
).toEqual({
message: "提交分析失败",
description: "分析参数不合法",
});
});
});
@@ -0,0 +1,36 @@
import { getApiErrorMessage } from "@/lib/apiError";
export interface BurstLocationErrorNotice {
message: string;
description: string;
}
const DATA_GAP_PATTERN =
/^(爆管压力数据|正常压力数据|爆管流量数据|正常流量数据) 在时间窗内无有效模拟数据: (.+)$/;
export const getBurstLocationErrorNotice = (
error: unknown,
): BurstLocationErrorNotice => {
const detail = getApiErrorMessage(error);
const match = detail.match(DATA_GAP_PATTERN);
if (!match) {
return {
message: "提交分析失败",
description: detail,
};
}
const [, dataLabel, missingIds] = match;
if (dataLabel.startsWith("正常")) {
return {
message: "缺少正常模拟基线",
description: `所选方案时间窗内没有可用的实时模拟${dataLabel},缺失点位:${missingIds}。请选择已有正常基线覆盖的方案或时间窗,系统不会自动补数据。`,
};
}
return {
message: "所选方案数据不完整",
description: `所选方案时间窗内没有可用的${dataLabel},缺失点位:${missingIds}。请选择数据完整的模拟方案。`,
};
};
+1 -1
View File
@@ -66,7 +66,7 @@ export interface BurstLocationSchemeDetail {
}
export interface BurstSchemeRecord {
scheme_id: number;
scheme_id: string;
scheme_name: string;
scheme_type?: string;
create_time: string;
@@ -29,9 +29,18 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { along, lineString, length, toMercator } from "@turf/turf";
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
export interface PipePoint {
id: string;
@@ -51,7 +60,7 @@ export const createBurstAnalysisParametersState = (): BurstAnalysisParametersSta
pipePoints: [],
startTime: dayjs(new Date()),
duration: 3600,
schemeName: "FANGAN" + new Date().getTime(),
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis),
network: NETWORK_NAME,
});
@@ -74,6 +83,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
);
const { pipePoints, startTime, duration, schemeName, network } =
parametersState;
useSessionRecoveryDraft("burst-simulation", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [highlightLayer, setHighlightLayer] =
@@ -97,7 +112,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
pipePoints.length > 0 &&
startTime !== null &&
duration > 0 &&
schemeName.trim() !== "";
isSchemeNameValid(schemeName);
// 地图点击选择要素事件处理函数
const handleMapClickSelectFeatures = useCallback(
@@ -109,7 +124,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
if (!feature) return;
if (
feature.getGeometry()?.getType() === "Point" ||
(layer !== "geo_pipes_mat" && layer !== "geo_pipes")
layer !== "pipes"
) {
open?.({
type: "error",
@@ -233,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
if (highlightFeatures.length > 0 || pipePoints.length === 0) return;
queryFeaturesByIds(
pipePoints.map((pipe) => pipe.id),
"geo_pipes_mat",
"pipes",
).then((features) => {
if (features.length > 0) {
setHighlightFeatures(features);
@@ -322,16 +337,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
: "";
const modify_total_duration = duration;
const params = {
network: network,
modify_pattern_start_time: modify_pattern_start_time,
burst_ID: burst_ID,
burst_id: burst_ID,
burst_size: burst_size,
modify_total_duration: modify_total_duration,
scheme_name: schemeName,
scheme_name: normalizeSchemeName(schemeName),
};
try {
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
params,
paramsSerializer: {
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
@@ -352,8 +366,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
key: "burst-analysis",
type: "error",
message: "提交分析失败",
description:
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
description: getApiErrorMessage(error, "爆管模拟请求失败"),
});
} finally {
setAnalyzing(false);
@@ -519,6 +532,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
value={schemeName}
onChange={(e) => setParameterField("schemeName", e.target.value)}
placeholder="输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
/>
</Box>
@@ -0,0 +1,280 @@
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import AnalysisReport, {
clearAnalysisReportDiameterCache,
matchesValveAnalysis,
} from "./AnalysisReport";
import { SchemeRecord, ValveIsolationResult } from "./types";
import {
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(),
}));
const scheme: SchemeRecord = {
id: "17",
schemeName: "burst-report-demo",
type: "burst_analysis",
username: "operator",
create_time: "2026-07-30T08:00:00+08:00",
startTime: "2026-07-30T09:00:00+08:00",
schemeDetail: {
burst_ID: ["P-1", "P-2"],
burst_size: [120, 80],
modify_total_duration: 5400,
modify_fixed_pump_pattern: null,
modify_valve_opening: null,
modify_variable_pump_pattern: null,
},
};
const valveResult: ValveIsolationResult = {
accident_elements: ["P-1"],
affected_nodes: ["J-1", "J-2"],
must_close_valves: ["V-1"],
optional_valves: ["V-2"],
isolatable: true,
};
const feature = (id: string, diameter: number) => ({
getProperties: () => ({ id, diameter }),
});
describe("AnalysisReport", () => {
beforeEach(() => {
(queryFeaturesByIds as jest.Mock).mockImplementation(
async (ids: string[], layerName: string) =>
layerName === "pipes"
? ids.map((id) =>
id === "P-1" ? feature(id, 315) : feature(id, 800),
)
: [],
);
});
afterEach(() => {
clearAnalysisReportDiameterCache();
jest.clearAllMocks();
document.body.classList.remove("burst-analysis-report-printing");
});
it("guides the user to choose a scheme when no report is active", () => {
render(
<AnalysisReport
scheme={null}
valveResult={null}
disabledValves={[]}
generatedAt={null}
/>,
);
expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument();
expect(
screen.getByText(
"请在“方案查询”中打开一个方案,再查看分析报告。",
),
).toBeInTheDocument();
});
it("renders the selected scheme, pipe data, and matching valve result", async () => {
render(
<AnalysisReport
scheme={scheme}
valveResult={valveResult}
disabledValves={["V-3"]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
expect(
preview.getByRole("heading", { name: "爆管分析报告" }),
).toBeInTheDocument();
expect(preview.getByText("burst-report-demo")).toBeInTheDocument();
expect(preview.getByText("可隔离")).toBeInTheDocument();
expect(preview.getByText("V-1")).toBeInTheDocument();
expect(preview.getByText("V-3")).toBeInTheDocument();
await waitFor(() => {
expect(preview.getByText("315 mm")).toBeInTheDocument();
expect(preview.getByText("800 mm")).toBeInTheDocument();
});
expect(queryFeaturesByIds).toHaveBeenCalledWith(
["P-1", "P-2"],
"pipes",
);
});
it("does not mix an unrelated valve analysis into the report", async () => {
render(
<AnalysisReport
scheme={scheme}
valveResult={{ ...valveResult, accident_elements: ["P-99"] }}
disabledValves={[]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
expect(
preview.getByText(/尚未对本方案执行匹配的关阀分析/),
).toBeInTheDocument();
expect(preview.queryByText("V-1")).not.toBeInTheDocument();
expect(
matchesValveAnalysis(scheme, {
...valveResult,
accident_elements: ["P-99"],
}),
).toBe(false);
await waitFor(() =>
expect(preview.getByText("315 mm")).toBeInTheDocument(),
);
});
it("shows only the affected count for a non-isolatable result", async () => {
const legacyAffectedNodes = Array.from(
{ length: 100 },
(_, index) => `legacy-node-${index}`,
);
const nonIsolatableResult = {
...valveResult,
affected_nodes: legacyAffectedNodes,
affected_node_count: 85747,
must_close_valves: [],
isolatable: false,
};
render(
<AnalysisReport
scheme={scheme}
valveResult={nonIsolatableResult}
disabledValves={[]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
expect(preview.getByText("85747 个")).toBeInTheDocument();
expect(
preview.getByText("不可隔离,未生成受影响节点清单。"),
).toBeInTheDocument();
expect(preview.queryByText("legacy-node-0")).not.toBeInTheDocument();
expect(preview.queryByText("legacy-node-99")).not.toBeInTheDocument();
await waitFor(() =>
expect(preview.getByText("315 mm")).toBeInTheDocument(),
);
});
it("limits scheme-bound valve analysis to the scheme accident pipes", () => {
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true);
});
it("normalizes legacy valve results without changing isolatable lists", () => {
expect(
normalizeValveIsolationResult({
...valveResult,
affected_nodes: ["J-1", "J-2", "J-3"],
must_close_valves: [],
isolatable: false,
}),
).toMatchObject({
affected_nodes: [],
affected_node_count: 3,
isolatable: false,
});
expect(normalizeValveIsolationResult(valveResult)).toMatchObject({
affected_nodes: ["J-1", "J-2"],
affected_node_count: 2,
isolatable: true,
});
});
it("prints only after assigning the report print state", async () => {
const originalTitle = document.title;
const print = jest
.spyOn(window, "print")
.mockImplementation(() => undefined);
render(
<AnalysisReport
scheme={scheme}
valveResult={null}
disabledValves={[]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
await waitFor(() =>
expect(preview.getByText("315 mm")).toBeInTheDocument(),
);
expect(
document.querySelector(".burst-analysis-report-print-root"),
).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "打印/保存 PDF" }),
);
expect(print).toHaveBeenCalledTimes(1);
expect(
document.querySelector(".burst-analysis-report-print-root"),
).toBeInTheDocument();
expect(document.body).toHaveClass("burst-analysis-report-printing");
expect(document.title).toBe("爆管分析报告-burst-report-demo");
fireEvent(window, new Event("afterprint"));
expect(document.body).not.toHaveClass(
"burst-analysis-report-printing",
);
expect(document.title).toBe(originalTitle);
print.mockRestore();
});
it("reuses pipe diameters after the report is remounted", async () => {
const props = {
scheme,
valveResult: null,
disabledValves: [],
generatedAt: new Date("2026-07-30T10:00:00+08:00"),
};
const firstRender = render(<AnalysisReport {...props} />);
await waitFor(() =>
expect(
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
"800 mm",
),
).toBeInTheDocument(),
);
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
firstRender.unmount();
render(<AnalysisReport {...props} />);
expect(
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
"800 mm",
),
).toBeInTheDocument();
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,545 @@
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Box,
Button,
Chip,
GlobalStyles,
Paper,
Portal,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import {
DescriptionOutlined,
PrintOutlined,
} from "@mui/icons-material";
import { NETWORK_NAME } from "@config/config";
import {
clearPipeDiameterCache,
getCachedPipeDiameters,
getPipeDiameterDisplay,
getPipeDiameterQueryKey,
loadPipeDiameters,
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types";
import { getAffectedNodeCount } from "./valveIsolationScope";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface AnalysisReportProps {
scheme: SchemeRecord | null;
valveResult: ValveIsolationResult | null;
disabledValves: string[];
generatedAt: Date | null;
}
interface ReportDocumentProps extends AnalysisReportProps {
diameters: PipeDiameterMap;
loadingDiameters: boolean;
}
const REPORT_BLUE = "#0b4f87";
const REPORT_INK = "#172435";
const REPORT_MUTED = "#5f6f80";
const REPORT_LINE = "#d8e0e8";
export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
const formatDateTime = (value: Date | string | null | undefined) => {
if (!value) return "未记录";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "未记录";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
};
const formatDuration = (seconds: number | undefined) => {
if (!Number.isFinite(seconds) || seconds === undefined || seconds < 0) {
return "未记录";
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return [
hours ? `${hours} 小时` : "",
minutes ? `${minutes} 分钟` : "",
remainingSeconds || (!hours && !minutes) ? `${remainingSeconds} 秒` : "",
]
.filter(Boolean)
.join(" ");
};
export const matchesValveAnalysis = (
scheme: SchemeRecord | null,
valveResult: ValveIsolationResult | null,
) => {
const schemePipeIds = new Set(scheme?.schemeDetail?.burst_ID ?? []);
const accidentElements = valveResult?.accident_elements ?? [];
return (
valveResult !== null &&
accidentElements.length > 0 &&
accidentElements.every((pipeId) => schemePipeIds.has(pipeId))
);
};
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.5 }}>
<Box sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }} />
<Typography
component="h2"
sx={{ color: REPORT_INK, fontSize: 17, fontWeight: 700 }}
>
{children}
</Typography>
</Stack>
);
const IdList = ({
values,
emptyText = "无",
}: {
values: string[] | undefined;
emptyText?: string;
}) =>
values?.length ? (
<Box className="flex flex-wrap gap-1.5">
{values.map((value) => (
<Chip
key={value}
label={value}
size="small"
variant="outlined"
sx={{ borderColor: REPORT_LINE, color: REPORT_INK }}
/>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: REPORT_MUTED }}>
{emptyText}
</Typography>
);
const ReportDocument: React.FC<ReportDocumentProps> = ({
scheme,
valveResult,
disabledValves,
generatedAt,
diameters,
loadingDiameters,
}) => {
if (!scheme) return null;
const pipeIds = scheme.schemeDetail?.burst_ID ?? [];
const burstSizes = scheme.schemeDetail?.burst_size ?? [];
const matchedValveResult = matchesValveAnalysis(scheme, valveResult)
? valveResult
: null;
const duration = scheme.schemeDetail?.modify_total_duration;
const valveDetailRows: Array<[string, string[] | undefined]> =
matchedValveResult
? [
["已分析事故管段", matchedValveResult.accident_elements],
["必关阀门", matchedValveResult.must_close_valves],
["可选阀门", matchedValveResult.optional_valves],
["不可用阀门", disabledValves],
]
: [];
if (matchedValveResult?.isolatable) {
valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]);
}
return (
<Box
sx={{
width: "100%",
minHeight: "100%",
bgcolor: "#fff",
color: REPORT_INK,
p: { xs: 2, sm: 3 },
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
}}
>
<Box
sx={{
borderBottom: `3px solid ${REPORT_BLUE}`,
pb: 2,
mb: 3,
}}
>
<Typography
component="h1"
sx={{ color: REPORT_BLUE, fontSize: 26, fontWeight: 800 }}
>
爆管分析报告
</Typography>
<Typography sx={{ mt: 0.75, color: REPORT_MUTED, fontSize: 13 }}>
报告编号:BA-{scheme.id} 生成时间:{formatDateTime(generatedAt)}
</Typography>
</Box>
<Box sx={{ mb: 3, breakInside: "avoid" }}>
<SectionTitle>方案概况</SectionTitle>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
borderTop: `1px solid ${REPORT_LINE}`,
borderLeft: `1px solid ${REPORT_LINE}`,
}}
>
{[
["管网", NETWORK_NAME],
["方案名称", scheme.schemeName],
["方案创建人", scheme.username || "未记录"],
["方案创建时间", formatDateTime(scheme.create_time)],
["模拟开始时间", formatDateTime(scheme.startTime)],
["模拟持续时间", formatDuration(duration)],
].map(([label, value]) => (
<Box
key={label}
sx={{
p: 1.25,
borderRight: `1px solid ${REPORT_LINE}`,
borderBottom: `1px solid ${REPORT_LINE}`,
}}
>
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
{label}
</Typography>
<Typography sx={{ mt: 0.3, fontSize: 14, fontWeight: 600 }}>
{value}
</Typography>
</Box>
))}
</Box>
</Box>
<Box sx={{ mb: 3, breakInside: "avoid" }}>
<SectionTitle>爆管模拟参数</SectionTitle>
<TableContainer component={Paper} variant="outlined" elevation={0}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "#f3f7fa" }}>
<TableCell>序号</TableCell>
<TableCell>爆管管段</TableCell>
<TableCell>管径</TableCell>
<TableCell align="right">爆管面积</TableCell>
</TableRow>
</TableHead>
<TableBody>
{pipeIds.length ? (
pipeIds.map((pipeId, index) => (
<TableRow key={`${pipeId}-${index}`}>
<TableCell>{index + 1}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{pipeId}</TableCell>
<TableCell>
{getPipeDiameterDisplay(
[pipeId],
diameters,
loadingDiameters,
)}
</TableCell>
<TableCell align="right">
{Number.isFinite(burstSizes[index])
? `${burstSizes[index]} cm²`
: "未记录"}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} align="center">
未记录爆管管段
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Box>
<Box sx={{ mb: 3, breakInside: "avoid" }}>
<SectionTitle>分析结论</SectionTitle>
<Alert
severity={matchedValveResult?.isolatable === false ? "warning" : "info"}
variant="outlined"
>
本方案记录了 {pipeIds.length} 条爆管管段,模拟持续时间为
{formatDuration(duration)}。
{matchedValveResult
? ` 当前关阀分析判定事故管段${
matchedValveResult.isolatable ? "可以" : "无法"
}有效隔离。`
: " 当前会话尚未对本方案执行匹配的关阀分析。"}
</Alert>
</Box>
<Box sx={{ mb: 2 }}>
<SectionTitle>关阀分析</SectionTitle>
{matchedValveResult ? (
<Stack spacing={2}>
<Box className="grid grid-cols-3 gap-2">
{[
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`],
["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`],
].map(([label, value]) => (
<Paper
key={label}
variant="outlined"
sx={{ p: 1.5, textAlign: "center", breakInside: "avoid" }}
>
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
{label}
</Typography>
<Typography sx={{ mt: 0.5, fontWeight: 700 }}>
{value}
</Typography>
</Paper>
))}
</Box>
{valveDetailRows.map(([label, values]) => (
<Box key={label} sx={{ breakInside: "avoid" }}>
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
{label}
</Typography>
<IdList values={values} />
</Box>
))}
{!matchedValveResult.isolatable && (
<Alert severity="info" variant="outlined">
不可隔离,未生成受影响节点清单。
</Alert>
)}
</Stack>
) : (
<Alert severity="info" variant="outlined">
本方案尚未执行关阀分析。可在“关阀分析”页签完成分析后重新查看报告。
</Alert>
)}
</Box>
</Box>
);
};
const AnalysisReport: React.FC<AnalysisReportProps> = ({
scheme,
valveResult,
disabledValves,
generatedAt,
}) => {
const pipeIds = useMemo(
() => scheme?.schemeDetail?.burst_ID ?? [],
[scheme],
);
const diameterQueryKey = getPipeDiameterQueryKey(pipeIds);
const cachedDiameters = getCachedPipeDiameters(pipeIds);
const [diameterState, setDiameterState] = useState<{
queryKey: string | null;
values: PipeDiameterMap;
}>({ queryKey: null, values: {} });
const [printReady, setPrintReady] = useState(false);
const printStateRef = useRef<{ title: string } | null>(null);
const diameters = useMemo(
() =>
diameterState.queryKey === diameterQueryKey
? diameterState.values
: cachedDiameters ?? {},
[cachedDiameters, diameterQueryKey, diameterState],
);
const loadingDiameters =
pipeIds.length > 0 &&
diameterState.queryKey !== diameterQueryKey &&
!cachedDiameters;
useEffect(() => {
if (!pipeIds.length) {
return;
}
if (getCachedPipeDiameters(pipeIds)) {
return;
}
let cancelled = false;
loadPipeDiameters(pipeIds)
.then((nextDiameters) => {
if (!cancelled) {
setDiameterState({
queryKey: diameterQueryKey,
values: nextDiameters,
});
}
})
.catch((error) => {
console.error("查询分析报告管径失败:", error);
if (!cancelled) {
setDiameterState({
queryKey: diameterQueryKey,
values: Object.fromEntries(
pipeIds.map((pipeId) => [pipeId, null]),
),
});
}
});
return () => {
cancelled = true;
};
}, [diameterQueryKey, pipeIds]);
const restoreAfterPrint = useCallback(() => {
if (printStateRef.current) {
document.title = printStateRef.current.title;
printStateRef.current = null;
document.body.classList.remove("burst-analysis-report-printing");
}
setPrintReady(false);
}, []);
useEffect(
() => () => {
restoreAfterPrint();
},
[restoreAfterPrint],
);
const handlePrint = () => {
if (!scheme || printStateRef.current) return;
printStateRef.current = { title: document.title };
document.title = `爆管分析报告-${scheme.schemeName}`;
document.body.classList.add("burst-analysis-report-printing");
setPrintReady(true);
};
useEffect(() => {
if (!printReady) return;
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
try {
window.print();
} catch (error) {
console.error("打印爆管分析报告失败:", error);
window.setTimeout(restoreAfterPrint, 0);
}
return () => {
window.removeEventListener("afterprint", restoreAfterPrint);
};
}, [printReady, restoreAfterPrint]);
const reportDocument = useMemo(
() => (
<ReportDocument
scheme={scheme}
valveResult={valveResult}
disabledValves={disabledValves}
generatedAt={generatedAt}
diameters={diameters}
loadingDiameters={loadingDiameters}
/>
),
[
diameters,
disabledValves,
generatedAt,
loadingDiameters,
scheme,
valveResult,
],
);
if (!scheme) {
return (
<PanelEmptyState
icon={<DescriptionOutlined />}
title="尚未选择分析方案"
description="请在“方案查询”中打开一个方案,再查看分析报告。"
/>
);
}
return (
<>
<GlobalStyles
styles={{
".burst-analysis-report-print-root": { display: "none" },
"@page": { size: "A4 portrait", margin: 0 },
"@media print": {
"html, body": {
width: "210mm",
minHeight: "297mm",
margin: 0,
padding: 0,
backgroundColor: "#fff",
},
"body.burst-analysis-report-printing > *:not(.burst-analysis-report-print-root)":
{ display: "none !important" },
".burst-analysis-report-print-root": {
display: "block !important",
width: "210mm !important",
minHeight: "297mm !important",
backgroundColor: "#fff !important",
},
".burst-analysis-report-print-root > div": {
padding: "14mm 16mm !important",
},
},
}}
/>
<Box className="space-y-3">
<Box className="flex justify-end">
<Button
variant="contained"
size="small"
startIcon={<PrintOutlined />}
onClick={handlePrint}
>
打印/保存 PDF
</Button>
</Box>
<Paper
data-testid="burst-analysis-report-preview"
variant="outlined"
sx={{ overflow: "hidden" }}
>
{reportDocument}
</Paper>
</Box>
{printReady && (
<Portal>
<Box
className="burst-analysis-report-print-root"
aria-hidden="true"
>
{reportDocument}
</Box>
</Portal>
)}
</>
);
};
export default AnalysisReport;
@@ -0,0 +1,154 @@
import { fireEvent, render, screen } from "@testing-library/react";
import BurstPipeAnalysisPanel from "./BurstPipeAnalysisPanel";
jest.mock("./AnalysisParameters", () => ({
__esModule: true,
default: () => <div>analysis parameters</div>,
createBurstAnalysisParametersState: () => ({}),
}));
jest.mock("./SchemeQuery", () => ({
__esModule: true,
default: ({
onViewReport,
}: {
onViewReport: (scheme: Record<string, unknown>) => void;
}) => {
const createScheme = (id: number, schemeName: string) => ({
id,
schemeName,
type: "burst_analysis",
username: "operator",
create_time: "2026-07-30T08:00:00+08:00",
startTime: "2026-07-30T09:00:00+08:00",
schemeDetail: {
burst_ID: ["P-1", "P-2"],
burst_size: [120, 80],
},
});
return (
<>
<button onClick={() => onViewReport(createScheme(1, "selected-scheme"))}>
mock report action
</button>
<button onClick={() => onViewReport(createScheme(2, "other-scheme"))}>
mock other report action
</button>
</>
);
},
createBurstSchemeQueryState: () => ({}),
}));
jest.mock("./AnalysisReport", () => ({
__esModule: true,
default: ({
scheme,
valveResult,
}: {
scheme: { schemeName: string } | null;
valveResult: { accident_elements: string[] } | null;
}) => (
<div>
report:{scheme?.schemeName ?? "empty"}:valve:
{valveResult?.accident_elements.join(",") ?? "none"}
</div>
),
matchesValveAnalysis: (
scheme: { schemeDetail: { burst_ID: string[] } },
valveResult: { accident_elements: string[] } | null,
) =>
valveResult !== null &&
valveResult.accident_elements.every((pipeId) =>
scheme.schemeDetail.burst_ID.includes(pipeId),
),
}));
jest.mock("./ValveIsolation", () => ({
__esModule: true,
default: ({
allowedPipeIds,
sourceSchemeName,
onResultChange,
}: {
allowedPipeIds?: string[];
sourceSchemeName?: string;
onResultChange: (result: Record<string, unknown>) => void;
}) => (
<>
<div data-testid="valve-scope">
valve scope:{sourceSchemeName ?? "independent"}:
{allowedPipeIds?.join(",") ?? "any"}
</div>
<button
onClick={() =>
onResultChange({
accident_elements: ["P-1"],
affected_nodes: ["J-1"],
must_close_valves: ["V-1"],
optional_valves: [],
isolatable: true,
})
}
>
mock valve result
</button>
</>
),
createValveIsolationState: () => ({
selectedPipeId: null,
activeStep: 0,
expandedResult: true,
disabledValves: [],
}),
}));
describe("BurstPipeAnalysisPanel", () => {
it("replaces the obsolete location tab and opens the selected report", () => {
render(<BurstPipeAnalysisPanel />);
expect(
screen.queryByRole("tab", { name: /定位结果/ }),
).not.toBeInTheDocument();
expect(
screen.getByRole("tab", { name: /分析报告/ }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock report action" }),
);
expect(
screen.getByText("report:selected-scheme:valve:none"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
expect(screen.getByTestId("valve-scope")).toHaveTextContent(
"valve scope:selected-scheme:P-1,P-2",
);
});
it("does not reuse valve results across schemes with the same pipe", () => {
render(<BurstPipeAnalysisPanel />);
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock report action" }),
);
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock valve result" }),
);
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock other report action" }),
);
expect(
screen.getByText("report:other-scheme:valve:none"),
).toBeInTheDocument();
});
});
@@ -15,7 +15,7 @@ import {
ChevronLeft,
Analytics as AnalyticsIcon,
Search as SearchIcon,
MyLocation as MyLocationIcon,
DescriptionOutlined as ReportIcon,
Handyman as HandymanIcon,
} from "@mui/icons-material";
import AnalysisParameters, {
@@ -26,15 +26,12 @@ import SchemeQuery, {
createBurstSchemeQueryState,
type BurstSchemeQueryState,
} from "./SchemeQuery";
import LocationResults from "./LocationResults";
import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
import ValveIsolation, {
createValveIsolationState,
type ValveIsolationState,
} from "./ValveIsolation";
import { api } from "@/lib/api";
import { config } from "@config/config";
import { useNotification } from "@refinedev/core";
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
import { SchemeRecord, ValveIsolationResult } from "./types";
interface TabPanelProps {
children?: React.ReactNode;
@@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
// 持久化方案查询结果
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
// 定位结果数据
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
// 关阀分析结果和加载状态
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
useState<string | null>(null);
const [valveIsolationState, setValveIsolationState] =
useState<ValveIsolationState>(createValveIsolationState);
const { open } = useNotification();
// 使用受控或非受控状态
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const handleToggle = () => {
@@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
setCurrentTab(newValue);
};
const handleLocateScheme = async (scheme: SchemeRecord) => {
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
);
setLocationResults(response.data);
setCurrentTab(2); // 切换到定位结果标签页
} catch (error) {
console.error("获取定位结果失败:", error);
open?.({
type: "error",
message: "获取定位结果失败",
description: "无法从服务器获取该方案的定位结果",
});
const handleViewReport = (scheme: SchemeRecord) => {
if (
valveAnalysisSchemeName !== scheme.schemeName ||
!matchesValveAnalysis(scheme, valveAnalysisResult)
) {
setValveAnalysisResult(null);
setValveAnalysisSchemeName(null);
setValveIsolationState(createValveIsolationState());
}
setReportScheme(scheme);
setReportGeneratedAt(new Date());
setCurrentTab(2);
};
const handleValveAnalysisResultChange = (
result: ValveIsolationResult | null,
) => {
setValveAnalysisResult(result);
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
};
const drawerWidth = 520;
@@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
label="方案查询"
/>
<Tab
icon={<MyLocationIcon fontSize="small" />}
icon={<ReportIcon fontSize="small" />}
iconPosition="start"
label="定位结果"
label="分析报告"
/>
<Tab
icon={<HandymanIcon fontSize="small" />}
@@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
<SchemeQuery
schemes={schemes}
onSchemesChange={setSchemes}
onLocate={handleLocateScheme}
onViewReport={handleViewReport}
state={queryState}
onStateChange={setQueryState}
/>
</TabPanel>
<TabPanel value={currentTab} index={2}>
<LocationResults
results={locationResults}
<AnalysisReport
scheme={reportScheme}
valveResult={valveAnalysisResult}
disabledValves={valveIsolationState.disabledValves}
generatedAt={reportGeneratedAt}
/>
</TabPanel>
@@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
loading={valveAnalysisLoading}
result={valveAnalysisResult}
onLoadingChange={setValveAnalysisLoading}
onResultChange={setValveAnalysisResult}
onResultChange={handleValveAnalysisResultChange}
state={valveIsolationState}
onStateChange={setValveIsolationState}
allowedPipeIds={
reportScheme?.schemeDetail?.burst_ID
}
sourceSchemeName={reportScheme?.schemeName}
/>
</TabPanel>
</Box>
@@ -1,416 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Box,
Typography,
Chip,
IconButton,
Tooltip,
Link,
} from "@mui/material";
import {
LocationOn as LocationIcon,
} from "@mui/icons-material";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Stroke, Style, Icon } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import {
along,
lineString,
length,
toMercator,
bbox,
featureCollection,
} from "@turf/turf";
import { Point } from "ol/geom";
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[];
}
const LocationResults: React.FC<LocationResultsProps> = ({
results = [],
}) => {
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const map = useMap();
// 格式化时间为 UTC+8
const formatTime = (timeStr: string) => {
return moment(timeStr).utcOffset(8).format("YYYY-MM-DD HH:mm:ss");
};
const handleLocatePipes = (pipeIds: string[]) => {
if (pipeIds.length > 0) {
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
if (features.length > 0) {
// 设置高亮要素
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 });
}
}
});
}
};
// 初始化管道图层和高亮图层
useEffect(() => {
if (!map) return;
const burstPipeStyle = function (feature: FeatureLike) {
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;
};
// 创建高亮图层
const highlightLayer = new VectorLayer({
source: new VectorSource(),
style: burstPipeStyle,
maxZoom: 24,
minZoom: 12,
properties: {
name: "爆管管段高亮",
value: "burst_pipe_highlight",
queryable: false,
},
});
map.addLayer(highlightLayer);
highlightLayerRef.current = highlightLayer;
return () => {
highlightLayerRef.current = null;
map.removeLayer(highlightLayer);
};
}, [map]);
// 高亮要素的函数
useEffect(() => {
const source = highlightLayerRef.current?.getSource();
if (!source) {
return;
}
// 清除之前的高亮
source.clear();
// 添加新的高亮要素
highlightFeatures.forEach((feature) => {
if (feature instanceof Feature) {
source.addFeature(feature);
}
});
}, [highlightFeatures]);
// 取第一条记录或空对象
const result = results.length > 0 ? results[0] : null;
return (
<Box className="flex flex-col h-full">
{/* 结果展示 */}
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200">
{!result ? (
<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"
/>
<circle cx="40" cy="40" r="5" fill="currentColor" />
<line
x1="40"
y1="15"
x2="40"
y2="25"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="40"
y1="55"
x2="40"
y2="65"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="15"
y1="40"
x2="25"
y2="40"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="55"
y1="40"
x2="65"
y2="40"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2">暂无定位结果</Typography>
<Typography variant="body2" className="mt-1">
请先执行方案分析
</Typography>
</Box>
) : (
<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"
title={result.burst_incident}
>
{result.burst_incident}
</Typography>
<Chip
label={
result.type === "burst_analysis" ? "爆管模拟" : result.type
}
size="small"
color="primary"
variant="outlined"
sx={{
fontWeight: 600,
fontSize: "0.75rem",
height: "24px",
}}
/>
</Box>
<Typography variant="caption" className="text-gray-500">
ID: {result.id}
</Typography>
</Box>
{/* 主要信息:三栏卡片布局 */}
<Box className="grid grid-cols-3 gap-3 mb-5">
{/* 检测时间卡片 */}
<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 leading-tight"
sx={{ fontSize: "0.875rem" }}
>
{formatTime(result.detect_time)}
</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.leakage !== null
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
: "N/A"}
</Typography>
</Box>
{/* 定位管段数量卡片 */}
<Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-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-green-600"></Box>
<Typography
variant="caption"
className="text-green-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
定位管段
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-green-900"
sx={{ fontSize: "0.875rem" }}
>
{result.locate_result ? result.locate_result.length : 0}{" "}
个管段
</Typography>
</Box>
</Box>
{/* 定位管段详细列表 */}
{result.locate_result && result.locate_result.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>
<Box className="flex items-center gap-2">
<Tooltip title="定位所有管道">
<IconButton
size="small"
onClick={() => handleLocatePipes(result.locate_result!)}
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>
<Box className="grid grid-cols-2 gap-2">
{result.locate_result.map((pipeId, 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={() => handleLocatePipes([pipeId])}
sx={{
"&:active": {
transform: "scale(0.98)",
boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)",
},
}}
>
<Box className="flex items-center justify-between">
<Typography
variant="body2"
className="font-semibold text-blue-700 group-hover:text-blue-900"
>
{pipeId}
</Typography>
<Box className="flex items-center gap-1">
{/* <Tooltip title="定位管段">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleLocatePipes([pipeId]);
}}
sx={{
"&:hover": {
backgroundColor: "rgba(37, 125, 212, 0.1)",
},
}}
>
<LocationIcon sx={{ fontSize: "1rem" }} />
</IconButton>
</Tooltip> */}
</Box>
</Box>
</Box>
))}
</Box>
</Box>
)}
</Box>
)}
</Box>
</Box>
);
};
export default LocationResults;
@@ -18,6 +18,7 @@ import {
Link,
} from "@mui/material";
import {
DescriptionOutlined as ReportIcon,
Info as InfoIcon,
LocationOn as LocationIcon,
} from "@mui/icons-material";
@@ -26,9 +27,9 @@ 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 { listAnalysisSchemes } from "@/lib/analysisRuns";
import moment from "moment";
import { config, NETWORK_NAME } from "@config/config";
import { NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
@@ -50,16 +51,20 @@ import {
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemaItem, SchemeRecord } from "./types";
import { SchemeRecord } from "./types";
import {
getCachedPipeDiameters,
getPipeDiameterDisplay,
loadPipeDiameters,
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps {
schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void;
onLocate?: (scheme: SchemeRecord) => void;
onViewReport?: (scheme: SchemeRecord) => void;
network?: string;
state?: BurstSchemeQueryState;
onStateChange?: (state: BurstSchemeQueryState) => void;
@@ -73,7 +78,8 @@ export interface BurstSchemeQueryState {
showTimeline: boolean;
selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null;
expandedId: string | null;
hasQueried: boolean;
}
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
@@ -83,12 +89,13 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
selectedDate: undefined,
timeRange: undefined,
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes,
onSchemesChange,
onLocate,
onViewReport,
network = NETWORK_NAME,
state,
onStateChange,
@@ -105,6 +112,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate,
timeRange,
expandedId,
hasQueried,
} = queryState;
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
@@ -112,19 +120,20 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const creatorName = useSchemeCreatorName();
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
Record<number, PipeDiameterMap>
Record<string, PipeDiameterMap>
>({});
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
Record<number, boolean>
Record<string, boolean>
>({});
const { open } = useNotification();
const map = useMap();
const data = useData();
const { schemeName, setSchemeName } = data || {};
const { schemeName, setSchemeName, schemeRunId, setSchemeRunId } = data || {};
// 使用外部提供的 schemes 或内部状态
const schemes =
@@ -152,33 +161,18 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true);
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
);
let filteredResults = response.data;
if (!queryAll) {
const formattedDate = queryDate!.format("YYYY-MM-DD");
filteredResults = response.data.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,
}));
const nextSchemes = (await listAnalysisSchemes({
runType: SCHEME_TYPE,
queryDate: !queryAll && queryDate
? queryDate.format("YYYY-MM-DD")
: undefined,
})) as unknown as SchemeRecord[];
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
if (nextSchemes.length === 0) {
open?.({
type: "error",
type: "success",
message: "查询结果",
description: queryAll
? "没有找到任何方案"
@@ -188,7 +182,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
description: `共找到 ${nextSchemes.length} 条方案记录`,
});
}
} catch (error) {
@@ -205,7 +199,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const handleLocatePipes = (pipeIds: string[]) => {
if (pipeIds.length > 0) {
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
queryFeaturesByIds(pipeIds, "pipes").then((features) => {
if (features.length > 0) {
// 设置高亮要素
setHighlightFeatures(features);
@@ -236,40 +230,23 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
return;
}
const cachedDiameters = getCachedPipeDiameters(pipeIds);
if (cachedDiameters) {
setPipeDiametersByScheme((previous) => ({
...previous,
[expandedId]: cachedDiameters,
}));
return;
}
let cancelled = false;
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: true,
}));
const loadPipeDiameters = async () => {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = pipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
const nextDiameters: PipeDiameterMap = Object.fromEntries(
pipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
});
const applyPipeDiameters = async () => {
const nextDiameters = await loadPipeDiameters(pipeIds);
if (cancelled) {
return;
}
@@ -284,7 +261,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}));
};
loadPipeDiameters().catch((error) => {
applyPipeDiameters().catch((error) => {
console.error("查询管径失败:", error);
if (!cancelled) {
setLoadingDiameterByScheme((previous) => ({
@@ -300,7 +277,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}, [expandedId, filteredSchemes, pipeDiametersByScheme]);
// 内部的方案查询函数
const handleViewDetails = (id: number) => {
const handleViewDetails = (id: string) => {
const scheme = filteredSchemes.find((s) => s.id === id);
if (!scheme) return;
@@ -322,6 +299,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}));
}
setSchemeName?.(scheme.schemeName);
setSchemeRunId?.(scheme.id);
handleLocatePipes(scheme.schemeDetail?.burst_ID || []);
};
@@ -439,6 +417,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
timeRange={timeRange}
disableDateSelection={!!timeRange}
schemeName={schemeName}
schemeRunId={schemeRunId}
schemeType={SCHEME_TYPE}
/>,
mapContainer, // 渲染到地图容器中,而不是 body
@@ -452,7 +431,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -465,9 +447,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
}
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -495,39 +480,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */}
<Box className="flex-1 overflow-auto">
{filteredSchemes.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>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -592,14 +545,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="查看定位结果">
<Tooltip title="查看分析报告">
<IconButton
size="small"
onClick={() => onLocate?.(scheme)}
onClick={() => onViewReport?.(scheme)}
color="primary"
className="p-1"
>
<LocationIcon fontSize="small" />
<ReportIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
@@ -715,7 +668,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="font-medium text-gray-900"
>
{scheme.user}
{creatorName(scheme.username)}
</Typography>
</Box>
<Box className="flex items-center gap-2">
@@ -52,9 +52,16 @@ import {
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import {
getAffectedNodeCount,
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
interface ValveIsolationProps {
initialPipeIds?: string[];
allowedPipeIds?: string[];
sourceSchemeName?: string;
shouldFetch?: boolean;
onFetchComplete?: () => void;
loading?: boolean;
@@ -81,6 +88,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({
const ValveIsolation: React.FC<ValveIsolationProps> = ({
initialPipeIds = [],
allowedPipeIds,
sourceSchemeName,
shouldFetch = false,
onFetchComplete,
loading: externalLoading,
@@ -160,14 +169,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
return;
}
setSelectedPipeId(pipeId);
const normalizedPipeId = String(pipeId);
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
open?.({
type: "error",
message: "请选择当前爆管方案中的事故管段",
});
return;
}
setSelectedPipeId(normalizedPipeId);
setHighlightFeature(feature);
setIsSelecting(false);
setResult(null); // 清除旧结果
}
}
},
[isSelecting, map, open, setResult, setSelectedPipeId],
[
allowedPipeIds,
isSelecting,
map,
open,
setResult,
setSelectedPipeId,
],
);
useEffect(() => {
@@ -185,7 +210,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
useEffect(() => {
if (!selectedPipeId || highlightFeature) return;
queryFeaturesByIds([selectedPipeId], "geo_pipes_mat").then((features) => {
queryFeaturesByIds([selectedPipeId], "pipes").then((features) => {
setHighlightFeature(features[0] ?? null);
});
}, [highlightFeature, selectedPipeId]);
@@ -207,10 +232,20 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
"must_close" | "optional" | "affected_node" | "pipe"
>("affected_node");
const selectSchemeAccidentPipe = (pipeId: string) => {
if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return;
setSelectedPipeId(pipeId);
setHighlightFeature(null);
setHighlightFeatures([]);
setResult(null);
setActiveStep(0);
setExpandedResult(true);
setDisabledValves([]);
};
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
if (pipeIds.length > 0) {
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
queryFeaturesByIds(pipeIds, "pipes").then((features) => {
if (features.length > 0) {
if (highlight) {
// 设置高亮类型为管段
@@ -235,7 +270,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
const handleLocateNodes = (nodeIds: string[]) => {
if (nodeIds.length > 0) {
queryFeaturesByIds(nodeIds, "geo_junctions").then((features) => {
queryFeaturesByIds(nodeIds, "junctions").then((features) => {
if (features.length > 0) {
// 设置高亮类型为受影响节点
setHighlightType("affected_node");
@@ -259,7 +294,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
const handleLocateMustCloseValves = (valveIds: string[]) => {
if (valveIds.length > 0) {
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
queryFeaturesByIds(valveIds, "valves").then((features) => {
if (features.length > 0) {
// 设置高亮类型为必关阀门
setHighlightType("must_close");
@@ -283,7 +318,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
const handleLocateOptionalValves = (valveIds: string[]) => {
if (valveIds.length > 0) {
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
queryFeaturesByIds(valveIds, "valves").then((features) => {
if (features.length > 0) {
// 设置高亮类型为可选阀门
setHighlightType("optional");
@@ -311,6 +346,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
return;
}
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
open?.({
type: "error",
message: "关阀分析仅限当前爆管方案中的事故管段",
});
return;
}
setLoading(true);
const isExpandSearch = disabled.length > 0;
if (!isExpandSearch) {
@@ -319,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
}
try {
const params: any = {
network: NETWORK_NAME,
accident_element: ids,
};
if (disabled.length > 0) {
params.disabled_valves = disabled;
}
const response = await api.get(
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
const response = await api.post<ValveIsolationResult>(
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
undefined,
{
params,
paramsSerializer: {
@@ -334,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
},
},
);
setResult(response.data);
setResult(normalizeValveIsolationResult(response.data));
if (!isExpandSearch) {
setActiveStep(1);
} else {
@@ -352,7 +394,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
setLoading(false);
}
},
[open, setActiveStep, setDisabledValves, setLoading, setResult],
[
allowedPipeIds,
open,
setActiveStep,
setDisabledValves,
setLoading,
setResult,
],
);
// 监听外部传入的分析请求
@@ -362,7 +411,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
setSelectedPipeId(initialPipeIds[0]);
// 尝试获取Feature以高亮 (可选)
queryFeaturesByIds(initialPipeIds, "geo_pipes_mat").then((features) => {
queryFeaturesByIds(initialPipeIds, "pipes").then((features) => {
if (features && features.length > 0) {
setHighlightFeature(features[0]);
}
@@ -666,7 +715,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
{[
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
{ label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
{ label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
].map((item, index) => (
<Box
key={index}
@@ -832,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</Box>
)}
{!result.isolatable && (
<Alert severity="info" variant="outlined">
不可隔离,未生成受影响节点清单。
</Alert>
)}
{/* 受影响节点 */}
{result.affected_nodes && result.affected_nodes.length > 0 && (
{result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && (
<Box>
<Box className="flex items-center justify-between mb-2">
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
@@ -912,6 +967,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</StepLabel>
<StepContent>
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
{allowedPipeIds !== undefined ? (
<Alert severity="info" variant="outlined">
已绑定爆管方案“{sourceSchemeName || "未命名方案"}”。关阀分析仅限该方案中的事故管段,分析结果将写入对应报告。
</Alert>
) : (
<Alert severity="warning" variant="outlined">
当前为独立关阀分析,结果不会自动写入爆管分析报告。请先从“方案查询”打开对应分析报告以建立关联。
</Alert>
)}
{/* 选择管段 */}
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
<Box className="flex items-center justify-between mb-2">
@@ -955,6 +1020,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
)}
</Box>
{allowedPipeIds !== undefined && (
<Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2">
<Box className="mb-2 flex flex-wrap gap-1.5">
{allowedPipeIds.map((pipeId) => (
<Chip
key={pipeId}
label={pipeId}
size="small"
color={selectedPipeId === pipeId ? "primary" : "default"}
variant={selectedPipeId === pipeId ? "filled" : "outlined"}
onClick={() => selectSchemeAccidentPipe(pipeId)}
/>
))}
</Box>
<Button
variant="outlined"
size="small"
fullWidth
disabled={allowedPipeIds.length === 0}
onClick={() =>
allowedPipeIds[0] &&
selectSchemeAccidentPipe(allowedPipeIds[0])
}
startIcon={<CheckCircleIcon />}
>
选择当前爆管管段
</Button>
</Box>
)}
{isSelecting && (
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
💡 点击地图上的管道添加爆管点
@@ -1,4 +1,48 @@
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import {
clearPipeDiameterCache,
getPipeDiameterDisplay,
loadPipeDiameters,
} from "./schemePipeDiameters";
jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(),
}));
const feature = (id: string, diameter: number) => ({
getProperties: () => ({ id, diameter }),
});
afterEach(() => {
clearPipeDiameterCache();
jest.clearAllMocks();
});
describe("loadPipeDiameters", () => {
it("shares an in-flight WFS request and reuses its resolved result", async () => {
let resolveFeatures: ((features: ReturnType<typeof feature>[]) => void) | undefined;
const pendingFeatures = new Promise<ReturnType<typeof feature>[]>((resolve) => {
resolveFeatures = resolve;
});
(queryFeaturesByIds as jest.Mock).mockImplementation(
async () => pendingFeatures,
);
const firstRequest = loadPipeDiameters(["P-1"]);
const duplicateRequest = loadPipeDiameters(["P-1"]);
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
resolveFeatures?.([feature("P-1", 315)]);
await expect(firstRequest).resolves.toEqual({ "P-1": 315 });
await expect(duplicateRequest).resolves.toEqual({ "P-1": 315 });
await expect(loadPipeDiameters(["P-1"])).resolves.toEqual({
"P-1": 315,
});
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
});
});
describe("getPipeDiameterDisplay", () => {
it("shows the actual diameter for one burst pipe", () => {
@@ -1,5 +1,80 @@
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import config from "@config/config";
export type PipeDiameterMap = Record<string, number | null | undefined>;
const diameterCache = new Map<string, PipeDiameterMap>();
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
const normalizePipeIds = (pipeIds: string[]) =>
[...new Set(pipeIds.map((pipeId) => String(pipeId).trim()).filter(Boolean))];
export const getPipeDiameterQueryKey = (pipeIds: string[]) =>
[
config.MAP_URL,
config.MAP_WORKSPACE,
...normalizePipeIds(pipeIds).sort(),
].join("\u0000");
export const getCachedPipeDiameters = (pipeIds: string[]) =>
diameterCache.get(getPipeDiameterQueryKey(pipeIds));
export const clearPipeDiameterCache = () => {
diameterCache.clear();
diameterRequestCache.clear();
};
export const loadPipeDiameters = async (
pipeIds: string[],
): Promise<PipeDiameterMap> => {
const normalizedPipeIds = normalizePipeIds(pipeIds);
if (normalizedPipeIds.length === 0) {
return {};
}
const queryKey = getPipeDiameterQueryKey(normalizedPipeIds);
const cachedDiameters = diameterCache.get(queryKey);
if (cachedDiameters) {
return cachedDiameters;
}
const cachedRequest = diameterRequestCache.get(queryKey);
if (cachedRequest) {
return cachedRequest;
}
const request = (async () => {
const features = await queryFeaturesByIds(
normalizedPipeIds,
"pipes",
);
const diameters: PipeDiameterMap = Object.fromEntries(
normalizedPipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
if (pipeId in diameters) {
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
}
});
diameterCache.set(queryKey, diameters);
return diameters;
})();
diameterRequestCache.set(queryKey, request);
try {
return await request;
} finally {
if (diameterRequestCache.get(queryKey) === request) {
diameterRequestCache.delete(queryKey);
}
}
};
export const getPipeDiameterDisplay = (
pipeIds: string[] | undefined,
diameters: PipeDiameterMap | undefined,

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