Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ed1fd212 | ||
|
|
81cc5dcae1 | ||
|
|
782363cfb6 | ||
|
|
b57e58ff87 | ||
|
|
38d246eb7b | ||
|
|
723931b6ae | ||
|
|
f355ddd002 | ||
|
|
6114e76735 | ||
|
|
d643f09fcf | ||
|
|
04557b9363 | ||
|
|
8836549697 | ||
|
|
1d7e07174f | ||
|
|
c4246cf25f | ||
|
|
1b2a7f4fb8 | ||
|
|
82e75c03d0 | ||
|
|
5894ee277a | ||
|
|
a3331691de | ||
|
|
7745333a58 | ||
|
|
4596af7c12 | ||
|
|
5332f8f0c5 | ||
|
|
1993cadba8 | ||
|
|
7a3677ee9f | ||
|
|
0d559f6130 | ||
|
|
7af90e495d | ||
|
|
a52c04204d | ||
|
|
08152ff978 | ||
|
|
589cf45aa7 | ||
|
|
59447a100c | ||
|
|
4adbcc1c4c | ||
|
|
202f18332f | ||
|
|
acf13639ef | ||
|
|
d986e563a6 | ||
|
|
d8ee2e1f0c | ||
|
|
fddb0ceb34 | ||
|
|
3d7b594682 | ||
|
|
eb8950c89a | ||
|
|
ef2b045306 | ||
|
|
209da0d295 | ||
|
|
dfaee645ff | ||
|
|
d90ca7c951 | ||
|
|
041b4ef89d | ||
|
|
f5e7312e3b | ||
|
|
c6e6e24aab | ||
|
|
adb53d9a13 | ||
|
|
14c76231d5 | ||
|
|
694f7629ee | ||
|
|
701c5a949d | ||
|
|
435a4172e4 |
@@ -3,6 +3,8 @@ node_modules
|
||||
out
|
||||
build
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.env*.local
|
||||
README.md
|
||||
docker-compose.yml
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -105,15 +105,6 @@ jobs:
|
||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
--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}:latest"
|
||||
|
||||
+4
-1
@@ -19,6 +19,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/public/runtime-config.js
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -26,7 +27,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
+4
-13
@@ -18,18 +18,6 @@ RUN \
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
|
||||
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
|
||||
ARG NEXT_PUBLIC_BACKEND_URL
|
||||
ARG NEXT_PUBLIC_AGENT_URL
|
||||
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
|
||||
ARG NEXT_PUBLIC_MAP_URL
|
||||
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
||||
ARG NEXT_PUBLIC_MAP_EXTENT
|
||||
ARG NEXT_PUBLIC_NETWORK_NAME
|
||||
ARG NEXT_PUBLIC_MAPBOX_TOKEN
|
||||
ARG NEXT_PUBLIC_TIANDITU_TOKEN
|
||||
|
||||
COPY --from=deps /app/refine/node_modules ./node_modules
|
||||
|
||||
COPY . .
|
||||
@@ -40,7 +28,9 @@ FROM base AS runner
|
||||
|
||||
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 chown node:node .next
|
||||
@@ -55,4 +45,5 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,48 +1,91 @@
|
||||
# my-refine-app
|
||||
# TJWaterFrontend_Refine 内部前端
|
||||
|
||||
<div align="center" style="margin: 30px;">
|
||||
<a href="https://refine.dev">
|
||||
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
|
||||
</a>
|
||||
</div>
|
||||
<br/>
|
||||
`TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
|
||||
|
||||
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app).
|
||||
## 技术栈
|
||||
|
||||
## Getting Started
|
||||
- Next.js 16
|
||||
- React 19
|
||||
- Refine 5
|
||||
- MUI 6 / MUI X
|
||||
- OpenLayers、deck.gl、Turf
|
||||
- Zustand、NextAuth、Jest
|
||||
|
||||
A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility ✨
|
||||
## 目录结构
|
||||
|
||||
Refine's hooks and components simplifies the development process and eliminates the repetitive tasks by providing industry-standard solutions for crucial aspects of a project, including authentication, access control, routing, networking, state management, and i18n.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
### Running the development server.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```text
|
||||
src/app/ Next.js App Router 页面
|
||||
src/components/ 复用 UI 组件
|
||||
src/providers/ Refine、认证、数据和主题 provider
|
||||
src/hooks/ 业务 hooks
|
||||
src/utils/ 通用工具
|
||||
public/ 静态资源
|
||||
scripts/ 运行时配置和辅助脚本
|
||||
Dockerfile 镜像构建文件
|
||||
docker-compose.yml 本地编排参考
|
||||
```
|
||||
|
||||
### Building for production.
|
||||
新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
|
||||
|
||||
## 本地开发
|
||||
|
||||
要求 Node.js 20 或更高版本:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Running the production server.
|
||||
`npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
npm run lint
|
||||
npm test
|
||||
npm run test:coverage
|
||||
npm run build
|
||||
npm run start
|
||||
docker build -t tjwater-frontend:local .
|
||||
```
|
||||
|
||||
## Learn More
|
||||
- `npm run lint`:运行 ESLint。
|
||||
- `npm test`:运行 Jest。
|
||||
- `npm run test:coverage`:生成测试覆盖率。
|
||||
- `npm run build`:生成生产构建。
|
||||
- `npm run start`:启动生产模式服务。
|
||||
|
||||
To learn more about **Refine**, please check out the [Documentation](https://refine.dev/docs)
|
||||
## 配置说明
|
||||
|
||||
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview)
|
||||
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
|
||||
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
|
||||
运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
|
||||
|
||||
## License
|
||||
只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。
|
||||
|
||||
MIT
|
||||
## 开发规范
|
||||
|
||||
- React 组件文件使用 `PascalCase.tsx`。
|
||||
- 普通 TypeScript 模块、hooks、store、provider 和工具使用 `camelCase.ts`。
|
||||
- `src/app` 路由目录使用 `kebab-case`,保留 Next.js 路由组和动态段语法。
|
||||
- UI 优先沿用 MUI、Refine 和既有地图/聊天界面模式。
|
||||
- 与后端或 Agent 通信的字段保持接口原始格式,通常为 `snake_case`。
|
||||
|
||||
## 测试与发布
|
||||
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
发布镜像前运行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
|
||||
|
||||
## 安全规则
|
||||
|
||||
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -6,19 +6,15 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
|
||||
NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL}
|
||||
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
|
||||
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
|
||||
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
|
||||
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
|
||||
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
|
||||
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
|
||||
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
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_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
||||
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
|
||||
|
||||
Executable
+35
@@ -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 "$@"
|
||||
Generated
+242
-16
@@ -35,6 +35,7 @@
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^4.24.5",
|
||||
"ol": "^10.7.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -63,6 +64,7 @@
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
@@ -5731,6 +5733,75 @@
|
||||
"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": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz",
|
||||
@@ -10466,6 +10537,16 @@
|
||||
"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": {
|
||||
"version": "4.3.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -14363,6 +14458,19 @@
|
||||
"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": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -16106,6 +16214,16 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -16113,10 +16231,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -16230,6 +16358,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"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_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": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
@@ -19355,6 +19557,16 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
@@ -20712,19 +20924,6 @@
|
||||
"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": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
@@ -20780,6 +20979,19 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||
@@ -21162,6 +21374,13 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
@@ -21579,6 +21798,13 @@
|
||||
"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": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
+6
-1
@@ -6,13 +6,16 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
|
||||
"dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
|
||||
"runtime:config": "node scripts/generate-runtime-config.mjs",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
|
||||
"api:check": "node scripts/check-api-contracts.mjs",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -43,6 +46,7 @@
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^4.24.5",
|
||||
"ol": "^10.7.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -74,6 +78,7 @@
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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}`,
|
||||
);
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import React, { Suspense } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||
import { Header } from "@components/header";
|
||||
import { Title } from "@components/title";
|
||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||
import { AppSider } from "@components/sider/AppSider";
|
||||
import { ThemedLayout } from "@refinedev/mui";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const theme = cookieStore.get("theme");
|
||||
@@ -38,15 +37,18 @@ export default async function MainLayout({
|
||||
Title={Title}
|
||||
Sider={AppSider}
|
||||
childrenBoxProps={{
|
||||
sx: { height: "100vh", p: 0 },
|
||||
sx: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
containerBoxProps={{
|
||||
sx: { height: "100%" },
|
||||
sx: { height: "100vh", overflow: "hidden" },
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<MapSkeleton />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
{children}
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+258
-194
@@ -1,32 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Refine, type AuthProvider } from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import {
|
||||
RefineSnackbarProvider,
|
||||
useNotificationProvider,
|
||||
} from "@refinedev/mui";
|
||||
Refine,
|
||||
type AccessControlProvider,
|
||||
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 { usePathname } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import routerProvider from "@refinedev/nextjs-router";
|
||||
|
||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||
import { dataProvider } from "@providers/data-provider";
|
||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||
import { config } from "@config/config";
|
||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||
|
||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
|
||||
import { LuReplace } from "react-icons/lu";
|
||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
||||
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
|
||||
import {
|
||||
ManageAccounts as ManageAccountsIcon,
|
||||
FactCheck as FactCheckIcon,
|
||||
ManageAccounts as ManageAccountsIcon,
|
||||
MyLocation as MyLocationIcon,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
@@ -36,16 +42,12 @@ type RefineContextProps = {
|
||||
};
|
||||
|
||||
export const RefineContext = (
|
||||
props: React.PropsWithChildren<RefineContextProps>
|
||||
) => {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ProjectProvider>
|
||||
<App {...props} />
|
||||
</ProjectProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
props: React.PropsWithChildren<RefineContextProps>,
|
||||
) => (
|
||||
<SessionProvider>
|
||||
<App {...props} />
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
type AppProps = {
|
||||
defaultMode?: string;
|
||||
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
||||
const resetAccess = useAccessStore((state) => state.reset);
|
||||
const can = (permission: string) => permissions.includes(permission);
|
||||
|
||||
useEffect(() => {
|
||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
||||
setAccessToken(
|
||||
typeof data?.accessToken === "string" ? data.accessToken : null,
|
||||
);
|
||||
}, [data?.accessToken, setAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated") {
|
||||
setIsMetadataAdmin(false);
|
||||
resetAccess();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
||||
projectHeaderMode: "omit",
|
||||
setAccessLoading(true);
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||
skipAuthRedirect: true,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (cancelled) return;
|
||||
if (!response.ok) {
|
||||
setIsMetadataAdmin(false);
|
||||
resetAccess();
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
||||
setAccessContext(await response.json());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setIsMetadataAdmin(false);
|
||||
if (!cancelled) resetAccess();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [status]);
|
||||
}, [
|
||||
currentProjectId,
|
||||
resetAccess,
|
||||
setAccessContext,
|
||||
setAccessLoading,
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated" || !data?.user?.id) return;
|
||||
const auditKey = `tjwater-login-audit:${data.user.id}`;
|
||||
if (sessionStorage.getItem(auditKey)) return;
|
||||
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "login" }),
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) sessionStorage.setItem(auditKey, "1");
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [data?.user?.id, status]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <span>loading...</span>;
|
||||
@@ -100,199 +133,230 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
callbackUrl: to ? to.toString() : "/",
|
||||
redirect: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
return { success: true };
|
||||
},
|
||||
logout: async () => {
|
||||
signOut({
|
||||
redirect: true,
|
||||
callbackUrl: "/login",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
try {
|
||||
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "logout" }),
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
} catch {
|
||||
// Logout must still complete when audit storage is unavailable.
|
||||
}
|
||||
if (data?.user?.id) {
|
||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||
}
|
||||
signOut({ redirect: true, callbackUrl: "/login" });
|
||||
return { success: true };
|
||||
},
|
||||
onError: async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
return {
|
||||
logout: true,
|
||||
};
|
||||
return { logout: true };
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
};
|
||||
},
|
||||
check: async () => {
|
||||
if (status === "unauthenticated") {
|
||||
return {
|
||||
authenticated: false,
|
||||
redirectTo: "/login",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
};
|
||||
},
|
||||
getPermissions: async () => {
|
||||
return null;
|
||||
return { error };
|
||||
},
|
||||
check: async () =>
|
||||
status === "unauthenticated"
|
||||
? { authenticated: false, redirectTo: "/login" }
|
||||
: { authenticated: true },
|
||||
getPermissions: async () => permissions,
|
||||
getIdentity: async () => {
|
||||
if (data?.user) {
|
||||
const { user } = data;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatar: user.image,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
if (!data?.user) return null;
|
||||
return {
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
name: data.user.name,
|
||||
avatar: data.user.image,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const defaultMode = props?.defaultMode;
|
||||
const accessControlProvider: AccessControlProvider = {
|
||||
can: async ({ resource }) => {
|
||||
const requiredPermission = resource
|
||||
? resourcePermissions[resource]
|
||||
: undefined;
|
||||
return {
|
||||
can: !requiredPermission || permissions.includes(requiredPermission),
|
||||
reason: requiredPermission
|
||||
? `需要权限:${requiredPermission}`
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const resources = [
|
||||
...(can(permissionCodes.simulationView)
|
||||
? [
|
||||
{
|
||||
name: "管网在线模拟",
|
||||
list: "/network-simulation",
|
||||
meta: {
|
||||
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.scadaClean)
|
||||
? [
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.optimizationRun)
|
||||
? [
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.riskRun)
|
||||
? [
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
label: "健康风险分析",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
|
||||
? [
|
||||
{
|
||||
name: "Hydraulic Simulation",
|
||||
meta: { label: "事件模拟" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.burstRun)
|
||||
? [
|
||||
{
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationRun)
|
||||
? [
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.environmentManage)
|
||||
? [
|
||||
{
|
||||
name: "系统管理",
|
||||
list: "/system-admin",
|
||||
meta: {
|
||||
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||
label: "系统管理",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.auditView)
|
||||
? [
|
||||
{
|
||||
name: "审计日志",
|
||||
list: "/audit-logs",
|
||||
meta: {
|
||||
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||
label: "审计日志",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectProvider>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useNotificationProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
resources={[
|
||||
{
|
||||
name: "管网在线模拟",
|
||||
list: "/network-simulation",
|
||||
meta: {
|
||||
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
label: "健康风险分析",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Hydraulic Simulation",
|
||||
meta: {
|
||||
// icon: <MdWater className="w-6 h-6" />,
|
||||
label: "事件模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
...(isMetadataAdmin
|
||||
? [
|
||||
{
|
||||
name: "系统管理",
|
||||
list: "/system-admin",
|
||||
meta: {
|
||||
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||
label: "系统管理",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "审计日志",
|
||||
list: "/audit-logs",
|
||||
meta: {
|
||||
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||
label: "审计日志",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
accessControlProvider={accessControlProvider}
|
||||
resources={resources}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</>
|
||||
</ProjectProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,16 +9,26 @@ type KeycloakTokenResponse = {
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!;
|
||||
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!;
|
||||
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`;
|
||||
const getKeycloakTokenEndpoint = () => {
|
||||
const issuer = process.env.KEYCLOAK_ISSUER;
|
||||
return issuer
|
||||
? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||
if (!token.refreshToken) {
|
||||
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({
|
||||
grant_type: "refresh_token",
|
||||
client_id: keycloakClientId,
|
||||
@@ -50,12 +60,13 @@ const authOptions: NextAuthOptions = {
|
||||
// Configure one or more authentication providers
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: keycloakClientId,
|
||||
clientSecret: keycloakClientSecret,
|
||||
issuer: keycloakIssuer,
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
||||
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
username: profile.preferred_username,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
email: profile.email,
|
||||
image: Avatar.src,
|
||||
@@ -69,6 +80,12 @@ const authOptions: NextAuthOptions = {
|
||||
if (profile?.sub) {
|
||||
token.sub = profile.sub;
|
||||
}
|
||||
const preferredUsername = (
|
||||
profile as { preferred_username?: unknown } | undefined
|
||||
)?.preferred_username;
|
||||
if (typeof preferredUsername === "string") {
|
||||
token.username = preferredUsername;
|
||||
}
|
||||
|
||||
if (account) {
|
||||
if (account.access_token) {
|
||||
@@ -94,6 +111,9 @@ const authOptions: NextAuthOptions = {
|
||||
if (session.user && token.sub) {
|
||||
session.user.id = token.sub;
|
||||
}
|
||||
if (session.user && token.username) {
|
||||
session.user.username = token.username;
|
||||
}
|
||||
if (token.accessToken) {
|
||||
session.accessToken = token.accessToken;
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import React, { Suspense } from "react";
|
||||
import { RefineContext } from "./RefineContext";
|
||||
import { META_DATA } from "@config/config";
|
||||
@@ -16,8 +17,9 @@ export default async function RootLayout({
|
||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
||||
<Suspense>
|
||||
<RefineContext defaultMode={defaultMode}>{children}</RefineContext>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import Login from "./page";
|
||||
|
||||
const mockLogin = jest.fn();
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useLogin: () => ({ mutate: mockLogin }),
|
||||
}));
|
||||
|
||||
describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockLogin.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts Keycloak login once when the page opens", () => {
|
||||
const { rerender } = render(<Login />);
|
||||
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogin).toHaveBeenCalledWith({});
|
||||
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
|
||||
|
||||
rerender(<Login />);
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a manual fallback when automatic redirect does not complete", () => {
|
||||
render(<Login />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "继续登录" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
|
||||
expect(mockLogin).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+239
-34
@@ -1,54 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Container from "@mui/material/Container";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useLogin } from "@refinedev/core";
|
||||
import { Title } from "@components/title";
|
||||
import { PROJECT_TITLE } from "@config/config";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const FALLBACK_DELAY_MS = 4000;
|
||||
|
||||
export default function Login() {
|
||||
const { mutate: login } = useLogin();
|
||||
const loginStartedRef = useRef(false);
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginStartedRef.current) {
|
||||
loginStartedRef.current = true;
|
||||
login({});
|
||||
}
|
||||
|
||||
const fallbackTimer = window.setTimeout(
|
||||
() => setShowFallback(true),
|
||||
FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(fallbackTimer);
|
||||
}, [login]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
"@supports (height: 100svh)": {
|
||||
minHeight: "100svh",
|
||||
},
|
||||
color: "oklch(0.3 0.055 215)",
|
||||
bgcolor: "oklch(0.965 0.014 205)",
|
||||
backgroundImage: `
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 50%,
|
||||
oklch(0.975 0.01 205 / 62%) 68%,
|
||||
oklch(0.975 0.01 205 / 82%) 100%
|
||||
),
|
||||
url("/login-network-blueprint.svg")
|
||||
`,
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
"@keyframes tjwaterEnter": {
|
||||
from: { opacity: 0, transform: "translateY(12px)" },
|
||||
to: { opacity: 1, transform: "translateY(0)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
gap="36px"
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 1720,
|
||||
minHeight: "100vh",
|
||||
"@supports (height: 100svh)": {
|
||||
minHeight: "100svh",
|
||||
},
|
||||
mx: "auto",
|
||||
px: {
|
||||
xs: 1.75,
|
||||
sm: 3,
|
||||
md: "clamp(40px, 5vw, 88px)",
|
||||
},
|
||||
py: {
|
||||
xs: "max(28px, env(safe-area-inset-top))",
|
||||
md: "clamp(32px, 4.5vw, 76px)",
|
||||
},
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "minmax(0, 1fr)",
|
||||
md: "minmax(360px, 1fr) minmax(400px, 460px)",
|
||||
},
|
||||
gridTemplateAreas: {
|
||||
xs: '"brand" "status"',
|
||||
md: '"brand status"',
|
||||
},
|
||||
alignContent: { xs: "center", md: "stretch" },
|
||||
alignItems: "center",
|
||||
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
|
||||
}}
|
||||
>
|
||||
<Box display="flex" justifyContent="center">
|
||||
<Title collapsed={false} />
|
||||
</Box>
|
||||
<Button
|
||||
style={{ width: "240px" }}
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => login({})}
|
||||
<Stack
|
||||
spacing={{ xs: 0, md: 3 }}
|
||||
direction={{ xs: "row", md: "column" }}
|
||||
alignItems={{ xs: "center", md: "flex-start" }}
|
||||
sx={{
|
||||
position: "relative",
|
||||
isolation: "isolate",
|
||||
gridArea: "brand",
|
||||
width: "fit-content",
|
||||
maxWidth: "100%",
|
||||
animation:
|
||||
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
"&::before": {
|
||||
position: "absolute",
|
||||
zIndex: -1,
|
||||
inset: { xs: "-24px -20px", md: "-54px -72px" },
|
||||
background: {
|
||||
xs: `radial-gradient(
|
||||
ellipse at center,
|
||||
oklch(0.965 0.014 205 / 98%) 0%,
|
||||
oklch(0.965 0.014 205 / 88%) 58%,
|
||||
transparent 84%
|
||||
)`,
|
||||
md: `radial-gradient(
|
||||
ellipse at center,
|
||||
oklch(0.925 0.018 205 / 98%) 0%,
|
||||
oklch(0.925 0.018 205 / 94%) 48%,
|
||||
oklch(0.925 0.018 205 / 62%) 65%,
|
||||
transparent 82%
|
||||
)`,
|
||||
},
|
||||
pointerEvents: "none",
|
||||
content: '""',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
||||
Powered by
|
||||
<Image
|
||||
style={{ padding: "0 5px" }}
|
||||
alt="Keycloak"
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
||||
width={18}
|
||||
height={18}
|
||||
<Box
|
||||
component="img"
|
||||
src="/login-logo-mark.svg"
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
sx={{
|
||||
width: { xs: 42, md: 56 },
|
||||
height: { xs: 42, md: 56 },
|
||||
flex: { xs: "0 0 42px", md: "0 0 auto" },
|
||||
mr: { xs: 1.5, md: 0 },
|
||||
}}
|
||||
/>
|
||||
Keycloak
|
||||
</Typography>
|
||||
<Typography
|
||||
component="h1"
|
||||
sx={{
|
||||
maxWidth: 680,
|
||||
m: 0,
|
||||
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
|
||||
fontWeight: 720,
|
||||
lineHeight: { xs: 1.4, md: 1.28 },
|
||||
textWrap: "balance",
|
||||
}}
|
||||
>
|
||||
{PROJECT_TITLE}
|
||||
</Typography>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
width: 64,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
bgcolor: "oklch(0.58 0.12 180)",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
gridArea: "status",
|
||||
width: "100%",
|
||||
maxWidth: 460,
|
||||
justifySelf: "stretch",
|
||||
p: { xs: 2.75, sm: 4.5 },
|
||||
overflow: "hidden",
|
||||
borderRadius: { xs: 4, sm: 4.5 },
|
||||
bgcolor: "oklch(0.995 0.004 205 / 97%)",
|
||||
boxShadow:
|
||||
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
|
||||
animation:
|
||||
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CircularProgress
|
||||
size={30}
|
||||
thickness={4.5}
|
||||
aria-label="正在连接统一身份认证"
|
||||
sx={{
|
||||
flex: "0 0 auto",
|
||||
color: "oklch(0.57 0.16 242)",
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
animation: "none",
|
||||
"& .MuiCircularProgress-circle": {
|
||||
animation: "none",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
|
||||
>
|
||||
正在进入账号登录
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "oklch(0.52 0.035 215)",
|
||||
fontSize: { xs: 14, sm: 15 },
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
正在连接统一身份认证服务,请稍候。
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{showFallback ? (
|
||||
<Button
|
||||
fullWidth
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => login({})}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
borderRadius: 3,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
textTransform: "none",
|
||||
boxShadow:
|
||||
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
|
||||
transitionProperty:
|
||||
"transform, background-color, box-shadow",
|
||||
"&:active": { transform: "scale(0.96)" },
|
||||
}}
|
||||
>
|
||||
继续登录
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
color: "oklch(0.52 0.035 215)",
|
||||
fontSize: { xs: 12, sm: 13 },
|
||||
lineHeight: 1.7,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
账号与访问权限由统一身份认证服务管理
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,8 @@ import {
|
||||
} from "@mui/icons-material";
|
||||
import { config } from "@config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { permissionCodes } from "@/lib/permissions";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
|
||||
end_time: "",
|
||||
};
|
||||
|
||||
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
|
||||
|
||||
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "success", label: "成功 2xx" },
|
||||
@@ -338,8 +342,14 @@ const DetailSection = ({
|
||||
);
|
||||
|
||||
export const AuditLogPanel = () => {
|
||||
const [adminChecked, setAdminChecked] = useState(false);
|
||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||
const accessLoading = useAccessStore((state) => state.loading);
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const isSystemAdmin = useAccessStore(
|
||||
(state) => state.context?.is_system_admin === true,
|
||||
);
|
||||
const canViewAudit = permissions.includes(permissionCodes.auditView);
|
||||
const adminChecked = !accessLoading;
|
||||
const isAuthorized = adminChecked && canViewAudit && isSystemAdmin;
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [users, setUsers] = useState<MetadataUser[]>([]);
|
||||
const [projects, setProjects] = useState<AdminProject[]>([]);
|
||||
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
|
||||
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
|
||||
const countParams = buildCountParams(appliedFilters);
|
||||
const [logsResponse, countResponse] = await Promise.all([
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`),
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`),
|
||||
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
|
||||
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
|
||||
]);
|
||||
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
|
||||
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
|
||||
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
|
||||
setTotalCount(Number(countPayload.count ?? 0));
|
||||
} else {
|
||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
|
||||
if (!response.ok) throw new Error(await readErrorText(response));
|
||||
const payload = (await response.json()) as AuditLog[];
|
||||
setLogs(payload);
|
||||
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
|
||||
}, [appliedFilters, page, rowsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!response.ok) {
|
||||
setIsAuthorized(false);
|
||||
setAdminChecked(true);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
||||
setAdminChecked(true);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setIsAuthorized(false);
|
||||
setAdminChecked(true);
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkAdmin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminChecked || !isAuthorized) return;
|
||||
if (!isAuthorized) return;
|
||||
void loadOptions();
|
||||
}, [adminChecked, isAuthorized, loadOptions]);
|
||||
}, [isAuthorized, loadOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminChecked || !isAuthorized) return;
|
||||
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
|
||||
setError(null);
|
||||
try {
|
||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
|
||||
if (!response.ok) throw new Error(await readErrorText(response));
|
||||
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
|
||||
matchesStatusFilter(log, appliedFilters.status),
|
||||
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
|
||||
审计日志
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
管理员审计查询
|
||||
全局审计查询
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
|
||||
<Chip
|
||||
color="success"
|
||||
icon={<AdminPanelSettingsIcon />}
|
||||
label="管理员权限已验证"
|
||||
label="系统管理员权限已验证"
|
||||
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import StopRounded from "@mui/icons-material/StopRounded";
|
||||
import MicRounded from "@mui/icons-material/MicRounded";
|
||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
||||
import BoltRounded from "@mui/icons-material/BoltRounded";
|
||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||
@@ -125,16 +124,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Image
|
||||
src="/ai-agent.svg"
|
||||
alt="TJWater Agent"
|
||||
width={18}
|
||||
height={18}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<AutoAwesomeRounded
|
||||
titleAccess="快捷指令图标"
|
||||
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||
管网分析快捷指令
|
||||
</Typography>
|
||||
@@ -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" spacing={0.5} alignItems="center">
|
||||
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
|
||||
<AttachFileRounded fontSize="small" />
|
||||
</IconButton>
|
||||
{isSttSupported ? (
|
||||
isListening ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.14, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onStopListening}
|
||||
aria-label="停止语音输入"
|
||||
size="small"
|
||||
sx={{
|
||||
color: "error.main",
|
||||
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||
width: 36,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={onStartListening}
|
||||
disabled={isStreaming || isHydrating}
|
||||
aria-label="语音输入"
|
||||
size="small"
|
||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
) : null}
|
||||
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||
<Select
|
||||
value={approvalMode}
|
||||
@@ -471,6 +429,39 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{isSttSupported ? (
|
||||
isListening ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.14, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onStopListening}
|
||||
aria-label="停止语音输入"
|
||||
size="small"
|
||||
sx={{
|
||||
color: "error.main",
|
||||
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||
width: 36,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={onStartListening}
|
||||
disabled={isStreaming || isHydrating}
|
||||
aria-label="语音输入"
|
||||
size="small"
|
||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||
>
|
||||
<MicRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isStreaming ? (
|
||||
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import React, { useMemo } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Button,
|
||||
Grow,
|
||||
IconButton,
|
||||
Paper,
|
||||
Popper,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -41,6 +44,48 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||
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 = {
|
||||
message: Message;
|
||||
isStreaming: boolean;
|
||||
@@ -180,10 +225,7 @@ export const AgentTurn = React.memo(
|
||||
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{
|
||||
offset: number;
|
||||
preview: string;
|
||||
} | null>(null);
|
||||
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
||||
const isProgressComplete = message.progress?.some(
|
||||
(item) => item.phase === "complete" && item.status === "completed",
|
||||
) ?? false;
|
||||
@@ -203,39 +245,94 @@ export const AgentTurn = React.memo(
|
||||
() => stripMarkdown(answerContent),
|
||||
[answerContent],
|
||||
);
|
||||
const handleCaptureSpeechSelection = React.useCallback(() => {
|
||||
const captureSpeechSelection = React.useCallback(() => {
|
||||
if (!isTtsSupported || isStreamingAssistant) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const container = answerContentRef.current;
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.commonAncestorContainer)) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = selection.toString();
|
||||
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
|
||||
if (startOffset === null) {
|
||||
setSelectedSpeechStart(null);
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24);
|
||||
setSelectedSpeechStart({
|
||||
offset: startOffset,
|
||||
preview: preview.length === 24 ? `${preview}...` : preview,
|
||||
});
|
||||
}, [speechText]);
|
||||
const anchorRect = range.getBoundingClientRect();
|
||||
if (anchorRect.width === 0 && anchorRect.height === 0) {
|
||||
setSpeechSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeechSelection({ startOffset, anchorRect });
|
||||
}, [isStreamingAssistant, isTtsSupported, speechText]);
|
||||
const handleCaptureSpeechSelection = React.useCallback(() => {
|
||||
window.requestAnimationFrame(captureSpeechSelection);
|
||||
}, [captureSpeechSelection]);
|
||||
React.useEffect(() => {
|
||||
setSelectedSpeechStart(null);
|
||||
setSpeechSelection(null);
|
||||
}, [message.id, speechText]);
|
||||
const handleSpeakFromCurrentStart = () => {
|
||||
onSpeak(message.id, speechText, {
|
||||
startOffset: selectedSpeechStart?.offset ?? 0,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (!speechSelection) return;
|
||||
|
||||
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(
|
||||
() =>
|
||||
!isUser && !isErrorMessage
|
||||
@@ -385,9 +482,8 @@ export const AgentTurn = React.memo(
|
||||
|
||||
<Box
|
||||
ref={answerContentRef}
|
||||
onMouseUp={handleCaptureSpeechSelection}
|
||||
onPointerUp={handleCaptureSpeechSelection}
|
||||
onKeyUp={handleCaptureSpeechSelection}
|
||||
onTouchEnd={handleCaptureSpeechSelection}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 4,
|
||||
@@ -457,6 +553,43 @@ export const AgentTurn = React.memo(
|
||||
</Stack>
|
||||
</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) => (
|
||||
<ChatInlineChart
|
||||
key={artifact.id}
|
||||
@@ -479,59 +612,49 @@ export const AgentTurn = React.memo(
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<AnimatePresence>
|
||||
{isHovered && !isStreaming && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
p: 0.5,
|
||||
borderRadius: "16px",
|
||||
bgcolor: alpha("#fff", 0.8),
|
||||
backdropFilter: "blur(16px)",
|
||||
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||
<Grow
|
||||
in={isHovered && !isStreaming}
|
||||
timeout={floatingActionTransitionTimeout}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
style={{ transformOrigin: "right bottom" }}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
...floatingActionSurfaceSx,
|
||||
position: "absolute",
|
||||
top: -14,
|
||||
right: 12,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
}}
|
||||
sx={floatingIconButtonSx}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
// Could add a toast here
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => {
|
||||
onCreateBranch(message.id);
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => onCreateBranch(message.id)}
|
||||
sx={floatingIconButtonSx}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</Grow>
|
||||
|
||||
</Paper>
|
||||
</Stack>
|
||||
@@ -542,8 +665,8 @@ export const AgentTurn = React.memo(
|
||||
{messageSpeechState === "idle" ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSpeakFromCurrentStart}
|
||||
aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"}
|
||||
onClick={handleSpeakMessage}
|
||||
aria-label="朗读消息"
|
||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||
>
|
||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
const isNearBottomRef = useRef(true);
|
||||
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||
const hasResetForOpenRef = useRef(false);
|
||||
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||
const theme = useTheme();
|
||||
const { open: openNotification } = useNotification();
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
@@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
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(() => {
|
||||
if (isStreaming) {
|
||||
if (!isNearBottomRef.current) return;
|
||||
@@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
hasResetForOpenRef.current = false;
|
||||
if (
|
||||
!open ||
|
||||
isHydrating ||
|
||||
initializedProjectIdRef.current === currentProjectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasResetForOpenRef.current || isHydrating) return;
|
||||
hasResetForOpenRef.current = true;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
createSession();
|
||||
composerRef.current?.clear();
|
||||
setIsHistoryOpen(false);
|
||||
composerRef.current?.focus();
|
||||
isNearBottomRef.current = true;
|
||||
cancelStreamingScroll();
|
||||
scrollToBottom("auto");
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
|
||||
initializedProjectIdRef.current = currentProjectId;
|
||||
createSession();
|
||||
resetConversationView();
|
||||
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
||||
|
||||
const handleSend = useCallback(async (prompt: string) => {
|
||||
if (isStreaming || isCheckingAuth) return;
|
||||
@@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
handleStopSpeech();
|
||||
stopListening();
|
||||
createSession();
|
||||
composerRef.current?.clear();
|
||||
window.setTimeout(() => {
|
||||
composerRef.current?.focus();
|
||||
isNearBottomRef.current = true;
|
||||
cancelStreamingScroll();
|
||||
scrollToBottom("auto");
|
||||
}, 0);
|
||||
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
||||
resetConversationView();
|
||||
}, [createSession, handleStopSpeech, resetConversationView, stopListening]);
|
||||
|
||||
const handleHistoryToggle = useCallback(() => {
|
||||
setIsHistoryOpen((prev) => !prev);
|
||||
@@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
hideBackdrop
|
||||
disableScrollLock
|
||||
disableEnforceFocus
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||
pointerEvents: "none",
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
|
||||
sessionId: "session-1",
|
||||
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||
});
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
|
||||
isTitleManuallyEdited: true,
|
||||
});
|
||||
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||
title: "新标题",
|
||||
|
||||
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
|
||||
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||
|
||||
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
|
||||
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
|
||||
isTitleManuallyEdited?: boolean,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
|
||||
|
||||
const deleteBackendChatSession = async (sessionId: string) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
projectHeaderMode: "include",
|
||||
|
||||
@@ -0,0 +1,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 } });
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
layerId: DefaultLayerStyleId;
|
||||
@@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined =>
|
||||
.filter((item): item is string => item !== 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 => {
|
||||
const normalized = asString(value)?.toLowerCase();
|
||||
if (normalized === "junctions" || normalized === "pipes") {
|
||||
@@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = (
|
||||
? (params.styleConfig as Record<string, unknown>)
|
||||
: 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
|
||||
? {
|
||||
property: asString(rawStyleConfig.property),
|
||||
classificationMethod: asString(
|
||||
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
|
||||
),
|
||||
segments: asNumber(rawStyleConfig.segments),
|
||||
classificationMethod: asClassificationMethod(classificationValue),
|
||||
segments,
|
||||
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
|
||||
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
|
||||
minStrokeWidth: asNumber(
|
||||
@@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = (
|
||||
fixedStrokeWidth: asNumber(
|
||||
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
|
||||
),
|
||||
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
|
||||
colorType: asColorType(colorTypeValue),
|
||||
singlePaletteIndex: asNumber(
|
||||
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
|
||||
),
|
||||
@@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = (
|
||||
}
|
||||
: 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 =
|
||||
styleConfig &&
|
||||
Object.values(styleConfig).some((value) =>
|
||||
|
||||
@@ -27,6 +27,10 @@ import { GlobalChatbox } from "@components/chat/GlobalChatbox";
|
||||
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
|
||||
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 = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -70,9 +74,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
setMapWorkspace(workspace);
|
||||
setNetworkName(networkName);
|
||||
setMapExtent(extent);
|
||||
localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", workspace);
|
||||
localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName);
|
||||
localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(","));
|
||||
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace);
|
||||
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
|
||||
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
|
||||
localStorage.removeItem(`${workspace}_map_view`);
|
||||
setCurrentProjectId(projectId || networkName || workspace);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||
@@ -23,147 +18,157 @@ import { useNotification } from "@refinedev/core";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
import { NETWORK_NAME } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { BurstDetectionResult } from "./types";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstDetectionResult) => void;
|
||||
state?: BurstDetectionAnalysisParametersState;
|
||||
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
|
||||
}
|
||||
|
||||
interface SchemeItem {
|
||||
scheme_id: number;
|
||||
scheme_name: string;
|
||||
scheme_type: string;
|
||||
create_time: string;
|
||||
scheme_start_time: string;
|
||||
scheme_detail?: {
|
||||
modify_total_duration: number;
|
||||
};
|
||||
export interface BurstDetectionAnalysisParametersState {
|
||||
schemeName: string;
|
||||
detectionMode: "latest" | "historical";
|
||||
targetTime: Dayjs | null;
|
||||
samplingIntervalMinutes: number;
|
||||
samplingIntervalSource: "metadata" | "manual";
|
||||
}
|
||||
|
||||
const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
const { open } = useNotification();
|
||||
const [schemeName, setSchemeName] = useState(`Burst_Detection_${Date.now()}`);
|
||||
const [dataSource, setDataSource] = useState<"monitoring" | "simulation">("monitoring");
|
||||
const [schemes, setSchemes] = useState<SchemeItem[]>([]);
|
||||
const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">("");
|
||||
const [schemeLoading, setSchemeLoading] = useState(false);
|
||||
const [scadaStart, setScadaStart] = useState<Dayjs | null>(dayjs().subtract(3, "day"));
|
||||
const [scadaEnd, setScadaEnd] = useState<Dayjs | null>(dayjs());
|
||||
const [mu, setMu] = useState<number>(100);
|
||||
const [pointsPerDay, setPointsPerDay] = useState<number>(96);
|
||||
const [nEstimators, setNEstimators] = useState<number>(50);
|
||||
const [contaminationInput, setContaminationInput] = useState<string>("auto");
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const isSimulationMode = dataSource === "simulation";
|
||||
interface ScadaInfoItem {
|
||||
type?: string;
|
||||
transmission_frequency?: string | number | null;
|
||||
}
|
||||
|
||||
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||||
const start = dayjs(scheme.scheme_start_time);
|
||||
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
|
||||
const end = start.add(durationSeconds, "second");
|
||||
const currentQuarterHour = () => {
|
||||
const now = dayjs().second(0).millisecond(0);
|
||||
return now.minute(Math.floor(now.minute() / 15) * 15);
|
||||
};
|
||||
|
||||
setScadaStart(start);
|
||||
setScadaEnd(end);
|
||||
}, []);
|
||||
export const createBurstDetectionAnalysisParametersState =
|
||||
(): BurstDetectionAnalysisParametersState => ({
|
||||
schemeName: `Burst_Detection_${Date.now()}`,
|
||||
detectionMode: "latest",
|
||||
targetTime: currentQuarterHour(),
|
||||
samplingIntervalMinutes: 15,
|
||||
samplingIntervalSource: "metadata",
|
||||
});
|
||||
|
||||
const fetchSchemes = useCallback(
|
||||
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
|
||||
if (schemeLoading || (!force && schemes.length > 0)) return;
|
||||
export const parseScadaFrequencyMinutes = (
|
||||
value: string | number | null | undefined,
|
||||
): number | null => {
|
||||
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;
|
||||
};
|
||||
|
||||
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",
|
||||
);
|
||||
|
||||
setSchemes(burstSchemes);
|
||||
|
||||
if (selectedSchemeId) {
|
||||
const matchedScheme = burstSchemes.find(
|
||||
(scheme) => scheme.scheme_id === selectedSchemeId,
|
||||
);
|
||||
if (matchedScheme) {
|
||||
applySchemeTimeRange(matchedScheme);
|
||||
} else {
|
||||
setSelectedSchemeId("");
|
||||
}
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "方案列表已刷新",
|
||||
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "刷新方案失败",
|
||||
description:
|
||||
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
||||
});
|
||||
} finally {
|
||||
setSchemeLoading(false);
|
||||
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);
|
||||
}
|
||||
},
|
||||
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId],
|
||||
});
|
||||
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> = ({
|
||||
onResult,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const [parametersState, setParametersState, setFormField] =
|
||||
useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstDetectionAnalysisParametersState(),
|
||||
);
|
||||
const {
|
||||
schemeName,
|
||||
detectionMode,
|
||||
targetTime,
|
||||
samplingIntervalMinutes,
|
||||
samplingIntervalSource,
|
||||
} = parametersState;
|
||||
const [running, setRunning] = useState(false);
|
||||
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (samplingIntervalSource !== "metadata") return;
|
||||
let active = true;
|
||||
setFrequencyLoading(true);
|
||||
api
|
||||
.get("/api/v1/scada-info")
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const interval = resolvePressureSamplingInterval(
|
||||
response.data as ScadaInfoItem[],
|
||||
);
|
||||
setParametersState((previous) =>
|
||||
previous.samplingIntervalSource === "metadata"
|
||||
? { ...previous, samplingIntervalMinutes: interval }
|
||||
: previous,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the 15-minute fallback when SCADA metadata is unavailable.
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setFrequencyLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [samplingIntervalSource, setParametersState]);
|
||||
|
||||
const samplingIntervalValid =
|
||||
Number.isInteger(samplingIntervalMinutes) &&
|
||||
samplingIntervalMinutes > 0 &&
|
||||
1440 % samplingIntervalMinutes === 0;
|
||||
|
||||
const isValid = useMemo(
|
||||
() =>
|
||||
schemeName.trim().length > 0 &&
|
||||
samplingIntervalValid &&
|
||||
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
|
||||
[detectionMode, samplingIntervalValid, schemeName, targetTime],
|
||||
);
|
||||
|
||||
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
|
||||
setDataSource(value);
|
||||
if (value === "simulation") {
|
||||
void fetchSchemes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchemeSelect = (schemeId: number) => {
|
||||
setSelectedSchemeId(schemeId);
|
||||
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
||||
if (scheme) {
|
||||
applySchemeTimeRange(scheme);
|
||||
}
|
||||
};
|
||||
|
||||
const timeWindowValid = useMemo(() => {
|
||||
if (!scadaStart || !scadaEnd) return false;
|
||||
return scadaEnd.diff(scadaStart, "day", true) >= 2;
|
||||
}, [scadaEnd, scadaStart]);
|
||||
|
||||
const contaminationValue = useMemo(() => {
|
||||
const normalized = contaminationInput.trim().toLowerCase();
|
||||
if (!normalized || normalized === "auto") {
|
||||
return "auto" as const;
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}, [contaminationInput]);
|
||||
|
||||
const isValid =
|
||||
Boolean(scadaStart && scadaEnd) &&
|
||||
timeWindowValid &&
|
||||
Number.isFinite(mu) &&
|
||||
mu > 0 &&
|
||||
Number.isFinite(pointsPerDay) &&
|
||||
pointsPerDay > 0 &&
|
||||
Number.isFinite(nEstimators) &&
|
||||
nEstimators > 0 &&
|
||||
contaminationValue !== null &&
|
||||
(dataSource !== "simulation" || Boolean(selectedSchemeId));
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) {
|
||||
if (!isValid) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "参数不完整",
|
||||
description: "请检查时间范围(至少2天)和高级参数是否填写正确。",
|
||||
description: "请输入方案名称,并检查历史目标时间。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -173,50 +178,23 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
key: "burst-detection-analysis-progress",
|
||||
type: "progress",
|
||||
message: "正在执行爆管侦测",
|
||||
description: "正在读取数据并计算异常分数。",
|
||||
description: "正在读取目标时刻及前 14 天同刻基线。",
|
||||
undoableTimeout: 3,
|
||||
});
|
||||
|
||||
try {
|
||||
const selectedScheme =
|
||||
dataSource === "simulation"
|
||||
? schemes.find((item) => item.scheme_id === selectedSchemeId)
|
||||
: undefined;
|
||||
|
||||
const response = await api.post("/api/v1/burst-detection/detect/", {
|
||||
network: NETWORK_NAME,
|
||||
data_source: dataSource,
|
||||
scheme_name: schemeName.trim() || undefined,
|
||||
scada_start: scadaStart.toISOString(),
|
||||
scada_end: scadaEnd.toISOString(),
|
||||
mu,
|
||||
points_per_day: pointsPerDay,
|
||||
iforest_params: {
|
||||
n_estimators: nEstimators,
|
||||
contamination: contaminationValue,
|
||||
},
|
||||
simulation_scheme_name: selectedScheme?.scheme_name,
|
||||
simulation_scheme_type: selectedScheme?.scheme_type,
|
||||
});
|
||||
|
||||
onResult({
|
||||
...(response.data as BurstDetectionResult),
|
||||
scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name,
|
||||
algorithm_params: {
|
||||
mu,
|
||||
points_per_day: pointsPerDay,
|
||||
iforest_params: {
|
||||
n_estimators: nEstimators,
|
||||
contamination: contaminationValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await api.post(
|
||||
"/api/v1/burst-detections",
|
||||
buildBurstDetectionRequest(parametersState),
|
||||
);
|
||||
onResult(response.data as BurstDetectionResult);
|
||||
open?.({
|
||||
key: "burst-detection-analysis-success",
|
||||
type: "success",
|
||||
message: "爆管侦测完成",
|
||||
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`,
|
||||
description: response.data.summary?.burst_detected
|
||||
? "目标时刻存在异常信号,请优先复核相关测点。"
|
||||
: "目标时刻未发现爆管异常。",
|
||||
});
|
||||
} catch (error: any) {
|
||||
open?.({
|
||||
@@ -231,7 +209,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
};
|
||||
|
||||
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>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
@@ -239,7 +217,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
</Typography>
|
||||
<TextField
|
||||
value={schemeName}
|
||||
onChange={(event) => setSchemeName(event.target.value)}
|
||||
onChange={(event) => setFormField("schemeName", event.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
fullWidth
|
||||
size="small"
|
||||
@@ -248,208 +226,89 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
数据来源
|
||||
侦测方式
|
||||
</Typography>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={dataSource}
|
||||
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")}
|
||||
value={detectionMode}
|
||||
onChange={(event) =>
|
||||
setFormField(
|
||||
"detectionMode",
|
||||
event.target.value as "latest" | "historical",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="monitoring">监测数据</MenuItem>
|
||||
<MenuItem value="simulation">模拟方案</MenuItem>
|
||||
<MenuItem value="latest">检测最新数据</MenuItem>
|
||||
<MenuItem value="historical">历史时刻回放</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{isSimulationMode && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
选择爆管分析方案
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={selectedSchemeId}
|
||||
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
|
||||
disabled={schemeLoading}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
请选择方案
|
||||
</MenuItem>
|
||||
{schemes.map((scheme) => (
|
||||
<MenuItem key={scheme.scheme_id} value={scheme.scheme_id}>
|
||||
{scheme.scheme_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => void fetchSchemes({ force: true, notify: true })}
|
||||
disabled={schemeLoading}
|
||||
aria-label="刷新爆管分析方案"
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{schemeLoading ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
<RefreshIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale="zh-cn"
|
||||
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
||||
>
|
||||
<Box className="grid grid-cols-2 gap-2">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
侦测开始时间
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={scadaStart}
|
||||
onChange={setScadaStart}
|
||||
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined}
|
||||
disabled={isSimulationMode}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
侦测结束时间
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={scadaEnd}
|
||||
onChange={setScadaEnd}
|
||||
minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined}
|
||||
disabled={isSimulationMode}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
|
||||
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
|
||||
当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "grey.200",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
setAdvancedOpen((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
"&:hover": { backgroundColor: "action.hover" },
|
||||
}}
|
||||
{detectionMode === "historical" ? (
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale="zh-cn"
|
||||
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
高级参数
|
||||
</Typography>
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.25,
|
||||
pt: 1.25,
|
||||
pb: 1.25,
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<Box className="flex flex-col gap-3">
|
||||
<TextField
|
||||
type="number"
|
||||
label="频域截断系数"
|
||||
value={mu}
|
||||
onChange={(event) => setMu(Number(event.target.value))}
|
||||
size="small"
|
||||
fullWidth
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="每日采样点数"
|
||||
value={pointsPerDay}
|
||||
onChange={(event) => setPointsPerDay(Number(event.target.value))}
|
||||
size="small"
|
||||
fullWidth
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="孤立森林树数量"
|
||||
value={nEstimators}
|
||||
onChange={(event) => setNEstimators(Number(event.target.value))}
|
||||
size="small"
|
||||
fullWidth
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="异常比例"
|
||||
value={contaminationInput}
|
||||
onChange={(event) => setContaminationInput(event.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
helperText="填写 auto 或 0~0.5 之间的小数。"
|
||||
error={contaminationValue === null}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
目标时刻
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={targetTime}
|
||||
onChange={(value) => setFormField("targetTime", value)}
|
||||
maxDateTime={dayjs()}
|
||||
minutesStep={15}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
) : null}
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="采样间隔(分钟)"
|
||||
value={samplingIntervalMinutes}
|
||||
onChange={(event) => {
|
||||
setParametersState((previous) => ({
|
||||
...previous,
|
||||
samplingIntervalMinutes: Number(event.target.value),
|
||||
samplingIntervalSource: "manual",
|
||||
}));
|
||||
}}
|
||||
size="small"
|
||||
fullWidth
|
||||
error={!samplingIntervalValid}
|
||||
inputProps={{ min: 1, max: 1440, step: 1 }}
|
||||
helperText={
|
||||
samplingIntervalValid
|
||||
? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点`
|
||||
: "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。"
|
||||
}
|
||||
/>
|
||||
|
||||
{detectionMode === "latest" ? (
|
||||
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
|
||||
系统自动读取目标时刻及前 14 天同一时刻数据。每天使用截至该时刻的
|
||||
24 小时压力序列提取扰动特征,仅判定目标时刻是否异常。
|
||||
</Box>
|
||||
) : null}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
当前口径:{samplingIntervalMinutes || "-"} 分钟采样、
|
||||
{samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} 点/天、14
|
||||
个参考日;缺失数据的测点不会插值,将从本次分析中排除。
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className="mt-auto pt-3 flex gap-2">
|
||||
<Box className="mt-auto flex gap-2 pt-3">
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
disabled={running}
|
||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||
onClick={() => {
|
||||
setSchemeName(`Burst_Detection_${Date.now()}`);
|
||||
setScadaStart(dayjs().subtract(3, "day"));
|
||||
setScadaEnd(dayjs());
|
||||
setMu(100);
|
||||
setPointsPerDay(96);
|
||||
setNEstimators(50);
|
||||
setContaminationInput("auto");
|
||||
}}
|
||||
onClick={() =>
|
||||
setParametersState(createBurstDetectionAnalysisParametersState())
|
||||
}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
@@ -457,11 +316,13 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={!isValid || running}
|
||||
onClick={handleRun}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||
onClick={() => void handleRun()}
|
||||
>
|
||||
{running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"}
|
||||
{running
|
||||
? "侦测中..."
|
||||
: detectionMode === "latest"
|
||||
? "侦测最新数据"
|
||||
: "回放目标时刻"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,9 +9,18 @@ import {
|
||||
FormatListBulleted,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
import AnalysisParameters from "./AnalysisParameters";
|
||||
import DetectionResults from "./DetectionResults";
|
||||
import SchemeQuery from "./SchemeQuery";
|
||||
import AnalysisParameters, {
|
||||
createBurstDetectionAnalysisParametersState,
|
||||
type BurstDetectionAnalysisParametersState,
|
||||
} from "./AnalysisParameters";
|
||||
import DetectionResults, {
|
||||
createBurstDetectionResultsState,
|
||||
type BurstDetectionResultsState,
|
||||
} from "./DetectionResults";
|
||||
import SchemeQuery, {
|
||||
createBurstDetectionSchemeQueryState,
|
||||
type BurstDetectionSchemeQueryState,
|
||||
} from "./SchemeQuery";
|
||||
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
|
||||
|
||||
const TabPanel = ({
|
||||
@@ -33,6 +42,18 @@ const BurstDetectionPanel: React.FC = () => {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
||||
const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
||||
const [analysisState, setAnalysisState] =
|
||||
useState<BurstDetectionAnalysisParametersState>(
|
||||
createBurstDetectionAnalysisParametersState,
|
||||
);
|
||||
const [queryState, setQueryState] =
|
||||
useState<BurstDetectionSchemeQueryState>(
|
||||
createBurstDetectionSchemeQueryState,
|
||||
);
|
||||
const [resultsState, setResultsState] =
|
||||
useState<BurstDetectionResultsState>(
|
||||
createBurstDetectionResultsState,
|
||||
);
|
||||
|
||||
const drawerWidth = 450;
|
||||
const panelTitle = "爆管侦测";
|
||||
@@ -137,13 +158,27 @@ const BurstDetectionPanel: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<AnalysisParameters onResult={handleResult} />
|
||||
<AnalysisParameters
|
||||
onResult={handleResult}
|
||||
state={analysisState}
|
||||
onStateChange={setAnalysisState}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={1}>
|
||||
<SchemeQuery onViewResult={handleResult} schemes={schemes} onSchemesChange={setSchemes} />
|
||||
<SchemeQuery
|
||||
onViewResult={handleResult}
|
||||
schemes={schemes}
|
||||
onSchemesChange={setSchemes}
|
||||
state={queryState}
|
||||
onStateChange={setQueryState}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={2}>
|
||||
<DetectionResults result={result} />
|
||||
<DetectionResults
|
||||
result={result}
|
||||
state={resultsState}
|
||||
onStateChange={setResultsState}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
@@ -5,27 +5,37 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { zhCN } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
CheckCircleOutline as CheckCircleIcon,
|
||||
ErrorOutline as ErrorOutlineIcon,
|
||||
FormatListBulleted,
|
||||
InfoOutlined as InfoOutlinedIcon,
|
||||
Room as RoomIcon,
|
||||
ShowChart as ShowChartIcon,
|
||||
CheckCircleOutline as CheckCircleIcon,
|
||||
ErrorOutline as ErrorOutlineIcon,
|
||||
} from "@mui/icons-material";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import dayjs from "dayjs";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import Feature from "ol/Feature";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { Circle, Fill, Stroke, Style } from "ol/style";
|
||||
import { bbox, featureCollection } from "@turf/turf";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
export interface BurstDetectionResultsState {
|
||||
selectedDay: number | null;
|
||||
}
|
||||
|
||||
export const createBurstDetectionResultsState =
|
||||
(): BurstDetectionResultsState => ({ selectedDay: null });
|
||||
|
||||
interface Props {
|
||||
result: BurstDetectionResult | null;
|
||||
state?: BurstDetectionResultsState;
|
||||
onStateChange?: (state: BurstDetectionResultsState) => void;
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
@@ -35,86 +45,58 @@ interface MetricCardProps {
|
||||
tone: "blue" | "orange" | "purple" | "green";
|
||||
}
|
||||
|
||||
const toneStyles: Record<
|
||||
MetricCardProps["tone"],
|
||||
{ bg: string; border: string; text: string; darkText: string }
|
||||
> = {
|
||||
blue: {
|
||||
bg: "from-blue-50 to-blue-100",
|
||||
border: "border-blue-200",
|
||||
text: "text-blue-700",
|
||||
darkText: "text-blue-900",
|
||||
},
|
||||
orange: {
|
||||
bg: "from-orange-50 to-orange-100",
|
||||
border: "border-orange-200",
|
||||
text: "text-orange-700",
|
||||
darkText: "text-orange-900",
|
||||
},
|
||||
purple: {
|
||||
bg: "from-purple-50 to-purple-100",
|
||||
border: "border-purple-200",
|
||||
text: "text-purple-700",
|
||||
darkText: "text-purple-900",
|
||||
},
|
||||
green: {
|
||||
bg: "from-green-50 to-green-100",
|
||||
border: "border-green-200",
|
||||
text: "text-green-700",
|
||||
darkText: "text-green-900",
|
||||
},
|
||||
const toneStyles: Record<MetricCardProps["tone"], string> = {
|
||||
blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900",
|
||||
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",
|
||||
green: "border-green-200 from-green-50 to-green-100 text-green-900",
|
||||
};
|
||||
|
||||
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
||||
const style = toneStyles[tone];
|
||||
return (
|
||||
<Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}>
|
||||
<Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" className={`font-bold ${style.darkText}`}>
|
||||
{value}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = () => (
|
||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
||||
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||
</Box>
|
||||
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
||||
等待侦测结果
|
||||
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
|
||||
<Box
|
||||
className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
|
||||
>
|
||||
<Typography variant="caption" className="mb-1 block font-semibold">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||
提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。
|
||||
<Typography variant="body2" className="font-bold">
|
||||
{value}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="caption" className="mt-0.5 block opacity-75">
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const getScoreLevel = (score: number) => {
|
||||
if (score <= -0.6) return { label: "高风险", color: "error" as const };
|
||||
if (score <= -0.2) return { label: "需关注", color: "warning" as const };
|
||||
return { label: "正常", color: "success" as const };
|
||||
};
|
||||
const formatDateTime = (value?: string) =>
|
||||
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
|
||||
|
||||
const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
|
||||
const EmptyState = () => (
|
||||
<PanelEmptyState
|
||||
icon={<ShowChartIcon />}
|
||||
title="尚未生成侦测结果"
|
||||
description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||
/>
|
||||
);
|
||||
|
||||
const DetectionResults: React.FC<Props> = ({ result }) => {
|
||||
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
|
||||
const map = useMap();
|
||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||
const [internalState, setInternalState] = useState<BurstDetectionResultsState>(
|
||||
createBurstDetectionResultsState,
|
||||
);
|
||||
const resultsState = state ?? internalState;
|
||||
const setSelectedDay = (selectedDay: number | null) => {
|
||||
const nextState = { selectedDay };
|
||||
if (state === undefined) setInternalState(nextState);
|
||||
onStateChange?.(nextState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const layer = new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
style: new Style({
|
||||
@@ -129,12 +111,11 @@ const DetectionResults: React.FC<Props> = ({ result }) => {
|
||||
properties: {
|
||||
name: "爆管侦测高亮",
|
||||
value: "burst_detection_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer(layer);
|
||||
highlightLayerRef.current = layer;
|
||||
|
||||
return () => {
|
||||
highlightLayerRef.current = null;
|
||||
map.removeLayer(layer);
|
||||
@@ -148,58 +129,67 @@ const DetectionResults: React.FC<Props> = ({ result }) => {
|
||||
highlightFeatures.forEach((feature) => source.addFeature(feature));
|
||||
}, [highlightFeatures]);
|
||||
|
||||
const defaultSelectedDay = useMemo(
|
||||
() =>
|
||||
result?.summary?.most_anomalous_day ??
|
||||
result?.summary?.latest_day?.Day ??
|
||||
result?.rows[0]?.Day ??
|
||||
null,
|
||||
const sortedRows = useMemo(
|
||||
() => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
|
||||
[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>(() => {
|
||||
if (!result || activeSelectedDay === null) return null;
|
||||
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null;
|
||||
}, [activeSelectedDay, result]);
|
||||
|
||||
const scoreSeries = useMemo(
|
||||
() =>
|
||||
result?.rows.map((row) => ({
|
||||
value: [row.Day, Number(row.Score.toFixed(4))],
|
||||
const scoreThreshold = result?.summary.score_threshold ?? 0;
|
||||
const scoreSeries = sortedRows.map((row) => ({
|
||||
day: row.Day,
|
||||
value: [
|
||||
timestampForRow(row)
|
||||
? dayjs(timestampForRow(row)).format("MM-DD HH:mm")
|
||||
: `第 ${row.Day} 天`,
|
||||
Number(row.Score.toFixed(4)),
|
||||
],
|
||||
itemStyle: {
|
||||
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981",
|
||||
color:
|
||||
row.Role === "target"
|
||||
? row.IsBurst
|
||||
? "#ef4444"
|
||||
: "#2563eb"
|
||||
: "#94a3b8",
|
||||
},
|
||||
})) ?? [],
|
||||
[result],
|
||||
);
|
||||
symbolSize: row.Role === "target" ? 11 : 7,
|
||||
}));
|
||||
|
||||
const rankingSeries = useMemo(
|
||||
() =>
|
||||
[...(result?.summary?.latest_sensor_rankings ?? [])]
|
||||
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value)
|
||||
[...(result?.summary.latest_sensor_rankings ?? [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.standardized_deviation ?? a.latest_high_frequency_value) -
|
||||
(b.standardized_deviation ?? b.latest_high_frequency_value),
|
||||
)
|
||||
.map((item) => ({
|
||||
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],
|
||||
);
|
||||
|
||||
const locateSensors = async (sensorIds: string[]) => {
|
||||
if (!map || sensorIds.length === 0) return;
|
||||
|
||||
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
|
||||
if (features.length === 0) {
|
||||
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
|
||||
}
|
||||
if (features.length === 0) return;
|
||||
|
||||
setHighlightFeatures(features);
|
||||
|
||||
const geojsonFormat = new GeoJSON();
|
||||
const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature));
|
||||
// @ts-ignore turf typing with ol geojson objects
|
||||
const format = new GeoJSON();
|
||||
const geojsonFeatures = features.map((feature) =>
|
||||
format.writeFeatureObject(feature),
|
||||
);
|
||||
// @ts-ignore turf accepts OpenLayers GeoJSON feature objects
|
||||
const extent = bbox(featureCollection(geojsonFeatures));
|
||||
map.getView().fit(extent, {
|
||||
maxZoom: 18,
|
||||
@@ -208,60 +198,50 @@ const DetectionResults: React.FC<Props> = ({ result }) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
if (!result) return <EmptyState />;
|
||||
|
||||
const latestDay = result.summary?.latest_day;
|
||||
const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0);
|
||||
const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null;
|
||||
const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0);
|
||||
const targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1);
|
||||
const isBurstDetected = result.summary.burst_detected;
|
||||
const targetRank = result.summary.target_rank;
|
||||
const excludedCount = result.data_quality?.excluded_sensors.length ?? 0;
|
||||
|
||||
const chartOption = {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter: (params: Array<{ data: { value: [number, number] } }>) => {
|
||||
const point = params[0]?.data?.value;
|
||||
if (!point) return "-";
|
||||
return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`;
|
||||
formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
|
||||
const data = params[0]?.data;
|
||||
return data
|
||||
? `${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: {
|
||||
type: "category",
|
||||
name: "侦测日",
|
||||
data: result.rows.map((row) => row.Day),
|
||||
axisLabel: { fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "异常分数",
|
||||
axisLabel: { fontSize: 10 },
|
||||
name: "同刻日期",
|
||||
boundaryGap: false,
|
||||
data: scoreSeries.map((item) => item.value[0]),
|
||||
axisLabel: { fontSize: 10, interval: 2, rotate: 25 },
|
||||
},
|
||||
yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } },
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbolSize: 8,
|
||||
data: scoreSeries,
|
||||
lineStyle: { color: "#2563eb", width: 2 },
|
||||
lineStyle: { color: "#94a3b8", width: 2 },
|
||||
markLine: {
|
||||
symbol: "none",
|
||||
lineStyle: { type: "dashed", color: "#94a3b8" },
|
||||
data: [{ yAxis: 0 }],
|
||||
lineStyle: { type: "dashed", color: "#ef4444" },
|
||||
data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rankingOption = {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
},
|
||||
grid: { top: 20, left: 70, right: 20, bottom: 20 },
|
||||
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
|
||||
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
|
||||
grid: { top: 12, left: 82, right: 20, bottom: 25 },
|
||||
xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } },
|
||||
yAxis: {
|
||||
type: "category",
|
||||
data: rankingSeries.map((item) => item.name),
|
||||
@@ -272,9 +252,7 @@ const DetectionResults: React.FC<Props> = ({ result }) => {
|
||||
type: "bar",
|
||||
data: rankingSeries.map((item) => ({
|
||||
value: item.value,
|
||||
itemStyle: {
|
||||
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
|
||||
},
|
||||
itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" },
|
||||
})),
|
||||
barWidth: 14,
|
||||
},
|
||||
@@ -283,323 +261,176 @@ const DetectionResults: React.FC<Props> = ({ result }) => {
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "Day",
|
||||
headerName: "侦测日",
|
||||
width: 96,
|
||||
valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"),
|
||||
field: "Timestamp",
|
||||
headerName: "同刻日期",
|
||||
minWidth: 145,
|
||||
flex: 1,
|
||||
valueGetter: (_value, row) => formatDateTime(timestampForRow(row)),
|
||||
},
|
||||
{
|
||||
field: "Role",
|
||||
headerName: "角色",
|
||||
width: 90,
|
||||
valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"),
|
||||
},
|
||||
{
|
||||
field: "Score",
|
||||
headerName: "异常分数",
|
||||
width: 120,
|
||||
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"),
|
||||
width: 110,
|
||||
valueFormatter: (value?: number) =>
|
||||
typeof value === "number" ? value.toFixed(4) : "-",
|
||||
},
|
||||
{
|
||||
field: "IsBurst",
|
||||
headerName: "判定结果",
|
||||
width: 120,
|
||||
renderCell: ({ value }) => {
|
||||
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const };
|
||||
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />;
|
||||
},
|
||||
headerName: "目标判定",
|
||||
width: 110,
|
||||
renderCell: ({ value, row }) =>
|
||||
row.Role === "target" || row.Day === result.day_count ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={value ? "爆管异常" : "正常"}
|
||||
color={value ? "error" : "success"}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
参考
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
|
||||
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
|
||||
|
||||
return (
|
||||
<Box className="h-full overflow-auto p-1">
|
||||
<Box className="mb-4 space-y-3">
|
||||
{/* Status Banner */}
|
||||
<Box
|
||||
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected
|
||||
? "bg-red-50 border-red-100 text-red-900"
|
||||
: "bg-green-50 border-green-100 text-green-900"
|
||||
}`}
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||
isBurstDetected
|
||||
? "border-red-100 bg-red-50 text-red-900"
|
||||
: "border-green-100 bg-green-50 text-green-900"
|
||||
}`}
|
||||
>
|
||||
{isBurstDetected ? (
|
||||
<ErrorOutlineIcon className="text-red-600" />
|
||||
) : (
|
||||
<CheckCircleIcon className="text-green-600" />
|
||||
)}
|
||||
{isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
|
||||
<Box className="flex-1">
|
||||
<Typography variant="subtitle2" className="font-bold">
|
||||
{isBurstDetected
|
||||
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
|
||||
: "未侦测到爆管异常"}
|
||||
{isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="opacity-80">
|
||||
{isBurstDetected
|
||||
? "建议检查异常日期的压力波动情况"
|
||||
: "当前时间窗口内数据特征平稳,符合历史模式"}
|
||||
目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<Box className="flex items-center justify-between px-1">
|
||||
<Box className="flex items-center gap-2">
|
||||
<Box className="h-4 w-1 rounded-full bg-blue-600" />
|
||||
<Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}>
|
||||
{result.scheme_name || "爆管侦测结果"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-2">
|
||||
{result.username ? (
|
||||
<Chip
|
||||
label={result.username}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
backgroundColor: "#f3f4f6",
|
||||
color: "#4b5563",
|
||||
border: "none",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RoomIcon />}
|
||||
onClick={() =>
|
||||
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5))
|
||||
}
|
||||
sx={{
|
||||
height: 24,
|
||||
minWidth: 0,
|
||||
padding: "0 8px",
|
||||
borderColor: "#bfdbfe",
|
||||
color: "#2563eb",
|
||||
fontSize: "0.75rem",
|
||||
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
||||
}}
|
||||
>
|
||||
定位
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className="flex items-center justify-between gap-2 px-1">
|
||||
<Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
|
||||
爆管侦测结果
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RoomIcon />}
|
||||
onClick={() =>
|
||||
void locateSensors(
|
||||
result.summary.latest_sensor_rankings
|
||||
.slice(0, 5)
|
||||
.map((item) => item.sensor_node),
|
||||
)
|
||||
}
|
||||
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
|
||||
>
|
||||
定位异常测点
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Configuration Summary */}
|
||||
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
|
||||
<Box className="flex items-center gap-1.5">
|
||||
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||
<span className="font-medium text-gray-700">时间窗口:</span>
|
||||
<span className="font-mono text-gray-600">
|
||||
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
|
||||
</span>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-1.5">
|
||||
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
|
||||
<span className="font-medium text-gray-700">数据来源:</span>
|
||||
<span className="text-gray-600">
|
||||
{(() => {
|
||||
const ds = result.data_source;
|
||||
const os = result.observed_source;
|
||||
if (ds === "simulation") return "模拟数据";
|
||||
if (ds === "monitoring") return "监测数据";
|
||||
if (os === "simulation_scheme_timerange") return "模拟数据";
|
||||
if (os === "backend_timerange") return "监测数据";
|
||||
return os || "-";
|
||||
})()}
|
||||
</span>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<Box className="grid grid-cols-2 gap-3">
|
||||
<MetricCard
|
||||
label="异常天数"
|
||||
value={`${result.summary.anomaly_day_count} / ${result.day_count}`}
|
||||
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`}
|
||||
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"}
|
||||
label="目标异常分数"
|
||||
value={targetRow ? targetRow.Score.toFixed(4) : "-"}
|
||||
hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
|
||||
tone={isBurstDetected ? "orange" : "green"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="最异常日"
|
||||
value={
|
||||
result.summary.burst_detected && result.summary.most_anomalous_day
|
||||
? `第 ${result.summary.most_anomalous_day} 天`
|
||||
: "无"
|
||||
}
|
||||
hint={
|
||||
result.summary.burst_detected && mostAnomalousRow
|
||||
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
|
||||
: "-"
|
||||
}
|
||||
label="目标异常排名"
|
||||
value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
|
||||
hint="在目标日与 14 个参考日中排序"
|
||||
tone="purple"
|
||||
/>
|
||||
<MetricCard
|
||||
label="最新状态"
|
||||
value={latestLevel.label}
|
||||
hint={latestDay ? `第 ${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"}
|
||||
tone={latestLevel.color === "success" ? "green" : "orange"}
|
||||
label="参考区间"
|
||||
value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
|
||||
hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
|
||||
tone="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
label="测点 / 样本"
|
||||
value={`${result.sensor_nodes.length} / ${result.sample_count}`}
|
||||
hint={`每日采样点数:${result.points_per_day}`}
|
||||
label="有效 / 排除测点"
|
||||
value={`${result.sensor_nodes.length} / ${excludedCount}`}
|
||||
hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
|
||||
tone="blue"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Score Trend Chart */}
|
||||
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||
<Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<Box className="flex items-center gap-2">
|
||||
<ShowChartIcon className="h-5 w-5 text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||
异常分数趋势
|
||||
<ShowChartIcon className="text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold">
|
||||
15 天同刻异常分数
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。">
|
||||
<Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
|
||||
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ height: 250, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ height: 270, px: 1.5, py: 1 }}>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
onEvents={{
|
||||
click: (params: { data?: { value?: [number, number] } }) => {
|
||||
const day = params?.data?.value?.[0];
|
||||
if (typeof day === "number") {
|
||||
setSelectedDay(day);
|
||||
}
|
||||
},
|
||||
click: (params: { data?: { day?: number } }) =>
|
||||
setSelectedDay(params.data?.day ?? null),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Selected Day Interpretation */}
|
||||
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||
选中日解读
|
||||
</Typography>
|
||||
{selectedRow ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`第 ${selectedRow.Day} 天`}
|
||||
sx={{
|
||||
height: 22,
|
||||
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
||||
color: "#2563eb",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
border: "none",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
{selectedRow ? (
|
||||
<Box className="space-y-3 px-4 py-3">
|
||||
<Box className="flex items-center gap-2">
|
||||
<Chip
|
||||
label={getScoreLevel(selectedRow.Score).label}
|
||||
color={getScoreLevel(selectedRow.Score).color}
|
||||
variant="filled"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" className="text-gray-700">
|
||||
异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span>
|
||||
{rankingSeries.length > 0 ? (
|
||||
<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 px-4 py-3">
|
||||
<Typography variant="subtitle1" className="font-bold">
|
||||
目标测点压力偏离
|
||||
</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 variant="caption" color="text.secondary">
|
||||
负值越小,压降相对历史越明显
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" className="px-4 py-3 text-gray-500">
|
||||
请在趋势图或表格中选择一天查看详细解释。
|
||||
</Typography>
|
||||
)}
|
||||
</Box> */}
|
||||
<Box sx={{ height: 280, px: 1.5, py: 1 }}>
|
||||
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Latest Sensor Rankings */}
|
||||
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||
最新测点高频特征排名
|
||||
</Typography>
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
仅展示最新一天
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 260, px: 1.5, py: 1 }}>
|
||||
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
|
||||
</Box>
|
||||
<Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3">
|
||||
{result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
|
||||
<Button
|
||||
key={item.sensor_node}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => locateSensors([item.sensor_node])}
|
||||
sx={{
|
||||
borderColor: "#bfdbfe",
|
||||
color: "#2563eb",
|
||||
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
||||
}}
|
||||
>
|
||||
{item.sensor_node}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box> */}
|
||||
|
||||
{/* Results Table */}
|
||||
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||
<Box className="flex items-center gap-2">
|
||||
<FormatListBulleted className="h-5 w-5 text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||
结果表格
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${rows.length} 条`}
|
||||
sx={{
|
||||
height: 22,
|
||||
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
||||
color: "#2563eb",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
border: "none",
|
||||
}}
|
||||
/>
|
||||
<Box className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||
<FormatListBulleted className="text-blue-600" />
|
||||
<Typography variant="subtitle1" className="font-bold">
|
||||
同刻对照明细
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 320, px: 1, py: 1 }}>
|
||||
<Box sx={{ height: 360, px: 1, py: 1 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
rows={tableRows}
|
||||
columns={columns}
|
||||
columnBufferPx={100}
|
||||
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 50, page: 0 } },
|
||||
}}
|
||||
pageSizeOptions={[50]}
|
||||
hideFooterSelectedRowCount
|
||||
sx={{
|
||||
border: "none",
|
||||
"& .MuiDataGrid-cell": { borderColor: "#f0f0f0" },
|
||||
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" },
|
||||
"& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" },
|
||||
// Hide the rows per page selector since it's fixed to 50
|
||||
"& .MuiTablePagination-selectLabel": { display: "none" },
|
||||
"& .MuiTablePagination-input": { display: "none" },
|
||||
}}
|
||||
pageSizeOptions={[15]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
|
||||
disableRowSelectionOnClick
|
||||
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
|
||||
getRowClassName={(params) =>
|
||||
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
|
||||
}
|
||||
sx={{ border: "none" }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -23,6 +23,8 @@ import "dayjs/locale/zh-cn";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
BurstDetectionResult,
|
||||
BurstDetectionSchemeDetail,
|
||||
@@ -33,17 +35,53 @@ interface Props {
|
||||
onViewResult: (result: BurstDetectionResult) => void;
|
||||
schemes?: BurstDetectionSchemeRecord[];
|
||||
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
||||
state?: BurstDetectionSchemeQueryState;
|
||||
onStateChange?: (state: BurstDetectionSchemeQueryState) => void;
|
||||
}
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => {
|
||||
export interface BurstDetectionSchemeQueryState {
|
||||
queryAll: boolean;
|
||||
queryDate: Dayjs | null;
|
||||
expandedId: number | null;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createBurstDetectionSchemeQueryState =
|
||||
(): BurstDetectionSchemeQueryState => ({
|
||||
queryAll: true,
|
||||
queryDate: dayjs(),
|
||||
expandedId: null,
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({
|
||||
onViewResult,
|
||||
schemes: externalSchemes,
|
||||
onSchemesChange,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const [queryAll, setQueryAll] = useState(true);
|
||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
||||
const [queryState, , setQueryField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstDetectionSchemeQueryState(),
|
||||
);
|
||||
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||
const sortedSchemes = useMemo(
|
||||
() =>
|
||||
schemes
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||
),
|
||||
[schemes],
|
||||
);
|
||||
|
||||
const buildDisplayResult = (
|
||||
scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||
@@ -80,20 +118,29 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
username: payload?.username ?? scheme.username,
|
||||
create_time: payload?.create_time ?? scheme.create_time,
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: "burst_detection",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
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[];
|
||||
setSchemes(nextSchemes);
|
||||
setQueryField("hasQueried", true);
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
@@ -113,8 +160,8 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
const handleViewSchemeResult = async (schemeName: string) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { network: NETWORK_NAME } },
|
||||
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { scheme_type: "burst_detection" } },
|
||||
);
|
||||
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
||||
result_payload?: BurstDetectionResult;
|
||||
@@ -159,7 +206,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={queryAll}
|
||||
onChange={(event) => setQueryAll(event.target.checked)}
|
||||
onChange={(event) => {
|
||||
setQueryField("queryAll", event.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">查询全部</Typography>}
|
||||
@@ -168,7 +218,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={setQueryDate}
|
||||
onChange={(value) => {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
disabled={queryAll}
|
||||
format="YYYY-MM-DD"
|
||||
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
|
||||
@@ -189,19 +242,14 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
</Box>
|
||||
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{schemes.length === 0 ? (
|
||||
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
|
||||
<Typography variant="body2">暂无侦测方案</Typography>
|
||||
<Typography variant="caption" className="mt-1">
|
||||
运行一次展示版侦测后,可在这里回看历史结果。
|
||||
</Typography>
|
||||
</Box>
|
||||
{sortedSchemes.length === 0 ? (
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="px-2 text-gray-500">
|
||||
共 {schemes.length} 条记录
|
||||
共 {sortedSchemes.length} 条记录
|
||||
</Typography>
|
||||
{schemes.map((scheme) => {
|
||||
{sortedSchemes.map((scheme) => {
|
||||
const summary = scheme.scheme_detail?.result_summary;
|
||||
const payload = scheme.scheme_detail?.result_payload;
|
||||
const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false;
|
||||
@@ -210,6 +258,9 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
const mostAnomalousDay =
|
||||
payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
|
||||
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 (
|
||||
<Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md">
|
||||
@@ -241,7 +292,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)
|
||||
setQueryField(
|
||||
"expandedId",
|
||||
expandedId === scheme.scheme_id ? null : scheme.scheme_id,
|
||||
)
|
||||
}
|
||||
color="primary"
|
||||
className="p-1"
|
||||
@@ -255,30 +309,36 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
<Box className="grid grid-cols-3 gap-2">
|
||||
<Box className="rounded bg-gray-50 p-2">
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
异常天数
|
||||
{targetTime ? "目标时刻" : "异常天数"}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||
{anomalyDayCount}
|
||||
{targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="rounded bg-gray-50 p-2">
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
最异常日
|
||||
{targetTime ? "目标分数" : "最异常日"}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||
{isBurst
|
||||
? typeof mostAnomalousDay === "number"
|
||||
? `第 ${mostAnomalousDay} 天`
|
||||
: mostAnomalousDay
|
||||
: "无"}
|
||||
{targetTime
|
||||
? typeof targetScore === "number"
|
||||
? targetScore.toFixed(4)
|
||||
: "-"
|
||||
: isBurst
|
||||
? typeof mostAnomalousDay === "number"
|
||||
? `第 ${mostAnomalousDay} 天`
|
||||
: mostAnomalousDay
|
||||
: "无"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="rounded bg-gray-50 p-2">
|
||||
<Typography variant="caption" className="text-gray-500">
|
||||
测点数
|
||||
{targetTime ? "异常排名" : "测点数"}
|
||||
</Typography>
|
||||
<Typography variant="body2" className="font-semibold text-gray-900">
|
||||
{sensorCount}
|
||||
{targetTime && targetRank
|
||||
? `${targetRank} / ${payload?.day_count ?? 15}`
|
||||
: sensorCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -298,6 +358,8 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
if (ds === "monitoring") return "监测数据";
|
||||
if (os === "simulation_scheme_timerange") return "模拟数据";
|
||||
if (os === "backend_timerange") return "监测数据";
|
||||
if (os === "latest_monitoring") return "最新监测数据";
|
||||
if (os === "historical_monitoring") return "历史监测回放";
|
||||
return os || "-";
|
||||
})()}
|
||||
</Typography>
|
||||
@@ -316,14 +378,16 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
</Box>
|
||||
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
||||
<Typography variant="caption" className="text-gray-600">
|
||||
算法参数:
|
||||
侦测口径:
|
||||
</Typography>
|
||||
<Typography variant="caption" className="font-medium text-gray-900">
|
||||
频域截断系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"}
|
||||
,每日采样点数:
|
||||
频域系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"}
|
||||
,每日采样:
|
||||
{scheme.scheme_detail?.algorithm_params?.points_per_day ??
|
||||
payload?.algorithm_params?.points_per_day ??
|
||||
"-"}
|
||||
点,阈值:
|
||||
{payload?.summary?.score_threshold ?? "-"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -3,11 +3,16 @@ export interface BurstDetectionRow {
|
||||
Score: number;
|
||||
Prediction: number;
|
||||
IsBurst: boolean;
|
||||
Timestamp?: string;
|
||||
Role?: "reference" | "target";
|
||||
}
|
||||
|
||||
export interface BurstDetectionSensorRanking {
|
||||
sensor_node: string;
|
||||
latest_high_frequency_value: number;
|
||||
historical_mean?: number;
|
||||
historical_std?: number;
|
||||
standardized_deviation?: number;
|
||||
}
|
||||
|
||||
export interface BurstDetectionSummary {
|
||||
@@ -17,6 +22,11 @@ export interface BurstDetectionSummary {
|
||||
anomaly_days: number[];
|
||||
anomaly_day_count: number;
|
||||
latest_sensor_rankings: BurstDetectionSensorRanking[];
|
||||
target_score?: number;
|
||||
score_threshold?: number;
|
||||
target_rank?: number;
|
||||
target_time?: string;
|
||||
reference_day_count?: number;
|
||||
}
|
||||
|
||||
export interface BurstDetectionAlgorithmParams {
|
||||
@@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams {
|
||||
contamination?: number | "auto";
|
||||
random_state?: number;
|
||||
};
|
||||
score_threshold?: number;
|
||||
}
|
||||
|
||||
export interface BurstDetectionResult {
|
||||
@@ -51,6 +62,25 @@ export interface BurstDetectionResult {
|
||||
type?: string;
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useState } from "react";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
@@ -24,14 +25,18 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||
import { BurstLocationResult } from "./types";
|
||||
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstLocationResult) => void;
|
||||
state?: BurstLocationAnalysisParametersState;
|
||||
onStateChange?: (state: BurstLocationAnalysisParametersState) => void;
|
||||
}
|
||||
|
||||
interface SchemeItem {
|
||||
export interface SchemeItem {
|
||||
scheme_id: number;
|
||||
scheme_name: string;
|
||||
scheme_type: string;
|
||||
@@ -39,29 +44,66 @@ interface SchemeItem {
|
||||
scheme_start_time: string;
|
||||
scheme_detail?: {
|
||||
modify_total_duration: number;
|
||||
burst_ID?: string[] | string;
|
||||
};
|
||||
}
|
||||
|
||||
type DataSource = "monitoring" | "simulation";
|
||||
|
||||
const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
export interface BurstLocationAnalysisParametersState {
|
||||
schemeName: string;
|
||||
dataSource: DataSource;
|
||||
schemes: SchemeItem[];
|
||||
selectedSchemeId: number | "";
|
||||
burstLeakage: number;
|
||||
enableFlow: boolean;
|
||||
burstStartTime: Dayjs | null;
|
||||
burstEndTime: Dayjs | null;
|
||||
minDpressure: number;
|
||||
basicPressure: number;
|
||||
advancedOpen: boolean;
|
||||
}
|
||||
|
||||
export const createBurstLocationAnalysisParametersState =
|
||||
(): BurstLocationAnalysisParametersState => ({
|
||||
schemeName: `Burst_Locate_${Date.now()}`,
|
||||
dataSource: "monitoring",
|
||||
schemes: [],
|
||||
selectedSchemeId: "",
|
||||
burstLeakage: 1440,
|
||||
enableFlow: false,
|
||||
burstStartTime: dayjs().subtract(20, "minute"),
|
||||
burstEndTime: dayjs().subtract(5, "minute"),
|
||||
minDpressure: 2,
|
||||
basicPressure: 10,
|
||||
advancedOpen: false,
|
||||
});
|
||||
|
||||
const AnalysisParameters: React.FC<Props> = ({
|
||||
onResult,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const [schemeName, setSchemeName] = useState(`Burst_Locate_${Date.now()}`);
|
||||
const [dataSource, setDataSource] = useState<DataSource>("monitoring");
|
||||
const [schemes, setSchemes] = useState<SchemeItem[]>([]);
|
||||
const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">("");
|
||||
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstLocationAnalysisParametersState(),
|
||||
);
|
||||
const {
|
||||
schemeName,
|
||||
dataSource,
|
||||
schemes,
|
||||
selectedSchemeId,
|
||||
burstLeakage,
|
||||
enableFlow,
|
||||
burstStartTime,
|
||||
burstEndTime,
|
||||
minDpressure,
|
||||
basicPressure,
|
||||
advancedOpen,
|
||||
} = parametersState;
|
||||
const [schemeLoading, setSchemeLoading] = useState(false);
|
||||
const [burstLeakage, setBurstLeakage] = useState<number>(1440);
|
||||
const [enableFlow, setEnableFlow] = useState(false);
|
||||
const [burstStartTime, setBurstStartTime] = useState<Dayjs | null>(
|
||||
dayjs().subtract(20, "minute"),
|
||||
);
|
||||
const [burstEndTime, setBurstEndTime] = useState<Dayjs | null>(
|
||||
dayjs().subtract(5, "minute"),
|
||||
);
|
||||
const [minDpressure, setMinDpressure] = useState<number>(2);
|
||||
const [basicPressure, setBasicPressure] = useState<number>(10);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const isSimulationMode = dataSource === "simulation";
|
||||
|
||||
@@ -70,9 +112,12 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
|
||||
const end = start.add(durationSeconds, "second");
|
||||
|
||||
setBurstStartTime(start);
|
||||
setBurstEndTime(end);
|
||||
}, []);
|
||||
setParametersState((previous) => ({
|
||||
...previous,
|
||||
burstStartTime: start,
|
||||
burstEndTime: end,
|
||||
}));
|
||||
}, [setParametersState]);
|
||||
|
||||
const fetchSchemes = useCallback(
|
||||
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
|
||||
@@ -81,13 +126,16 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
setSchemeLoading(true);
|
||||
try {
|
||||
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(
|
||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||
).sort(
|
||||
(a, b) =>
|
||||
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||
);
|
||||
|
||||
setSchemes(burstSchemes);
|
||||
setFormField("schemes", burstSchemes);
|
||||
|
||||
if (selectedSchemeId) {
|
||||
const matchedScheme = burstSchemes.find(
|
||||
@@ -96,7 +144,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
if (matchedScheme) {
|
||||
applySchemeTimeRange(matchedScheme);
|
||||
} else {
|
||||
setSelectedSchemeId("");
|
||||
setFormField("selectedSchemeId", "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,18 +166,18 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
setSchemeLoading(false);
|
||||
}
|
||||
},
|
||||
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId],
|
||||
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField],
|
||||
);
|
||||
|
||||
const handleDataSourceChange = (value: DataSource) => {
|
||||
setDataSource(value);
|
||||
setFormField("dataSource", value);
|
||||
if (value === "simulation") {
|
||||
void fetchSchemes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchemeSelect = (schemeId: number) => {
|
||||
setSelectedSchemeId(schemeId);
|
||||
setFormField("selectedSchemeId", schemeId);
|
||||
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
||||
if (scheme) {
|
||||
applySchemeTimeRange(scheme);
|
||||
@@ -175,9 +223,8 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
: undefined;
|
||||
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
|
||||
`${config.BACKEND_URL}/api/v1/burst-locations`,
|
||||
{
|
||||
network: NETWORK_NAME,
|
||||
data_source: dataSource,
|
||||
scheme_name: schemeName.trim() || undefined,
|
||||
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
||||
@@ -191,7 +238,24 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
},
|
||||
);
|
||||
|
||||
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?.({
|
||||
key: "burst-location-analysis-success",
|
||||
type: "success",
|
||||
@@ -199,11 +263,12 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const notice = getBurstLocationErrorNotice(error);
|
||||
open?.({
|
||||
key: "burst-location-analysis-error",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
|
||||
message: notice.message,
|
||||
description: notice.description,
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -219,7 +284,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
</Typography>
|
||||
<TextField
|
||||
value={schemeName}
|
||||
onChange={(e) => setSchemeName(e.target.value)}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
fullWidth
|
||||
size="small"
|
||||
@@ -239,6 +304,34 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
<MenuItem value="simulation">模拟方案</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert
|
||||
severity={isSimulationMode ? "warning" : "info"}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mt: 1,
|
||||
alignItems: "flex-start",
|
||||
borderColor: isSimulationMode ? "warning.light" : "info.light",
|
||||
backgroundColor: isSimulationMode ? "#fff8e1" : "#eff6ff",
|
||||
"& .MuiAlert-message": { width: "100%" },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="block font-semibold"
|
||||
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
||||
>
|
||||
{isSimulationMode ? "模拟方案分析前置条件" : "数据选择规则"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="block leading-5"
|
||||
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
||||
>
|
||||
{isSimulationMode
|
||||
? "爆管数据取所选方案,正常数据取同一时间窗的实时模拟结果。所选时间窗必须已有实时模拟正常基线,否则无法分析;系统不会自动补数据。"
|
||||
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
|
||||
{isSimulationMode && (
|
||||
@@ -300,7 +393,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={burstStartTime}
|
||||
onChange={setBurstStartTime}
|
||||
onChange={(value) => setFormField("burstStartTime", value)}
|
||||
maxDateTime={burstEndTime ?? undefined}
|
||||
disabled={isSimulationMode}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
@@ -313,7 +406,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={burstEndTime}
|
||||
onChange={setBurstEndTime}
|
||||
onChange={(value) => setFormField("burstEndTime", value)}
|
||||
minDateTime={burstStartTime ?? undefined}
|
||||
disabled={isSimulationMode}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
@@ -333,7 +426,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
value={burstLeakage}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setBurstLeakage(Number.isNaN(value) ? 1440 : Math.max(0, value));
|
||||
setFormField("burstLeakage", Number.isNaN(value) ? 1440 : Math.max(0, value));
|
||||
}}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: 10 }}
|
||||
@@ -349,9 +442,9 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
onClick={() => setFormField("advancedOpen", !advancedOpen)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setAdvancedOpen((prev) => !prev);
|
||||
if (e.key === "Enter" || e.key === " ") setFormField("advancedOpen", !advancedOpen);
|
||||
}}
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -391,7 +484,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={enableFlow ? "enabled" : "disabled"}
|
||||
onChange={(e) => setEnableFlow(e.target.value === "enabled")}
|
||||
onChange={(e) => setFormField("enableFlow", e.target.value === "enabled")}
|
||||
>
|
||||
<MenuItem value="disabled">禁用</MenuItem>
|
||||
<MenuItem value="enabled">启用(使用流量计)</MenuItem>
|
||||
@@ -404,14 +497,14 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
label="最小压降 (m)"
|
||||
size="small"
|
||||
value={minDpressure}
|
||||
onChange={(e) => setMinDpressure(Number(e.target.value))}
|
||||
onChange={(e) => setFormField("minDpressure", Number(e.target.value))}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="基础压力 (m)"
|
||||
size="small"
|
||||
value={basicPressure}
|
||||
onChange={(e) => setBasicPressure(Number(e.target.value))}
|
||||
onChange={(e) => setFormField("basicPressure", Number(e.target.value))}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -437,3 +530,11 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
};
|
||||
|
||||
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)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,9 +9,15 @@ import {
|
||||
FormatListBulleted,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
import AnalysisParameters from "./AnalysisParameters";
|
||||
import AnalysisParameters, {
|
||||
createBurstLocationAnalysisParametersState,
|
||||
type BurstLocationAnalysisParametersState,
|
||||
} from "./AnalysisParameters";
|
||||
import LocationResults from "./LocationResults";
|
||||
import SchemeQuery from "./SchemeQuery";
|
||||
import SchemeQuery, {
|
||||
createBurstLocationSchemeQueryState,
|
||||
type BurstLocationSchemeQueryState,
|
||||
} from "./SchemeQuery";
|
||||
import { BurstLocationResult, BurstSchemeRecord } from "./types";
|
||||
|
||||
const TabPanel = ({
|
||||
@@ -33,6 +39,14 @@ const BurstLocationPanel: React.FC = () => {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
||||
const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||
const [analysisState, setAnalysisState] =
|
||||
useState<BurstLocationAnalysisParametersState>(
|
||||
createBurstLocationAnalysisParametersState,
|
||||
);
|
||||
const [queryState, setQueryState] =
|
||||
useState<BurstLocationSchemeQueryState>(
|
||||
createBurstLocationSchemeQueryState,
|
||||
);
|
||||
|
||||
const drawerWidth = 450;
|
||||
const panelTitle = "爆管定位";
|
||||
@@ -146,10 +160,20 @@ const BurstLocationPanel: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<AnalysisParameters onResult={handleResult} />
|
||||
<AnalysisParameters
|
||||
onResult={handleResult}
|
||||
state={analysisState}
|
||||
onStateChange={setAnalysisState}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={1}>
|
||||
<SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} />
|
||||
<SchemeQuery
|
||||
onViewResult={handleViewResult}
|
||||
schemes={schemes}
|
||||
onSchemesChange={setSchemes}
|
||||
state={queryState}
|
||||
onStateChange={setQueryState}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={2}>
|
||||
<LocationResults result={result} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
Button,
|
||||
Link,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
FormatListBulleted,
|
||||
@@ -30,6 +31,7 @@ import { Stroke, Style, Circle, Fill } from "ol/style";
|
||||
import { bbox, featureCollection } from "@turf/turf";
|
||||
import { BurstCandidate, BurstLocationResult } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
result: BurstLocationResult | null;
|
||||
@@ -75,6 +77,33 @@ const toneStyles: Record<
|
||||
const formatDateTime = (value?: string) =>
|
||||
value ? dayjs(value).format("MM-DD HH:mm") : "-";
|
||||
|
||||
const formatDateTimeRange = (start?: string, end?: string) => {
|
||||
if (!start || !end) return "-";
|
||||
return `${formatDateTime(start)} 至 ${formatDateTime(end)}`;
|
||||
};
|
||||
|
||||
const getDataSourceLabel = (result: BurstLocationResult) =>
|
||||
result.data_source === "simulation" ? "模拟方案" : "监测数据";
|
||||
|
||||
const getNormalDataDescription = (result: BurstLocationResult) => {
|
||||
switch (result.observed_source) {
|
||||
case "simulation_scheme_burst_realtime_normal_timerange":
|
||||
return "正常数据读取实时模拟结果,与爆管数据使用同一时间窗。";
|
||||
case "scada_burst_scada_normal_timerange":
|
||||
return "正常数据读取监测数据;未单独指定正常时间窗时,默认使用爆管时段前一天同一时段。";
|
||||
case "scada_burst_payload_normal_timerange":
|
||||
return "爆管数据读取监测数据,正常数据来自请求载荷。";
|
||||
case "simulation_scheme_timerange":
|
||||
return "历史方案记录:爆管数据和正常数据均读取方案模拟结果。";
|
||||
case "scada_timerange":
|
||||
return "历史方案记录:爆管数据和正常数据使用同一监测时间窗。";
|
||||
case "request_payload":
|
||||
return "爆管数据和正常数据均来自请求载荷。";
|
||||
default:
|
||||
return "正常数据按后端返回的数据源规则选择。";
|
||||
}
|
||||
};
|
||||
|
||||
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
||||
const style = toneStyles[tone];
|
||||
return (
|
||||
@@ -100,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
||||
};
|
||||
|
||||
const EmptyState = () => (
|
||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
||||
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||
</Box>
|
||||
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
||||
等待定位结果
|
||||
</Typography>
|
||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||
请先提交爆管定位分析,结果面板将展示定位摘要、时间窗、采样情况和候选管段。
|
||||
</Typography>
|
||||
</Box>
|
||||
<PanelEmptyState
|
||||
icon={<MapIcon />}
|
||||
title="尚未生成定位结果"
|
||||
description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||
/>
|
||||
);
|
||||
|
||||
const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
@@ -156,6 +179,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
properties: {
|
||||
name: "爆管定位高亮",
|
||||
value: "burst_location_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
map.addLayer(layer);
|
||||
@@ -214,6 +238,17 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
const burstTime = result.scada_window?.burst_start
|
||||
? formatDateTime(result.scada_window.burst_start)
|
||||
: "-";
|
||||
const burstWindow = formatDateTimeRange(
|
||||
result.scada_window?.burst_start,
|
||||
result.scada_window?.burst_end,
|
||||
);
|
||||
const normalWindow = formatDateTimeRange(
|
||||
result.scada_window?.normal_start,
|
||||
result.scada_window?.normal_end,
|
||||
);
|
||||
const sourceLabel = getDataSourceLabel(result);
|
||||
const normalDataDescription = getNormalDataDescription(result);
|
||||
const simulationBurstIds = result.simulation_scheme?.burst_ids ?? [];
|
||||
|
||||
return (
|
||||
<Box className="h-full overflow-auto p-1">
|
||||
@@ -250,6 +285,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
variant="outlined"
|
||||
startIcon={<LocationOnIcon />}
|
||||
onClick={() => locatePipes([result.located_pipe])}
|
||||
disabled={!result.located_pipe}
|
||||
sx={{
|
||||
height: 24,
|
||||
minWidth: 0,
|
||||
@@ -287,6 +323,80 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
||||
tone="green"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<Box className="mb-1 flex flex-wrap items-center gap-1.5">
|
||||
<Chip
|
||||
size="small"
|
||||
label={sourceLabel}
|
||||
color={result.data_source === "simulation" ? "secondary" : "primary"}
|
||||
sx={{ height: 22, fontSize: "0.72rem", fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="caption" className="text-gray-600">
|
||||
正常数据选择说明
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" className="block leading-5 text-gray-700">
|
||||
{normalDataDescription}
|
||||
</Typography>
|
||||
<Typography variant="caption" className="mt-1 block leading-5 text-gray-500">
|
||||
爆管窗口: {burstWindow};正常窗口: {normalWindow}
|
||||
</Typography>
|
||||
{result.data_source === "simulation" && result.simulation_scheme?.name ? (
|
||||
<Typography variant="caption" className="mt-1 block truncate text-purple-600">
|
||||
爆管方案: {result.simulation_scheme.name}
|
||||
</Typography>
|
||||
) : 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>
|
||||
|
||||
{/* Candidate List */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -13,8 +13,12 @@ import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Link,
|
||||
} 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 { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
@@ -23,28 +27,169 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
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 {
|
||||
BurstLocationResult,
|
||||
BurstLocationSchemeDetail,
|
||||
BurstSchemeRecord,
|
||||
} from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
onViewResult: (result: BurstLocationResult) => void;
|
||||
schemes?: BurstSchemeRecord[];
|
||||
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
|
||||
state?: BurstLocationSchemeQueryState;
|
||||
onStateChange?: (state: BurstLocationSchemeQueryState) => void;
|
||||
}
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => {
|
||||
export interface BurstLocationSchemeQueryState {
|
||||
queryAll: boolean;
|
||||
queryDate: Dayjs | null;
|
||||
expandedId: number | null;
|
||||
simulationBurstIdsByName: Record<string, string[]>;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createBurstLocationSchemeQueryState =
|
||||
(): BurstLocationSchemeQueryState => ({
|
||||
queryAll: true,
|
||||
queryDate: dayjs(),
|
||||
expandedId: null,
|
||||
simulationBurstIdsByName: {},
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<Props> = ({
|
||||
onViewResult,
|
||||
schemes: externalSchemes,
|
||||
onSchemesChange,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const [queryAll, setQueryAll] = useState(true);
|
||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
||||
const map = useMap();
|
||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const [queryState, , setQueryField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstLocationSchemeQueryState(),
|
||||
);
|
||||
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
|
||||
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const creatorName = useSchemeCreatorName();
|
||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||
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 = (
|
||||
scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">,
|
||||
@@ -82,17 +227,38 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
const handleQuery = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// API call to fetch schemes
|
||||
// Adjust URL as needed
|
||||
let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`;
|
||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
||||
const params: Record<string, string> = {
|
||||
scheme_type: "burst_location",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
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[];
|
||||
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?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
@@ -113,8 +279,8 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
const handleViewSchemeResult = async (schemeName: string) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { network: NETWORK_NAME } },
|
||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { scheme_type: "burst_location" } },
|
||||
);
|
||||
const schemeRecord = response.data as BurstSchemeRecord & {
|
||||
result_payload?: BurstLocationResult;
|
||||
@@ -132,7 +298,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
if (!normalizedResult) {
|
||||
throw new Error("方案详情缺少定位结果数据");
|
||||
}
|
||||
onViewResult(normalizedResult);
|
||||
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "方案加载成功",
|
||||
@@ -157,7 +323,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={queryAll}
|
||||
onChange={(e) => setQueryAll(e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setQueryField("queryAll", e.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">查询全部</Typography>}
|
||||
@@ -166,7 +335,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={setQueryDate}
|
||||
onChange={(value) => {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
disabled={queryAll}
|
||||
format="YYYY-MM-DD"
|
||||
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
|
||||
@@ -186,49 +358,18 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{schemes.length === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">总共 0 条</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
No data
|
||||
</Typography>
|
||||
</Box>
|
||||
{sortedSchemes.length === 0 ? (
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
共 {schemes.length} 条记录
|
||||
共 {sortedSchemes.length} 条记录
|
||||
</Typography>
|
||||
{schemes.map((scheme) => {
|
||||
{sortedSchemes.map((scheme) => {
|
||||
const summary = scheme.scheme_detail?.result_summary;
|
||||
const payload = scheme.scheme_detail?.result_payload;
|
||||
const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-";
|
||||
const simulationBurstIds = getSimulationBurstIds(payload);
|
||||
const leakage =
|
||||
payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage;
|
||||
|
||||
@@ -281,7 +422,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)
|
||||
setQueryField(
|
||||
"expandedId",
|
||||
expandedId === scheme.scheme_id ? null : scheme.scheme_id,
|
||||
)
|
||||
}
|
||||
color="primary"
|
||||
className="p-1"
|
||||
@@ -302,6 +446,52 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
{locatedPipe}
|
||||
</Typography>
|
||||
</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">
|
||||
<Typography variant="caption" className="text-gray-600">
|
||||
漏损量:
|
||||
@@ -315,7 +505,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
用户:
|
||||
</Typography>
|
||||
<Typography variant="caption" className="font-medium text-gray-900">
|
||||
{scheme.username || "-"}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -345,3 +535,46 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
|
||||
};
|
||||
|
||||
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}。请选择数据完整的模拟方案。`,
|
||||
};
|
||||
};
|
||||
@@ -23,6 +23,8 @@ export interface BurstLocationResult {
|
||||
scada_window?: {
|
||||
burst_start?: string;
|
||||
burst_end?: string;
|
||||
normal_start?: string;
|
||||
normal_end?: string;
|
||||
};
|
||||
pressure_samples?: {
|
||||
burst?: number;
|
||||
@@ -35,6 +37,7 @@ export interface BurstLocationResult {
|
||||
simulation_scheme?: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
burst_ids?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +54,8 @@ export interface BurstLocationSchemeDetail {
|
||||
scada_window?: {
|
||||
burst_start?: string;
|
||||
burst_end?: string;
|
||||
normal_start?: string;
|
||||
normal_end?: string;
|
||||
};
|
||||
result_summary?: {
|
||||
located_pipe?: string;
|
||||
|
||||
@@ -20,33 +20,60 @@ import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { Style, Stroke, Icon } from "ol/style";
|
||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||
import {
|
||||
handleMapClickSelectFeatures as mapClickSelectFeatures,
|
||||
queryFeaturesByIds,
|
||||
} from "@/utils/mapQueryService";
|
||||
import Feature, { FeatureLike } from "ol/Feature";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { along, lineString, length, toMercator } from "@turf/turf";
|
||||
import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
|
||||
interface PipePoint {
|
||||
export interface PipePoint {
|
||||
id: string;
|
||||
diameter: number;
|
||||
area: number;
|
||||
feature?: any; // 存储管道要素用于高亮
|
||||
}
|
||||
|
||||
const AnalysisParameters: React.FC = () => {
|
||||
export interface BurstAnalysisParametersState {
|
||||
pipePoints: PipePoint[];
|
||||
startTime: Dayjs | null;
|
||||
duration: number;
|
||||
schemeName: string;
|
||||
network: string;
|
||||
}
|
||||
|
||||
export const createBurstAnalysisParametersState = (): BurstAnalysisParametersState => ({
|
||||
pipePoints: [],
|
||||
startTime: dayjs(new Date()),
|
||||
duration: 3600,
|
||||
schemeName: "FANGAN" + new Date().getTime(),
|
||||
network: NETWORK_NAME,
|
||||
});
|
||||
|
||||
interface AnalysisParametersProps {
|
||||
state?: BurstAnalysisParametersState;
|
||||
onStateChange?: (state: BurstAnalysisParametersState) => void;
|
||||
}
|
||||
|
||||
const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const map = useMap();
|
||||
const { open } = useNotification();
|
||||
|
||||
const [pipePoints, setPipePoints] = useState<PipePoint[]>([]);
|
||||
const [startTime, setStartTime] = useState<Dayjs | null>(dayjs(new Date()));
|
||||
const [duration, setDuration] = useState<number>(3600);
|
||||
const [schemeName, setSchemeName] = useState<string>(
|
||||
"FANGAN" + new Date().getTime(),
|
||||
const [parametersState, setParametersState, setParameterField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstAnalysisParametersState(),
|
||||
);
|
||||
const [network, setNetwork] = useState<string>(NETWORK_NAME);
|
||||
const { pipePoints, startTime, duration, schemeName, network } =
|
||||
parametersState;
|
||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
@@ -54,6 +81,17 @@ const AnalysisParameters: React.FC = () => {
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||
|
||||
const setPipePoints = useCallback(
|
||||
(next: PipePoint[] | ((previous: PipePoint[]) => PipePoint[])) => {
|
||||
setParametersState((previous) => ({
|
||||
...previous,
|
||||
pipePoints:
|
||||
typeof next === "function" ? next(previous.pipePoints) : next,
|
||||
}));
|
||||
},
|
||||
[setParametersState],
|
||||
);
|
||||
|
||||
// 检查是否所有必要参数都已填写
|
||||
const isFormValid =
|
||||
pipePoints.length > 0 &&
|
||||
@@ -160,6 +198,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
properties: {
|
||||
name: "高亮管道",
|
||||
value: "highlight_pipeline",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -190,6 +229,18 @@ const AnalysisParameters: React.FC = () => {
|
||||
});
|
||||
}, [highlightFeatures, highlightLayer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightFeatures.length > 0 || pipePoints.length === 0) return;
|
||||
queryFeaturesByIds(
|
||||
pipePoints.map((pipe) => pipe.id),
|
||||
"geo_pipes_mat",
|
||||
).then((features) => {
|
||||
if (features.length > 0) {
|
||||
setHighlightFeatures(features);
|
||||
}
|
||||
});
|
||||
}, [highlightFeatures.length, pipePoints]);
|
||||
|
||||
// 同步高亮要素和爆管点信息
|
||||
useEffect(() => {
|
||||
setPipePoints((prevPipes) => {
|
||||
@@ -211,12 +262,11 @@ const AnalysisParameters: React.FC = () => {
|
||||
id: properties.id,
|
||||
diameter: properties.diameter || 0,
|
||||
area: 15,
|
||||
feature: feature,
|
||||
};
|
||||
});
|
||||
return [...filtered, ...newPipes];
|
||||
});
|
||||
}, [highlightFeatures]);
|
||||
}, [highlightFeatures, setPipePoints]);
|
||||
|
||||
// 开始选择管道
|
||||
const handleStartSelection = () => {
|
||||
@@ -237,6 +287,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRemovePipe = (id: string) => {
|
||||
setPipePoints((prev) => prev.filter((pipe) => pipe.id !== id));
|
||||
// 从高亮features中移除
|
||||
setHighlightFeatures((prev) =>
|
||||
prev.filter((f) => f.getProperties().id !== id),
|
||||
@@ -271,16 +322,15 @@ const AnalysisParameters: React.FC = () => {
|
||||
: "";
|
||||
const modify_total_duration = duration;
|
||||
const params = {
|
||||
network: network,
|
||||
modify_pattern_start_time: modify_pattern_start_time,
|
||||
burst_ID: burst_ID,
|
||||
burst_id: burst_ID,
|
||||
burst_size: burst_size,
|
||||
modify_total_duration: modify_total_duration,
|
||||
scheme_name: schemeName,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
|
||||
await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
||||
@@ -425,7 +475,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
<DateTimePicker
|
||||
value={startTime}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setStartTime(value)
|
||||
value && dayjs.isDayjs(value) && setParameterField("startTime", value)
|
||||
}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{
|
||||
@@ -452,7 +502,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
size="small"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => setParameterField("duration", parseInt(e.target.value) || 0)}
|
||||
placeholder="输入持续时长"
|
||||
/>
|
||||
</Box>
|
||||
@@ -466,7 +516,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
fullWidth
|
||||
size="small"
|
||||
value={schemeName}
|
||||
onChange={(e) => setSchemeName(e.target.value)}
|
||||
onChange={(e) => setParameterField("schemeName", e.target.value)}
|
||||
placeholder="输入方案名称"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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,17 +15,23 @@ import {
|
||||
ChevronLeft,
|
||||
Analytics as AnalyticsIcon,
|
||||
Search as SearchIcon,
|
||||
MyLocation as MyLocationIcon,
|
||||
DescriptionOutlined as ReportIcon,
|
||||
Handyman as HandymanIcon,
|
||||
} from "@mui/icons-material";
|
||||
import AnalysisParameters from "./AnalysisParameters";
|
||||
import SchemeQuery from "./SchemeQuery";
|
||||
import LocationResults from "./LocationResults";
|
||||
import ValveIsolation from "./ValveIsolation";
|
||||
import { api } from "@/lib/api";
|
||||
import { config } from "@config/config";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
|
||||
import AnalysisParameters, {
|
||||
createBurstAnalysisParametersState,
|
||||
type BurstAnalysisParametersState,
|
||||
} from "./AnalysisParameters";
|
||||
import SchemeQuery, {
|
||||
createBurstSchemeQueryState,
|
||||
type BurstSchemeQueryState,
|
||||
} from "./SchemeQuery";
|
||||
import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
|
||||
import ValveIsolation, {
|
||||
createValveIsolationState,
|
||||
type ValveIsolationState,
|
||||
} from "./ValveIsolation";
|
||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -58,16 +64,23 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(true);
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const [analysisState, setAnalysisState] =
|
||||
useState<BurstAnalysisParametersState>(createBurstAnalysisParametersState);
|
||||
const [queryState, setQueryState] = useState<BurstSchemeQueryState>(
|
||||
createBurstSchemeQueryState,
|
||||
);
|
||||
|
||||
// 持久化方案查询结果
|
||||
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||
// 定位结果数据
|
||||
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
|
||||
const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
|
||||
const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
|
||||
// 关阀分析结果和加载状态
|
||||
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
||||
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
||||
|
||||
const { open } = useNotification();
|
||||
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
|
||||
useState<string | null>(null);
|
||||
const [valveIsolationState, setValveIsolationState] =
|
||||
useState<ValveIsolationState>(createValveIsolationState);
|
||||
|
||||
// 使用受控或非受控状态
|
||||
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
@@ -83,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
setCurrentTab(newValue);
|
||||
};
|
||||
|
||||
const handleLocateScheme = async (scheme: SchemeRecord) => {
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
|
||||
);
|
||||
setLocationResults(response.data);
|
||||
setCurrentTab(2); // 切换到定位结果标签页
|
||||
} catch (error) {
|
||||
console.error("获取定位结果失败:", error);
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "获取定位结果失败",
|
||||
description: "无法从服务器获取该方案的定位结果",
|
||||
});
|
||||
const handleViewReport = (scheme: SchemeRecord) => {
|
||||
if (
|
||||
valveAnalysisSchemeName !== scheme.schemeName ||
|
||||
!matchesValveAnalysis(scheme, valveAnalysisResult)
|
||||
) {
|
||||
setValveAnalysisResult(null);
|
||||
setValveAnalysisSchemeName(null);
|
||||
setValveIsolationState(createValveIsolationState());
|
||||
}
|
||||
setReportScheme(scheme);
|
||||
setReportGeneratedAt(new Date());
|
||||
setCurrentTab(2);
|
||||
};
|
||||
|
||||
const handleValveAnalysisResultChange = (
|
||||
result: ValveIsolationResult | null,
|
||||
) => {
|
||||
setValveAnalysisResult(result);
|
||||
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
|
||||
};
|
||||
|
||||
const drawerWidth = 520;
|
||||
@@ -210,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
label="方案查询"
|
||||
/>
|
||||
<Tab
|
||||
icon={<MyLocationIcon fontSize="small" />}
|
||||
icon={<ReportIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label="定位结果"
|
||||
label="分析报告"
|
||||
/>
|
||||
<Tab
|
||||
icon={<HandymanIcon fontSize="small" />}
|
||||
@@ -224,20 +241,28 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<TabPanel value={currentTab} index={0}>
|
||||
<AnalysisParameters />
|
||||
<AnalysisParameters
|
||||
state={analysisState}
|
||||
onStateChange={setAnalysisState}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={currentTab} index={1}>
|
||||
<SchemeQuery
|
||||
schemes={schemes}
|
||||
onSchemesChange={setSchemes}
|
||||
onLocate={handleLocateScheme}
|
||||
onViewReport={handleViewReport}
|
||||
state={queryState}
|
||||
onStateChange={setQueryState}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={currentTab} index={2}>
|
||||
<LocationResults
|
||||
results={locationResults}
|
||||
<AnalysisReport
|
||||
scheme={reportScheme}
|
||||
valveResult={valveAnalysisResult}
|
||||
disabledValves={valveIsolationState.disabledValves}
|
||||
generatedAt={reportGeneratedAt}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
@@ -246,7 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
loading={valveAnalysisLoading}
|
||||
result={valveAnalysisResult}
|
||||
onLoadingChange={setValveAnalysisLoading}
|
||||
onResultChange={setValveAnalysisResult}
|
||||
onResultChange={handleValveAnalysisResultChange}
|
||||
state={valveIsolationState}
|
||||
onStateChange={setValveIsolationState}
|
||||
allowedPipeIds={
|
||||
reportScheme?.schemeDetail?.burst_ID
|
||||
}
|
||||
sourceSchemeName={reportScheme?.schemeName}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
@@ -1,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,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DescriptionOutlined as ReportIcon,
|
||||
Info as InfoIcon,
|
||||
LocationOn as LocationIcon,
|
||||
} from "@mui/icons-material";
|
||||
@@ -30,6 +31,7 @@ import { api } from "@/lib/api";
|
||||
import moment from "moment";
|
||||
import { config, NETWORK_NAME } from "@config/config";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
||||
@@ -50,38 +52,82 @@ import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
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 {
|
||||
schemes?: SchemeRecord[];
|
||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||
onLocate?: (scheme: SchemeRecord) => void;
|
||||
onViewReport?: (scheme: SchemeRecord) => void;
|
||||
network?: string;
|
||||
state?: BurstSchemeQueryState;
|
||||
onStateChange?: (state: BurstSchemeQueryState) => void;
|
||||
}
|
||||
|
||||
const SCHEME_TYPE = "burst_analysis";
|
||||
|
||||
export interface BurstSchemeQueryState {
|
||||
queryAll: boolean;
|
||||
queryDate: Dayjs | null;
|
||||
showTimeline: boolean;
|
||||
selectedDate: Date | undefined;
|
||||
timeRange: { start: Date; end: Date } | undefined;
|
||||
expandedId: number | null;
|
||||
hasQueried: boolean;
|
||||
}
|
||||
|
||||
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
|
||||
queryAll: true,
|
||||
queryDate: dayjs(new Date()),
|
||||
showTimeline: false,
|
||||
selectedDate: undefined,
|
||||
timeRange: undefined,
|
||||
expandedId: null,
|
||||
hasQueried: false,
|
||||
});
|
||||
|
||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
schemes: externalSchemes,
|
||||
onSchemesChange,
|
||||
onLocate,
|
||||
onViewReport,
|
||||
network = NETWORK_NAME,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const [queryAll, setQueryAll] = useState<boolean>(true);
|
||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date()));
|
||||
const [queryState, setQueryState, setQueryField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createBurstSchemeQueryState(),
|
||||
);
|
||||
const {
|
||||
queryAll,
|
||||
queryDate,
|
||||
showTimeline,
|
||||
selectedDate,
|
||||
timeRange,
|
||||
expandedId,
|
||||
hasQueried,
|
||||
} = queryState;
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
useState<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
|
||||
// 时间轴相关状态
|
||||
const [showTimeline, setShowTimeline] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||
const [timeRange, setTimeRange] = useState<
|
||||
{ start: Date; end: Date } | undefined
|
||||
>();
|
||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const creatorName = useSchemeCreatorName();
|
||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
||||
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
||||
Record<number, PipeDiameterMap>
|
||||
>({});
|
||||
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
const { open } = useNotification();
|
||||
|
||||
@@ -101,7 +147,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const handleQuery = async () => {
|
||||
@@ -109,9 +161,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
||||
);
|
||||
const params: Record<string, string> = {
|
||||
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;
|
||||
|
||||
if (!queryAll) {
|
||||
@@ -126,21 +184,28 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
id: item.scheme_id,
|
||||
schemeName: item.scheme_name,
|
||||
type: item.scheme_type,
|
||||
user: item.username,
|
||||
username: item.username,
|
||||
create_time: item.create_time,
|
||||
startTime: item.scheme_start_time,
|
||||
schemeDetail: item.scheme_detail,
|
||||
}));
|
||||
setSchemes(nextSchemes);
|
||||
setQueryField("hasQueried", true);
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
open?.({
|
||||
type: "error",
|
||||
type: "success",
|
||||
message: "查询结果",
|
||||
description: queryAll
|
||||
? "没有找到任何方案"
|
||||
: `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`,
|
||||
});
|
||||
} else {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "查询成功",
|
||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("查询请求失败:", error);
|
||||
@@ -176,12 +241,69 @@ 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 scheme = filteredSchemes.find((s) => s.id === id);
|
||||
if (!scheme) return;
|
||||
|
||||
setShowTimeline(true);
|
||||
setQueryState((previous) => ({ ...previous, showTimeline: true }));
|
||||
// 计算时间范围
|
||||
const schemeDate = scheme.startTime
|
||||
? new Date(scheme.startTime)
|
||||
@@ -191,8 +313,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
const end = new Date(
|
||||
start.getTime() + scheme.schemeDetail.modify_total_duration * 1000,
|
||||
);
|
||||
setSelectedDate(schemeDate);
|
||||
setTimeRange({ start, end });
|
||||
setQueryState((previous) => ({
|
||||
...previous,
|
||||
showTimeline: true,
|
||||
selectedDate: schemeDate,
|
||||
timeRange: { start, end },
|
||||
}));
|
||||
}
|
||||
setSchemeName?.(scheme.schemeName);
|
||||
handleLocatePipes(scheme.schemeDetail?.burst_ID || []);
|
||||
@@ -270,6 +396,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
properties: {
|
||||
name: "爆管管段高亮",
|
||||
value: "burst_pipe_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -324,7 +451,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
control={
|
||||
<Checkbox
|
||||
checked={queryAll}
|
||||
onChange={(e) => setQueryAll(e.target.checked)}
|
||||
onChange={(e) => {
|
||||
setQueryField("queryAll", e.target.checked);
|
||||
setQueryField("hasQueried", false);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
@@ -337,9 +467,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
>
|
||||
<DatePicker
|
||||
value={queryDate}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setQueryDate(value)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value)) {
|
||||
setQueryField("queryDate", value);
|
||||
setQueryField("hasQueried", false);
|
||||
}
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
disabled={queryAll}
|
||||
slotProps={{
|
||||
@@ -367,39 +500,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
{/* 结果列表 */}
|
||||
<Box className="flex-1 overflow-auto">
|
||||
{filteredSchemes.length === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<Box className="mb-4">
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
className="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="60"
|
||||
height="45"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="70"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Typography variant="body2">总共 0 条</Typography>
|
||||
<Typography variant="body2" className="mt-1">
|
||||
No data
|
||||
</Typography>
|
||||
</Box>
|
||||
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||
) : (
|
||||
<Box className="space-y-2 p-2">
|
||||
<Typography variant="caption" className="text-gray-500 px-2">
|
||||
@@ -450,27 +551,28 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
expandedId === scheme.id ? "收起详情" : "查看详情"
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setExpandedId(
|
||||
expandedId === scheme.id ? null : scheme.id,
|
||||
)
|
||||
}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setQueryField(
|
||||
"expandedId",
|
||||
expandedId === scheme.id ? null : scheme.id,
|
||||
)
|
||||
}
|
||||
color="primary"
|
||||
className="p-1"
|
||||
>
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="查看定位结果">
|
||||
<Tooltip title="查看分析报告">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onLocate?.(scheme)}
|
||||
onClick={() => onViewReport?.(scheme)}
|
||||
color="primary"
|
||||
className="p-1"
|
||||
>
|
||||
<LocationIcon fontSize="small" />
|
||||
<ReportIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
@@ -530,7 +632,11 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="font-medium text-gray-900"
|
||||
>
|
||||
560 mm
|
||||
{getPipeDiameterDisplay(
|
||||
scheme.schemeDetail?.burst_ID,
|
||||
pipeDiametersByScheme[scheme.id],
|
||||
!!loadingDiameterByScheme[scheme.id],
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-2">
|
||||
@@ -582,7 +688,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
variant="caption"
|
||||
className="font-medium text-gray-900"
|
||||
>
|
||||
{scheme.user}
|
||||
{creatorName(scheme.username)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className="flex items-center gap-2">
|
||||
|
||||
@@ -51,25 +51,53 @@ import {
|
||||
} from "@turf/turf";
|
||||
import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import {
|
||||
getAffectedNodeCount,
|
||||
isAllowedAccidentPipe,
|
||||
normalizeValveIsolationResult,
|
||||
} from "./valveIsolationScope";
|
||||
|
||||
interface ValveIsolationProps {
|
||||
initialPipeIds?: string[];
|
||||
allowedPipeIds?: string[];
|
||||
sourceSchemeName?: string;
|
||||
shouldFetch?: boolean;
|
||||
onFetchComplete?: () => void;
|
||||
loading?: boolean;
|
||||
result?: ValveIsolationResult | null;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
onResultChange?: (result: ValveIsolationResult | null) => void;
|
||||
state?: ValveIsolationState;
|
||||
onStateChange?: (state: ValveIsolationState) => void;
|
||||
}
|
||||
|
||||
export interface ValveIsolationState {
|
||||
selectedPipeId: string | null;
|
||||
activeStep: number;
|
||||
expandedResult: boolean;
|
||||
disabledValves: string[];
|
||||
}
|
||||
|
||||
export const createValveIsolationState = (): ValveIsolationState => ({
|
||||
selectedPipeId: null,
|
||||
activeStep: 0,
|
||||
expandedResult: true,
|
||||
disabledValves: [],
|
||||
});
|
||||
|
||||
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
initialPipeIds = [],
|
||||
allowedPipeIds,
|
||||
sourceSchemeName,
|
||||
shouldFetch = false,
|
||||
onFetchComplete,
|
||||
loading: externalLoading,
|
||||
result: externalResult,
|
||||
onLoadingChange,
|
||||
onResultChange,
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
const [internalResult, setInternalResult] =
|
||||
@@ -82,12 +110,41 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
const setLoading = onLoadingChange || setInternalLoading;
|
||||
const setResult = onResultChange || setInternalResult;
|
||||
|
||||
const [selectedPipeId, setSelectedPipeId] = useState<string | null>(null);
|
||||
const [flowState, setFlowState, setFlowField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createValveIsolationState(),
|
||||
);
|
||||
const { selectedPipeId, activeStep, expandedResult, disabledValves } =
|
||||
flowState;
|
||||
const [highlightFeature, setHighlightFeature] = useState<Feature | null>(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [expandedResult, setExpandedResult] = useState(true);
|
||||
const [disabledValves, setDisabledValves] = useState<string[]>([]);
|
||||
|
||||
const setSelectedPipeId = useCallback(
|
||||
(value: string | null) => setFlowField("selectedPipeId", value),
|
||||
[setFlowField],
|
||||
);
|
||||
|
||||
const setActiveStep = useCallback(
|
||||
(value: number) => setFlowField("activeStep", value),
|
||||
[setFlowField],
|
||||
);
|
||||
|
||||
const setExpandedResult = useCallback(
|
||||
(value: boolean) => setFlowField("expandedResult", value),
|
||||
[setFlowField],
|
||||
);
|
||||
|
||||
const setDisabledValves = useCallback(
|
||||
(next: string[] | ((previous: string[]) => string[])) => {
|
||||
setFlowState((previous) => ({
|
||||
...previous,
|
||||
disabledValves:
|
||||
typeof next === "function" ? next(previous.disabledValves) : next,
|
||||
}));
|
||||
},
|
||||
[setFlowState],
|
||||
);
|
||||
|
||||
const { open } = useNotification();
|
||||
const map = useMap();
|
||||
@@ -112,14 +169,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPipeId(pipeId);
|
||||
const normalizedPipeId = String(pipeId);
|
||||
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请选择当前爆管方案中的事故管段",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPipeId(normalizedPipeId);
|
||||
setHighlightFeature(feature);
|
||||
setIsSelecting(false);
|
||||
setResult(null); // 清除旧结果
|
||||
}
|
||||
}
|
||||
},
|
||||
[isSelecting, map, open, setResult],
|
||||
[
|
||||
allowedPipeIds,
|
||||
isSelecting,
|
||||
map,
|
||||
open,
|
||||
setResult,
|
||||
setSelectedPipeId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -135,6 +208,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
};
|
||||
}, [map, isSelecting, handleMapClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPipeId || highlightFeature) return;
|
||||
queryFeaturesByIds([selectedPipeId], "geo_pipes_mat").then((features) => {
|
||||
setHighlightFeature(features[0] ?? null);
|
||||
});
|
||||
}, [highlightFeature, selectedPipeId]);
|
||||
|
||||
const clearSelectedPipe = () => {
|
||||
setSelectedPipeId(null);
|
||||
setHighlightFeature(null);
|
||||
@@ -152,6 +232,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
"must_close" | "optional" | "affected_node" | "pipe"
|
||||
>("affected_node");
|
||||
|
||||
const selectSchemeAccidentPipe = (pipeId: string) => {
|
||||
if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return;
|
||||
setSelectedPipeId(pipeId);
|
||||
setHighlightFeature(null);
|
||||
setHighlightFeatures([]);
|
||||
setResult(null);
|
||||
setActiveStep(0);
|
||||
setExpandedResult(true);
|
||||
setDisabledValves([]);
|
||||
};
|
||||
|
||||
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
||||
if (pipeIds.length > 0) {
|
||||
@@ -256,6 +346,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
|
||||
return;
|
||||
}
|
||||
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "关阀分析仅限当前爆管方案中的事故管段",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const isExpandSearch = disabled.length > 0;
|
||||
if (!isExpandSearch) {
|
||||
@@ -264,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
}
|
||||
try {
|
||||
const params: any = {
|
||||
network: NETWORK_NAME,
|
||||
accident_element: ids,
|
||||
};
|
||||
if (disabled.length > 0) {
|
||||
params.disabled_valves = disabled;
|
||||
}
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
||||
const response = await api.post<ValveIsolationResult>(
|
||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
|
||||
undefined,
|
||||
{
|
||||
params,
|
||||
paramsSerializer: {
|
||||
@@ -279,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
},
|
||||
},
|
||||
);
|
||||
setResult(response.data);
|
||||
setResult(normalizeValveIsolationResult(response.data));
|
||||
if (!isExpandSearch) {
|
||||
setActiveStep(1);
|
||||
} else {
|
||||
@@ -297,7 +394,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[open, setLoading, setResult],
|
||||
[
|
||||
allowedPipeIds,
|
||||
open,
|
||||
setActiveStep,
|
||||
setDisabledValves,
|
||||
setLoading,
|
||||
setResult,
|
||||
],
|
||||
);
|
||||
|
||||
// 监听外部传入的分析请求
|
||||
@@ -319,7 +423,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
onFetchComplete();
|
||||
}
|
||||
}
|
||||
}, [shouldFetch, initialPipeIds, fetchAnalysis, onFetchComplete]);
|
||||
}, [shouldFetch, initialPipeIds, fetchAnalysis, onFetchComplete, setSelectedPipeId]);
|
||||
|
||||
// 初始化高亮图层
|
||||
useEffect(() => {
|
||||
@@ -491,6 +595,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
properties: {
|
||||
name: "阀门节点高亮",
|
||||
value: "valve_node_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -610,7 +715,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
{[
|
||||
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
|
||||
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
|
||||
{ label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
|
||||
{ label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
|
||||
].map((item, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
@@ -776,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!result.isolatable && (
|
||||
<Alert severity="info" variant="outlined">
|
||||
不可隔离,未生成受影响节点清单。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 受影响节点 */}
|
||||
{result.affected_nodes && result.affected_nodes.length > 0 && (
|
||||
{result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && (
|
||||
<Box>
|
||||
<Box className="flex items-center justify-between mb-2">
|
||||
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
|
||||
@@ -856,6 +967,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
</StepLabel>
|
||||
<StepContent>
|
||||
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
||||
{allowedPipeIds !== undefined ? (
|
||||
<Alert severity="info" variant="outlined">
|
||||
已绑定爆管方案“{sourceSchemeName || "未命名方案"}”。关阀分析仅限该方案中的事故管段,分析结果将写入对应报告。
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="warning" variant="outlined">
|
||||
当前为独立关阀分析,结果不会自动写入爆管分析报告。请先从“方案查询”打开对应分析报告以建立关联。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 选择管段 */}
|
||||
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
|
||||
<Box className="flex items-center justify-between mb-2">
|
||||
@@ -899,6 +1020,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{allowedPipeIds !== undefined && (
|
||||
<Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2">
|
||||
<Box className="mb-2 flex flex-wrap gap-1.5">
|
||||
{allowedPipeIds.map((pipeId) => (
|
||||
<Chip
|
||||
key={pipeId}
|
||||
label={pipeId}
|
||||
size="small"
|
||||
color={selectedPipeId === pipeId ? "primary" : "default"}
|
||||
variant={selectedPipeId === pipeId ? "filled" : "outlined"}
|
||||
onClick={() => selectSchemeAccidentPipe(pipeId)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={allowedPipeIds.length === 0}
|
||||
onClick={() =>
|
||||
allowedPipeIds[0] &&
|
||||
selectSchemeAccidentPipe(allowedPipeIds[0])
|
||||
}
|
||||
startIcon={<CheckCircleIcon />}
|
||||
>
|
||||
选择当前爆管管段
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isSelecting && (
|
||||
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||||
💡 点击地图上的管道添加爆管点
|
||||
|
||||
@@ -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 mm;P-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(";");
|
||||
};
|
||||
@@ -11,7 +11,7 @@ export interface SchemeRecord {
|
||||
id: number;
|
||||
schemeName: string;
|
||||
type: string;
|
||||
user: string;
|
||||
username: string;
|
||||
create_time: string;
|
||||
startTime: string;
|
||||
// 详情信息
|
||||
@@ -28,18 +28,10 @@ export interface SchemaItem {
|
||||
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 {
|
||||
accident_elements: string[];
|
||||
affected_nodes: string[];
|
||||
affected_node_count?: number;
|
||||
must_close_valves: string[];
|
||||
optional_valves: string[];
|
||||
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),
|
||||
});
|
||||
@@ -24,21 +24,57 @@ import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style";
|
||||
import Feature from "ol/Feature";
|
||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||
import {
|
||||
handleMapClickSelectFeatures as mapClickSelectFeatures,
|
||||
queryFeaturesByIds,
|
||||
} from "@/utils/mapQueryService";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
|
||||
const AnalysisParameters: React.FC = () => {
|
||||
export interface ContaminantAnalysisParametersState {
|
||||
schemeName: string;
|
||||
startTime: Dayjs | null;
|
||||
sourceNode: string;
|
||||
concentration: number;
|
||||
duration: number;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export const createContaminantAnalysisParametersState =
|
||||
(): ContaminantAnalysisParametersState => ({
|
||||
schemeName: "WQ_" + new Date().getTime(),
|
||||
startTime: dayjs(new Date()),
|
||||
sourceNode: "",
|
||||
concentration: 100,
|
||||
duration: 3600,
|
||||
pattern: "",
|
||||
});
|
||||
|
||||
interface AnalysisParametersProps {
|
||||
state?: ContaminantAnalysisParametersState;
|
||||
onStateChange?: (state: ContaminantAnalysisParametersState) => void;
|
||||
}
|
||||
|
||||
const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
state,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const map = useMap();
|
||||
const { open } = useNotification();
|
||||
|
||||
const network = NETWORK_NAME;
|
||||
const [schemeName, setSchemeName] = useState<string>(
|
||||
"WQ_" + new Date().getTime(),
|
||||
const [parametersState, , setFormField] = useControllableObjectState(
|
||||
state,
|
||||
onStateChange,
|
||||
createContaminantAnalysisParametersState(),
|
||||
);
|
||||
const [startTime, setStartTime] = useState<Dayjs | null>(dayjs(new Date()));
|
||||
const [sourceNode, setSourceNode] = useState<string>("");
|
||||
const [concentration, setConcentration] = useState<number>(100);
|
||||
const [duration, setDuration] = useState<number>(3600);
|
||||
const [pattern, setPattern] = useState<string>("");
|
||||
const {
|
||||
schemeName,
|
||||
startTime,
|
||||
sourceNode,
|
||||
concentration,
|
||||
duration,
|
||||
pattern,
|
||||
} = parametersState;
|
||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
@@ -77,12 +113,12 @@ const AnalysisParameters: React.FC = () => {
|
||||
|
||||
const id = feature.getProperties().id;
|
||||
if (!id) return;
|
||||
setSourceNode(id);
|
||||
setFormField("sourceNode", id);
|
||||
setHighlightFeature(feature);
|
||||
setIsSelecting(false);
|
||||
map.un("click", handleMapClickSelectFeatures);
|
||||
},
|
||||
[map, open],
|
||||
[map, open, setFormField],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,6 +158,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
properties: {
|
||||
name: "污染源节点",
|
||||
value: "contaminant_source_highlight",
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -144,6 +181,17 @@ const AnalysisParameters: React.FC = () => {
|
||||
}
|
||||
}, [highlightFeature, highlightLayer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceNode) {
|
||||
setHighlightFeature(null);
|
||||
return;
|
||||
}
|
||||
if (highlightFeature) return;
|
||||
queryFeaturesByIds([sourceNode], "geo_junctions_mat").then((features) => {
|
||||
setHighlightFeature(features[0] ?? null);
|
||||
});
|
||||
}, [highlightFeature, sourceNode]);
|
||||
|
||||
const handleStartSelection = () => {
|
||||
if (!map) return;
|
||||
setIsSelecting(true);
|
||||
@@ -157,7 +205,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleClearSource = () => {
|
||||
setSourceNode("");
|
||||
setFormField("sourceNode", "");
|
||||
setHighlightFeature(null);
|
||||
};
|
||||
|
||||
@@ -176,11 +224,10 @@ const AnalysisParameters: React.FC = () => {
|
||||
: "";
|
||||
try {
|
||||
if (!pattern) {
|
||||
setPattern("CONSTANT");
|
||||
setFormField("pattern", "CONSTANT");
|
||||
console.log("默认设置 pattern 为 CONSTANT");
|
||||
}
|
||||
const params = {
|
||||
network,
|
||||
start_time: start_time,
|
||||
source: sourceNode,
|
||||
concentration,
|
||||
@@ -189,7 +236,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -310,7 +357,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
<DateTimePicker
|
||||
value={startTime}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setStartTime(value)
|
||||
value && dayjs.isDayjs(value) && setFormField("startTime", value)
|
||||
}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{
|
||||
@@ -335,7 +382,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
fullWidth
|
||||
size="small"
|
||||
value={schemeName}
|
||||
onChange={(e) => setSchemeName(e.target.value)}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
placeholder="输入方案名称"
|
||||
/>
|
||||
</Box>
|
||||
@@ -349,7 +396,9 @@ const AnalysisParameters: React.FC = () => {
|
||||
size="small"
|
||||
type="number"
|
||||
value={concentration}
|
||||
onChange={(e) => setConcentration(parseFloat(e.target.value) || 0)}
|
||||
onChange={(e) =>
|
||||
setFormField("concentration", parseFloat(e.target.value) || 0)
|
||||
}
|
||||
placeholder="输入浓度"
|
||||
/>
|
||||
</Box>
|
||||
@@ -363,7 +412,9 @@ const AnalysisParameters: React.FC = () => {
|
||||
size="small"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(parseInt(e.target.value, 10) || 0)}
|
||||
onChange={(e) =>
|
||||
setFormField("duration", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
placeholder="输入持续时长"
|
||||
/>
|
||||
</Box>
|
||||
@@ -376,7 +427,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
fullWidth
|
||||
size="small"
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
onChange={(e) => setFormField("pattern", e.target.value)}
|
||||
placeholder="可选,输入 pattern 名称,默认为 CONSTANT"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user