44 Commits
Author SHA1 Message Date
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
jiang 1993cadba8 chore(config): replace env file with example 2026-07-20 12:09:24 +08:00
jiang 7a3677ee9f feat(config): inject frontend runtime config 2026-07-20 12:06:57 +08:00
jiang 0d559f6130 fix(scada): show backend detail on cleaning errors
Build Push and Deploy / docker-image (push) Successful in 1m3s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-17 16:27:40 +08:00
jiang 7af90e495d feat(burst-detection): update analysis parameters 2026-07-17 16:24:04 +08:00
jiang a52c04204d refactor(admin): simplify system management UI 2026-07-17 16:22:16 +08:00
jiang 08152ff978 fix(map): restore saved styles as inactive drafts 2026-07-17 15:28:44 +08:00
jiang 589cf45aa7 fix(map): stabilize tiled style rendering 2026-07-17 15:12:10 +08:00
jiang 59447a100c fix(scada): align device panel collapse animation 2026-07-17 14:25:03 +08:00
jiang 4adbcc1c4c fix(dma): defer leakage flow validation 2026-07-17 11:45:58 +08:00
jiang 202f18332f fix(notification): clarify query and progress feedback 2026-07-17 10:48:58 +08:00
jiang acf13639ef fix(map): allow selecting features under overlays 2026-07-17 10:21:16 +08:00
jiang d986e563a6 fix(map): convert actual demand display units 2026-07-17 10:13:50 +08:00
jiang d8ee2e1f0c fix(burst): show actual pipe diameters 2026-07-17 10:01:06 +08:00
jiang fddb0ceb34 fix(map): restrict waterflow layer to flow 2026-07-16 18:02:29 +08:00
jiang 3d7b594682 style(map): align valve setting input 2026-07-16 17:55:58 +08:00
jiang eb8950c89a feat(map): add valve setting editor 2026-07-16 17:48:16 +08:00
jiang ef2b045306 fix(map): show simulation pipe ids 2026-07-16 14:50:22 +08:00
jiang 209da0d295 fix(map): sort scheme queries by time 2026-07-16 14:29:29 +08:00
jiang dfaee645ff fix(timeline): use backend timestep 2026-07-16 14:16:07 +08:00
jiang d90ca7c951 fix(timeline): align range mask with slider 2026-07-16 11:38:22 +08:00
jiang 041b4ef89d fix(timeline): prevent negative initial time 2026-07-16 11:31:51 +08:00
jiang f5e7312e3b perf(map): reuse resources across routes
Build Push and Deploy / docker-image (push) Successful in 54s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
Preserve standard network layers between map pages while disposing route-owned overlays and controls to prevent memory growth.
2026-07-10 15:31:14 +08:00
jiang c6e6e24aab fix(chat): preserve agent UI across close 2026-07-10 14:38:25 +08:00
jiang adb53d9a13 feat(chat): add selection-based speech playback 2026-07-10 14:29:27 +08:00
196 changed files with 115677 additions and 6716 deletions
+2
View File
@@ -3,6 +3,8 @@ node_modules
out out
build build
.git .git
.env
.env.*
.env*.local .env*.local
README.md README.md
docker-compose.yml docker-compose.yml
-16
View File
@@ -1,16 +0,0 @@
KEYCLOAK_CLIENT_ID="tjwater"
KEYCLOAK_CLIENT_SECRET="83h0n413hau9bldzWdEaq6xRfASv24s5"
KEYCLOAK_ISSUER="https://keycloak.waternetwork.cn/realms/tjwater"
NEXTAUTH_SECRET="eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiS"
NEXTAUTH_URL="https://demo.waternetwork.cn/"
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
NEXT_PUBLIC_AGENT_URL="https://agent.waternetwork.cn"
NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25"
NEXT_PUBLIC_NETWORK_NAME="tjwater"
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"
NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec"
+14
View File
@@ -0,0 +1,14 @@
KEYCLOAK_CLIENT_ID="tjwater"
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
NEXTAUTH_SECRET="replace-with-nextauth-secret"
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"
MAPBOX_TOKEN="replace-with-public-mapbox-token"
TIANDITU_TOKEN="replace-with-public-tianditu-token"
-9
View File
@@ -105,15 +105,6 @@ jobs:
-t "${IMAGE_NAME}:${IMAGE_TAG}" \ -t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \ -t "${IMAGE_NAME}:latest" \
--build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \ --build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
--build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
--build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \
--build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \
--build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \
--build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \
. .
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}" push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest" push_with_retry "${IMAGE_NAME}:latest"
+4 -1
View File
@@ -19,6 +19,7 @@
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
/public/runtime-config.js
# debug # debug
npm-debug.log* npm-debug.log*
@@ -26,7 +27,9 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env.local .env
.env.*
!.env.example
# vercel # vercel
.vercel .vercel
+4 -13
View File
@@ -18,18 +18,6 @@ RUN \
FROM base AS builder FROM base AS builder
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
ARG NEXT_PUBLIC_BACKEND_URL
ARG NEXT_PUBLIC_AGENT_URL
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
ARG NEXT_PUBLIC_MAP_URL
ARG NEXT_PUBLIC_MAP_WORKSPACE
ARG NEXT_PUBLIC_MAP_EXTENT
ARG NEXT_PUBLIC_NETWORK_NAME
ARG NEXT_PUBLIC_MAPBOX_TOKEN
ARG NEXT_PUBLIC_TIANDITU_TOKEN
COPY --from=deps /app/refine/node_modules ./node_modules COPY --from=deps /app/refine/node_modules ./node_modules
COPY . . COPY . .
@@ -40,7 +28,9 @@ FROM base AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=builder /app/refine/public ./public COPY --from=builder --chown=node:node /app/refine/public ./public
COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir .next RUN mkdir .next
RUN chown node:node .next RUN chown node:node .next
@@ -55,4 +45,5 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"] CMD ["node", "server.js"]
+72 -29
View File
@@ -1,48 +1,91 @@
# my-refine-app # TJWaterFrontend_Refine 内部前端
<div align="center" style="margin: 30px;"> `TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
<a href="https://refine.dev">
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
</a>
</div>
<br/>
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. ```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 本地编排参考
```
## Available Scripts 新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
### Running the development server. ## 本地开发
要求 Node.js 20 或更高版本:
```bash ```bash
npm install
npm run dev npm run dev
``` ```
### Building for production. `npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
## 常用命令
```bash
npm run lint
npm test
npm run test:coverage
npm run build
npm run start
docker build -t tjwater-frontend:local .
```
- `npm run lint`:运行 ESLint。
- `npm test`:运行 Jest。
- `npm run test:coverage`:生成测试覆盖率。
- `npm run build`:生成生产构建。
- `npm run start`:启动生产模式服务。
## 配置说明
运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。
## 开发规范
- 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 ```bash
npm run build npm run build
``` ```
### Running the production server. Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
```bash ## 安全规则
npm run start
```
## Learn More 不要提交 `.env``.next/``node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
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/)
## License
MIT
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": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
}
}
}
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -6,19 +6,15 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL}
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
env_file:
- .env
environment: environment:
BACKEND_URL: ${BACKEND_URL}
AGENT_URL: ${AGENT_URL}
MAP_URL: ${MAP_URL}
MAP_WORKSPACE: ${MAP_WORKSPACE}
MAP_EXTENT: ${MAP_EXTENT}
NETWORK_NAME: ${NETWORK_NAME}
MAPBOX_TOKEN: ${MAPBOX_TOKEN}
TIANDITU_TOKEN: ${TIANDITU_TOKEN}
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
node <<'NODE'
const fs = require("fs");
const parseExtent = (value) => {
if (!value) {
return [13508849, 3608036, 13555781, 3633813];
}
const extent = value.split(",").map(Number);
return extent.length === 4 && extent.every(Number.isFinite)
? extent
: [13508849, 3608036, 13555781, 3633813];
};
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",
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
};
fs.writeFileSync(
"/app/refine/public/runtime-config.js",
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
);
NODE
exec "$@"
+242 -16
View File
@@ -35,6 +35,7 @@
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"ol": "^10.7.0", "ol": "^10.7.0",
"openapi-fetch": "^0.17.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -63,6 +64,7 @@
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"openapi-typescript": "^7.13.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
@@ -5731,6 +5733,75 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.18",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.18.tgz",
"integrity": "sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.3.0",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@refinedev/core": { "node_modules/@refinedev/core": {
"version": "5.0.12", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz", "resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz",
@@ -10466,6 +10537,16 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -11260,6 +11341,13 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/char-regex": { "node_modules/char-regex": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@@ -11430,6 +11518,13 @@
} }
] ]
}, },
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -14363,6 +14458,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -16106,6 +16214,16 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -16113,10 +16231,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -16230,6 +16358,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -18369,6 +18504,73 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/openapi-fetch": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
"integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.1.0"
}
},
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.34.6",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.3.0",
"supports-color": "^10.2.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/openapi-typescript-helpers": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
"license": "MIT"
},
"node_modules/openapi-typescript/node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-typescript/node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/openid-client": { "node_modules/openid-client": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -19355,6 +19557,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -20712,19 +20924,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/ts-jest/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -20780,6 +20979,19 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": { "node_modules/typed-array-buffer": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -21162,6 +21374,13 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -21579,6 +21798,13 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+6 -1
View File
@@ -6,13 +6,16 @@
"node": ">=20" "node": ">=20"
}, },
"scripts": { "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", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "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" "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
}, },
"dependencies": { "dependencies": {
@@ -43,6 +46,7 @@
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"ol": "^10.7.0", "ol": "^10.7.0",
"openapi-fetch": "^0.17.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -74,6 +78,7 @@
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"openapi-typescript": "^7.13.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"typescript": "^5.8.3" "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 [13508849, 3608036, 13555781, 3633813];
}
const extent = value.split(",").map(Number);
return extent.length === 4 && extent.every(Number.isFinite)
? extent
: [13508849, 3608036, 13555781, 3633813];
};
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",
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
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)}`);
@@ -0,0 +1,58 @@
"use client";
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 StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import {
RAINBOW_COLORS,
RISK_BREAKS,
} 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>
<MapToolbar
queryType="realtime"
hiddenButtons={["style"]}
HistoryPanel={PredictDataPanel}
/>
<Timeline />
<HealthRiskStatistics />
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
<StyleLegend
layerName="管道"
layerId="health-risk"
property="健康风险"
colors={RAINBOW_COLORS}
type="line"
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
breaks={[0, ...RISK_BREAKS]}
/>
</Box>
</HealthRiskProvider>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const BurstDetectionPanel = dynamic(
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-detection" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="burst_detection"
hiddenButtons={["style"]}
/>
<BurstDetectionPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const BurstLocationPanel = dynamic(
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-location" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="burst_location"
hiddenButtons={["style"]}
/>
<BurstLocationPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const BurstPipeAnalysisPanel = dynamic(
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const WaterQualityPanel = dynamic(
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
{
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const DMALeakDetectionPanel = dynamic(
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="dma_leak_identification"
hiddenButtons={["style"]}
/>
<DMALeakDetectionPanel />
</>
);
}
@@ -0,0 +1,22 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const FlushingAnalysisPanel = dynamic(
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
},
);
export default function Home() {
return (
<>
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
<FlushingAnalysisPanel />
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import type { ReactNode } from "react";
import MapComponent from "@components/olmap/core/MapComponent";
export default function MapLayout({ children }: { children: ReactNode }) {
return <MapComponent>{children}</MapComponent>;
}
@@ -0,0 +1,27 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const MonitoringPlaceOptimizationPanel = dynamic(
() =>
import(
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
),
{
loading: () => (
<MapPanelSkeleton variant="monitoring-place-optimization" />
),
},
);
export default function Home() {
return (
<>
<MapToolbar hiddenButtons={["style"]} />
<MonitoringPlaceOptimizationPanel />
</>
);
}
@@ -0,0 +1,56 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import {
MapPanelSkeleton,
MapTimelineSkeleton,
} from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
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[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<>
<MapToolbar queryType="realtime" />
<Timeline />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
/>
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
</>
);
}
@@ -0,0 +1,51 @@
"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";
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[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<>
<MapToolbar hiddenButtons={["style"]} />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
showCleaning={true}
/>
<SCADADataPanel
deviceIds={selectedDeviceIds}
visible={panelVisible}
showCleaning={true}
/>
</>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,43 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
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,
RISK_BREAKS,
} from "@components/olmap/HealthRiskAnalysis/types";
import { Box } from "@mui/material";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<HealthRiskProvider>
<MapComponent>
<MapToolbar
queryType="realtime"
hiddenButtons={["style"]}
HistoryPanel={PredictDataPanel}
/>
<Timeline />
<HealthRiskStatistics />
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
<StyleLegend
layerName="管道"
layerId="health-risk"
property="健康风险"
colors={RAINBOW_COLORS}
type="line"
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
breaks={[0, ...RISK_BREAKS]}
/>
</Box>
</MapComponent>
</HealthRiskProvider>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,16 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
export default function Home() {
return (
<div className="relative h-full w-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="burst_detection" hiddenButtons={["style"]} />
<BurstDetectionPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="burst_location"
hiddenButtons={["style"]}
/>
<BurstLocationPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="dma_leak_identification"
hiddenButtons={["style"]}
/>
<DMALeakDetectionPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,16 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
<FlushingAnalysisPanel />
</MapComponent>
</div>
);
}
+9 -7
View File
@@ -1,11 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import React, { Suspense } from "react"; import type { ReactNode } from "react";
import authOptions from "@app/api/auth/[...nextauth]/options"; import authOptions from "@app/api/auth/[...nextauth]/options";
import { Header } from "@components/header"; import { Header } from "@components/header";
import { Title } from "@components/title"; import { Title } from "@components/title";
import { MapSkeleton } from "@components/loading/MapSkeleton";
import { AppSider } from "@components/sider/AppSider"; import { AppSider } from "@components/sider/AppSider";
import { ThemedLayout } from "@refinedev/mui"; import { ThemedLayout } from "@refinedev/mui";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
export default async function MainLayout({ export default async function MainLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: ReactNode;
}>) { }>) {
const cookieStore = await cookies(); const cookieStore = await cookies();
const theme = cookieStore.get("theme"); const theme = cookieStore.get("theme");
@@ -38,15 +37,18 @@ export default async function MainLayout({
Title={Title} Title={Title}
Sider={AppSider} Sider={AppSider}
childrenBoxProps={{ childrenBoxProps={{
sx: { height: "100vh", p: 0 }, sx: {
flex: 1,
minHeight: 0,
overflow: "auto",
p: 0,
},
}} }}
containerBoxProps={{ containerBoxProps={{
sx: { height: "100%" }, sx: { height: "100vh", overflow: "hidden" },
}} }}
> >
<Suspense fallback={<MapSkeleton />}>
{children} {children}
</Suspense>
</ThemedLayout> </ThemedLayout>
); );
} }
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,15 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar hiddenButtons={["style"]} />
<MonitoringPlaceOptimizationPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,38 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import MapComponent from "@components/olmap/core/MapComponent";
import Timeline from "@components/olmap/core/Controls/Timeline";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="realtime" />
<Timeline />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
/>
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,41 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar hiddenButtons={["style"]} />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
showCleaning={true}
/>
<SCADADataPanel
deviceIds={selectedDeviceIds}
visible={panelVisible}
showCleaning={true}
/>
</MapComponent>
</div>
);
}
+150 -86
View File
@@ -1,32 +1,38 @@
"use client"; "use client";
import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { import {
RefineSnackbarProvider, Refine,
useNotificationProvider, type AccessControlProvider,
} from "@refinedev/mui"; 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"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router"; import routerProvider from "@refinedev/nextjs-router";
import { ColorModeContextProvider } from "@contexts/color-mode"; import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider"; import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext"; import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config"; import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia"; 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 { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai"; import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
import { import {
ManageAccounts as ManageAccountsIcon,
FactCheck as FactCheckIcon, FactCheck as FactCheckIcon,
ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon, MyLocation as MyLocationIcon,
Search as SearchIcon, Search as SearchIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
@@ -36,16 +42,12 @@ type RefineContextProps = {
}; };
export const RefineContext = ( export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps> props: React.PropsWithChildren<RefineContextProps>,
) => { ) => (
return (
<SessionProvider> <SessionProvider>
<ProjectProvider>
<App {...props} /> <App {...props} />
</ProjectProvider>
</SessionProvider> </SessionProvider>
); );
};
type AppProps = { type AppProps = {
defaultMode?: string; defaultMode?: string;
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession(); const { data, status } = useSession();
const to = usePathname(); const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken); const setAccessToken = useAuthStore((state) => state.setAccessToken);
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false); 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(() => { useEffect(() => {
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null); setAccessToken(
typeof data?.accessToken === "string" ? data.accessToken : null,
);
}, [data?.accessToken, setAccessToken]); }, [data?.accessToken, setAccessToken]);
useEffect(() => { useEffect(() => {
if (status !== "authenticated") { if (status !== "authenticated") {
setIsMetadataAdmin(false); resetAccess();
return; return;
} }
let cancelled = false; let cancelled = false;
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { setAccessLoading(true);
projectHeaderMode: "omit", apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
skipAuthRedirect: true, skipAuthRedirect: true,
}) })
.then(async (response) => { .then(async (response) => {
if (cancelled) return; if (cancelled) return;
if (!response.ok) { if (!response.ok) {
setIsMetadataAdmin(false); resetAccess();
return; return;
} }
const payload = await response.json(); setAccessContext(await response.json());
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
}) })
.catch(() => { .catch(() => {
if (!cancelled) setIsMetadataAdmin(false); if (!cancelled) resetAccess();
}); });
return () => { return () => {
cancelled = true; 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") { if (status === "loading") {
return <span>loading...</span>; return <span>loading...</span>;
@@ -100,74 +133,65 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
callbackUrl: to ? to.toString() : "/", callbackUrl: to ? to.toString() : "/",
redirect: true, redirect: true,
}); });
return { success: true };
return {
success: true,
};
}, },
logout: async () => { logout: async () => {
signOut({ try {
redirect: true, await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
callbackUrl: "/login", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "logout" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
}); });
} catch {
return { // Logout must still complete when audit storage is unavailable.
success: true, }
}; if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
signOut({ redirect: true, callbackUrl: "/login" });
return { success: true };
}, },
onError: async (error) => { onError: async (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
return { return { logout: true };
logout: true,
};
} }
return { error };
return {
error,
};
},
check: async () => {
if (status === "unauthenticated") {
return {
authenticated: false,
redirectTo: "/login",
};
}
return {
authenticated: true,
};
},
getPermissions: async () => {
return null;
}, },
check: async () =>
status === "unauthenticated"
? { authenticated: false, redirectTo: "/login" }
: { authenticated: true },
getPermissions: async () => permissions,
getIdentity: async () => { getIdentity: async () => {
if (data?.user) { if (!data?.user) return null;
const { user } = data;
return { return {
id: user.id, id: data.user.id,
name: user.name, username: data.user.username,
avatar: user.image, name: data.user.name,
avatar: data.user.image,
}; };
}
return null;
}, },
}; };
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,
};
},
};
return ( const resources = [
<> ...(can(permissionCodes.simulationView)
<RefineKbarProvider> ? [
<ColorModeContextProvider defaultMode={defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
authProvider={authProvider}
resources={[
{ {
name: "管网在线模拟", name: "管网在线模拟",
list: "/network-simulation", list: "/network-simulation",
@@ -176,6 +200,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "管网在线模拟", label: "管网在线模拟",
}, },
}, },
]
: []),
...(can(permissionCodes.scadaClean)
? [
{ {
name: "SCADA 数据清洗", name: "SCADA 数据清洗",
list: "/scada-data-cleaning", list: "/scada-data-cleaning",
@@ -184,6 +212,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "SCADA 数据清洗", label: "SCADA 数据清洗",
}, },
}, },
]
: []),
...(can(permissionCodes.optimizationRun)
? [
{ {
name: "监测点优化布置", name: "监测点优化布置",
list: "/monitoring-place-optimization", list: "/monitoring-place-optimization",
@@ -192,6 +224,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "监测点优化布置", label: "监测点优化布置",
}, },
}, },
]
: []),
...(can(permissionCodes.riskRun)
? [
{ {
name: "健康风险分析", name: "健康风险分析",
list: "/health-risk-analysis", list: "/health-risk-analysis",
@@ -200,13 +236,18 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "健康风险分析", label: "健康风险分析",
}, },
}, },
]
: []),
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
? [
{ {
name: "Hydraulic Simulation", name: "Hydraulic Simulation",
meta: { meta: { label: "事件模拟" },
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
}, },
]
: []),
...(can(permissionCodes.burstRun)
? [
{ {
name: "爆管模拟", name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation", list: "/hydraulic-simulation/burst-simulation",
@@ -243,6 +284,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "DMA 漏损识别", label: "DMA 漏损识别",
}, },
}, },
]
: []),
...(can(permissionCodes.simulationRun)
? [
{ {
name: "水质模拟", name: "水质模拟",
list: "/hydraulic-simulation/contaminant-simulation", list: "/hydraulic-simulation/contaminant-simulation",
@@ -261,7 +306,9 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "管道冲洗", label: "管道冲洗",
}, },
}, },
...(isMetadataAdmin ]
: []),
...(can(permissionCodes.environmentManage)
? [ ? [
{ {
name: "系统管理", name: "系统管理",
@@ -271,6 +318,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "系统管理", label: "系统管理",
}, },
}, },
]
: []),
...(can(permissionCodes.auditView)
? [
{ {
name: "审计日志", name: "审计日志",
list: "/audit-logs", list: "/audit-logs",
@@ -281,18 +332,31 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
}, },
] ]
: []), : []),
]} ];
return (
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{ options={{
syncWithLocation: true, syncWithLocation: true,
warnWhenUnsavedChanges: true, warnWhenUnsavedChanges: true,
}} }}
> >
{props.children} <RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar /> <RefineKbar />
</Refine> </Refine>
</RefineSnackbarProvider> </RefineSnackbarProvider>
</ColorModeContextProvider> </ColorModeContextProvider>
</RefineKbarProvider> </RefineKbarProvider>
</> </ProjectProvider>
); );
}; };
+27 -7
View File
@@ -9,16 +9,26 @@ type KeycloakTokenResponse = {
refresh_token?: string; refresh_token?: string;
}; };
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!; const getKeycloakTokenEndpoint = () => {
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!; const issuer = process.env.KEYCLOAK_ISSUER;
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!; return issuer
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`; ? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
: undefined;
};
const refreshAccessToken = async (token: JWT): Promise<JWT> => { const refreshAccessToken = async (token: JWT): Promise<JWT> => {
if (!token.refreshToken) { if (!token.refreshToken) {
return { ...token, error: "RefreshAccessTokenError" }; return { ...token, error: "RefreshAccessTokenError" };
} }
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID;
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
const keycloakTokenEndpoint = getKeycloakTokenEndpoint();
if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) {
return { ...token, error: "RefreshAccessTokenError" };
}
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: keycloakClientId, client_id: keycloakClientId,
@@ -50,12 +60,13 @@ const authOptions: NextAuthOptions = {
// Configure one or more authentication providers // Configure one or more authentication providers
providers: [ providers: [
KeycloakProvider({ KeycloakProvider({
clientId: keycloakClientId, clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
clientSecret: keycloakClientSecret, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: keycloakIssuer, issuer: process.env.KEYCLOAK_ISSUER ?? "",
profile(profile) { profile(profile) {
return { return {
id: profile.sub, id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username, name: profile.name ?? profile.preferred_username,
email: profile.email, email: profile.email,
image: Avatar.src, image: Avatar.src,
@@ -69,6 +80,12 @@ const authOptions: NextAuthOptions = {
if (profile?.sub) { if (profile?.sub) {
token.sub = 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) { if (account) {
if (account.access_token) { if (account.access_token) {
@@ -94,6 +111,9 @@ const authOptions: NextAuthOptions = {
if (session.user && token.sub) { if (session.user && token.sub) {
session.user.id = token.sub; session.user.id = token.sub;
} }
if (session.user && token.username) {
session.user.username = token.username;
}
if (token.accessToken) { if (token.accessToken) {
session.accessToken = token.accessToken; session.accessToken = token.accessToken;
} }
+3 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import Script from "next/script";
import React, { Suspense } from "react"; import React, { Suspense } from "react";
import { RefineContext } from "./RefineContext"; import { RefineContext } from "./RefineContext";
import { META_DATA } from "@config/config"; import { META_DATA } from "@config/config";
@@ -16,8 +17,9 @@ export default async function RootLayout({
const defaultMode = theme?.value === "dark" ? "dark" : "light"; const defaultMode = theme?.value === "dark" ? "dark" : "light";
return ( return (
<html lang="en"> <html lang="zh-CN">
<body> <body>
<Script src="/runtime-config.js" strategy="beforeInteractive" />
<Suspense> <Suspense>
<RefineContext defaultMode={defaultMode}>{children}</RefineContext> <RefineContext defaultMode={defaultMode}>{children}</RefineContext>
</Suspense> </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);
});
});
+234 -29
View File
@@ -1,54 +1,259 @@
"use client"; "use client";
import Image from "next/image";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Container from "@mui/material/Container"; import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useLogin } from "@refinedev/core"; 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() { export default function Login() {
const { mutate: login } = useLogin(); 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 ( return (
<Container <Box
style={{ component="main"
height: "100vh", sx={{
display: "flex", minHeight: "100vh",
justifyContent: "center", "@supports (height: 100svh)": {
alignItems: "center", 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 <Box
display="flex" sx={{
gap="36px" width: "100%",
justifyContent="center" maxWidth: 1720,
flexDirection="column" 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"> <Stack
<Title collapsed={false} /> spacing={{ xs: 0, md: 3 }}
</Box> 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: '""',
},
}}
>
<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 },
}}
/>
<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 <Button
style={{ width: "240px" }} fullWidth
size="large" size="large"
variant="contained" variant="contained"
onClick={() => login({})} 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)" },
}}
> >
Sign in
</Button> </Button>
<Typography align="center" color={"text.secondary"} fontSize="12px"> ) : null}
Powered by
<Image <Typography
style={{ padding: "0 5px" }} sx={{
alt="Keycloak" color: "oklch(0.52 0.035 215)",
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg" fontSize: { xs: 12, sm: 13 },
width={18} lineHeight: 1.7,
height={18} textAlign: "center",
/> }}
Keycloak >
访
</Typography> </Typography>
</Stack>
</Box>
</Box>
</Box> </Box>
</Container>
); );
} }
File diff suppressed because it is too large Load Diff
+20 -43
View File
@@ -49,6 +49,8 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import { config } from "@config/config"; import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
type AuditLog = { type AuditLog = {
id: string; id: string;
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
end_time: "", end_time: "",
}; };
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
{ value: "all", label: "全部状态" }, { value: "all", label: "全部状态" },
{ value: "success", label: "成功 2xx" }, { value: "success", label: "成功 2xx" },
@@ -338,8 +342,14 @@ const DetailSection = ({
); );
export const AuditLogPanel = () => { export const AuditLogPanel = () => {
const [adminChecked, setAdminChecked] = useState(false); const accessLoading = useAccessStore((state) => state.loading);
const [isAuthorized, setIsAuthorized] = useState(false); 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 [logs, setLogs] = useState<AuditLog[]>([]);
const [users, setUsers] = useState<MetadataUser[]>([]); const [users, setUsers] = useState<MetadataUser[]>([]);
const [projects, setProjects] = useState<AdminProject[]>([]); const [projects, setProjects] = useState<AdminProject[]>([]);
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage); const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
const countParams = buildCountParams(appliedFilters); const countParams = buildCountParams(appliedFilters);
const [logsResponse, countResponse] = await Promise.all([ const [logsResponse, countResponse] = await Promise.all([
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`), apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`), apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
]); ]);
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse)); if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
if (!countResponse.ok) throw new Error(await readErrorText(countResponse)); if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
setTotalCount(Number(countPayload.count ?? 0)); setTotalCount(Number(countPayload.count ?? 0));
} else { } else {
const params = buildServerParams(appliedFilters, 0, 1000); 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)); if (!response.ok) throw new Error(await readErrorText(response));
const payload = (await response.json()) as AuditLog[]; const payload = (await response.json()) as AuditLog[];
setLogs(payload); setLogs(payload);
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
}, [appliedFilters, page, rowsPerPage]); }, [appliedFilters, page, rowsPerPage]);
useEffect(() => { useEffect(() => {
let cancelled = false; if (!isAuthorized) return;
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;
void loadOptions(); void loadOptions();
}, [adminChecked, isAuthorized, loadOptions]); }, [isAuthorized, loadOptions]);
useEffect(() => { useEffect(() => {
if (!adminChecked || !isAuthorized) return; if (!adminChecked || !isAuthorized) return;
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
setError(null); setError(null);
try { try {
const params = buildServerParams(appliedFilters, 0, 1000); 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)); if (!response.ok) throw new Error(await readErrorText(response));
const payload = ((await response.json()) as AuditLog[]).filter((log) => const payload = ((await response.json()) as AuditLog[]).filter((log) =>
matchesStatusFilter(log, appliedFilters.status), matchesStatusFilter(log, appliedFilters.status),
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
<Chip <Chip
color="success" color="success"
icon={<AdminPanelSettingsIcon />} icon={<AdminPanelSettingsIcon />}
label="管理员权限已验证" label="系统管理员权限已验证"
sx={{ alignSelf: { xs: "flex-start", md: "center" } }} sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
/> />
)} )}
@@ -0,0 +1,45 @@
"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";
export const RoutePermissionGuard = ({
children,
}: {
children: ReactNode;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const requiredPermission = permissionForPath(pathname);
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,61 @@
/* 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="request"
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(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
});
});
+36 -45
View File
@@ -23,7 +23,6 @@ import StopRounded from "@mui/icons-material/StopRounded";
import MicRounded from "@mui/icons-material/MicRounded"; import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
import BoltRounded from "@mui/icons-material/BoltRounded"; import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
@@ -125,15 +124,9 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
}} }}
> >
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<Image <AutoAwesomeRounded
src="/ai-agent.svg" titleAccess="快捷指令图标"
alt="TJWater Agent" sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
width={18}
height={18}
style={{
objectFit: "contain",
flexShrink: 0,
}}
/> />
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> <Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
@@ -230,41 +223,6 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}> <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center"> <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: 96 }}>
<Select <Select
value={approvalMode} value={approvalMode}
@@ -471,6 +429,39 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
</Select> </Select>
</FormControl> </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}
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"> <AnimatePresence mode="wait">
{isStreaming ? ( {isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> <motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
+115
View File
@@ -0,0 +1,115 @@
/* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom";
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AgentTurn } from "./AgentTurn";
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>
),
span: ({
children,
animate: _animate,
transition: _transition,
...props
}: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => (
<span {...props}>{children}</span>
),
},
}));
jest.mock("./AgentMarkdownBlock", () => ({
MarkdownBlock: ({ children }: { children: string }) => (
<div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div>
),
normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""),
}));
describe("AgentTurn speech selection", () => {
it("shows a floating action and reads from the selected text", async () => {
const content = "第一段内容。\n\n第二段内容。";
const speechText = "第一段内容。\n第二段内容。";
const onSpeak = jest.fn();
const removeAllRanges = jest.fn();
render(
<AgentTurn
message={{ id: "assistant-1", role: "assistant", content }}
isStreaming={false}
messageSpeechState="idle"
onSpeak={onSpeak}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
const selectedParagraph = screen.getByText("第二段内容。");
const selectedTextNode = selectedParagraph.firstChild as Text;
const range = {
commonAncestorContainer: selectedTextNode,
getBoundingClientRect: () => ({
width: 72,
height: 20,
top: 120,
right: 172,
bottom: 140,
left: 100,
x: 100,
y: 120,
toJSON: () => ({}),
}),
} as unknown as Range;
const selection = {
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => range,
toString: () => "第二段",
removeAllRanges,
} as unknown as Selection;
jest.spyOn(window, "getSelection").mockReturnValue(selection);
jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
fireEvent.pointerUp(selectedParagraph);
const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" });
fireEvent.click(speechAction);
await waitFor(() => {
expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, {
startOffset: speechText.indexOf("第二段"),
});
});
expect(removeAllRanges).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
});
});
});
+170 -47
View File
@@ -2,13 +2,16 @@
import Image from "next/image"; import Image from "next/image";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
Avatar, Avatar,
Box, Box,
CircularProgress, CircularProgress,
Button,
Grow,
IconButton, IconButton,
Paper, Paper,
Popper,
Stack, Stack,
Tooltip, Tooltip,
Typography, Typography,
@@ -41,6 +44,48 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded"; import StopRounded from "@mui/icons-material/StopRounded";
const floatingActionSurfaceSx = {
display: "flex",
gap: 0.5,
p: 0.5,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.8),
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
overflow: "hidden",
} as const;
const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const;
const floatingIconButtonSx = {
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "#00acc1",
bgcolor: alpha("#00acc1", 0.1),
},
} as const;
const floatingSpeechButtonSx = {
minHeight: 34,
px: 1.25,
color: "text.primary",
fontSize: 13,
fontWeight: 700,
letterSpacing: 0,
whiteSpace: "nowrap",
borderRadius: "12px",
"&:hover": {
bgcolor: alpha("#00acc1", 0.1),
color: "#00acc1",
},
} as const;
type SpeechSelection = {
startOffset: number;
anchorRect: DOMRect;
};
type AgentTurnProps = { type AgentTurnProps = {
message: Message; message: Message;
isStreaming: boolean; isStreaming: boolean;
@@ -180,10 +225,7 @@ export const AgentTurn = React.memo(
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
const [isHovered, setIsHovered] = React.useState(false); const [isHovered, setIsHovered] = React.useState(false);
const answerContentRef = React.useRef<HTMLDivElement | null>(null); const answerContentRef = React.useRef<HTMLDivElement | null>(null);
const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{ const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
offset: number;
preview: string;
} | null>(null);
const isProgressComplete = message.progress?.some( const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed", (item) => item.phase === "complete" && item.status === "completed",
) ?? false; ) ?? false;
@@ -203,39 +245,94 @@ export const AgentTurn = React.memo(
() => stripMarkdown(answerContent), () => stripMarkdown(answerContent),
[answerContent], [answerContent],
); );
const handleCaptureSpeechSelection = React.useCallback(() => { const captureSpeechSelection = React.useCallback(() => {
if (!isTtsSupported || isStreamingAssistant) {
setSpeechSelection(null);
return;
}
const selection = window.getSelection(); const selection = window.getSelection();
const container = answerContentRef.current; const container = answerContentRef.current;
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) { if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
setSpeechSelection(null);
return; return;
} }
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) { if (!container.contains(range.commonAncestorContainer)) {
setSpeechSelection(null);
return; return;
} }
const selectedText = selection.toString(); const selectedText = selection.toString();
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText); const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
if (startOffset === null) { if (startOffset === null) {
setSelectedSpeechStart(null); setSpeechSelection(null);
return; return;
} }
const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24); const anchorRect = range.getBoundingClientRect();
setSelectedSpeechStart({ if (anchorRect.width === 0 && anchorRect.height === 0) {
offset: startOffset, setSpeechSelection(null);
preview: preview.length === 24 ? `${preview}...` : preview, return;
}); }
}, [speechText]);
setSpeechSelection({ startOffset, anchorRect });
}, [isStreamingAssistant, isTtsSupported, speechText]);
const handleCaptureSpeechSelection = React.useCallback(() => {
window.requestAnimationFrame(captureSpeechSelection);
}, [captureSpeechSelection]);
React.useEffect(() => { React.useEffect(() => {
setSelectedSpeechStart(null); setSpeechSelection(null);
}, [message.id, speechText]); }, [message.id, speechText]);
const handleSpeakFromCurrentStart = () => { React.useEffect(() => {
onSpeak(message.id, speechText, { if (!speechSelection) return;
startOffset: selectedSpeechStart?.offset ?? 0,
}); const closeSpeechSelection = () => setSpeechSelection(null);
const handleSelectionChange = () => {
if (window.getSelection()?.isCollapsed) {
closeSpeechSelection();
}
}; };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeSpeechSelection();
}
};
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeSpeechSelection);
window.addEventListener("scroll", closeSpeechSelection, true);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeSpeechSelection);
window.removeEventListener("scroll", closeSpeechSelection, true);
};
}, [speechSelection]);
const handleSpeakMessage = () => {
onSpeak(message.id, speechText);
};
const handleSpeakFromSelection = () => {
if (!speechSelection) return;
onSpeak(message.id, speechText, {
startOffset: speechSelection.startOffset,
});
window.getSelection()?.removeAllRanges();
setSpeechSelection(null);
};
const speechSelectionAnchor = React.useMemo(
() => speechSelection
? {
getBoundingClientRect: () => speechSelection.anchorRect,
}
: null,
[speechSelection],
);
const isSpeechSelectionOpen = Boolean(speechSelection);
const contentSegments: ContentSegment[] = useMemo( const contentSegments: ContentSegment[] = useMemo(
() => () =>
!isUser && !isErrorMessage !isUser && !isErrorMessage
@@ -385,9 +482,8 @@ export const AgentTurn = React.memo(
<Box <Box
ref={answerContentRef} ref={answerContentRef}
onMouseUp={handleCaptureSpeechSelection} onPointerUp={handleCaptureSpeechSelection}
onKeyUp={handleCaptureSpeechSelection} onKeyUp={handleCaptureSpeechSelection}
onTouchEnd={handleCaptureSpeechSelection}
sx={{ sx={{
p: 1.5, p: 1.5,
borderRadius: 4, borderRadius: 4,
@@ -457,6 +553,43 @@ export const AgentTurn = React.memo(
</Stack> </Stack>
</Box> </Box>
<Popper
open={isSpeechSelectionOpen}
anchorEl={speechSelectionAnchor}
placement="top"
transition
modifiers={[
{ name: "offset", options: { offset: [0, 8] } },
{ name: "flip", enabled: true },
{ name: "preventOverflow", options: { padding: 8 } },
]}
sx={{ zIndex: theme.zIndex.tooltip }}
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
timeout={floatingActionTransitionTimeout}
style={{
transformOrigin: placement.startsWith("bottom")
? "center top"
: "center bottom",
}}
>
<Paper elevation={4} sx={floatingActionSurfaceSx}>
<Button
size="small"
startIcon={<VolumeUpRounded sx={{ fontSize: 16 }} />}
onMouseDown={(event) => event.preventDefault()}
onClick={handleSpeakFromSelection}
sx={floatingSpeechButtonSx}
>
</Button>
</Paper>
</Grow>
)}
</Popper>
{visibleChartArtifacts.map((artifact) => ( {visibleChartArtifacts.map((artifact) => (
<ChatInlineChart <ChatInlineChart
key={artifact.id} key={artifact.id}
@@ -479,26 +612,21 @@ export const AgentTurn = React.memo(
))} ))}
</Stack> </Stack>
<AnimatePresence> <Grow
{isHovered && !isStreaming && ( in={isHovered && !isStreaming}
<motion.div timeout={floatingActionTransitionTimeout}
initial={{ opacity: 0, scale: 0.9, y: 5 }} mountOnEnter
animate={{ opacity: 1, scale: 1, y: 0 }} unmountOnExit
exit={{ opacity: 0, scale: 0.9, y: 5 }} style={{ transformOrigin: "right bottom" }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
> >
<Paper <Paper
elevation={4} elevation={4}
sx={{ sx={{
display: "flex", ...floatingActionSurfaceSx,
gap: 0.5, position: "absolute",
p: 0.5, top: -14,
borderRadius: "16px", right: 12,
bgcolor: alpha("#fff", 0.8), zIndex: 10,
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}} }}
> >
<Tooltip title="复制"> <Tooltip title="复制">
@@ -509,9 +637,8 @@ export const AgentTurn = React.memo(
navigator.clipboard.writeText( navigator.clipboard.writeText(
normalizeClipboardText(message.content), normalizeClipboardText(message.content),
); );
// Could add a toast here
}} }}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} sx={floatingIconButtonSx}
> >
<ContentCopyRounded sx={{ fontSize: 16 }} /> <ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
@@ -520,18 +647,14 @@ export const AgentTurn = React.memo(
<IconButton <IconButton
size="small" size="small"
aria-label="拆分为新会话" aria-label="拆分为新会话"
onClick={() => { onClick={() => onCreateBranch(message.id)}
onCreateBranch(message.id); sx={floatingIconButtonSx}
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
> >
<TbArrowsSplit2 size={16} /> <TbArrowsSplit2 size={16} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Paper> </Paper>
</motion.div> </Grow>
)}
</AnimatePresence>
</Paper> </Paper>
</Stack> </Stack>
@@ -542,8 +665,8 @@ export const AgentTurn = React.memo(
{messageSpeechState === "idle" ? ( {messageSpeechState === "idle" ? (
<IconButton <IconButton
size="small" size="small"
onClick={handleSpeakFromCurrentStart} onClick={handleSpeakMessage}
aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"} aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
> >
<VolumeUpRounded sx={{ fontSize: 16 }} /> <VolumeUpRounded sx={{ fontSize: 16 }} />
+124
View File
@@ -0,0 +1,124 @@
import "@testing-library/jest-dom";
import React from "react";
import { act, render, screen } from "@testing-library/react";
import { GlobalChatbox } from "./GlobalChatbox";
const createSession = jest.fn();
let mockCurrentProjectId = "project-1";
jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: jest.fn() }),
}));
jest.mock("@/lib/chatModels", () => ({
fetchAgentModels: jest.fn(() => new Promise(() => {})),
}));
jest.mock("@/store/projectStore", () => ({
useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
selector({ currentProjectId: mockCurrentProjectId }),
}));
jest.mock("./globalChatboxVoice", () => ({
useSpeechSynthesis: () => ({
speechState: "idle",
speakingMessageId: null,
speak: jest.fn(),
pause: jest.fn(),
resume: jest.fn(),
stop: jest.fn(),
isSupported: true,
}),
useSpeechRecognition: () => ({
isListening: false,
start: jest.fn(),
stop: jest.fn(),
isSupported: true,
}),
}));
jest.mock("./hooks/useAgentToolActions", () => ({
useAgentToolActions: () => jest.fn(),
}));
jest.mock("./hooks/useAgentChatSession", () => ({
useAgentChatSession: () => ({
messages: [],
chatSessions: [],
activeSessionId: undefined,
isHydrating: false,
loadingSessionId: null,
isStreaming: false,
sessionTitle: "新会话",
sendPrompt: jest.fn(),
createBranch: jest.fn(),
abort: jest.fn(),
replyPermission: jest.fn(),
replyQuestion: jest.fn(),
rejectQuestion: jest.fn(),
createSession,
renameSession: jest.fn(),
removeSession: jest.fn(),
switchSession: jest.fn(),
}),
}));
jest.mock("./AgentHeader", () => ({
AgentHeader: () => <div>Agent header</div>,
}));
jest.mock("./AgentHistoryPanel", () => ({
AgentHistoryPanel: () => <div>History</div>,
}));
jest.mock("./AgentWorkspace", () => ({
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
}));
jest.mock("./AgentComposer", () => ({
AgentComposer: React.forwardRef(function MockAgentComposer() {
return <div>Composer</div>;
}),
}));
jest.mock("./GlobalChatboxParts", () => ({
Blob: () => null,
}));
describe("GlobalChatbox lifecycle", () => {
beforeEach(() => {
jest.useFakeTimers();
createSession.mockClear();
mockCurrentProjectId = "project-1";
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it("keeps content mounted and preserves the session across close and reopen", async () => {
const { rerender } = render(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
rerender(<GlobalChatbox open={false} onClose={jest.fn()} />);
act(() => jest.advanceTimersByTime(300));
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
rerender(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(1);
mockCurrentProjectId = "project-2";
rerender(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(2);
});
});
+23 -23
View File
@@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const streamingScrollFrameRef = useRef<number | null>(null); const streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false); const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
const theme = useTheme(); const theme = useTheme();
const { open: openNotification } = useNotification(); const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isNearBottomRef.current = isNearBottom; isNearBottomRef.current = isNearBottom;
}, []); }, []);
const resetConversationView = useCallback(() => {
composerRef.current?.clear();
setIsHistoryOpen(false);
window.setTimeout(() => {
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
}, [cancelStreamingScroll, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (isStreaming) { if (isStreaming) {
if (!isNearBottomRef.current) return; if (!isNearBottomRef.current) return;
@@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
); );
useEffect(() => { useEffect(() => {
if (!open) { if (
hasResetForOpenRef.current = false; !open ||
isHydrating ||
initializedProjectIdRef.current === currentProjectId
) {
return; return;
} }
if (hasResetForOpenRef.current || isHydrating) return;
hasResetForOpenRef.current = true;
const timer = window.setTimeout(() => { initializedProjectIdRef.current = currentProjectId;
createSession(); createSession();
composerRef.current?.clear(); resetConversationView();
setIsHistoryOpen(false); }, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
return () => window.clearTimeout(timer);
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback(async (prompt: string) => { const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return; if (isStreaming || isCheckingAuth) return;
@@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
handleStopSpeech(); handleStopSpeech();
stopListening(); stopListening();
createSession(); createSession();
composerRef.current?.clear(); resetConversationView();
window.setTimeout(() => { }, [createSession, handleStopSpeech, resetConversationView, stopListening]);
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => { const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev); setIsHistoryOpen((prev) => !prev);
@@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
hideBackdrop hideBackdrop
disableScrollLock disableScrollLock
disableEnforceFocus disableEnforceFocus
ModalProps={{ keepMounted: true }}
sx={{ sx={{
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
pointerEvents: "none", pointerEvents: "none",
+2 -2
View File
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
sessionId: "session-1", sessionId: "session-1",
messages: [{ id: "message-1", role: "user", content: "查压力" }], 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" }); expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
}); });
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
isTitleManuallyEdited: true, 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(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({ expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
title: "新标题", title: "新标题",
+4 -4
View File
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
const normalizeTitle = (value?: string) => value?.trim() || "新对话"; const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => { 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", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => { const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch( 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", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
isTitleManuallyEdited?: boolean, isTitleManuallyEdited?: boolean,
) => { ) => {
const response = await apiFetch( 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", method: "PATCH",
headers: { headers: {
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
const deleteBackendChatSession = async (sessionId: string) => { const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch( 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", method: "DELETE",
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -0,0 +1,50 @@
import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
describe("parseApplyLayerStylePayload", () => {
it("accepts a valid snake_case interval contract", () => {
expect(
parseApplyLayerStylePayload({
layer_id: "pipes",
style_config: {
property: "velocity",
classification_method: "custom_breaks",
segments: 3,
custom_breaks: [0, 1, 2, 3],
color_type: "custom",
custom_colors: ["#000000", "#777777", "#ffffff"],
},
}),
).toMatchObject({
layerId: "pipes",
resetToDefault: false,
styleConfig: { segments: 3, customBreaks: [0, 1, 2, 3] },
});
});
it("rejects invalid class counts and array cardinalities", () => {
expect(
parseApplyLayerStylePayload({
layer_id: "pipes",
style_config: { segments: 1, property: "velocity" },
}),
).toBeNull();
expect(
parseApplyLayerStylePayload({
layer_id: "junctions",
style_config: {
segments: 3,
custom_breaks: [0, 1, 2],
},
}),
).toBeNull();
});
it("keeps camelCase compatibility", () => {
expect(
parseApplyLayerStylePayload({
layerId: "junctions",
styleConfig: { opacity: 0.5, colorType: "gradient" },
}),
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } });
});
});
+83 -6
View File
@@ -1,4 +1,9 @@
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes"; import type {
ClassificationMethod,
ColorType,
StyleConfig,
DefaultLayerStyleId,
} from "@components/olmap/core/Controls/styleEditorTypes";
export type ApplyLayerStyleActionPayload = { export type ApplyLayerStyleActionPayload = {
layerId: DefaultLayerStyleId; layerId: DefaultLayerStyleId;
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
.filter((item): item is string => item !== undefined) .filter((item): item is string => item !== undefined)
: undefined; : undefined;
const asClassificationMethod = (value: unknown): ClassificationMethod | undefined => {
const normalized = asString(value);
return normalized === "pretty_breaks" || normalized === "custom_breaks"
? normalized
: undefined;
};
const asColorType = (value: unknown): ColorType | undefined => {
const normalized = asString(value);
return normalized === "single" ||
normalized === "gradient" ||
normalized === "rainbow" ||
normalized === "custom"
? normalized
: undefined;
};
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => { export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
const normalized = asString(value)?.toLowerCase(); const normalized = asString(value)?.toLowerCase();
if (normalized === "junctions" || normalized === "pipes") { if (normalized === "junctions" || normalized === "pipes") {
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
? (params.styleConfig as Record<string, unknown>) ? (params.styleConfig as Record<string, unknown>)
: null; : null;
const classificationValue =
rawStyleConfig?.classification_method ?? rawStyleConfig?.classificationMethod;
const colorTypeValue = rawStyleConfig?.color_type ?? rawStyleConfig?.colorType;
const segmentsValue = rawStyleConfig?.segments;
const segments = asNumber(segmentsValue);
if (
(classificationValue !== undefined && !asClassificationMethod(classificationValue)) ||
(colorTypeValue !== undefined && !asColorType(colorTypeValue)) ||
(segmentsValue !== undefined &&
(!Number.isInteger(segments) || (segments as number) < 2 || (segments as number) > 10))
) {
return null;
}
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
? { ? {
property: asString(rawStyleConfig.property), property: asString(rawStyleConfig.property),
classificationMethod: asString( classificationMethod: asClassificationMethod(classificationValue),
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod, segments,
),
segments: asNumber(rawStyleConfig.segments),
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize), minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize), maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
minStrokeWidth: asNumber( minStrokeWidth: asNumber(
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
fixedStrokeWidth: asNumber( fixedStrokeWidth: asNumber(
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth, rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
), ),
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType), colorType: asColorType(colorTypeValue),
singlePaletteIndex: asNumber( singlePaletteIndex: asNumber(
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex, rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
), ),
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
} }
: undefined; : undefined;
if (styleConfig) {
const numericValues = [
styleConfig.minSize,
styleConfig.maxSize,
styleConfig.minStrokeWidth,
styleConfig.maxStrokeWidth,
styleConfig.fixedStrokeWidth,
].filter((value): value is number => value !== undefined);
if (numericValues.some((value) => value <= 0)) return null;
const paletteIndexes: Array<[number | undefined, number]> = [
[styleConfig.singlePaletteIndex, 7],
[styleConfig.gradientPaletteIndex, 3],
[styleConfig.rainbowPaletteIndex, 2],
];
if (
paletteIndexes.some(
([index, length]) =>
index !== undefined &&
(!Number.isInteger(index) || index < 0 || index >= length),
)
) return null;
if (
styleConfig.opacity !== undefined &&
(styleConfig.opacity < 0 || styleConfig.opacity > 1)
) return null;
if (
styleConfig.customBreaks &&
styleConfig.customBreaks.some(
(value, index, values) => index > 0 && value <= values[index - 1],
)
) return null;
if (
styleConfig.segments !== undefined &&
styleConfig.customBreaks &&
styleConfig.customBreaks.length !== styleConfig.segments + 1
) return null;
if (
styleConfig.segments !== undefined &&
styleConfig.customColors &&
styleConfig.customColors.length !== styleConfig.segments
) return null;
}
const hasStyleOverrides = const hasStyleOverrides =
styleConfig && styleConfig &&
Object.values(styleConfig).some((value) => Object.values(styleConfig).some((value) =>
+7 -3
View File
@@ -27,6 +27,10 @@ import { GlobalChatbox } from "@components/chat/GlobalChatbox";
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
const MAP_WORKSPACE_STORAGE_KEY = "MAP_WORKSPACE";
const NETWORK_NAME_STORAGE_KEY = "NETWORK_NAME";
const MAP_EXTENT_STORAGE_KEY = "MAP_EXTENT";
type IUser = { type IUser = {
id?: string; id?: string;
name?: string; name?: string;
@@ -70,9 +74,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
setMapWorkspace(workspace); setMapWorkspace(workspace);
setNetworkName(networkName); setNetworkName(networkName);
setMapExtent(extent); setMapExtent(extent);
localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", workspace); localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace);
localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName); localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
localStorage.removeItem(`${workspace}_map_view`); localStorage.removeItem(`${workspace}_map_view`);
setCurrentProjectId(projectId || networkName || workspace); setCurrentProjectId(projectId || networkName || workspace);
setShowProjectSelector(false); setShowProjectSelector(false);
@@ -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: ["分析参数", "方案查询"],
},
};
@@ -0,0 +1,55 @@
import dayjs from "dayjs";
import {
buildBurstDetectionRequest,
createBurstDetectionAnalysisParametersState,
parseScadaFrequencyMinutes,
resolvePressureSamplingInterval,
} from "./AnalysisParameters";
describe("burst detection request", () => {
it("requests the latest complete monitoring time by default", () => {
const state = createBurstDetectionAnalysisParametersState();
state.schemeName = " latest-case ";
expect(buildBurstDetectionRequest(state)).toEqual({
scheme_name: "latest-case",
sampling_interval_minutes: 15,
});
expect(state.detectionMode).toBe("latest");
expect(state.targetTime).not.toBeNull();
expect(state.targetTime!.minute() % 15).toBe(0);
});
it("sends one target time for historical replay", () => {
const targetTime = dayjs("2026-06-20T13:30:00+08:00");
expect(
buildBurstDetectionRequest(
{
schemeName: "history-case",
detectionMode: "historical",
targetTime,
samplingIntervalMinutes: 30,
samplingIntervalSource: "manual",
},
),
).toEqual({
scheme_name: "history-case",
sampling_interval_minutes: 30,
target_time: targetTime.toISOString(),
});
});
it("uses the dominant pressure SCADA frequency as the editable default", () => {
expect(parseScadaFrequencyMinutes("0:15:00")).toBe(15);
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" },
]),
).toBe(15);
});
});
@@ -1,19 +1,14 @@
"use client"; "use client";
import React, { useMemo, useState, useCallback } from "react"; import React, { useEffect, useMemo, useState } from "react";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import RefreshIcon from "@mui/icons-material/Refresh";
import { import {
Box, Box,
Button, Button,
CircularProgress,
Collapse,
FormControl, FormControl,
MenuItem, MenuItem,
Select, Select,
TextField, TextField,
Typography, Typography,
IconButton,
} from "@mui/material"; } from "@mui/material";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
@@ -23,7 +18,7 @@ import { useNotification } from "@refinedev/core";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { BurstDetectionResult } from "./types"; import { BurstDetectionResult } from "./types";
@@ -33,44 +28,76 @@ interface Props {
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void; onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
} }
export interface SchemeItem {
scheme_id: number;
scheme_name: string;
scheme_type: string;
create_time: string;
scheme_start_time: string;
scheme_detail?: {
modify_total_duration: number;
};
}
export interface BurstDetectionAnalysisParametersState { export interface BurstDetectionAnalysisParametersState {
schemeName: string; schemeName: string;
dataSource: "monitoring" | "simulation"; detectionMode: "latest" | "historical";
schemes: SchemeItem[]; targetTime: Dayjs | null;
selectedSchemeId: number | ""; samplingIntervalMinutes: number;
scadaStart: Dayjs | null; samplingIntervalSource: "metadata" | "manual";
scadaEnd: Dayjs | null;
mu: number;
pointsPerDay: number;
nEstimators: number;
contaminationInput: string;
advancedOpen: boolean;
} }
interface ScadaInfoItem {
type?: string;
transmission_frequency?: string | number | null;
}
const currentQuarterHour = () => {
const now = dayjs().second(0).millisecond(0);
return now.minute(Math.floor(now.minute() / 15) * 15);
};
export const createBurstDetectionAnalysisParametersState = export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({ (): BurstDetectionAnalysisParametersState => ({
schemeName: `Burst_Detection_${Date.now()}`, schemeName: `Burst_Detection_${Date.now()}`,
dataSource: "monitoring", detectionMode: "latest",
schemes: [], targetTime: currentQuarterHour(),
selectedSchemeId: "", samplingIntervalMinutes: 15,
scadaStart: dayjs().subtract(3, "day"), samplingIntervalSource: "metadata",
scadaEnd: dayjs(), });
mu: 100,
pointsPerDay: 96, export const parseScadaFrequencyMinutes = (
nEstimators: 50, value: string | number | null | undefined,
contaminationInput: "auto", ): number | null => {
advancedOpen: false, if (typeof value === "number") {
return Number.isInteger(value) && value > 0 ? value : null;
}
if (!value) return null;
const normalized = value.trim();
const dayMatch = normalized.match(/^(\d+)\s+days?,\s*(.+)$/i);
const days = dayMatch ? Number(dayMatch[1]) : 0;
const timePart = dayMatch ? dayMatch[2] : normalized;
const parts = timePart.split(":").map(Number);
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
return null;
}
const minutes = days * 1440 + parts[0] * 60 + parts[1] + parts[2] / 60;
return Number.isInteger(minutes) && minutes > 0 ? minutes : null;
};
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
const counts = new Map<number, number>();
items
.filter((item) => item.type?.toLowerCase() === "pressure")
.forEach((item) => {
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
if (minutes && 1440 % minutes === 0) {
counts.set(minutes, (counts.get(minutes) ?? 0) + 1);
}
});
return [...counts.entries()].sort(
([minutesA, countA], [minutesB, countB]) =>
countB - countA || minutesA - minutesB,
)[0]?.[0] ?? 15;
};
export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState,
) => ({
scheme_name: parameters.schemeName.trim(),
sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime
? { target_time: parameters.targetTime.toISOString() }
: {}),
}); });
const AnalysisParameters: React.FC<Props> = ({ const AnalysisParameters: React.FC<Props> = ({
@@ -79,137 +106,69 @@ const AnalysisParameters: React.FC<Props> = ({
onStateChange, onStateChange,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const [parametersState, setParametersState, setFormField] = useControllableObjectState( const [parametersState, setParametersState, setFormField] =
useControllableObjectState(
state, state,
onStateChange, onStateChange,
createBurstDetectionAnalysisParametersState(), createBurstDetectionAnalysisParametersState(),
); );
const { const {
schemeName, schemeName,
dataSource, detectionMode,
schemes, targetTime,
selectedSchemeId, samplingIntervalMinutes,
scadaStart, samplingIntervalSource,
scadaEnd,
mu,
pointsPerDay,
nEstimators,
contaminationInput,
advancedOpen,
} = parametersState; } = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const isSimulationMode = dataSource === "simulation"; const [frequencyLoading, setFrequencyLoading] = useState(false);
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { useEffect(() => {
const start = dayjs(scheme.scheme_start_time); if (samplingIntervalSource !== "metadata") return;
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; let active = true;
const end = start.add(durationSeconds, "second"); setFrequencyLoading(true);
api
setParametersState((previous) => ({ .get("/api/v1/scada-info")
...previous, .then((response) => {
scadaStart: start, if (!active) return;
scadaEnd: end, const interval = resolvePressureSamplingInterval(
})); response.data as ScadaInfoItem[],
}, [setParametersState]);
const fetchSchemes = useCallback(
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
if (schemeLoading || (!force && schemes.length > 0)) return;
setSchemeLoading(true);
try {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME },
});
const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis",
); );
setParametersState((previous) =>
setFormField("schemes", burstSchemes); previous.samplingIntervalSource === "metadata"
? { ...previous, samplingIntervalMinutes: interval }
if (selectedSchemeId) { : previous,
const matchedScheme = burstSchemes.find(
(scheme) => scheme.scheme_id === selectedSchemeId,
); );
if (matchedScheme) { })
applySchemeTimeRange(matchedScheme); .catch(() => {
} else { // Keep the 15-minute fallback when SCADA metadata is unavailable.
setFormField("selectedSchemeId", ""); })
} .finally(() => {
} if (active) setFrequencyLoading(false);
if (notify) {
open?.({
type: "success",
message: "方案列表已刷新",
description: `当前可选爆管分析方案 ${burstSchemes.length}`,
}); });
} return () => {
} catch (error: any) { active = false;
open?.({
type: "error",
message: "刷新方案失败",
description:
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
});
} finally {
setSchemeLoading(false);
}
},
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField],
);
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
setFormField("dataSource", value);
if (value === "simulation") {
void fetchSchemes();
}
}; };
}, [samplingIntervalSource, setParametersState]);
const handleSchemeSelect = (schemeId: number) => { const samplingIntervalValid =
setFormField("selectedSchemeId", schemeId); Number.isInteger(samplingIntervalMinutes) &&
const scheme = schemes.find((item) => item.scheme_id === schemeId); samplingIntervalMinutes > 0 &&
if (scheme) { 1440 % samplingIntervalMinutes === 0;
applySchemeTimeRange(scheme);
}
};
const timeWindowValid = useMemo(() => { const isValid = useMemo(
if (!scadaStart || !scadaEnd) return false; () =>
return scadaEnd.diff(scadaStart, "day", true) >= 2; schemeName.trim().length > 0 &&
}, [scadaEnd, scadaStart]); samplingIntervalValid &&
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
const contaminationValue = useMemo(() => { [detectionMode, samplingIntervalValid, schemeName, targetTime],
const normalized = contaminationInput.trim().toLowerCase(); );
if (!normalized || normalized === "auto") {
return "auto" as const;
}
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) {
return null;
}
return parsed;
}, [contaminationInput]);
const isValid =
Boolean(scadaStart && scadaEnd) &&
timeWindowValid &&
Number.isFinite(mu) &&
mu > 0 &&
Number.isFinite(pointsPerDay) &&
pointsPerDay > 0 &&
Number.isFinite(nEstimators) &&
nEstimators > 0 &&
contaminationValue !== null &&
(dataSource !== "simulation" || Boolean(selectedSchemeId));
const handleRun = async () => { const handleRun = async () => {
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) { if (!isValid) {
open?.({ open?.({
type: "error", type: "error",
message: "参数不完整", message: "参数不完整",
description: "请检查时间范围(至少2天)和高级参数是否填写正确。", description: "请输入方案名称,并检查历史目标时间。",
}); });
return; return;
} }
@@ -219,50 +178,23 @@ const AnalysisParameters: React.FC<Props> = ({
key: "burst-detection-analysis-progress", key: "burst-detection-analysis-progress",
type: "progress", type: "progress",
message: "正在执行爆管侦测", message: "正在执行爆管侦测",
description: "正在读取数据并计算异常分数。", description: "正在读取目标时刻及前 14 天同刻基线。",
undoableTimeout: 3, undoableTimeout: 3,
}); });
try { try {
const selectedScheme = const response = await api.post(
dataSource === "simulation" "/api/v1/burst-detections",
? schemes.find((item) => item.scheme_id === selectedSchemeId) buildBurstDetectionRequest(parametersState),
: undefined; );
onResult(response.data as BurstDetectionResult);
const response = await api.post("/api/v1/burst-detection/detect/", {
network: NETWORK_NAME,
data_source: dataSource,
scheme_name: schemeName.trim() || undefined,
scada_start: scadaStart.toISOString(),
scada_end: scadaEnd.toISOString(),
mu,
points_per_day: pointsPerDay,
iforest_params: {
n_estimators: nEstimators,
contamination: contaminationValue,
},
simulation_scheme_name: selectedScheme?.scheme_name,
simulation_scheme_type: selectedScheme?.scheme_type,
});
onResult({
...(response.data as BurstDetectionResult),
scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name,
algorithm_params: {
mu,
points_per_day: pointsPerDay,
iforest_params: {
n_estimators: nEstimators,
contamination: contaminationValue,
},
},
});
open?.({ open?.({
key: "burst-detection-analysis-success", key: "burst-detection-analysis-success",
type: "success", type: "success",
message: "爆管侦测完成", message: "爆管侦测完成",
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, description: response.data.summary?.burst_detected
? "目标时刻存在异常信号,请优先复核相关测点。"
: "目标时刻未发现爆管异常。",
}); });
} catch (error: any) { } catch (error: any) {
open?.({ open?.({
@@ -277,7 +209,7 @@ const AnalysisParameters: React.FC<Props> = ({
}; };
return ( return (
<Box className="flex flex-col flex-1 min-h-0"> <Box className="flex min-h-0 flex-1 flex-col">
<Box className="flex flex-col gap-3"> <Box className="flex flex-col gap-3">
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
@@ -294,211 +226,89 @@ const AnalysisParameters: React.FC<Props> = ({
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
</Typography> </Typography>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<Select <Select
value={dataSource} value={detectionMode}
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")} onChange={(event) =>
setFormField(
"detectionMode",
event.target.value as "latest" | "historical",
)
}
> >
<MenuItem value="monitoring"></MenuItem> <MenuItem value="latest"></MenuItem>
<MenuItem value="simulation"></MenuItem> <MenuItem value="historical"></MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Box> </Box>
{isSimulationMode && ( {detectionMode === "historical" ? (
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FormControl fullWidth size="small">
<Select
value={selectedSchemeId}
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
disabled={schemeLoading}
displayEmpty
>
<MenuItem value="" disabled>
</MenuItem>
{schemes.map((scheme) => (
<MenuItem key={scheme.scheme_id} value={scheme.scheme_id}>
{scheme.scheme_name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton
size="small"
color="primary"
onClick={() => void fetchSchemes({ force: true, notify: true })}
disabled={schemeLoading}
aria-label="刷新爆管分析方案"
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
}}
>
{schemeLoading ? (
<CircularProgress size={18} color="inherit" />
) : (
<RefreshIcon fontSize="small" />
)}
</IconButton>
</Box>
</Box>
)}
<LocalizationProvider <LocalizationProvider
dateAdapter={AdapterDayjs} dateAdapter={AdapterDayjs}
adapterLocale="zh-cn" adapterLocale="zh-cn"
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText} localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
> >
<Box className="grid grid-cols-2 gap-2">
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
</Typography> </Typography>
<DateTimePicker <DateTimePicker
value={scadaStart} value={targetTime}
onChange={(value) => setFormField("scadaStart", value)} onChange={(value) => setFormField("targetTime", value)}
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined} maxDateTime={dayjs()}
disabled={isSimulationMode} minutesStep={15}
format="YYYY-MM-DD HH:mm" format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }} slotProps={{ textField: { size: "small", fullWidth: true } }}
/> />
</Box> </Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<DateTimePicker
value={scadaEnd}
onChange={(value) => setFormField("scadaEnd", value)}
minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined}
disabled={isSimulationMode}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
</Box>
</LocalizationProvider> </LocalizationProvider>
) : null}
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900"> <TextField
type="number"
</Box> label="采样间隔(分钟)"
value={samplingIntervalMinutes}
<Box onChange={(event) => {
sx={{ setParametersState((previous) => ({
border: "1px solid", ...previous,
borderColor: "grey.200", samplingIntervalMinutes: Number(event.target.value),
borderRadius: 1, samplingIntervalSource: "manual",
overflow: "hidden", }));
}} }}
> size="small"
<Box fullWidth
role="button" error={!samplingIntervalValid}
tabIndex={0} inputProps={{ min: 1, max: 1440, step: 1 }}
onClick={() => setFormField("advancedOpen", !advancedOpen)} helperText={
onKeyDown={(event) => { samplingIntervalValid
if (event.key === "Enter" || event.key === " ") { ? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点`
setFormField("advancedOpen", !advancedOpen); : "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。"
} }
}} />
sx={{
display: "flex", {detectionMode === "latest" ? (
alignItems: "center", <Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
justifyContent: "space-between", 14 使
px: 1.25, 24
py: 0.75, </Box>
cursor: "pointer", ) : null}
backgroundColor: "transparent", <Typography variant="caption" color="text.secondary">
"&:hover": { backgroundColor: "action.hover" }, {samplingIntervalMinutes || "-"}
}} {samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} /14
>
<Typography variant="body2" color="text.secondary">
</Typography> </Typography>
<ExpandMoreIcon
sx={{
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
/>
</Box>
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
<Box
sx={{
px: 1.25,
pt: 1.25,
pb: 1.25,
backgroundColor: "transparent",
}}
>
<Box className="flex flex-col gap-3">
<TextField
type="number"
label="频域截断系数"
value={mu}
onChange={(event) => setFormField("mu", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="每日采样点数"
value={pointsPerDay}
onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="孤立森林树数量"
value={nEstimators}
onChange={(event) => setFormField("nEstimators", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
label="异常比例"
value={contaminationInput}
onChange={(event) => setFormField("contaminationInput", event.target.value)}
size="small"
fullWidth
helperText="填写 auto 或 0~0.5 之间的小数。"
error={contaminationValue === null}
/>
</Box>
</Box>
</Collapse>
</Box>
</Box> </Box>
<Box className="mt-auto pt-3 flex gap-2"> <Box className="mt-auto flex gap-2 pt-3">
<Button <Button
variant="outlined" variant="outlined"
fullWidth fullWidth
disabled={running} disabled={running}
sx={{ textTransform: "none", fontWeight: 500 }} onClick={() =>
onClick={() => { setParametersState(createBurstDetectionAnalysisParametersState())
setParametersState((previous) => ({ }
...previous,
schemeName: `Burst_Detection_${Date.now()}`,
scadaStart: dayjs().subtract(3, "day"),
scadaEnd: dayjs(),
mu: 100,
pointsPerDay: 96,
nEstimators: 50,
contaminationInput: "auto",
}));
}}
> >
</Button> </Button>
@@ -506,11 +316,13 @@ const AnalysisParameters: React.FC<Props> = ({
variant="contained" variant="contained"
fullWidth fullWidth
disabled={!isValid || running} disabled={!isValid || running}
onClick={handleRun} onClick={() => void handleRun()}
className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }}
> >
{running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"} {running
? "侦测中..."
: detectionMode === "latest"
? "侦测最新数据"
: "回放目标时刻"}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -5,33 +5,32 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales"; import { zhCN } from "@mui/x-data-grid/locales";
import { import {
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
FormatListBulleted, FormatListBulleted,
InfoOutlined as InfoOutlinedIcon, InfoOutlined as InfoOutlinedIcon,
Room as RoomIcon, Room as RoomIcon,
ShowChart as ShowChartIcon, ShowChart as ShowChartIcon,
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import Feature from "ol/Feature"; import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector"; import VectorSource from "ol/source/Vector";
import { Circle, Fill, Stroke, Style } from "ol/style"; import { Circle, Fill, Stroke, Style } from "ol/style";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types"; import { BurstDetectionResult, BurstDetectionRow } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface BurstDetectionResultsState { export interface BurstDetectionResultsState {
selectedDay: number | null; selectedDay: number | null;
} }
export const createBurstDetectionResultsState = export const createBurstDetectionResultsState =
(): BurstDetectionResultsState => ({ (): BurstDetectionResultsState => ({ selectedDay: null });
selectedDay: null,
});
interface Props { interface Props {
result: BurstDetectionResult | null; result: BurstDetectionResult | null;
@@ -46,100 +45,58 @@ interface MetricCardProps {
tone: "blue" | "orange" | "purple" | "green"; tone: "blue" | "orange" | "purple" | "green";
} }
const toneStyles: Record< const toneStyles: Record<MetricCardProps["tone"], string> = {
MetricCardProps["tone"], blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900",
{ bg: string; border: string; text: string; darkText: string } orange: "border-orange-200 from-orange-50 to-orange-100 text-orange-900",
> = { purple: "border-purple-200 from-purple-50 to-purple-100 text-purple-900",
blue: { green: "border-green-200 from-green-50 to-green-100 text-green-900",
bg: "from-blue-50 to-blue-100",
border: "border-blue-200",
text: "text-blue-700",
darkText: "text-blue-900",
},
orange: {
bg: "from-orange-50 to-orange-100",
border: "border-orange-200",
text: "text-orange-700",
darkText: "text-orange-900",
},
purple: {
bg: "from-purple-50 to-purple-100",
border: "border-purple-200",
text: "text-purple-700",
darkText: "text-purple-900",
},
green: {
bg: "from-green-50 to-green-100",
border: "border-green-200",
text: "text-green-700",
darkText: "text-green-900",
},
}; };
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
const style = toneStyles[tone]; <Box
return ( className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
<Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}> >
<Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}> <Typography variant="caption" className="mb-1 block font-semibold">
{label} {label}
</Typography> </Typography>
<Typography variant="body2" className={`font-bold ${style.darkText}`}> <Typography variant="body2" className="font-bold">
{value} {value}
</Typography> </Typography>
{hint ? ( {hint ? (
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}> <Typography variant="caption" className="mt-0.5 block opacity-75">
{hint} {hint}
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
); );
};
const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
const EmptyState = () => ( const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> <PanelEmptyState
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> icon={<ShowChartIcon />}
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> title="尚未生成侦测结果"
</Box> description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
<Typography variant="h6" className="mb-1 font-bold text-gray-700"> />
</Typography>
<Typography variant="body2" className="max-w-xs text-gray-500">
</Typography>
</Box>
); );
const getScoreLevel = (score: number) => { const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
if (score <= -0.6) return { label: "高风险", color: "error" as const };
if (score <= -0.2) return { label: "需关注", color: "warning" as const };
return { label: "正常", color: "success" as const };
};
const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
const DetectionResults: React.FC<Props> = ({
result,
state,
onStateChange,
}) => {
const map = useMap(); const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [internalResultsState, setInternalResultsState] = const [internalState, setInternalState] = useState<BurstDetectionResultsState>(
useState<BurstDetectionResultsState>(createBurstDetectionResultsState); createBurstDetectionResultsState,
const resultsState = state ?? internalResultsState; );
const selectedDay = resultsState.selectedDay; const resultsState = state ?? internalState;
const setSelectedDay = (value: number | null) => { const setSelectedDay = (selectedDay: number | null) => {
const nextState = { ...resultsState, selectedDay: value }; const nextState = { selectedDay };
if (state === undefined) { if (state === undefined) setInternalState(nextState);
setInternalResultsState(nextState);
}
onStateChange?.(nextState); onStateChange?.(nextState);
}; };
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
const layer = new VectorLayer({ const layer = new VectorLayer({
source: new VectorSource(), source: new VectorSource(),
style: new Style({ style: new Style({
@@ -154,12 +111,11 @@ const DetectionResults: React.FC<Props> = ({
properties: { properties: {
name: "爆管侦测高亮", name: "爆管侦测高亮",
value: "burst_detection_highlight", value: "burst_detection_highlight",
queryable: false,
}, },
}); });
map.addLayer(layer); map.addLayer(layer);
highlightLayerRef.current = layer; highlightLayerRef.current = layer;
return () => { return () => {
highlightLayerRef.current = null; highlightLayerRef.current = null;
map.removeLayer(layer); map.removeLayer(layer);
@@ -173,58 +129,67 @@ const DetectionResults: React.FC<Props> = ({
highlightFeatures.forEach((feature) => source.addFeature(feature)); highlightFeatures.forEach((feature) => source.addFeature(feature));
}, [highlightFeatures]); }, [highlightFeatures]);
const defaultSelectedDay = useMemo( const sortedRows = useMemo(
() => () => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
result?.summary?.most_anomalous_day ??
result?.summary?.latest_day?.Day ??
result?.rows[0]?.Day ??
null,
[result], [result],
); );
const activeSelectedDay = selectedDay ?? defaultSelectedDay; const timestampForRow = (row: BurstDetectionRow) => {
if (row.Timestamp) return row.Timestamp;
const start = dayjs(result?.scada_window?.start);
return start.isValid() ? start.add(row.Day, "day").toISOString() : undefined;
};
const selectedRow = useMemo<BurstDetectionRow | null>(() => { const scoreThreshold = result?.summary.score_threshold ?? 0;
if (!result || activeSelectedDay === null) return null; const scoreSeries = sortedRows.map((row) => ({
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null; day: row.Day,
}, [activeSelectedDay, result]); value: [
timestampForRow(row)
const scoreSeries = useMemo( ? dayjs(timestampForRow(row)).format("MM-DD HH:mm")
() => : `${row.Day}`,
result?.rows.map((row) => ({ Number(row.Score.toFixed(4)),
value: [row.Day, Number(row.Score.toFixed(4))], ],
itemStyle: { itemStyle: {
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981", color:
row.Role === "target"
? row.IsBurst
? "#ef4444"
: "#2563eb"
: "#94a3b8",
}, },
})) ?? [], symbolSize: row.Role === "target" ? 11 : 7,
[result], }));
);
const rankingSeries = useMemo( const rankingSeries = useMemo(
() => () =>
[...(result?.summary?.latest_sensor_rankings ?? [])] [...(result?.summary.latest_sensor_rankings ?? [])]
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value) .sort(
(a, b) =>
(a.standardized_deviation ?? a.latest_high_frequency_value) -
(b.standardized_deviation ?? b.latest_high_frequency_value),
)
.map((item) => ({ .map((item) => ({
name: item.sensor_node, name: item.sensor_node,
value: Number(item.latest_high_frequency_value.toFixed(4)), value: Number(
(item.standardized_deviation ?? item.latest_high_frequency_value).toFixed(3),
),
})), })),
[result], [result],
); );
const locateSensors = async (sensorIds: string[]) => { const locateSensors = async (sensorIds: string[]) => {
if (!map || sensorIds.length === 0) return; if (!map || sensorIds.length === 0) return;
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat"); let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
if (features.length === 0) { if (features.length === 0) {
features = await queryFeaturesByIds(sensorIds, "geo_junctions"); features = await queryFeaturesByIds(sensorIds, "geo_junctions");
} }
if (features.length === 0) return; if (features.length === 0) return;
setHighlightFeatures(features); setHighlightFeatures(features);
const format = new GeoJSON();
const geojsonFormat = new GeoJSON(); const geojsonFeatures = features.map((feature) =>
const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); format.writeFeatureObject(feature),
// @ts-ignore turf typing with ol geojson objects );
// @ts-ignore turf accepts OpenLayers GeoJSON feature objects
const extent = bbox(featureCollection(geojsonFeatures)); const extent = bbox(featureCollection(geojsonFeatures));
map.getView().fit(extent, { map.getView().fit(extent, {
maxZoom: 18, maxZoom: 18,
@@ -233,60 +198,50 @@ const DetectionResults: React.FC<Props> = ({
}); });
}; };
if (!result) { if (!result) return <EmptyState />;
return <EmptyState />;
}
const latestDay = result.summary?.latest_day; const targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1);
const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0);
const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null;
const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0);
const isBurstDetected = result.summary.burst_detected; const isBurstDetected = result.summary.burst_detected;
const targetRank = result.summary.target_rank;
const excludedCount = result.data_quality?.excluded_sensors.length ?? 0;
const chartOption = { const chartOption = {
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
formatter: (params: Array<{ data: { value: [number, number] } }>) => { formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
const point = params[0]?.data?.value; const data = params[0]?.data;
if (!point) return "-"; return data
return `侦测日第 ${point[0]}<br/>异常分数:${point[1]}`; ? `${data.value[0]}<br/>${data.day === 15 ? "目标时刻" : "参考日"}<br/>异常分数:${data.value[1]}`
: "-";
}, },
}, },
grid: { top: 30, left: 40, right: 20, bottom: 35 }, grid: { top: 30, left: 48, right: 20, bottom: 48 },
xAxis: { xAxis: {
type: "category", type: "category",
name: "侦测日", name: "同刻日期",
data: result.rows.map((row) => row.Day), boundaryGap: false,
axisLabel: { fontSize: 10 }, data: scoreSeries.map((item) => item.value[0]),
}, axisLabel: { fontSize: 10, interval: 2, rotate: 25 },
yAxis: {
type: "value",
name: "异常分数",
axisLabel: { fontSize: 10 },
}, },
yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } },
series: [ series: [
{ {
type: "line", type: "line",
smooth: true,
symbolSize: 8,
data: scoreSeries, data: scoreSeries,
lineStyle: { color: "#2563eb", width: 2 }, lineStyle: { color: "#94a3b8", width: 2 },
markLine: { markLine: {
symbol: "none", symbol: "none",
lineStyle: { type: "dashed", color: "#94a3b8" }, lineStyle: { type: "dashed", color: "#ef4444" },
data: [{ yAxis: 0 }], data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
}, },
}, },
], ],
}; };
const rankingOption = { const rankingOption = {
tooltip: { tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
trigger: "axis", grid: { top: 12, left: 82, right: 20, bottom: 25 },
axisPointer: { type: "shadow" }, xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } },
},
grid: { top: 20, left: 70, right: 20, bottom: 20 },
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
yAxis: { yAxis: {
type: "category", type: "category",
data: rankingSeries.map((item) => item.name), data: rankingSeries.map((item) => item.name),
@@ -297,9 +252,7 @@ const DetectionResults: React.FC<Props> = ({
type: "bar", type: "bar",
data: rankingSeries.map((item) => ({ data: rankingSeries.map((item) => ({
value: item.value, value: item.value,
itemStyle: { itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" },
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
},
})), })),
barWidth: 14, barWidth: 14,
}, },
@@ -308,323 +261,176 @@ const DetectionResults: React.FC<Props> = ({
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "Day", field: "Timestamp",
headerName: "侦测日", headerName: "同刻日期",
width: 96, minWidth: 145,
valueFormatter: (value?: number) => (typeof value === "number" ? `${value}` : "-"), flex: 1,
valueGetter: (_value, row) => formatDateTime(timestampForRow(row)),
},
{
field: "Role",
headerName: "角色",
width: 90,
valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"),
}, },
{ {
field: "Score", field: "Score",
headerName: "异常分数", headerName: "异常分数",
width: 120, width: 110,
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"), valueFormatter: (value?: number) =>
typeof value === "number" ? value.toFixed(4) : "-",
}, },
{ {
field: "IsBurst", field: "IsBurst",
headerName: "判定结果", headerName: "目标判定",
width: 120, width: 110,
renderCell: ({ value }) => { renderCell: ({ value, row }) =>
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const }; row.Role === "target" || row.Day === result.day_count ? (
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />; <Chip
}, size="small"
label={value ? "爆管异常" : "正常"}
color={value ? "error" : "success"}
variant="outlined"
/>
) : (
<Typography variant="caption" color="text.secondary">
</Typography>
),
}, },
]; ];
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
return ( return (
<Box className="h-full overflow-auto p-1"> <Box className="h-full overflow-auto p-1">
<Box className="mb-4 space-y-3"> <Box className="mb-4 space-y-3">
{/* Status Banner */}
<Box <Box
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
? "bg-red-50 border-red-100 text-red-900" isBurstDetected
: "bg-green-50 border-green-100 text-green-900" ? "border-red-100 bg-red-50 text-red-900"
: "border-green-100 bg-green-50 text-green-900"
}`} }`}
> >
{isBurstDetected ? ( {isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
<ErrorOutlineIcon className="text-red-600" />
) : (
<CheckCircleIcon className="text-green-600" />
)}
<Box className="flex-1"> <Box className="flex-1">
<Typography variant="subtitle2" className="font-bold"> <Typography variant="subtitle2" className="font-bold">
{isBurstDetected {isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
: "未侦测到爆管异常"}
</Typography> </Typography>
<Typography variant="caption" className="opacity-80"> <Typography variant="caption" className="opacity-80">
{isBurstDetected {formatDateTime(result.target_time ?? result.summary.target_time)}
? "建议检查异常日期的压力波动情况"
: "当前时间窗口内数据特征平稳,符合历史模式"}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
{/* Header */} <Box className="flex items-center justify-between gap-2 px-1">
<Box className="flex items-center justify-between px-1"> <Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
<Box className="flex items-center gap-2">
<Box className="h-4 w-1 rounded-full bg-blue-600" />
<Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}>
{result.scheme_name || "爆管侦测结果"}
</Typography> </Typography>
</Box>
<Box className="flex items-center gap-2">
{result.username ? (
<Chip
label={result.username}
size="small"
sx={{
height: 24,
backgroundColor: "#f3f4f6",
color: "#4b5563",
border: "none",
fontWeight: 500,
}}
/>
) : null}
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
startIcon={<RoomIcon />} startIcon={<RoomIcon />}
onClick={() => onClick={() =>
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5)) void locateSensors(
result.summary.latest_sensor_rankings
.slice(0, 5)
.map((item) => item.sensor_node),
)
} }
sx={{ sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
height: 24,
minWidth: 0,
padding: "0 8px",
borderColor: "#bfdbfe",
color: "#2563eb",
fontSize: "0.75rem",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
> >
</Button> </Button>
</Box> </Box>
</Box>
{/* Configuration Summary */}
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span className="font-medium text-gray-700"></span>
<span className="font-mono text-gray-600">
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
</span>
</Box>
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-600">
{(() => {
const ds = result.data_source;
const os = result.observed_source;
if (ds === "simulation") return "模拟数据";
if (ds === "monitoring") return "监测数据";
if (os === "simulation_scheme_timerange") return "模拟数据";
if (os === "backend_timerange") return "监测数据";
return os || "-";
})()}
</span>
</Box>
</Box>
{/* Metrics Grid */}
<Box className="grid grid-cols-2 gap-3"> <Box className="grid grid-cols-2 gap-3">
<MetricCard <MetricCard
label="异常数" label="目标异常数"
value={`${result.summary.anomaly_day_count} / ${result.day_count}`} value={targetRow ? targetRow.Score.toFixed(4) : "-"}
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`} hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"} tone={isBurstDetected ? "orange" : "green"}
/> />
<MetricCard <MetricCard
label="最异常日" label="目标异常排名"
value={ value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
result.summary.burst_detected && result.summary.most_anomalous_day hint="在目标日与 14 个参考日中排序"
? `${result.summary.most_anomalous_day}`
: "无"
}
hint={
result.summary.burst_detected && mostAnomalousRow
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
: "-"
}
tone="purple" tone="purple"
/> />
<MetricCard <MetricCard
label="最新状态" label="参考区间"
value={latestLevel.label} value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
hint={latestDay ? `${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"} hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
tone={latestLevel.color === "success" ? "green" : "orange"} tone="blue"
/> />
<MetricCard <MetricCard
label="测点 / 样本" label="有效 / 排除测点"
value={`${result.sensor_nodes.length} / ${result.sample_count}`} value={`${result.sensor_nodes.length} / ${excludedCount}`}
hint={`每日采样点数:${result.points_per_day}`} hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
tone="blue" tone="blue"
/> />
</Box> </Box>
</Box> </Box>
{/* Score Trend Chart */}
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
<ShowChartIcon className="h-5 w-5 text-blue-600" /> <ShowChartIcon className="text-blue-600" />
<Typography variant="subtitle1" className="font-bold text-gray-800"> <Typography variant="subtitle1" className="font-bold">
15
</Typography> </Typography>
</Box> </Box>
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。"> <Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
<InfoOutlinedIcon fontSize="small" className="text-gray-400" /> <InfoOutlinedIcon fontSize="small" className="text-gray-400" />
</Tooltip> </Tooltip>
</Box> </Box>
<Box sx={{ height: 250, px: 1.5, py: 1 }}> <Box sx={{ height: 270, px: 1.5, py: 1 }}>
<ReactECharts <ReactECharts
option={chartOption} option={chartOption}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
onEvents={{ onEvents={{
click: (params: { data?: { value?: [number, number] } }) => { click: (params: { data?: { day?: number } }) =>
const day = params?.data?.value?.[0]; setSelectedDay(params.data?.day ?? null),
if (typeof day === "number") {
setSelectedDay(day);
}
},
}} }}
/> />
</Box> </Box>
</Box> </Box>
{/* Selected Day Interpretation */} {rankingSeries.length > 0 ? (
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<Typography variant="subtitle1" className="font-bold text-gray-800"> <Typography variant="subtitle1" className="font-bold">
选中日解读
</Typography> </Typography>
{selectedRow ? ( <Typography variant="caption" color="text.secondary">
<Chip
size="small"
label={`第 ${selectedRow.Day} 天`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
) : null}
</Box>
{selectedRow ? (
<Box className="space-y-3 px-4 py-3">
<Box className="flex items-center gap-2">
<Chip
label={getScoreLevel(selectedRow.Score).label}
color={getScoreLevel(selectedRow.Score).color}
variant="filled"
/>
</Box>
<Typography variant="body2" className="text-gray-700">
异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span>
</Typography>
<Typography variant="body2" className="text-gray-700">
模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1" : "正常日(Prediction = 1"}
</Typography>
<Typography variant="body2" className="text-gray-700">
解读建议:
{selectedRow.Score <= -0.6
? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
: selectedRow.Score <= -0.2
? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
: "未见明显异常,可作为基线日参考。"}
</Typography> </Typography>
</Box> </Box>
) : ( <Box sx={{ height: 280, px: 1.5, py: 1 }}>
<Typography variant="body2" className="px-4 py-3 text-gray-500">
请在趋势图或表格中选择一天查看详细解释。
</Typography>
)}
</Box> */}
{/* Latest Sensor Rankings */}
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
<Typography variant="subtitle1" className="font-bold text-gray-800">
最新测点高频特征排名
</Typography>
<Typography variant="caption" className="text-gray-500">
仅展示最新一天
</Typography>
</Box>
<Box sx={{ height: 260, px: 1.5, py: 1 }}>
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} /> <ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
</Box> </Box>
<Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3">
{result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
<Button
key={item.sensor_node}
size="small"
variant="outlined"
onClick={() => locateSensors([item.sensor_node])}
sx={{
borderColor: "#bfdbfe",
color: "#2563eb",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
>
{item.sensor_node}
</Button>
))}
</Box> </Box>
</Box> */} ) : null}
{/* Results Table */}
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
<Box className="flex items-center gap-2"> <FormatListBulleted className="text-blue-600" />
<FormatListBulleted className="h-5 w-5 text-blue-600" /> <Typography variant="subtitle1" className="font-bold">
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography> </Typography>
</Box> </Box>
<Chip <Box sx={{ height: 360, px: 1, py: 1 }}>
size="small"
label={`${rows.length}`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
</Box>
<Box sx={{ height: 320, px: 1, py: 1 }}>
<DataGrid <DataGrid
rows={rows} rows={tableRows}
columns={columns} columns={columns}
columnBufferPx={100}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText} localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
initialState={{ pageSizeOptions={[15]}
pagination: { paginationModel: { pageSize: 50, page: 0 } }, initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
}}
pageSizeOptions={[50]}
hideFooterSelectedRowCount
sx={{
border: "none",
"& .MuiDataGrid-cell": { borderColor: "#f0f0f0" },
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" },
"& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" },
// Hide the rows per page selector since it's fixed to 50
"& .MuiTablePagination-selectLabel": { display: "none" },
"& .MuiTablePagination-input": { display: "none" },
}}
disableRowSelectionOnClick disableRowSelectionOnClick
onRowClick={(params) => setSelectedDay(Number(params.row.Day))} onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
getRowClassName={(params) =>
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
}
sx={{ border: "none" }}
/> />
</Box> </Box>
</Box> </Box>
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
import { import {
BurstDetectionResult, BurstDetectionResult,
BurstDetectionSchemeDetail, BurstDetectionSchemeDetail,
@@ -42,6 +43,7 @@ export interface BurstDetectionSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createBurstDetectionSchemeQueryState = export const createBurstDetectionSchemeQueryState =
@@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState =
queryAll: true, queryAll: true,
queryDate: dayjs(), queryDate: dayjs(),
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<Props> = ({ const SchemeQuery: React.FC<Props> = ({
@@ -64,11 +67,21 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange, onStateChange,
createBurstDetectionSchemeQueryState(), createBurstDetectionSchemeQueryState(),
); );
const { queryAll, queryDate, expandedId } = queryState; const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
() =>
schemes
.slice()
.sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
),
[schemes],
);
const buildDisplayResult = ( const buildDisplayResult = (
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">, scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
@@ -105,20 +118,29 @@ const SchemeQuery: React.FC<Props> = ({
username: payload?.username ?? scheme.username, username: payload?.username ?? scheme.username,
create_time: payload?.create_time ?? scheme.create_time, create_time: payload?.create_time ?? scheme.create_time,
algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params, algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params,
requested_target_time: payload?.requested_target_time,
target_time: payload?.target_time,
reference_window: payload?.reference_window,
sampling_interval_minutes: payload?.sampling_interval_minutes,
daily_scores: payload?.daily_scores,
data_quality: payload?.data_quality,
}; };
}; };
const handleQuery = async () => { const handleQuery = async () => {
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { network: NETWORK_NAME }; const params: Record<string, string> = {
scheme_type: "burst_detection",
};
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString(); params.query_date = queryDate.startOf("day").toISOString();
} }
const response = await api.get("/api/v1/burst-detection/schemes/", { params }); const response = await api.get("/api/v1/schemes", { params });
const nextSchemes = response.data as BurstDetectionSchemeRecord[]; const nextSchemes = response.data as BurstDetectionSchemeRecord[];
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
@@ -138,8 +160,8 @@ const SchemeQuery: React.FC<Props> = ({
const handleViewSchemeResult = async (schemeName: string) => { const handleViewSchemeResult = async (schemeName: string) => {
try { try {
const response = await api.get( const response = await api.get(
`/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`, `/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } }, { params: { scheme_type: "burst_detection" } },
); );
const schemeRecord = response.data as BurstDetectionSchemeRecord & { const schemeRecord = response.data as BurstDetectionSchemeRecord & {
result_payload?: BurstDetectionResult; result_payload?: BurstDetectionResult;
@@ -184,7 +206,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox <Checkbox
size="small" size="small"
checked={queryAll} checked={queryAll}
onChange={(event) => setQueryField("queryAll", event.target.checked)} onChange={(event) => {
setQueryField("queryAll", event.target.checked);
setQueryField("hasQueried", false);
}}
/> />
} }
label={<Typography variant="body2"></Typography>} label={<Typography variant="body2"></Typography>}
@@ -193,7 +218,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => setQueryField("queryDate", value)} onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll} disabled={queryAll}
format="YYYY-MM-DD" format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 180 } } }} slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
@@ -214,19 +242,14 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{schemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<Typography variant="body2"></Typography>
<Typography variant="caption" className="mt-1">
</Typography>
</Box>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="px-2 text-gray-500"> <Typography variant="caption" className="px-2 text-gray-500">
{schemes.length} {sortedSchemes.length}
</Typography> </Typography>
{schemes.map((scheme) => { {sortedSchemes.map((scheme) => {
const summary = scheme.scheme_detail?.result_summary; const summary = scheme.scheme_detail?.result_summary;
const payload = scheme.scheme_detail?.result_payload; const payload = scheme.scheme_detail?.result_payload;
const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false; const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false;
@@ -235,6 +258,9 @@ const SchemeQuery: React.FC<Props> = ({
const mostAnomalousDay = const mostAnomalousDay =
payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-"; payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0; const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0;
const targetTime = payload?.target_time ?? payload?.summary?.target_time;
const targetScore = payload?.summary?.target_score ?? summary?.target_score;
const targetRank = payload?.summary?.target_rank ?? summary?.target_rank;
return ( return (
<Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md"> <Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md">
@@ -283,18 +309,22 @@ const SchemeQuery: React.FC<Props> = ({
<Box className="grid grid-cols-3 gap-2"> <Box className="grid grid-cols-3 gap-2">
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "目标时刻" : "异常天数"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{anomalyDayCount} {targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount}
</Typography> </Typography>
</Box> </Box>
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "目标分数" : "最异常日"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{isBurst {targetTime
? typeof targetScore === "number"
? targetScore.toFixed(4)
: "-"
: isBurst
? typeof mostAnomalousDay === "number" ? typeof mostAnomalousDay === "number"
? `${mostAnomalousDay}` ? `${mostAnomalousDay}`
: mostAnomalousDay : mostAnomalousDay
@@ -303,10 +333,12 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "异常排名" : "测点数"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{sensorCount} {targetTime && targetRank
? `${targetRank} / ${payload?.day_count ?? 15}`
: sensorCount}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -326,6 +358,8 @@ const SchemeQuery: React.FC<Props> = ({
if (ds === "monitoring") return "监测数据"; if (ds === "monitoring") return "监测数据";
if (os === "simulation_scheme_timerange") return "模拟数据"; if (os === "simulation_scheme_timerange") return "模拟数据";
if (os === "backend_timerange") return "监测数据"; if (os === "backend_timerange") return "监测数据";
if (os === "latest_monitoring") return "最新监测数据";
if (os === "historical_monitoring") return "历史监测回放";
return os || "-"; return os || "-";
})()} })()}
</Typography> </Typography>
@@ -344,14 +378,16 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600"> <Typography variant="caption" className="text-gray-600">
: :
</Typography> </Typography>
<Typography variant="caption" className="font-medium text-gray-900"> <Typography variant="caption" className="font-medium text-gray-900">
{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} {scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"}
{scheme.scheme_detail?.algorithm_params?.points_per_day ?? {scheme.scheme_detail?.algorithm_params?.points_per_day ??
payload?.algorithm_params?.points_per_day ?? payload?.algorithm_params?.points_per_day ??
"-"} "-"}
{payload?.summary?.score_threshold ?? "-"}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -3,11 +3,16 @@ export interface BurstDetectionRow {
Score: number; Score: number;
Prediction: number; Prediction: number;
IsBurst: boolean; IsBurst: boolean;
Timestamp?: string;
Role?: "reference" | "target";
} }
export interface BurstDetectionSensorRanking { export interface BurstDetectionSensorRanking {
sensor_node: string; sensor_node: string;
latest_high_frequency_value: number; latest_high_frequency_value: number;
historical_mean?: number;
historical_std?: number;
standardized_deviation?: number;
} }
export interface BurstDetectionSummary { export interface BurstDetectionSummary {
@@ -17,6 +22,11 @@ export interface BurstDetectionSummary {
anomaly_days: number[]; anomaly_days: number[];
anomaly_day_count: number; anomaly_day_count: number;
latest_sensor_rankings: BurstDetectionSensorRanking[]; latest_sensor_rankings: BurstDetectionSensorRanking[];
target_score?: number;
score_threshold?: number;
target_rank?: number;
target_time?: string;
reference_day_count?: number;
} }
export interface BurstDetectionAlgorithmParams { export interface BurstDetectionAlgorithmParams {
@@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams {
contamination?: number | "auto"; contamination?: number | "auto";
random_state?: number; random_state?: number;
}; };
score_threshold?: number;
} }
export interface BurstDetectionResult { export interface BurstDetectionResult {
@@ -51,6 +62,25 @@ export interface BurstDetectionResult {
type?: string; type?: string;
}; };
algorithm_params?: BurstDetectionAlgorithmParams; algorithm_params?: BurstDetectionAlgorithmParams;
requested_target_time?: string | null;
target_time?: string;
reference_window?: {
start: string;
end: string;
day_count: number;
};
sampling_interval_minutes?: number;
daily_scores?: Array<{
timestamp: string;
role: "reference" | "target";
score: number;
raw_prediction: number;
}>;
data_quality?: {
included_sensors: string[];
excluded_sensors: Array<{ sensor_node: string; reason: string }>;
minimum_required_sensors: number;
};
} }
export interface BurstDetectionSchemeDetail { export interface BurstDetectionSchemeDetail {
@@ -28,6 +28,7 @@ import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import { BurstLocationResult } from "./types"; import { BurstLocationResult } from "./types";
import { getBurstLocationErrorNotice } from "./burstLocationError";
interface Props { interface Props {
onResult: (result: BurstLocationResult) => void; onResult: (result: BurstLocationResult) => void;
@@ -43,6 +44,7 @@ export interface SchemeItem {
scheme_start_time: string; scheme_start_time: string;
scheme_detail?: { scheme_detail?: {
modify_total_duration: number; modify_total_duration: number;
burst_ID?: string[] | string;
}; };
} }
@@ -124,10 +126,13 @@ const AnalysisParameters: React.FC<Props> = ({
setSchemeLoading(true); setSchemeLoading(true);
try { try {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME }, params: { scheme_type: "burst_analysis" },
}); });
const burstSchemes = (response.data as SchemeItem[]).filter( const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis", (scheme) => scheme.scheme_type === "burst_analysis",
).sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
); );
setFormField("schemes", burstSchemes); setFormField("schemes", burstSchemes);
@@ -218,9 +223,8 @@ const AnalysisParameters: React.FC<Props> = ({
: undefined; : undefined;
const response = await api.post( 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, data_source: dataSource,
scheme_name: schemeName.trim() || undefined, scheme_name: schemeName.trim() || undefined,
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT), burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
@@ -234,7 +238,24 @@ const AnalysisParameters: React.FC<Props> = ({
}, },
); );
onResult(response.data as BurstLocationResult); const resultPayload = response.data as BurstLocationResult;
const selectedBurstIds = normalizeBurstIds(selectedScheme?.scheme_detail?.burst_ID);
onResult(
selectedBurstIds.length > 0
? {
...resultPayload,
simulation_scheme: {
...resultPayload.simulation_scheme,
name: resultPayload.simulation_scheme?.name ?? selectedScheme?.scheme_name,
type: resultPayload.simulation_scheme?.type ?? selectedScheme?.scheme_type,
burst_ids:
resultPayload.simulation_scheme?.burst_ids?.length
? resultPayload.simulation_scheme.burst_ids
: selectedBurstIds,
},
}
: resultPayload,
);
open?.({ open?.({
key: "burst-location-analysis-success", key: "burst-location-analysis-success",
type: "success", type: "success",
@@ -242,11 +263,12 @@ const AnalysisParameters: React.FC<Props> = ({
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`, description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
}); });
} catch (error: any) { } catch (error: any) {
const notice = getBurstLocationErrorNotice(error);
open?.({ open?.({
key: "burst-location-analysis-error", key: "burst-location-analysis-error",
type: "error", type: "error",
message: "提交分析失败", message: notice.message,
description: error?.response?.data?.detail ?? error?.message ?? "请求失败", description: notice.description,
}); });
} finally { } finally {
setRunning(false); setRunning(false);
@@ -283,22 +305,30 @@ const AnalysisParameters: React.FC<Props> = ({
</Select> </Select>
</FormControl> </FormControl>
<Alert <Alert
severity="info" severity={isSimulationMode ? "warning" : "info"}
variant="outlined" variant="outlined"
sx={{ sx={{
mt: 1, mt: 1,
alignItems: "flex-start", alignItems: "flex-start",
borderColor: "info.light", borderColor: isSimulationMode ? "warning.light" : "info.light",
backgroundColor: "#eff6ff", backgroundColor: isSimulationMode ? "#fff8e1" : "#eff6ff",
"& .MuiAlert-message": { width: "100%" }, "& .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>
<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 {isSimulationMode
? "当前为模拟方案:爆管数据取所选方案模拟结果,正常数据取实时模拟结果,两者使用同一爆管时间窗。" ? "爆管数据取所选方案,正常数据取同一时间窗的实时模拟结果。所选时间窗必须已有实时模拟正常基线,否则无法分析;系统不会自动补数据。"
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"} : "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
</Typography> </Typography>
</Alert> </Alert>
@@ -500,3 +530,11 @@ const AnalysisParameters: React.FC<Props> = ({
}; };
export default AnalysisParameters; export default AnalysisParameters;
const normalizeBurstIds = (value: string[] | string | undefined) => {
if (!value) return [];
const values = Array.isArray(value) ? value : [value];
return Array.from(
new Set(values.map((item) => String(item).trim()).filter(Boolean)),
);
};
@@ -13,6 +13,7 @@ import {
TableHead, TableHead,
TableRow, TableRow,
Button, Button,
Link,
} from "@mui/material"; } from "@mui/material";
import { import {
FormatListBulleted, FormatListBulleted,
@@ -30,6 +31,7 @@ import { Stroke, Style, Circle, Fill } from "ol/style";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import { BurstCandidate, BurstLocationResult } from "./types"; import { BurstCandidate, BurstLocationResult } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props { interface Props {
result: BurstLocationResult | null; result: BurstLocationResult | null;
@@ -127,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
}; };
const EmptyState = () => ( const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> <PanelEmptyState
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> icon={<MapIcon />}
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> title="尚未生成定位结果"
</Box> description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
<Typography variant="h6" className="mb-1 font-bold text-gray-700"> />
</Typography>
<Typography variant="body2" className="max-w-xs text-gray-500">
</Typography>
</Box>
); );
const LocationResults: React.FC<Props> = ({ result }) => { const LocationResults: React.FC<Props> = ({ result }) => {
@@ -183,6 +179,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
properties: { properties: {
name: "爆管定位高亮", name: "爆管定位高亮",
value: "burst_location_highlight", value: "burst_location_highlight",
queryable: false,
}, },
}); });
map.addLayer(layer); map.addLayer(layer);
@@ -251,6 +248,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
); );
const sourceLabel = getDataSourceLabel(result); const sourceLabel = getDataSourceLabel(result);
const normalDataDescription = getNormalDataDescription(result); const normalDataDescription = getNormalDataDescription(result);
const simulationBurstIds = result.simulation_scheme?.burst_ids ?? [];
return ( return (
<Box className="h-full overflow-auto p-1"> <Box className="h-full overflow-auto p-1">
@@ -287,6 +285,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
variant="outlined" variant="outlined"
startIcon={<LocationOnIcon />} startIcon={<LocationOnIcon />}
onClick={() => locatePipes([result.located_pipe])} onClick={() => locatePipes([result.located_pipe])}
disabled={!result.located_pipe}
sx={{ sx={{
height: 24, height: 24,
minWidth: 0, minWidth: 0,
@@ -348,6 +347,55 @@ const LocationResults: React.FC<Props> = ({ result }) => {
: {result.simulation_scheme.name} : {result.simulation_scheme.name}
</Typography> </Typography>
) : null} ) : null}
{simulationBurstIds.length > 0 ? (
<Box className="mt-1 flex flex-wrap items-center gap-1">
<Typography variant="caption" className="text-purple-600">
:
</Typography>
{simulationBurstIds.map((pipeId) => (
<Link
key={pipeId}
component="button"
variant="caption"
onClick={() => locatePipes([pipeId])}
title={pipeId}
sx={{
maxWidth: 132,
color: "#7c3aed",
fontSize: "0.75rem",
fontWeight: 700,
lineHeight: "22px",
cursor: "pointer",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "underline",
textUnderlineOffset: "2px",
"&:hover": {
color: "#5b21b6",
},
}}
>
{pipeId}
</Link>
))}
<Button
size="small"
variant="text"
startIcon={<LocationOnIcon />}
onClick={() => locatePipes(simulationBurstIds)}
sx={{
minWidth: 0,
px: 0.5,
py: 0,
color: "#7c3aed",
fontSize: "0.72rem",
}}
>
</Button>
</Box>
) : null}
</Box> </Box>
</Box> </Box>
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -13,8 +13,12 @@ import {
IconButton, IconButton,
Tooltip, Tooltip,
Typography, Typography,
Link,
} from "@mui/material"; } from "@mui/material";
import { Info as InfoIcon } from "@mui/icons-material"; import {
Info as InfoIcon,
LocationOn as LocationOnIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
@@ -24,12 +28,22 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import Feature from "ol/Feature";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Stroke, Style, Circle, Fill } from "ol/style";
import { bbox, featureCollection } from "@turf/turf";
import { import {
BurstLocationResult, BurstLocationResult,
BurstLocationSchemeDetail, BurstLocationSchemeDetail,
BurstSchemeRecord, BurstSchemeRecord,
} from "./types"; } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props { interface Props {
onViewResult: (result: BurstLocationResult) => void; onViewResult: (result: BurstLocationResult) => void;
@@ -43,6 +57,8 @@ export interface BurstLocationSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: number | null;
simulationBurstIdsByName: Record<string, string[]>;
hasQueried: boolean;
} }
export const createBurstLocationSchemeQueryState = export const createBurstLocationSchemeQueryState =
@@ -50,6 +66,8 @@ export const createBurstLocationSchemeQueryState =
queryAll: true, queryAll: true,
queryDate: dayjs(), queryDate: dayjs(),
expandedId: null, expandedId: null,
simulationBurstIdsByName: {},
hasQueried: false,
}); });
const SchemeQuery: React.FC<Props> = ({ const SchemeQuery: React.FC<Props> = ({
@@ -60,16 +78,118 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange, onStateChange,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [queryState, , setQueryField] = useControllableObjectState( const [queryState, , setQueryField] = useControllableObjectState(
state, state,
onStateChange, onStateChange,
createBurstLocationSchemeQueryState(), createBurstLocationSchemeQueryState(),
); );
const { queryAll, queryDate, expandedId } = queryState; const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const creatorName = useSchemeCreatorName();
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes; const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
() =>
schemes
.slice()
.sort(
(a, b) =>
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
),
[schemes],
);
useEffect(() => {
if (!map) return;
const layer = new VectorLayer({
source: new VectorSource(),
style: new Style({
stroke: new Stroke({
color: "#a855f7",
width: 6,
}),
image: new Circle({
radius: 8,
fill: new Fill({ color: "#a855f7" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
}),
}),
properties: {
name: "爆管定位模拟管段高亮",
value: "burst_location_simulation_pipe_highlight",
queryable: false,
},
});
map.addLayer(layer);
highlightLayerRef.current = layer;
return () => {
highlightLayerRef.current = null;
map.removeLayer(layer);
};
}, [map]);
useEffect(() => {
const source = highlightLayerRef.current?.getSource();
if (!source) return;
source.clear();
highlightFeatures.forEach((feature) => source.addFeature(feature));
}, [highlightFeatures]);
const locatePipes = async (pipeIds: string[]) => {
const uniquePipeIds = Array.from(new Set(pipeIds.filter(Boolean)));
if (!uniquePipeIds.length || !map) return;
try {
let features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes_mat");
if (features.length === 0) {
features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes");
}
if (features.length === 0) return;
setHighlightFeatures(features);
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
// @ts-ignore turf typing with ol geojson objects
const extent = bbox(featureCollection(geojsonFeatures));
map.getView().fit(extent, {
maxZoom: 19,
duration: 1000,
padding: [100, 100, 100, 100],
});
} catch (error) {
console.error("Locate failed", error);
}
};
const getSimulationBurstIds = (payload?: BurstLocationResult) => {
const directIds = payload?.simulation_scheme?.burst_ids ?? [];
if (directIds.length > 0) return directIds;
const simulationSchemeName = payload?.simulation_scheme?.name;
return simulationSchemeName
? simulationBurstIdsByName[simulationSchemeName] ?? []
: [];
};
const enrichResultWithSimulationBurstIds = (payload: BurstLocationResult) => {
const simulationBurstIds = getSimulationBurstIds(payload);
if (simulationBurstIds.length === 0) return payload;
return {
...payload,
simulation_scheme: {
...payload.simulation_scheme,
burst_ids: simulationBurstIds,
},
};
};
const buildDisplayResult = ( const buildDisplayResult = (
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">, scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
@@ -107,17 +227,38 @@ const SchemeQuery: React.FC<Props> = ({
const handleQuery = async () => { const handleQuery = async () => {
setLoading(true); setLoading(true);
try { try {
// API call to fetch schemes const params: Record<string, string> = {
// Adjust URL as needed scheme_type: "burst_location",
let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`; };
const params: Record<string, string> = { network: NETWORK_NAME };
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString(); params.query_date = queryDate.startOf("day").toISOString();
} }
const response = await api.get(url, { params }); const [response, simulationResponse] = await Promise.all([
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { scheme_type: "burst_analysis" },
}),
]);
const nextSchemes = response.data as BurstSchemeRecord[]; const nextSchemes = response.data as BurstSchemeRecord[];
setSchemes(nextSchemes); const nextSimulationBurstIdsByName = Object.fromEntries(
(simulationResponse.data as BurstSimulationSchemeItem[])
.filter((scheme) => scheme.scheme_type === "burst_analysis")
.map((scheme) => [
scheme.scheme_name,
normalizeBurstIds(scheme.scheme_detail?.burst_ID),
]),
);
setQueryField("simulationBurstIdsByName", nextSimulationBurstIdsByName);
setSchemes(
nextSchemes.map((scheme) =>
enrichSchemeWithSimulationBurstIds(
scheme,
nextSimulationBurstIdsByName,
),
),
);
setQueryField("hasQueried", true);
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
@@ -138,8 +279,8 @@ const SchemeQuery: React.FC<Props> = ({
const handleViewSchemeResult = async (schemeName: string) => { const handleViewSchemeResult = async (schemeName: string) => {
try { try {
const response = await api.get( const response = await api.get(
`${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`, `${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } }, { params: { scheme_type: "burst_location" } },
); );
const schemeRecord = response.data as BurstSchemeRecord & { const schemeRecord = response.data as BurstSchemeRecord & {
result_payload?: BurstLocationResult; result_payload?: BurstLocationResult;
@@ -157,7 +298,7 @@ const SchemeQuery: React.FC<Props> = ({
if (!normalizedResult) { if (!normalizedResult) {
throw new Error("方案详情缺少定位结果数据"); throw new Error("方案详情缺少定位结果数据");
} }
onViewResult(normalizedResult); onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
open?.({ open?.({
type: "success", type: "success",
message: "方案加载成功", message: "方案加载成功",
@@ -182,7 +323,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox <Checkbox
size="small" size="small"
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/> />
} }
label={<Typography variant="body2"></Typography>} label={<Typography variant="body2"></Typography>}
@@ -191,7 +335,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => setQueryField("queryDate", value)} onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll} disabled={queryAll}
format="YYYY-MM-DD" format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }} slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -211,49 +358,18 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
</Box> </Box>
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{schemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
{schemes.length} {sortedSchemes.length}
</Typography> </Typography>
{schemes.map((scheme) => { {sortedSchemes.map((scheme) => {
const summary = scheme.scheme_detail?.result_summary; const summary = scheme.scheme_detail?.result_summary;
const payload = scheme.scheme_detail?.result_payload; const payload = scheme.scheme_detail?.result_payload;
const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-"; const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-";
const simulationBurstIds = getSimulationBurstIds(payload);
const leakage = const leakage =
payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage; payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage;
@@ -330,6 +446,52 @@ const SchemeQuery: React.FC<Props> = ({
{locatedPipe} {locatedPipe}
</Typography> </Typography>
</Box> </Box>
{simulationBurstIds.length > 0 ? (
<Box className="grid grid-cols-[78px_1fr] items-start gap-x-2">
<Typography variant="caption" className="mt-1 text-gray-600">
:
</Typography>
<Box className="flex flex-wrap gap-1">
{simulationBurstIds.map((pipeId) => (
<Link
key={pipeId}
component="button"
variant="caption"
onClick={() => locatePipes([pipeId])}
title={pipeId}
sx={{
maxWidth: 132,
color: "#7c3aed",
fontSize: "0.75rem",
fontWeight: 700,
lineHeight: "22px",
cursor: "pointer",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "underline",
textUnderlineOffset: "2px",
"&:hover": {
color: "#5b21b6",
},
}}
>
{pipeId}
</Link>
))}
<Tooltip title="定位全部模拟管段">
<IconButton
size="small"
color="secondary"
onClick={() => locatePipes(simulationBurstIds)}
className="h-6 w-6 p-0"
>
<LocationOnIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
) : null}
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600"> <Typography variant="caption" className="text-gray-600">
: :
@@ -343,7 +505,7 @@ const SchemeQuery: React.FC<Props> = ({
: :
</Typography> </Typography>
<Typography variant="caption" className="font-medium text-gray-900"> <Typography variant="caption" className="font-medium text-gray-900">
{scheme.username || "-"} {creatorName(scheme.username)}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -373,3 +535,46 @@ const SchemeQuery: React.FC<Props> = ({
}; };
export default SchemeQuery; export default SchemeQuery;
interface BurstSimulationSchemeItem {
scheme_name: string;
scheme_type: string;
scheme_detail?: {
burst_ID?: string[] | string;
};
}
const normalizeBurstIds = (value: string[] | string | undefined) => {
if (!value) return [];
const values = Array.isArray(value) ? value : [value];
return Array.from(
new Set(values.map((item) => String(item).trim()).filter(Boolean)),
);
};
const enrichSchemeWithSimulationBurstIds = (
scheme: BurstSchemeRecord,
simulationBurstIdsByName: Record<string, string[]>,
) => {
const payload = scheme.scheme_detail?.result_payload;
const simulationSchemeName = payload?.simulation_scheme?.name;
const simulationBurstIds = simulationSchemeName
? simulationBurstIdsByName[simulationSchemeName] ?? []
: [];
if (!payload || simulationBurstIds.length === 0) return scheme;
if (payload.simulation_scheme?.burst_ids?.length) return scheme;
return {
...scheme,
scheme_detail: {
...scheme.scheme_detail,
result_payload: {
...payload,
simulation_scheme: {
...payload.simulation_scheme,
burst_ids: simulationBurstIds,
},
},
},
};
};
@@ -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,50 @@
export interface BurstLocationErrorNotice {
message: string;
description: string;
}
const DATA_GAP_PATTERN =
/^(爆管压力数据|正常压力数据|爆管流量数据|正常流量数据) 在时间窗内无有效模拟数据: (.+)$/;
const extractErrorDetail = (error: unknown): string => {
const candidate = error as {
message?: string;
response?: { data?: { detail?: unknown } };
};
const detail = candidate?.response?.data?.detail;
if (typeof detail === "string" && detail.trim()) {
return detail.trim();
}
if (typeof candidate?.message === "string" && candidate.message.trim()) {
return candidate.message.trim();
}
return "请求失败";
};
export const getBurstLocationErrorNotice = (
error: unknown,
): BurstLocationErrorNotice => {
const detail = extractErrorDetail(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}。请选择数据完整的模拟方案。`,
};
};
@@ -37,6 +37,7 @@ export interface BurstLocationResult {
simulation_scheme?: { simulation_scheme?: {
name?: string; name?: string;
type?: string; type?: string;
burst_ids?: string[];
}; };
} }
@@ -198,6 +198,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
properties: { properties: {
name: "高亮管道", name: "高亮管道",
value: "highlight_pipeline", value: "highlight_pipeline",
queryable: false,
}, },
}); });
@@ -321,16 +322,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
: ""; : "";
const modify_total_duration = duration; const modify_total_duration = duration;
const params = { const params = {
network: network,
modify_pattern_start_time: modify_pattern_start_time, modify_pattern_start_time: modify_pattern_start_time,
burst_ID: burst_ID, burst_id: burst_ID,
burst_size: burst_size, burst_size: burst_size,
modify_total_duration: modify_total_duration, modify_total_duration: modify_total_duration,
scheme_name: schemeName, scheme_name: schemeName,
}; };
try { try {
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, { await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
params, params,
paramsSerializer: { paramsSerializer: {
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
@@ -0,0 +1,278 @@
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 === "geo_pipes_mat"
? [feature("P-1", 315)]
: [feature("P-2", 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-2"],
"geo_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(2);
firstRender.unmount();
render(<AnalysisReport {...props} />);
expect(
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
"800 mm",
),
).toBeInTheDocument();
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
});
});
@@ -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, ChevronLeft,
Analytics as AnalyticsIcon, Analytics as AnalyticsIcon,
Search as SearchIcon, Search as SearchIcon,
MyLocation as MyLocationIcon, DescriptionOutlined as ReportIcon,
Handyman as HandymanIcon, Handyman as HandymanIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import AnalysisParameters, { import AnalysisParameters, {
@@ -26,15 +26,12 @@ import SchemeQuery, {
createBurstSchemeQueryState, createBurstSchemeQueryState,
type BurstSchemeQueryState, type BurstSchemeQueryState,
} from "./SchemeQuery"; } from "./SchemeQuery";
import LocationResults from "./LocationResults"; import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
import ValveIsolation, { import ValveIsolation, {
createValveIsolationState, createValveIsolationState,
type ValveIsolationState, type ValveIsolationState,
} from "./ValveIsolation"; } from "./ValveIsolation";
import { api } from "@/lib/api"; import { SchemeRecord, ValveIsolationResult } from "./types";
import { config } from "@config/config";
import { useNotification } from "@refinedev/core";
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
interface TabPanelProps { interface TabPanelProps {
children?: React.ReactNode; children?: React.ReactNode;
@@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
// 持久化方案查询结果 // 持久化方案查询结果
const [schemes, setSchemes] = useState<SchemeRecord[]>([]); const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
// 定位结果数据 const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
const [locationResults, setLocationResults] = useState<LocationResult[]>([]); const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
// 关阀分析结果和加载状态 // 关阀分析结果和加载状态
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false); const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null); const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
useState<string | null>(null);
const [valveIsolationState, setValveIsolationState] = const [valveIsolationState, setValveIsolationState] =
useState<ValveIsolationState>(createValveIsolationState); useState<ValveIsolationState>(createValveIsolationState);
const { open } = useNotification();
// 使用受控或非受控状态 // 使用受控或非受控状态
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const handleToggle = () => { const handleToggle = () => {
@@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
setCurrentTab(newValue); setCurrentTab(newValue);
}; };
const handleLocateScheme = async (scheme: SchemeRecord) => { const handleViewReport = (scheme: SchemeRecord) => {
try { if (
const response = await api.get( valveAnalysisSchemeName !== scheme.schemeName ||
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`, !matchesValveAnalysis(scheme, valveAnalysisResult)
); ) {
setLocationResults(response.data); setValveAnalysisResult(null);
setCurrentTab(2); // 切换到定位结果标签页 setValveAnalysisSchemeName(null);
} catch (error) { setValveIsolationState(createValveIsolationState());
console.error("获取定位结果失败:", error);
open?.({
type: "error",
message: "获取定位结果失败",
description: "无法从服务器获取该方案的定位结果",
});
} }
setReportScheme(scheme);
setReportGeneratedAt(new Date());
setCurrentTab(2);
};
const handleValveAnalysisResultChange = (
result: ValveIsolationResult | null,
) => {
setValveAnalysisResult(result);
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
}; };
const drawerWidth = 520; const drawerWidth = 520;
@@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
label="方案查询" label="方案查询"
/> />
<Tab <Tab
icon={<MyLocationIcon fontSize="small" />} icon={<ReportIcon fontSize="small" />}
iconPosition="start" iconPosition="start"
label="定位结果" label="分析报告"
/> />
<Tab <Tab
icon={<HandymanIcon fontSize="small" />} icon={<HandymanIcon fontSize="small" />}
@@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
<SchemeQuery <SchemeQuery
schemes={schemes} schemes={schemes}
onSchemesChange={setSchemes} onSchemesChange={setSchemes}
onLocate={handleLocateScheme} onViewReport={handleViewReport}
state={queryState} state={queryState}
onStateChange={setQueryState} onStateChange={setQueryState}
/> />
</TabPanel> </TabPanel>
<TabPanel value={currentTab} index={2}> <TabPanel value={currentTab} index={2}>
<LocationResults <AnalysisReport
results={locationResults} scheme={reportScheme}
valveResult={valveAnalysisResult}
disabledValves={valveIsolationState.disabledValves}
generatedAt={reportGeneratedAt}
/> />
</TabPanel> </TabPanel>
@@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
loading={valveAnalysisLoading} loading={valveAnalysisLoading}
result={valveAnalysisResult} result={valveAnalysisResult}
onLoadingChange={setValveAnalysisLoading} onLoadingChange={setValveAnalysisLoading}
onResultChange={setValveAnalysisResult} onResultChange={handleValveAnalysisResultChange}
state={valveIsolationState} state={valveIsolationState}
onStateChange={setValveIsolationState} onStateChange={setValveIsolationState}
allowedPipeIds={
reportScheme?.schemeDetail?.burst_ID
}
sourceSchemeName={reportScheme?.schemeName}
/> />
</TabPanel> </TabPanel>
</Box> </Box>
@@ -1,415 +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",
},
});
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, Link,
} from "@mui/material"; } from "@mui/material";
import { import {
DescriptionOutlined as ReportIcon,
Info as InfoIcon, Info as InfoIcon,
LocationOn as LocationIcon, LocationOn as LocationIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
@@ -51,11 +52,19 @@ import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
import Timeline from "@components/olmap/core/Controls/Timeline"; import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemaItem, SchemeRecord } from "./types"; import { SchemaItem, 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 { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void; onSchemesChange?: (schemes: SchemeRecord[]) => void;
onLocate?: (scheme: SchemeRecord) => void; onViewReport?: (scheme: SchemeRecord) => void;
network?: string; network?: string;
state?: BurstSchemeQueryState; state?: BurstSchemeQueryState;
onStateChange?: (state: BurstSchemeQueryState) => void; onStateChange?: (state: BurstSchemeQueryState) => void;
@@ -70,6 +79,7 @@ export interface BurstSchemeQueryState {
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
@@ -79,12 +89,13 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
selectedDate: undefined, selectedDate: undefined,
timeRange: undefined, timeRange: undefined,
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes, schemes: externalSchemes,
onSchemesChange, onSchemesChange,
onLocate, onViewReport,
network = NETWORK_NAME, network = NETWORK_NAME,
state, state,
onStateChange, onStateChange,
@@ -101,6 +112,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate, selectedDate,
timeRange, timeRange,
expandedId, expandedId,
hasQueried,
} = queryState; } = queryState;
const [highlightLayer, setHighlightLayer] = const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null); useState<VectorLayer<VectorSource> | null>(null);
@@ -108,7 +120,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const creatorName = useSchemeCreatorName();
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素 const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
Record<number, PipeDiameterMap>
>({});
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
Record<number, boolean>
>({});
const { open } = useNotification(); const { open } = useNotification();
@@ -128,7 +147,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}; };
const filteredSchemes = useMemo(() => { const filteredSchemes = useMemo(() => {
return schemes.filter((scheme) => scheme.type === SCHEME_TYPE); return schemes
.filter((scheme) => scheme.type === SCHEME_TYPE)
.slice()
.sort(
(a, b) =>
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
);
}, [schemes]); }, [schemes]);
const handleQuery = async () => { const handleQuery = async () => {
@@ -136,9 +161,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const response = await api.get( const params: Record<string, string> = {
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`, scheme_type: SCHEME_TYPE,
); };
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data; let filteredResults = response.data;
if (!queryAll) { if (!queryAll) {
@@ -153,21 +184,28 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
id: item.scheme_id, id: item.scheme_id,
schemeName: item.scheme_name, schemeName: item.scheme_name,
type: item.scheme_type, type: item.scheme_type,
user: item.username, username: item.username,
create_time: item.create_time, create_time: item.create_time,
startTime: item.scheme_start_time, startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail, schemeDetail: item.scheme_detail,
})); }));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
open?.({ open?.({
type: "error", type: "success",
message: "查询结果", message: "查询结果",
description: queryAll description: queryAll
? "没有找到任何方案" ? "没有找到任何方案"
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
}); });
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
} }
} catch (error) { } catch (error) {
console.error("查询请求失败:", error); console.error("查询请求失败:", error);
@@ -203,6 +241,63 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
} }
}; };
useEffect(() => {
if (expandedId === null || pipeDiametersByScheme[expandedId]) {
return;
}
const scheme = filteredSchemes.find((scheme) => scheme.id === expandedId);
const pipeIds = scheme?.schemeDetail?.burst_ID ?? [];
if (pipeIds.length === 0) {
return;
}
const cachedDiameters = getCachedPipeDiameters(pipeIds);
if (cachedDiameters) {
setPipeDiametersByScheme((previous) => ({
...previous,
[expandedId]: cachedDiameters,
}));
return;
}
let cancelled = false;
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: true,
}));
const applyPipeDiameters = async () => {
const nextDiameters = await loadPipeDiameters(pipeIds);
if (cancelled) {
return;
}
setPipeDiametersByScheme((previous) => ({
...previous,
[expandedId]: nextDiameters,
}));
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: false,
}));
};
applyPipeDiameters().catch((error) => {
console.error("查询管径失败:", error);
if (!cancelled) {
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: false,
}));
}
});
return () => {
cancelled = true;
};
}, [expandedId, filteredSchemes, pipeDiametersByScheme]);
// 内部的方案查询函数 // 内部的方案查询函数
const handleViewDetails = (id: number) => { const handleViewDetails = (id: number) => {
const scheme = filteredSchemes.find((s) => s.id === id); const scheme = filteredSchemes.find((s) => s.id === id);
@@ -301,6 +396,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
properties: { properties: {
name: "爆管管段高亮", name: "爆管管段高亮",
value: "burst_pipe_highlight", value: "burst_pipe_highlight",
queryable: false,
}, },
}); });
@@ -355,7 +451,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={ control={
<Checkbox <Checkbox
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small" size="small"
/> />
} }
@@ -368,9 +467,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
> >
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setQueryField("queryDate", value) if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
} }
}}
format="YYYY-MM-DD" format="YYYY-MM-DD"
disabled={queryAll} disabled={queryAll}
slotProps={{ slotProps={{
@@ -398,39 +500,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */} {/* 结果列表 */}
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? ( {filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
@@ -495,14 +565,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<InfoIcon fontSize="small" /> <InfoIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="查看定位结果"> <Tooltip title="查看分析报告">
<IconButton <IconButton
size="small" size="small"
onClick={() => onLocate?.(scheme)} onClick={() => onViewReport?.(scheme)}
color="primary" color="primary"
className="p-1" className="p-1"
> >
<LocationIcon fontSize="small" /> <ReportIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
@@ -562,7 +632,11 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption" variant="caption"
className="font-medium text-gray-900" className="font-medium text-gray-900"
> >
560 mm {getPipeDiameterDisplay(
scheme.schemeDetail?.burst_ID,
pipeDiametersByScheme[scheme.id],
!!loadingDiameterByScheme[scheme.id],
)}
</Typography> </Typography>
</Box> </Box>
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
@@ -614,7 +688,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption" variant="caption"
className="font-medium text-gray-900" className="font-medium text-gray-900"
> >
{scheme.user} {creatorName(scheme.username)}
</Typography> </Typography>
</Box> </Box>
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
@@ -52,9 +52,16 @@ import {
import { Point } from "ol/geom"; import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import {
getAffectedNodeCount,
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
interface ValveIsolationProps { interface ValveIsolationProps {
initialPipeIds?: string[]; initialPipeIds?: string[];
allowedPipeIds?: string[];
sourceSchemeName?: string;
shouldFetch?: boolean; shouldFetch?: boolean;
onFetchComplete?: () => void; onFetchComplete?: () => void;
loading?: boolean; loading?: boolean;
@@ -81,6 +88,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({
const ValveIsolation: React.FC<ValveIsolationProps> = ({ const ValveIsolation: React.FC<ValveIsolationProps> = ({
initialPipeIds = [], initialPipeIds = [],
allowedPipeIds,
sourceSchemeName,
shouldFetch = false, shouldFetch = false,
onFetchComplete, onFetchComplete,
loading: externalLoading, loading: externalLoading,
@@ -160,14 +169,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
return; return;
} }
setSelectedPipeId(pipeId); const normalizedPipeId = String(pipeId);
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
open?.({
type: "error",
message: "请选择当前爆管方案中的事故管段",
});
return;
}
setSelectedPipeId(normalizedPipeId);
setHighlightFeature(feature); setHighlightFeature(feature);
setIsSelecting(false); setIsSelecting(false);
setResult(null); // 清除旧结果 setResult(null); // 清除旧结果
} }
} }
}, },
[isSelecting, map, open, setResult, setSelectedPipeId], [
allowedPipeIds,
isSelecting,
map,
open,
setResult,
setSelectedPipeId,
],
); );
useEffect(() => { useEffect(() => {
@@ -207,6 +232,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
"must_close" | "optional" | "affected_node" | "pipe" "must_close" | "optional" | "affected_node" | "pipe"
>("affected_node"); >("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) => { const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
if (pipeIds.length > 0) { if (pipeIds.length > 0) {
@@ -311,6 +346,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
open?.({ type: "error", message: "请在地图上选择要分析的管段" }); open?.({ type: "error", message: "请在地图上选择要分析的管段" });
return; return;
} }
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
open?.({
type: "error",
message: "关阀分析仅限当前爆管方案中的事故管段",
});
return;
}
setLoading(true); setLoading(true);
const isExpandSearch = disabled.length > 0; const isExpandSearch = disabled.length > 0;
if (!isExpandSearch) { if (!isExpandSearch) {
@@ -319,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
} }
try { try {
const params: any = { const params: any = {
network: NETWORK_NAME,
accident_element: ids, accident_element: ids,
}; };
if (disabled.length > 0) { if (disabled.length > 0) {
params.disabled_valves = disabled; params.disabled_valves = disabled;
} }
const response = await api.get( const response = await api.post<ValveIsolationResult>(
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`, `${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
undefined,
{ {
params, params,
paramsSerializer: { paramsSerializer: {
@@ -334,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
}, },
}, },
); );
setResult(response.data); setResult(normalizeValveIsolationResult(response.data));
if (!isExpandSearch) { if (!isExpandSearch) {
setActiveStep(1); setActiveStep(1);
} else { } else {
@@ -352,7 +394,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
setLoading(false); setLoading(false);
} }
}, },
[open, setActiveStep, setDisabledValves, setLoading, setResult], [
allowedPipeIds,
open,
setActiveStep,
setDisabledValves,
setLoading,
setResult,
],
); );
// 监听外部传入的分析请求 // 监听外部传入的分析请求
@@ -546,6 +595,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
properties: { properties: {
name: "阀门节点高亮", name: "阀门节点高亮",
value: "valve_node_highlight", value: "valve_node_highlight",
queryable: false,
}, },
}); });
@@ -665,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.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.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) => ( ].map((item, index) => (
<Box <Box
key={index} key={index}
@@ -831,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</Box> </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>
<Box className="flex items-center justify-between mb-2"> <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"> <Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
@@ -911,6 +967,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</StepLabel> </StepLabel>
<StepContent> <StepContent>
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3"> <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"> <Paper elevation={0} className="p-3 bg-white border border-gray-200">
<Box className="flex items-center justify-between mb-2"> <Box className="flex items-center justify-between mb-2">
@@ -954,6 +1020,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
)} )}
</Box> </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 && ( {isSelecting && (
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700"> <Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
💡 💡
@@ -0,0 +1,64 @@
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", () => {
expect(getPipeDiameterDisplay(["P-1"], { "P-1": 315 })).toBe("315 mm");
});
it("shows pipe IDs with diameters for multiple burst pipes", () => {
expect(
getPipeDiameterDisplay(["P-1", "P-2"], {
"P-1": 315,
"P-2": 800,
}),
).toBe("P-1: 315 mmP-2: 800 mm");
});
it("does not fall back to a fixed diameter when a diameter is missing", () => {
expect(getPipeDiameterDisplay(["P-1"], {})).toBe("N/A");
});
});
@@ -0,0 +1,116 @@
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 () => {
let features = await queryFeaturesByIds(
normalizedPipeIds,
"geo_pipes_mat",
);
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = normalizedPipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
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,
loading = false,
): string => {
if (loading) {
return "查询中...";
}
if (!pipeIds?.length) {
return "N/A";
}
const values = pipeIds.map((pipeId) => {
const diameter = diameters?.[pipeId];
const displayValue =
typeof diameter === "number" && Number.isFinite(diameter)
? `${diameter} mm`
: "N/A";
return pipeIds.length === 1 ? displayValue : `${pipeId}: ${displayValue}`;
});
return values.join("");
};
+2 -10
View File
@@ -11,7 +11,7 @@ export interface SchemeRecord {
id: number; id: number;
schemeName: string; schemeName: string;
type: string; type: string;
user: string; username: string;
create_time: string; create_time: string;
startTime: string; startTime: string;
// 详情信息 // 详情信息
@@ -28,18 +28,10 @@ export interface SchemaItem {
scheme_detail?: SchemeDetail; scheme_detail?: SchemeDetail;
} }
export interface LocationResult {
id: number;
type: string;
burst_incident: string;
leakage: number | null;
detect_time: string;
locate_result: string[] | null;
}
export interface ValveIsolationResult { export interface ValveIsolationResult {
accident_elements: string[]; accident_elements: string[];
affected_nodes: string[]; affected_nodes: string[];
affected_node_count?: number;
must_close_valves: string[]; must_close_valves: string[];
optional_valves: string[]; optional_valves: string[];
isolatable: boolean; isolatable: boolean;
@@ -0,0 +1,17 @@
import type { ValveIsolationResult } from "./types";
export const isAllowedAccidentPipe = (
pipeId: string,
allowedPipeIds: string[] | undefined,
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);
export const getAffectedNodeCount = (result: ValveIsolationResult) =>
result.affected_node_count ?? result.affected_nodes?.length ?? 0;
export const normalizeValveIsolationResult = (
result: ValveIsolationResult,
): ValveIsolationResult => ({
...result,
affected_nodes: result.isolatable ? result.affected_nodes ?? [] : [],
affected_node_count: getAffectedNodeCount(result),
});
@@ -158,6 +158,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
properties: { properties: {
name: "污染源节点", name: "污染源节点",
value: "contaminant_source_highlight", value: "contaminant_source_highlight",
queryable: false,
}, },
}); });
@@ -227,7 +228,6 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
console.log("默认设置 pattern 为 CONSTANT"); console.log("默认设置 pattern 为 CONSTANT");
} }
const params = { const params = {
network,
start_time: start_time, start_time: start_time,
source: sourceNode, source: sourceNode,
concentration, concentration,
@@ -236,7 +236,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
scheme_name: schemeName, scheme_name: schemeName,
}; };
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, { await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
params, params,
}); });
@@ -40,6 +40,8 @@ import Feature from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@components/olmap/core/Controls/Timeline"; import Timeline from "@components/olmap/core/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types"; import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: ContaminantSchemeRecord[]; schemes?: ContaminantSchemeRecord[];
@@ -59,6 +61,7 @@ export interface ContaminantSchemeQueryState {
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createContaminantSchemeQueryState = export const createContaminantSchemeQueryState =
@@ -69,6 +72,7 @@ export const createContaminantSchemeQueryState =
selectedDate: undefined, selectedDate: undefined,
timeRange: undefined, timeRange: undefined,
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -91,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate, selectedDate,
timeRange, timeRange,
expandedId, expandedId,
hasQueried,
} = queryState; } = queryState;
const [highlightLayer, setHighlightLayer] = const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null); useState<VectorLayer<VectorSource> | null>(null);
@@ -101,6 +106,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>([]); >([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
const creatorName = useSchemeCreatorName();
const { open } = useNotification(); const { open } = useNotification();
const map = useMap(); const map = useMap();
@@ -166,6 +172,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
properties: { properties: {
name: "污染源高亮", name: "污染源高亮",
value: "contaminant_source_highlight", value: "contaminant_source_highlight",
queryable: false,
}, },
}); });
@@ -202,16 +209,28 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}; };
const filteredSchemes = useMemo(() => { const filteredSchemes = useMemo(() => {
return schemes.filter((scheme) => scheme.type === SCHEME_TYPE); return schemes
.filter((scheme) => scheme.type === SCHEME_TYPE)
.slice()
.sort(
(a, b) =>
moment(b.create_time).valueOf() - moment(a.create_time).valueOf(),
);
}, [schemes]); }, [schemes]);
const handleQuery = async () => { const handleQuery = async () => {
if (!queryAll && !queryDate) return; if (!queryAll && !queryDate) return;
setLoading(true); setLoading(true);
try { try {
const response = await api.get( const params: Record<string, string> = {
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`, scheme_type: SCHEME_TYPE,
); };
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data; let filteredResults = response.data;
if (!queryAll) { if (!queryAll) {
@@ -228,21 +247,28 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
id: item.scheme_id, id: item.scheme_id,
schemeName: item.scheme_name, schemeName: item.scheme_name,
type: item.scheme_type, type: item.scheme_type,
user: item.username, username: item.username,
create_time: item.create_time, create_time: item.create_time,
startTime: item.scheme_start_time, startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail, schemeDetail: item.scheme_detail,
})); }));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
open?.({ open?.({
type: "error", type: "success",
message: "查询结果", message: "查询结果",
description: queryAll description: queryAll
? "没有找到任何方案" ? "没有找到任何方案"
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
}); });
} else {
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${filteredResults.length} 条方案记录`,
});
} }
} catch (error) { } catch (error) {
console.error("查询请求失败:", error); console.error("查询请求失败:", error);
@@ -329,7 +355,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={ control={
<Checkbox <Checkbox
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small" size="small"
/> />
} }
@@ -342,9 +371,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
> >
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setQueryField("queryDate", value) if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
} }
}}
format="YYYY-MM-DD" format="YYYY-MM-DD"
disabled={queryAll} disabled={queryAll}
slotProps={{ slotProps={{
@@ -371,39 +403,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? ( {filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
@@ -578,7 +578,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption" variant="caption"
className="font-medium text-gray-900" className="font-medium text-gray-900"
> >
{scheme.user} {creatorName(scheme.username)}
</Typography> </Typography>
</Box> </Box>
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
@@ -9,7 +9,7 @@ export interface ContaminantSchemeRecord {
id: number; id: number;
schemeName: string; schemeName: string;
type: string; type: string;
user: string; username: string;
create_time: string; create_time: string;
startTime: string; startTime: string;
schemeDetail?: ContaminantSchemeDetail; schemeDetail?: ContaminantSchemeDetail;

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