Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ed1fd212 | ||
|
|
81cc5dcae1 | ||
|
|
782363cfb6 | ||
|
|
b57e58ff87 | ||
|
|
38d246eb7b | ||
|
|
723931b6ae | ||
|
|
f355ddd002 | ||
|
|
6114e76735 | ||
|
|
d643f09fcf | ||
|
|
04557b9363 | ||
|
|
8836549697 | ||
|
|
1d7e07174f | ||
|
|
c4246cf25f | ||
|
|
1b2a7f4fb8 | ||
|
|
82e75c03d0 | ||
|
|
5894ee277a | ||
|
|
a3331691de | ||
|
|
7745333a58 |
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
Generated
+242
-16
@@ -35,6 +35,7 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
@@ -5731,6 +5733,75 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@redocly/ajv": {
|
||||||
|
"version": "8.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||||
|
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2",
|
||||||
|
"uri-js-replace": "^1.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/config": {
|
||||||
|
"version": "0.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||||
|
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core": {
|
||||||
|
"version": "1.34.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.18.tgz",
|
||||||
|
"integrity": "sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/ajv": "8.11.2",
|
||||||
|
"@redocly/config": "0.22.0",
|
||||||
|
"colorette": "1.4.0",
|
||||||
|
"https-proxy-agent": "7.0.6",
|
||||||
|
"js-levenshtein": "1.1.6",
|
||||||
|
"js-yaml": "4.3.0",
|
||||||
|
"minimatch": "5.1.9",
|
||||||
|
"pluralize": "8.0.0",
|
||||||
|
"yaml-ast-parser": "0.0.43"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0",
|
||||||
|
"npm": ">=9.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||||
|
"version": "5.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@refinedev/core": {
|
"node_modules/@refinedev/core": {
|
||||||
"version": "5.0.12",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz",
|
||||||
@@ -10466,6 +10537,16 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-colors": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-escapes": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||||
@@ -11260,6 +11341,13 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/change-case": {
|
||||||
|
"version": "5.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||||
|
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/char-regex": {
|
"node_modules/char-regex": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
|
||||||
@@ -11430,6 +11518,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -14363,6 +14458,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/index-to-position": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@@ -16106,6 +16214,16 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-levenshtein": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -16113,10 +16231,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -16230,6 +16358,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -18369,6 +18504,73 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openapi-fetch": {
|
||||||
|
"version": "0.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
|
||||||
|
"integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"openapi-typescript-helpers": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript": {
|
||||||
|
"version": "7.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||||
|
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/openapi-core": "^1.34.6",
|
||||||
|
"ansi-colors": "^4.1.3",
|
||||||
|
"change-case": "^5.4.4",
|
||||||
|
"parse-json": "^8.3.0",
|
||||||
|
"supports-color": "^10.2.2",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openapi-typescript": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript-helpers": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript/node_modules/parse-json": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.26.2",
|
||||||
|
"index-to-position": "^1.1.0",
|
||||||
|
"type-fest": "^4.39.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openid-client": {
|
"node_modules/openid-client": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
@@ -19355,6 +19557,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reselect": {
|
"node_modules/reselect": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
@@ -20712,19 +20924,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-jest/node_modules/type-fest": {
|
|
||||||
"version": "4.41.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
|
||||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
@@ -20780,6 +20979,19 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typed-array-buffer": {
|
"node_modules/typed-array-buffer": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||||
@@ -21162,6 +21374,13 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uri-js-replace": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
@@ -21579,6 +21798,13 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml-ast-parser": {
|
||||||
|
"version": "0.0.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||||
|
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
|
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
|
||||||
|
"api:check": "node scripts/check-api-contracts.mjs",
|
||||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -75,6 +78,7 @@
|
|||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
);
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
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 StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
import {
|
import {
|
||||||
RAINBOW_COLORS,
|
RAINBOW_COLORS,
|
||||||
@@ -12,6 +12,26 @@ import {
|
|||||||
} from "@components/olmap/HealthRiskAnalysis/types";
|
} from "@components/olmap/HealthRiskAnalysis/types";
|
||||||
import { Box } from "@mui/material";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<HealthRiskProvider>
|
<HealthRiskProvider>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
|
|
||||||
|
const BurstDetectionPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-detection" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
|
|
||||||
|
const BurstLocationPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-location" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
|
|
||||||
|
const BurstPipeAnalysisPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
|
|
||||||
|
const WaterQualityPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
|
|
||||||
|
const DMALeakDetectionPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
|
|
||||||
|
const FlushingAnalysisPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
|
||||||
|
const MonitoringPlaceOptimizationPanel = dynamic(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<MapPanelSkeleton variant="monitoring-place-optimization" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
|
||||||
|
import {
|
||||||
|
MapPanelSkeleton,
|
||||||
|
MapTimelineSkeleton,
|
||||||
|
} from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
const Timeline = dynamic(
|
||||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
() => 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() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
const SCADADeviceList = dynamic(
|
||||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
() => 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() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import React, { Suspense } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||||
import { Header } from "@components/header";
|
import { Header } from "@components/header";
|
||||||
import { Title } from "@components/title";
|
import { Title } from "@components/title";
|
||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
import { AppSider } from "@components/sider/AppSider";
|
import { AppSider } from "@components/sider/AppSider";
|
||||||
import { ThemedLayout } from "@refinedev/mui";
|
import { ThemedLayout } from "@refinedev/mui";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
|
|||||||
export default async function MainLayout({
|
export default async function MainLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const theme = cookieStore.get("theme");
|
const theme = cookieStore.get("theme");
|
||||||
@@ -49,9 +48,7 @@ export default async function MainLayout({
|
|||||||
sx: { height: "100vh", overflow: "hidden" },
|
sx: { height: "100vh", overflow: "hidden" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<MapSkeleton />}>
|
|
||||||
{children}
|
{children}
|
||||||
</Suspense>
|
|
||||||
</ThemedLayout>
|
</ThemedLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-86
@@ -1,32 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Refine, type AuthProvider } from "@refinedev/core";
|
|
||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
|
||||||
import {
|
import {
|
||||||
RefineSnackbarProvider,
|
Refine,
|
||||||
} from "@refinedev/mui";
|
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 { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
import routerProvider from "@refinedev/nextjs-router";
|
||||||
|
|
||||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||||
|
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||||
|
|
||||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
|
||||||
import { LuReplace } from "react-icons/lu";
|
import { LuReplace } from "react-icons/lu";
|
||||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
ManageAccounts as ManageAccountsIcon,
|
|
||||||
FactCheck as FactCheckIcon,
|
FactCheck as FactCheckIcon,
|
||||||
|
ManageAccounts as ManageAccountsIcon,
|
||||||
MyLocation as MyLocationIcon,
|
MyLocation as MyLocationIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
@@ -36,16 +42,12 @@ type RefineContextProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RefineContext = (
|
export const RefineContext = (
|
||||||
props: React.PropsWithChildren<RefineContextProps>
|
props: React.PropsWithChildren<RefineContextProps>,
|
||||||
) => {
|
) => (
|
||||||
return (
|
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ProjectProvider>
|
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
</ProjectProvider>
|
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
type AppProps = {
|
type AppProps = {
|
||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const { data, status } = useSession();
|
const { data, status } = useSession();
|
||||||
const to = usePathname();
|
const to = usePathname();
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||||
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
|
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||||
|
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
||||||
|
const resetAccess = useAccessStore((state) => state.reset);
|
||||||
|
const can = (permission: string) => permissions.includes(permission);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
setAccessToken(
|
||||||
|
typeof data?.accessToken === "string" ? data.accessToken : null,
|
||||||
|
);
|
||||||
}, [data?.accessToken, setAccessToken]);
|
}, [data?.accessToken, setAccessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
setIsMetadataAdmin(false);
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
setAccessLoading(true);
|
||||||
projectHeaderMode: "omit",
|
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||||
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setIsMetadataAdmin(false);
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await response.json();
|
setAccessContext(await response.json());
|
||||||
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setIsMetadataAdmin(false);
|
if (!cancelled) resetAccess();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [status]);
|
}, [
|
||||||
|
currentProjectId,
|
||||||
|
resetAccess,
|
||||||
|
setAccessContext,
|
||||||
|
setAccessLoading,
|
||||||
|
status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "authenticated" || !data?.user?.id) return;
|
||||||
|
const auditKey = `tjwater-login-audit:${data.user.id}`;
|
||||||
|
if (sessionStorage.getItem(auditKey)) return;
|
||||||
|
|
||||||
|
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ event: "login" }),
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) sessionStorage.setItem(auditKey, "1");
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [data?.user?.id, status]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <span>loading...</span>;
|
return <span>loading...</span>;
|
||||||
@@ -100,74 +133,65 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
callbackUrl: to ? to.toString() : "/",
|
callbackUrl: to ? to.toString() : "/",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
|
return { success: true };
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
signOut({
|
try {
|
||||||
redirect: true,
|
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||||
callbackUrl: "/login",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
return {
|
// Logout must still complete when audit storage is unavailable.
|
||||||
success: true,
|
}
|
||||||
};
|
if (data?.user?.id) {
|
||||||
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
|
}
|
||||||
|
signOut({ redirect: true, callbackUrl: "/login" });
|
||||||
|
return { success: true };
|
||||||
},
|
},
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
return {
|
return { logout: true };
|
||||||
logout: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return { error };
|
||||||
return {
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
check: async () => {
|
|
||||||
if (status === "unauthenticated") {
|
|
||||||
return {
|
|
||||||
authenticated: false,
|
|
||||||
redirectTo: "/login",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
authenticated: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getPermissions: async () => {
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
|
check: async () =>
|
||||||
|
status === "unauthenticated"
|
||||||
|
? { authenticated: false, redirectTo: "/login" }
|
||||||
|
: { authenticated: true },
|
||||||
|
getPermissions: async () => permissions,
|
||||||
getIdentity: async () => {
|
getIdentity: async () => {
|
||||||
if (data?.user) {
|
if (!data?.user) return null;
|
||||||
const { user } = data;
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: data.user.id,
|
||||||
name: user.name,
|
username: data.user.username,
|
||||||
avatar: user.image,
|
name: data.user.name,
|
||||||
|
avatar: data.user.image,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultMode = props?.defaultMode;
|
const accessControlProvider: AccessControlProvider = {
|
||||||
|
can: async ({ resource }) => {
|
||||||
|
const requiredPermission = resource
|
||||||
|
? resourcePermissions[resource]
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
can: !requiredPermission || permissions.includes(requiredPermission),
|
||||||
|
reason: requiredPermission
|
||||||
|
? `需要权限:${requiredPermission}`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const resources = [
|
||||||
<>
|
...(can(permissionCodes.simulationView)
|
||||||
<RefineKbarProvider>
|
? [
|
||||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
|
||||||
<RefineSnackbarProvider>
|
|
||||||
<Refine
|
|
||||||
routerProvider={routerProvider}
|
|
||||||
dataProvider={dataProvider}
|
|
||||||
notificationProvider={useAppNotificationProvider}
|
|
||||||
authProvider={authProvider}
|
|
||||||
resources={[
|
|
||||||
{
|
{
|
||||||
name: "管网在线模拟",
|
name: "管网在线模拟",
|
||||||
list: "/network-simulation",
|
list: "/network-simulation",
|
||||||
@@ -176,6 +200,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "管网在线模拟",
|
label: "管网在线模拟",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.scadaClean)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "SCADA 数据清洗",
|
name: "SCADA 数据清洗",
|
||||||
list: "/scada-data-cleaning",
|
list: "/scada-data-cleaning",
|
||||||
@@ -184,6 +212,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "SCADA 数据清洗",
|
label: "SCADA 数据清洗",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.optimizationRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "监测点优化布置",
|
name: "监测点优化布置",
|
||||||
list: "/monitoring-place-optimization",
|
list: "/monitoring-place-optimization",
|
||||||
@@ -192,6 +224,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "监测点优化布置",
|
label: "监测点优化布置",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.riskRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "健康风险分析",
|
name: "健康风险分析",
|
||||||
list: "/health-risk-analysis",
|
list: "/health-risk-analysis",
|
||||||
@@ -200,13 +236,18 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "健康风险分析",
|
label: "健康风险分析",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "Hydraulic Simulation",
|
name: "Hydraulic Simulation",
|
||||||
meta: {
|
meta: { label: "事件模拟" },
|
||||||
// icon: <MdWater className="w-6 h-6" />,
|
|
||||||
label: "事件模拟",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.burstRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "爆管模拟",
|
name: "爆管模拟",
|
||||||
list: "/hydraulic-simulation/burst-simulation",
|
list: "/hydraulic-simulation/burst-simulation",
|
||||||
@@ -243,6 +284,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "DMA 漏损识别",
|
label: "DMA 漏损识别",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.simulationRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "水质模拟",
|
name: "水质模拟",
|
||||||
list: "/hydraulic-simulation/contaminant-simulation",
|
list: "/hydraulic-simulation/contaminant-simulation",
|
||||||
@@ -261,7 +306,9 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "管道冲洗",
|
label: "管道冲洗",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(isMetadataAdmin
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.environmentManage)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
name: "系统管理",
|
name: "系统管理",
|
||||||
@@ -271,6 +318,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "系统管理",
|
label: "系统管理",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.auditView)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "审计日志",
|
name: "审计日志",
|
||||||
list: "/audit-logs",
|
list: "/audit-logs",
|
||||||
@@ -281,18 +332,31 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]}
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectProvider>
|
||||||
|
<RefineKbarProvider>
|
||||||
|
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||||
|
<RefineSnackbarProvider>
|
||||||
|
<Refine
|
||||||
|
routerProvider={routerProvider}
|
||||||
|
dataProvider={dataProvider}
|
||||||
|
notificationProvider={useAppNotificationProvider}
|
||||||
|
authProvider={authProvider}
|
||||||
|
accessControlProvider={accessControlProvider}
|
||||||
|
resources={resources}
|
||||||
options={{
|
options={{
|
||||||
syncWithLocation: true,
|
syncWithLocation: true,
|
||||||
warnWhenUnsavedChanges: true,
|
warnWhenUnsavedChanges: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<RefineKbar />
|
||||||
</Refine>
|
</Refine>
|
||||||
</RefineSnackbarProvider>
|
</RefineSnackbarProvider>
|
||||||
</ColorModeContextProvider>
|
</ColorModeContextProvider>
|
||||||
</RefineKbarProvider>
|
</RefineKbarProvider>
|
||||||
</>
|
</ProjectProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const authOptions: NextAuthOptions = {
|
|||||||
profile(profile) {
|
profile(profile) {
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
|
username: profile.preferred_username,
|
||||||
name: profile.name ?? profile.preferred_username,
|
name: profile.name ?? profile.preferred_username,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
image: Avatar.src,
|
image: Avatar.src,
|
||||||
@@ -79,6 +80,12 @@ const authOptions: NextAuthOptions = {
|
|||||||
if (profile?.sub) {
|
if (profile?.sub) {
|
||||||
token.sub = profile.sub;
|
token.sub = profile.sub;
|
||||||
}
|
}
|
||||||
|
const preferredUsername = (
|
||||||
|
profile as { preferred_username?: unknown } | undefined
|
||||||
|
)?.preferred_username;
|
||||||
|
if (typeof preferredUsername === "string") {
|
||||||
|
token.username = preferredUsername;
|
||||||
|
}
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
if (account.access_token) {
|
if (account.access_token) {
|
||||||
@@ -104,6 +111,9 @@ const authOptions: NextAuthOptions = {
|
|||||||
if (session.user && token.sub) {
|
if (session.user && token.sub) {
|
||||||
session.user.id = token.sub;
|
session.user.id = token.sub;
|
||||||
}
|
}
|
||||||
|
if (session.user && token.username) {
|
||||||
|
session.user.username = token.username;
|
||||||
|
}
|
||||||
if (token.accessToken) {
|
if (token.accessToken) {
|
||||||
session.accessToken = token.accessToken;
|
session.accessToken = token.accessToken;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ export default async function RootLayout({
|
|||||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body>
|
<body>
|
||||||
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
||||||
<Suspense>
|
<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+234
-29
@@ -1,54 +1,259 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Container from "@mui/material/Container";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useLogin } from "@refinedev/core";
|
import { useLogin } from "@refinedev/core";
|
||||||
import { Title } from "@components/title";
|
import { PROJECT_TITLE } from "@config/config";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const FALLBACK_DELAY_MS = 4000;
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { mutate: login } = useLogin();
|
const { mutate: login } = useLogin();
|
||||||
|
const loginStartedRef = useRef(false);
|
||||||
|
const [showFallback, setShowFallback] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loginStartedRef.current) {
|
||||||
|
loginStartedRef.current = true;
|
||||||
|
login({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTimer = window.setTimeout(
|
||||||
|
() => setShowFallback(true),
|
||||||
|
FALLBACK_DELAY_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(fallbackTimer);
|
||||||
|
}, [login]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Box
|
||||||
style={{
|
component="main"
|
||||||
height: "100vh",
|
sx={{
|
||||||
display: "flex",
|
minHeight: "100vh",
|
||||||
justifyContent: "center",
|
"@supports (height: 100svh)": {
|
||||||
alignItems: "center",
|
minHeight: "100svh",
|
||||||
|
},
|
||||||
|
color: "oklch(0.3 0.055 215)",
|
||||||
|
bgcolor: "oklch(0.965 0.014 205)",
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 50%,
|
||||||
|
oklch(0.975 0.01 205 / 62%) 68%,
|
||||||
|
oklch(0.975 0.01 205 / 82%) 100%
|
||||||
|
),
|
||||||
|
url("/login-network-blueprint.svg")
|
||||||
|
`,
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
"@keyframes tjwaterEnter": {
|
||||||
|
from: { opacity: 0, transform: "translateY(12px)" },
|
||||||
|
to: { opacity: 1, transform: "translateY(0)" },
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
sx={{
|
||||||
gap="36px"
|
width: "100%",
|
||||||
justifyContent="center"
|
maxWidth: 1720,
|
||||||
flexDirection="column"
|
minHeight: "100vh",
|
||||||
|
"@supports (height: 100svh)": {
|
||||||
|
minHeight: "100svh",
|
||||||
|
},
|
||||||
|
mx: "auto",
|
||||||
|
px: {
|
||||||
|
xs: 1.75,
|
||||||
|
sm: 3,
|
||||||
|
md: "clamp(40px, 5vw, 88px)",
|
||||||
|
},
|
||||||
|
py: {
|
||||||
|
xs: "max(28px, env(safe-area-inset-top))",
|
||||||
|
md: "clamp(32px, 4.5vw, 76px)",
|
||||||
|
},
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "minmax(0, 1fr)",
|
||||||
|
md: "minmax(360px, 1fr) minmax(400px, 460px)",
|
||||||
|
},
|
||||||
|
gridTemplateAreas: {
|
||||||
|
xs: '"brand" "status"',
|
||||||
|
md: '"brand status"',
|
||||||
|
},
|
||||||
|
alignContent: { xs: "center", md: "stretch" },
|
||||||
|
alignItems: "center",
|
||||||
|
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box display="flex" justifyContent="center">
|
<Stack
|
||||||
<Title collapsed={false} />
|
spacing={{ xs: 0, md: 3 }}
|
||||||
</Box>
|
direction={{ xs: "row", md: "column" }}
|
||||||
|
alignItems={{ xs: "center", md: "flex-start" }}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
isolation: "isolate",
|
||||||
|
gridArea: "brand",
|
||||||
|
width: "fit-content",
|
||||||
|
maxWidth: "100%",
|
||||||
|
animation:
|
||||||
|
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||||
|
"&::before": {
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: -1,
|
||||||
|
inset: { xs: "-24px -20px", md: "-54px -72px" },
|
||||||
|
background: {
|
||||||
|
xs: `radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
oklch(0.965 0.014 205 / 98%) 0%,
|
||||||
|
oklch(0.965 0.014 205 / 88%) 58%,
|
||||||
|
transparent 84%
|
||||||
|
)`,
|
||||||
|
md: `radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
oklch(0.925 0.018 205 / 98%) 0%,
|
||||||
|
oklch(0.925 0.018 205 / 94%) 48%,
|
||||||
|
oklch(0.925 0.018 205 / 62%) 65%,
|
||||||
|
transparent 82%
|
||||||
|
)`,
|
||||||
|
},
|
||||||
|
pointerEvents: "none",
|
||||||
|
content: '""',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src="/login-logo-mark.svg"
|
||||||
|
alt=""
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
sx={{
|
||||||
|
width: { xs: 42, md: 56 },
|
||||||
|
height: { xs: 42, md: 56 },
|
||||||
|
flex: { xs: "0 0 42px", md: "0 0 auto" },
|
||||||
|
mr: { xs: 1.5, md: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
maxWidth: 680,
|
||||||
|
m: 0,
|
||||||
|
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
|
||||||
|
fontWeight: 720,
|
||||||
|
lineHeight: { xs: 1.4, md: 1.28 },
|
||||||
|
textWrap: "balance",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PROJECT_TITLE}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
display: { xs: "none", md: "block" },
|
||||||
|
width: 64,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: "oklch(0.58 0.12 180)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
aria-live="polite"
|
||||||
|
sx={{
|
||||||
|
gridArea: "status",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 460,
|
||||||
|
justifySelf: "stretch",
|
||||||
|
p: { xs: 2.75, sm: 4.5 },
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: { xs: 4, sm: 4.5 },
|
||||||
|
bgcolor: "oklch(0.995 0.004 205 / 97%)",
|
||||||
|
boxShadow:
|
||||||
|
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
|
||||||
|
animation:
|
||||||
|
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<CircularProgress
|
||||||
|
size={30}
|
||||||
|
thickness={4.5}
|
||||||
|
aria-label="正在连接统一身份认证"
|
||||||
|
sx={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: "oklch(0.57 0.16 242)",
|
||||||
|
"@media (prefers-reduced-motion: reduce)": {
|
||||||
|
animation: "none",
|
||||||
|
"& .MuiCircularProgress-circle": {
|
||||||
|
animation: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography
|
||||||
|
component="h2"
|
||||||
|
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
|
||||||
|
>
|
||||||
|
正在进入账号登录
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "oklch(0.52 0.035 215)",
|
||||||
|
fontSize: { xs: 14, sm: 15 },
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
正在连接统一身份认证服务,请稍候。
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{showFallback ? (
|
||||||
<Button
|
<Button
|
||||||
style={{ width: "240px" }}
|
fullWidth
|
||||||
size="large"
|
size="large"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => login({})}
|
onClick={() => login({})}
|
||||||
|
sx={{
|
||||||
|
minHeight: 48,
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow:
|
||||||
|
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
|
||||||
|
transitionProperty:
|
||||||
|
"transform, background-color, box-shadow",
|
||||||
|
"&:active": { transform: "scale(0.96)" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Sign in
|
继续登录
|
||||||
</Button>
|
</Button>
|
||||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
) : null}
|
||||||
Powered by
|
|
||||||
<Image
|
<Typography
|
||||||
style={{ padding: "0 5px" }}
|
sx={{
|
||||||
alt="Keycloak"
|
color: "oklch(0.52 0.035 215)",
|
||||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
fontSize: { xs: 12, sm: 13 },
|
||||||
width={18}
|
lineHeight: 1.7,
|
||||||
height={18}
|
textAlign: "center",
|
||||||
/>
|
}}
|
||||||
Keycloak
|
>
|
||||||
|
账号与访问权限由统一身份认证服务管理
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,24 +106,30 @@ type DatabaseHealth = {
|
|||||||
|
|
||||||
const businessRoleOptions = [
|
const businessRoleOptions = [
|
||||||
{ value: "admin", label: "系统管理员" },
|
{ value: "admin", label: "系统管理员" },
|
||||||
{ value: "operator", label: "运行人员" },
|
|
||||||
{ value: "user", label: "普通用户" },
|
{ value: "user", label: "普通用户" },
|
||||||
{ value: "viewer", label: "只读用户" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const projectRoleLabels: Record<string, string> = {
|
const projectRoleLabels: Record<string, string> = {
|
||||||
owner: "项目负责人",
|
|
||||||
admin: "项目管理员",
|
|
||||||
member: "项目成员",
|
member: "项目成员",
|
||||||
viewer: "只读成员",
|
viewer: "只读成员",
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectRoleOptions = [
|
const projectRoleOptions = [
|
||||||
{ value: "admin", label: projectRoleLabels.admin },
|
|
||||||
{ value: "member", label: projectRoleLabels.member },
|
{ value: "member", label: projectRoleLabels.member },
|
||||||
{ value: "viewer", label: projectRoleLabels.viewer },
|
{ value: "viewer", label: projectRoleLabels.viewer },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const projectRolePermissionSummary = [
|
||||||
|
{
|
||||||
|
role: "项目成员",
|
||||||
|
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "只读成员",
|
||||||
|
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const projectStatusOptions = [
|
const projectStatusOptions = [
|
||||||
{ value: "active", label: "启用" },
|
{ value: "active", label: "启用" },
|
||||||
{ value: "inactive", label: "停用" },
|
{ value: "inactive", label: "停用" },
|
||||||
@@ -634,7 +640,7 @@ export const SystemAdminPanel = () => {
|
|||||||
|
|
||||||
const loadInitialState = async () => {
|
const loadInitialState = async () => {
|
||||||
try {
|
try {
|
||||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
||||||
projectHeaderMode: "omit",
|
projectHeaderMode: "omit",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
@@ -941,7 +947,7 @@ export const SystemAdminPanel = () => {
|
|||||||
const form = databaseForms[role];
|
const form = databaseForms[role];
|
||||||
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
|
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`,
|
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health-checks`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -1340,7 +1346,7 @@ export const SystemAdminPanel = () => {
|
|||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="项目成员"
|
title="项目成员"
|
||||||
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
|
description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -1352,6 +1358,25 @@ export const SystemAdminPanel = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1 }}>
|
||||||
|
固定项目权限
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={1}
|
||||||
|
useFlexGap
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
{projectRolePermissionSummary.map((item) => (
|
||||||
|
<Chip
|
||||||
|
key={item.role}
|
||||||
|
label={`${item.role}:${item.permissions}`}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
{!hasProjectId && (
|
{!hasProjectId && (
|
||||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||||
当前未选择管理项目。
|
当前未选择管理项目。
|
||||||
@@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => {
|
|||||||
)}
|
)}
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
const isSelf = member.user_id === currentAdmin?.id;
|
const isSelf = member.user_id === currentAdmin?.id;
|
||||||
const isOwner = member.project_role === "owner";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={member.id} hover>
|
<TableRow key={member.id} hover>
|
||||||
@@ -1486,15 +1510,6 @@ export const SystemAdminPanel = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{member.email}</TableCell>
|
<TableCell>{member.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{isOwner ? (
|
|
||||||
<Tooltip title="项目负责人不在系统管理页维护">
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
color="warning"
|
|
||||||
label={getProjectRoleLabel(member.project_role)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
||||||
<span>
|
<span>
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -1520,20 +1535,13 @@ export const SystemAdminPanel = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusChip active={member.is_active} />
|
<StatusChip active={member.is_active} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={isSelf ? "不能操作当前登录用户" : ""}
|
||||||
isSelf
|
|
||||||
? "不能操作当前登录用户"
|
|
||||||
: isOwner
|
|
||||||
? "项目负责人不在系统管理页维护"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
@@ -1542,7 +1550,7 @@ export const SystemAdminPanel = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<RemoveCircleOutlineIcon />}
|
startIcon={<RemoveCircleOutlineIcon />}
|
||||||
onClick={() => removeMember(member)}
|
onClick={() => removeMember(member)}
|
||||||
disabled={isSelf || isOwner}
|
disabled={isSelf}
|
||||||
>
|
>
|
||||||
移除
|
移除
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import {
|
|||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { permissionCodes } from "@/lib/permissions";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
|
||||||
type AuditLog = {
|
type AuditLog = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
|
|||||||
end_time: "",
|
end_time: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
|
||||||
|
|
||||||
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
||||||
{ value: "all", label: "全部状态" },
|
{ value: "all", label: "全部状态" },
|
||||||
{ value: "success", label: "成功 2xx" },
|
{ value: "success", label: "成功 2xx" },
|
||||||
@@ -338,8 +342,14 @@ const DetailSection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const AuditLogPanel = () => {
|
export const AuditLogPanel = () => {
|
||||||
const [adminChecked, setAdminChecked] = useState(false);
|
const accessLoading = useAccessStore((state) => state.loading);
|
||||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
|
const isSystemAdmin = useAccessStore(
|
||||||
|
(state) => state.context?.is_system_admin === true,
|
||||||
|
);
|
||||||
|
const canViewAudit = permissions.includes(permissionCodes.auditView);
|
||||||
|
const adminChecked = !accessLoading;
|
||||||
|
const isAuthorized = adminChecked && canViewAudit && isSystemAdmin;
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
const [users, setUsers] = useState<MetadataUser[]>([]);
|
const [users, setUsers] = useState<MetadataUser[]>([]);
|
||||||
const [projects, setProjects] = useState<AdminProject[]>([]);
|
const [projects, setProjects] = useState<AdminProject[]>([]);
|
||||||
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
|
|||||||
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
|
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
|
||||||
const countParams = buildCountParams(appliedFilters);
|
const countParams = buildCountParams(appliedFilters);
|
||||||
const [logsResponse, countResponse] = await Promise.all([
|
const [logsResponse, countResponse] = await Promise.all([
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`),
|
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`),
|
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
|
||||||
]);
|
]);
|
||||||
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
|
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
|
||||||
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
|
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
|
||||||
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
|
|||||||
setTotalCount(Number(countPayload.count ?? 0));
|
setTotalCount(Number(countPayload.count ?? 0));
|
||||||
} else {
|
} else {
|
||||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
const params = buildServerParams(appliedFilters, 0, 1000);
|
||||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
|
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
|
||||||
if (!response.ok) throw new Error(await readErrorText(response));
|
if (!response.ok) throw new Error(await readErrorText(response));
|
||||||
const payload = (await response.json()) as AuditLog[];
|
const payload = (await response.json()) as AuditLog[];
|
||||||
setLogs(payload);
|
setLogs(payload);
|
||||||
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
|
|||||||
}, [appliedFilters, page, rowsPerPage]);
|
}, [appliedFilters, page, rowsPerPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (!isAuthorized) return;
|
||||||
|
|
||||||
const checkAdmin = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
|
||||||
projectHeaderMode: "omit",
|
|
||||||
skipAuthRedirect: true,
|
|
||||||
});
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
setIsAuthorized(false);
|
|
||||||
setAdminChecked(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = await response.json();
|
|
||||||
setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
|
||||||
setAdminChecked(true);
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsAuthorized(false);
|
|
||||||
setAdminChecked(true);
|
|
||||||
setError(String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void checkAdmin();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!adminChecked || !isAuthorized) return;
|
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
}, [adminChecked, isAuthorized, loadOptions]);
|
}, [isAuthorized, loadOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adminChecked || !isAuthorized) return;
|
if (!adminChecked || !isAuthorized) return;
|
||||||
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
const params = buildServerParams(appliedFilters, 0, 1000);
|
||||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
|
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
|
||||||
if (!response.ok) throw new Error(await readErrorText(response));
|
if (!response.ok) throw new Error(await readErrorText(response));
|
||||||
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
|
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
|
||||||
matchesStatusFilter(log, appliedFilters.status),
|
matchesStatusFilter(log, appliedFilters.status),
|
||||||
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
|
|||||||
审计日志
|
审计日志
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
管理员审计查询
|
全局审计查询
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
|
|||||||
<Chip
|
<Chip
|
||||||
color="success"
|
color="success"
|
||||||
icon={<AdminPanelSettingsIcon />}
|
icon={<AdminPanelSettingsIcon />}
|
||||||
label="管理员权限已验证"
|
label="系统管理员权限已验证"
|
||||||
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
|
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||||
|
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { permissionForPath } from "@/lib/permissions";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
|
||||||
|
export const RoutePermissionGuard = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
|
const loading = useAccessStore((state) => state.loading);
|
||||||
|
const requiredPermission = permissionForPath(pathname);
|
||||||
|
|
||||||
|
if (requiredPermission && loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredPermission && !permissions.includes(requiredPermission)) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: { xs: 2, md: 4 } }}>
|
||||||
|
<Alert severity="error" icon={<LockOutlinedIcon />}>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography fontWeight={700}>无权访问此功能</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
当前项目角色缺少权限:{requiredPermission}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
import { AgentComposer } from "./AgentComposer";
|
||||||
|
|
||||||
|
jest.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
|
<img {...props} alt={props.alt ?? ""} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("framer-motion", () => ({
|
||||||
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
motion: {
|
||||||
|
div: ({
|
||||||
|
children,
|
||||||
|
animate: _animate,
|
||||||
|
exit: _exit,
|
||||||
|
initial: _initial,
|
||||||
|
transition: _transition,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||||
|
<div {...props}>{children}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AgentComposer", () => {
|
||||||
|
it("places voice input immediately before send without an attachment action", () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider theme={createTheme()}>
|
||||||
|
<AgentComposer
|
||||||
|
isStreaming={false}
|
||||||
|
isListening={false}
|
||||||
|
isSttSupported
|
||||||
|
presets={[]}
|
||||||
|
onSend={jest.fn()}
|
||||||
|
onAbort={jest.fn()}
|
||||||
|
onStartListening={jest.fn()}
|
||||||
|
onStopListening={jest.fn()}
|
||||||
|
modelOptions={[{ id: "test-model", label: "测试模型" }]}
|
||||||
|
selectedModel="test-model"
|
||||||
|
onModelChange={jest.fn()}
|
||||||
|
approvalMode="request"
|
||||||
|
onApprovalModeChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const voiceButton = screen.getByRole("button", { name: "语音输入" });
|
||||||
|
const sendButton = screen.getByRole("button", { name: "发送" });
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
|
||||||
|
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,6 @@ import StopRounded from "@mui/icons-material/StopRounded";
|
|||||||
import MicRounded from "@mui/icons-material/MicRounded";
|
import MicRounded from "@mui/icons-material/MicRounded";
|
||||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
|
||||||
import BoltRounded from "@mui/icons-material/BoltRounded";
|
import BoltRounded from "@mui/icons-material/BoltRounded";
|
||||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
@@ -125,15 +124,9 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Image
|
<AutoAwesomeRounded
|
||||||
src="/ai-agent.svg"
|
titleAccess="快捷指令图标"
|
||||||
alt="TJWater Agent"
|
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
style={{
|
|
||||||
objectFit: "contain",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
管网分析快捷指令
|
管网分析快捷指令
|
||||||
@@ -230,41 +223,6 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
|
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
|
|
||||||
<AttachFileRounded fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
{isSttSupported ? (
|
|
||||||
isListening ? (
|
|
||||||
<motion.div
|
|
||||||
animate={{ scale: [1, 1.14, 1] }}
|
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={onStopListening}
|
|
||||||
aria-label="停止语音输入"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
color: "error.main",
|
|
||||||
bgcolor: alpha(theme.palette.error.main, 0.15),
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MicRounded fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
onClick={onStartListening}
|
|
||||||
disabled={isStreaming || isHydrating}
|
|
||||||
aria-label="语音输入"
|
|
||||||
size="small"
|
|
||||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
|
||||||
>
|
|
||||||
<MicRounded fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
<FormControl size="small" sx={{ minWidth: 96 }}>
|
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||||
<Select
|
<Select
|
||||||
value={approvalMode}
|
value={approvalMode}
|
||||||
@@ -471,6 +429,39 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{isSttSupported ? (
|
||||||
|
isListening ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.14, 1] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={onStopListening}
|
||||||
|
aria-label="停止语音输入"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: "error.main",
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MicRounded fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
onClick={onStartListening}
|
||||||
|
disabled={isStreaming || isHydrating}
|
||||||
|
aria-label="语音输入"
|
||||||
|
size="small"
|
||||||
|
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||||
|
>
|
||||||
|
<MicRounded fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
|
|||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
});
|
});
|
||||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
|
|||||||
isTitleManuallyEdited: true,
|
isTitleManuallyEdited: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||||
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||||
title: "新标题",
|
title: "新标题",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
|
|||||||
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||||
|
|
||||||
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
|||||||
|
|
||||||
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
|
|||||||
isTitleManuallyEdited?: boolean,
|
isTitleManuallyEdited?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
|
|||||||
|
|
||||||
const deleteBackendChatSession = async (sessionId: string) => {
|
const deleteBackendChatSession = async (sessionId: string) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
|
|||||||
@@ -0,0 +1,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: ["分析参数", "方案查询"],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,13 +11,13 @@ describe("burst detection request", () => {
|
|||||||
const state = createBurstDetectionAnalysisParametersState();
|
const state = createBurstDetectionAnalysisParametersState();
|
||||||
state.schemeName = " latest-case ";
|
state.schemeName = " latest-case ";
|
||||||
|
|
||||||
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
|
expect(buildBurstDetectionRequest(state)).toEqual({
|
||||||
network: "fengyang",
|
|
||||||
scheme_name: "latest-case",
|
scheme_name: "latest-case",
|
||||||
sampling_interval_minutes: 15,
|
sampling_interval_minutes: 15,
|
||||||
});
|
});
|
||||||
expect(state.detectionMode).toBe("latest");
|
expect(state.detectionMode).toBe("latest");
|
||||||
expect(state.targetTime?.minute() % 15).toBe(0);
|
expect(state.targetTime).not.toBeNull();
|
||||||
|
expect(state.targetTime!.minute() % 15).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends one target time for historical replay", () => {
|
it("sends one target time for historical replay", () => {
|
||||||
@@ -32,10 +32,8 @@ describe("burst detection request", () => {
|
|||||||
samplingIntervalMinutes: 30,
|
samplingIntervalMinutes: 30,
|
||||||
samplingIntervalSource: "manual",
|
samplingIntervalSource: "manual",
|
||||||
},
|
},
|
||||||
"fengyang",
|
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
network: "fengyang",
|
|
||||||
scheme_name: "history-case",
|
scheme_name: "history-case",
|
||||||
sampling_interval_minutes: 30,
|
sampling_interval_minutes: 30,
|
||||||
target_time: targetTime.toISOString(),
|
target_time: targetTime.toISOString(),
|
||||||
|
|||||||
@@ -92,9 +92,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
|||||||
|
|
||||||
export const buildBurstDetectionRequest = (
|
export const buildBurstDetectionRequest = (
|
||||||
parameters: BurstDetectionAnalysisParametersState,
|
parameters: BurstDetectionAnalysisParametersState,
|
||||||
network: string,
|
|
||||||
) => ({
|
) => ({
|
||||||
network,
|
|
||||||
scheme_name: parameters.schemeName.trim(),
|
scheme_name: parameters.schemeName.trim(),
|
||||||
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
||||||
...(parameters.detectionMode === "historical" && parameters.targetTime
|
...(parameters.detectionMode === "historical" && parameters.targetTime
|
||||||
@@ -129,7 +127,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
let active = true;
|
let active = true;
|
||||||
setFrequencyLoading(true);
|
setFrequencyLoading(true);
|
||||||
api
|
api
|
||||||
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
|
.get("/api/v1/scada-info")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const interval = resolvePressureSamplingInterval(
|
const interval = resolvePressureSamplingInterval(
|
||||||
@@ -186,8 +184,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
"/api/v1/burst-detection/detect/",
|
"/api/v1/burst-detections",
|
||||||
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
|
buildBurstDetectionRequest(parametersState),
|
||||||
);
|
);
|
||||||
onResult(response.data as BurstDetectionResult);
|
onResult(response.data as BurstDetectionResult);
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { bbox, featureCollection } from "@turf/turf";
|
|||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
export interface BurstDetectionResultsState {
|
export interface BurstDetectionResultsState {
|
||||||
selectedDay: number | null;
|
selectedDay: number | null;
|
||||||
@@ -73,17 +74,11 @@ const formatDateTime = (value?: string) =>
|
|||||||
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
|
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
<PanelEmptyState
|
||||||
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
icon={<ShowChartIcon />}
|
||||||
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
title="尚未生成侦测结果"
|
||||||
</Box>
|
description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||||
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
/>
|
||||||
等待侦测结果
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
|
||||||
提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
|
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
import {
|
import {
|
||||||
BurstDetectionResult,
|
BurstDetectionResult,
|
||||||
BurstDetectionSchemeDetail,
|
BurstDetectionSchemeDetail,
|
||||||
@@ -42,6 +43,7 @@ export interface BurstDetectionSchemeQueryState {
|
|||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: number | null;
|
||||||
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBurstDetectionSchemeQueryState =
|
export const createBurstDetectionSchemeQueryState =
|
||||||
@@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState =
|
|||||||
queryAll: true,
|
queryAll: true,
|
||||||
queryDate: dayjs(),
|
queryDate: dayjs(),
|
||||||
expandedId: null,
|
expandedId: null,
|
||||||
|
hasQueried: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<Props> = ({
|
const SchemeQuery: React.FC<Props> = ({
|
||||||
@@ -64,7 +67,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
createBurstDetectionSchemeQueryState(),
|
createBurstDetectionSchemeQueryState(),
|
||||||
);
|
);
|
||||||
const { queryAll, queryDate, expandedId } = queryState;
|
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||||
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
@@ -127,14 +130,17 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
const params: Record<string, string> = {
|
||||||
|
scheme_type: "burst_detection",
|
||||||
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
params.query_date = queryDate.startOf("day").toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get("/api/v1/burst-detection/schemes/", { params });
|
const response = await api.get("/api/v1/schemes", { params });
|
||||||
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
|
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
|
setQueryField("hasQueried", true);
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
@@ -154,8 +160,8 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleViewSchemeResult = async (schemeName: string) => {
|
const handleViewSchemeResult = async (schemeName: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`,
|
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||||
{ params: { network: NETWORK_NAME } },
|
{ params: { scheme_type: "burst_detection" } },
|
||||||
);
|
);
|
||||||
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
||||||
result_payload?: BurstDetectionResult;
|
result_payload?: BurstDetectionResult;
|
||||||
@@ -200,7 +206,10 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
size="small"
|
size="small"
|
||||||
checked={queryAll}
|
checked={queryAll}
|
||||||
onChange={(event) => setQueryField("queryAll", event.target.checked)}
|
onChange={(event) => {
|
||||||
|
setQueryField("queryAll", event.target.checked);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={<Typography variant="body2">查询全部</Typography>}
|
label={<Typography variant="body2">查询全部</Typography>}
|
||||||
@@ -209,7 +218,10 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={queryDate}
|
value={queryDate}
|
||||||
onChange={(value) => setQueryField("queryDate", value)}
|
onChange={(value) => {
|
||||||
|
setQueryField("queryDate", value);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
disabled={queryAll}
|
disabled={queryAll}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
|
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
|
||||||
@@ -231,12 +243,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
|
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{sortedSchemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
|
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||||
<Typography variant="body2">暂无侦测方案</Typography>
|
|
||||||
<Typography variant="caption" className="mt-1">
|
|
||||||
运行一次展示版侦测后,可在这里回看历史结果。
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="px-2 text-gray-500">
|
<Typography variant="caption" className="px-2 text-gray-500">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { NETWORK_NAME, config } from "@config/config";
|
|||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||||
import { BurstLocationResult } from "./types";
|
import { BurstLocationResult } from "./types";
|
||||||
|
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onResult: (result: BurstLocationResult) => void;
|
onResult: (result: BurstLocationResult) => void;
|
||||||
@@ -125,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
setSchemeLoading(true);
|
setSchemeLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
params: { network: NETWORK_NAME },
|
params: { scheme_type: "burst_analysis" },
|
||||||
});
|
});
|
||||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||||
@@ -222,9 +223,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
|
`${config.BACKEND_URL}/api/v1/burst-locations`,
|
||||||
{
|
{
|
||||||
network: NETWORK_NAME,
|
|
||||||
data_source: dataSource,
|
data_source: dataSource,
|
||||||
scheme_name: schemeName.trim() || undefined,
|
scheme_name: schemeName.trim() || undefined,
|
||||||
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
||||||
@@ -263,11 +263,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const notice = getBurstLocationErrorNotice(error);
|
||||||
open?.({
|
open?.({
|
||||||
key: "burst-location-analysis-error",
|
key: "burst-location-analysis-error",
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "提交分析失败",
|
message: notice.message,
|
||||||
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
|
description: notice.description,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -304,22 +305,30 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Alert
|
<Alert
|
||||||
severity="info"
|
severity={isSimulationMode ? "warning" : "info"}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1,
|
mt: 1,
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
borderColor: "info.light",
|
borderColor: isSimulationMode ? "warning.light" : "info.light",
|
||||||
backgroundColor: "#eff6ff",
|
backgroundColor: isSimulationMode ? "#fff8e1" : "#eff6ff",
|
||||||
"& .MuiAlert-message": { width: "100%" },
|
"& .MuiAlert-message": { width: "100%" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" className="block font-semibold text-blue-900">
|
<Typography
|
||||||
数据选择规则
|
variant="caption"
|
||||||
|
className="block font-semibold"
|
||||||
|
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
||||||
|
>
|
||||||
|
{isSimulationMode ? "模拟方案分析前置条件" : "数据选择规则"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" className="block leading-5 text-blue-800">
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
className="block leading-5"
|
||||||
|
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
||||||
|
>
|
||||||
{isSimulationMode
|
{isSimulationMode
|
||||||
? "当前为模拟方案:爆管数据取所选方案模拟结果,正常数据取实时模拟结果,两者使用同一爆管时间窗。"
|
? "爆管数据取所选方案,正常数据取同一时间窗的实时模拟结果。所选时间窗必须已有实时模拟正常基线,否则无法分析;系统不会自动补数据。"
|
||||||
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
|
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { Stroke, Style, Circle, Fill } from "ol/style";
|
|||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
import { BurstCandidate, BurstLocationResult } from "./types";
|
import { BurstCandidate, BurstLocationResult } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: BurstLocationResult | null;
|
result: BurstLocationResult | null;
|
||||||
@@ -128,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
<PanelEmptyState
|
||||||
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
icon={<MapIcon />}
|
||||||
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
title="尚未生成定位结果"
|
||||||
</Box>
|
description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||||
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
/>
|
||||||
等待定位结果
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" className="max-w-xs text-gray-500">
|
|
||||||
请先提交爆管定位分析,结果面板将展示定位摘要、时间窗、采样情况和候选管段。
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const LocationResults: React.FC<Props> = ({ result }) => {
|
const LocationResults: React.FC<Props> = ({ result }) => {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
BurstSchemeRecord,
|
BurstSchemeRecord,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||||
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: BurstLocationResult) => void;
|
onViewResult: (result: BurstLocationResult) => void;
|
||||||
@@ -56,6 +58,7 @@ export interface BurstLocationSchemeQueryState {
|
|||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: number | null;
|
||||||
simulationBurstIdsByName: Record<string, string[]>;
|
simulationBurstIdsByName: Record<string, string[]>;
|
||||||
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBurstLocationSchemeQueryState =
|
export const createBurstLocationSchemeQueryState =
|
||||||
@@ -64,6 +67,7 @@ export const createBurstLocationSchemeQueryState =
|
|||||||
queryDate: dayjs(),
|
queryDate: dayjs(),
|
||||||
expandedId: null,
|
expandedId: null,
|
||||||
simulationBurstIdsByName: {},
|
simulationBurstIdsByName: {},
|
||||||
|
hasQueried: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<Props> = ({
|
const SchemeQuery: React.FC<Props> = ({
|
||||||
@@ -82,10 +86,11 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
createBurstLocationSchemeQueryState(),
|
createBurstLocationSchemeQueryState(),
|
||||||
);
|
);
|
||||||
const { queryAll, queryDate, expandedId } = queryState;
|
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||||
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
|
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
|
||||||
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const creatorName = useSchemeCreatorName();
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
const sortedSchemes = useMemo(
|
const sortedSchemes = useMemo(
|
||||||
@@ -222,18 +227,17 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// API call to fetch schemes
|
const params: Record<string, string> = {
|
||||||
// Adjust URL as needed
|
scheme_type: "burst_location",
|
||||||
let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`;
|
};
|
||||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
params.query_date = queryDate.startOf("day").toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [response, simulationResponse] = await Promise.all([
|
const [response, simulationResponse] = await Promise.all([
|
||||||
api.get(url, { params }),
|
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
|
||||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
params: { network: NETWORK_NAME },
|
params: { scheme_type: "burst_analysis" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const nextSchemes = response.data as BurstSchemeRecord[];
|
const nextSchemes = response.data as BurstSchemeRecord[];
|
||||||
@@ -254,6 +258,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setQueryField("hasQueried", true);
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
@@ -274,8 +279,8 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleViewSchemeResult = async (schemeName: string) => {
|
const handleViewSchemeResult = async (schemeName: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`,
|
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||||
{ params: { network: NETWORK_NAME } },
|
{ params: { scheme_type: "burst_location" } },
|
||||||
);
|
);
|
||||||
const schemeRecord = response.data as BurstSchemeRecord & {
|
const schemeRecord = response.data as BurstSchemeRecord & {
|
||||||
result_payload?: BurstLocationResult;
|
result_payload?: BurstLocationResult;
|
||||||
@@ -318,7 +323,10 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
size="small"
|
size="small"
|
||||||
checked={queryAll}
|
checked={queryAll}
|
||||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setQueryField("queryAll", e.target.checked);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={<Typography variant="body2">查询全部</Typography>}
|
label={<Typography variant="body2">查询全部</Typography>}
|
||||||
@@ -327,7 +335,10 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={queryDate}
|
value={queryDate}
|
||||||
onChange={(value) => setQueryField("queryDate", value)}
|
onChange={(value) => {
|
||||||
|
setQueryField("queryDate", value);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
disabled={queryAll}
|
disabled={queryAll}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
|
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
|
||||||
@@ -348,39 +359,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{sortedSchemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||||
<Box className="mb-4">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="20"
|
|
||||||
width="60"
|
|
||||||
height="45"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="10"
|
|
||||||
y1="30"
|
|
||||||
x2="70"
|
|
||||||
y2="30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">总共 0 条</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
No data
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
@@ -526,7 +505,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
用户:
|
用户:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
<Typography variant="caption" className="font-medium text-gray-900">
|
||||||
{scheme.username || "-"}
|
{creatorName(scheme.username)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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}。请选择数据完整的模拟方案。`,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -322,16 +322,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
: "";
|
: "";
|
||||||
const modify_total_duration = duration;
|
const modify_total_duration = duration;
|
||||||
const params = {
|
const params = {
|
||||||
network: network,
|
|
||||||
modify_pattern_start_time: modify_pattern_start_time,
|
modify_pattern_start_time: modify_pattern_start_time,
|
||||||
burst_ID: burst_ID,
|
burst_id: burst_ID,
|
||||||
burst_size: burst_size,
|
burst_size: burst_size,
|
||||||
modify_total_duration: modify_total_duration,
|
modify_total_duration: modify_total_duration,
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
|
await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import AnalysisReport, {
|
||||||
|
clearAnalysisReportDiameterCache,
|
||||||
|
matchesValveAnalysis,
|
||||||
|
} from "./AnalysisReport";
|
||||||
|
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
|
import {
|
||||||
|
isAllowedAccidentPipe,
|
||||||
|
normalizeValveIsolationResult,
|
||||||
|
} from "./valveIsolationScope";
|
||||||
|
|
||||||
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
|
queryFeaturesByIds: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scheme: SchemeRecord = {
|
||||||
|
id: 17,
|
||||||
|
schemeName: "burst-report-demo",
|
||||||
|
type: "burst_analysis",
|
||||||
|
username: "operator",
|
||||||
|
create_time: "2026-07-30T08:00:00+08:00",
|
||||||
|
startTime: "2026-07-30T09:00:00+08:00",
|
||||||
|
schemeDetail: {
|
||||||
|
burst_ID: ["P-1", "P-2"],
|
||||||
|
burst_size: [120, 80],
|
||||||
|
modify_total_duration: 5400,
|
||||||
|
modify_fixed_pump_pattern: null,
|
||||||
|
modify_valve_opening: null,
|
||||||
|
modify_variable_pump_pattern: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const valveResult: ValveIsolationResult = {
|
||||||
|
accident_elements: ["P-1"],
|
||||||
|
affected_nodes: ["J-1", "J-2"],
|
||||||
|
must_close_valves: ["V-1"],
|
||||||
|
optional_valves: ["V-2"],
|
||||||
|
isolatable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const feature = (id: string, diameter: number) => ({
|
||||||
|
getProperties: () => ({ id, diameter }),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AnalysisReport", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
||||||
|
async (_ids: string[], layerName: string) =>
|
||||||
|
layerName === "geo_pipes_mat"
|
||||||
|
? [feature("P-1", 315)]
|
||||||
|
: [feature("P-2", 800)],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearAnalysisReportDiameterCache();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
document.body.classList.remove("burst-analysis-report-printing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guides the user to choose a scheme when no report is active", () => {
|
||||||
|
render(
|
||||||
|
<AnalysisReport
|
||||||
|
scheme={null}
|
||||||
|
valveResult={null}
|
||||||
|
disabledValves={[]}
|
||||||
|
generatedAt={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"请在“方案查询”中打开一个方案,再查看分析报告。",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the selected scheme, pipe data, and matching valve result", async () => {
|
||||||
|
render(
|
||||||
|
<AnalysisReport
|
||||||
|
scheme={scheme}
|
||||||
|
valveResult={valveResult}
|
||||||
|
disabledValves={["V-3"]}
|
||||||
|
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = within(
|
||||||
|
screen.getByTestId("burst-analysis-report-preview"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
preview.getByRole("heading", { name: "爆管分析报告" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(preview.getByText("burst-report-demo")).toBeInTheDocument();
|
||||||
|
expect(preview.getByText("可隔离")).toBeInTheDocument();
|
||||||
|
expect(preview.getByText("V-1")).toBeInTheDocument();
|
||||||
|
expect(preview.getByText("V-3")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(preview.getByText("315 mm")).toBeInTheDocument();
|
||||||
|
expect(preview.getByText("800 mm")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(queryFeaturesByIds).toHaveBeenCalledWith(
|
||||||
|
["P-2"],
|
||||||
|
"geo_pipes",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mix an unrelated valve analysis into the report", async () => {
|
||||||
|
render(
|
||||||
|
<AnalysisReport
|
||||||
|
scheme={scheme}
|
||||||
|
valveResult={{ ...valveResult, accident_elements: ["P-99"] }}
|
||||||
|
disabledValves={[]}
|
||||||
|
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = within(
|
||||||
|
screen.getByTestId("burst-analysis-report-preview"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
preview.getByText(/尚未对本方案执行匹配的关阀分析/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(preview.queryByText("V-1")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
matchesValveAnalysis(scheme, {
|
||||||
|
...valveResult,
|
||||||
|
accident_elements: ["P-99"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(preview.getByText("315 mm")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the affected count for a non-isolatable result", async () => {
|
||||||
|
const legacyAffectedNodes = Array.from(
|
||||||
|
{ length: 100 },
|
||||||
|
(_, index) => `legacy-node-${index}`,
|
||||||
|
);
|
||||||
|
const nonIsolatableResult = {
|
||||||
|
...valveResult,
|
||||||
|
affected_nodes: legacyAffectedNodes,
|
||||||
|
affected_node_count: 85747,
|
||||||
|
must_close_valves: [],
|
||||||
|
isolatable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AnalysisReport
|
||||||
|
scheme={scheme}
|
||||||
|
valveResult={nonIsolatableResult}
|
||||||
|
disabledValves={[]}
|
||||||
|
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = within(
|
||||||
|
screen.getByTestId("burst-analysis-report-preview"),
|
||||||
|
);
|
||||||
|
expect(preview.getByText("85747 个")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
preview.getByText("不可隔离,未生成受影响节点清单。"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(preview.queryByText("legacy-node-0")).not.toBeInTheDocument();
|
||||||
|
expect(preview.queryByText("legacy-node-99")).not.toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(preview.getByText("315 mm")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits scheme-bound valve analysis to the scheme accident pipes", () => {
|
||||||
|
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
|
||||||
|
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
|
||||||
|
expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy valve results without changing isolatable lists", () => {
|
||||||
|
expect(
|
||||||
|
normalizeValveIsolationResult({
|
||||||
|
...valveResult,
|
||||||
|
affected_nodes: ["J-1", "J-2", "J-3"],
|
||||||
|
must_close_valves: [],
|
||||||
|
isolatable: false,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
affected_nodes: [],
|
||||||
|
affected_node_count: 3,
|
||||||
|
isolatable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeValveIsolationResult(valveResult)).toMatchObject({
|
||||||
|
affected_nodes: ["J-1", "J-2"],
|
||||||
|
affected_node_count: 2,
|
||||||
|
isolatable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints only after assigning the report print state", async () => {
|
||||||
|
const originalTitle = document.title;
|
||||||
|
const print = jest
|
||||||
|
.spyOn(window, "print")
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AnalysisReport
|
||||||
|
scheme={scheme}
|
||||||
|
valveResult={null}
|
||||||
|
disabledValves={[]}
|
||||||
|
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = within(
|
||||||
|
screen.getByTestId("burst-analysis-report-preview"),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(preview.getByText("315 mm")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.querySelector(".burst-analysis-report-print-root"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "打印/保存 PDF" }),
|
||||||
|
);
|
||||||
|
expect(print).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
document.querySelector(".burst-analysis-report-print-root"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(document.body).toHaveClass("burst-analysis-report-printing");
|
||||||
|
expect(document.title).toBe("爆管分析报告-burst-report-demo");
|
||||||
|
|
||||||
|
fireEvent(window, new Event("afterprint"));
|
||||||
|
expect(document.body).not.toHaveClass(
|
||||||
|
"burst-analysis-report-printing",
|
||||||
|
);
|
||||||
|
expect(document.title).toBe(originalTitle);
|
||||||
|
print.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses pipe diameters after the report is remounted", async () => {
|
||||||
|
const props = {
|
||||||
|
scheme,
|
||||||
|
valveResult: null,
|
||||||
|
disabledValves: [],
|
||||||
|
generatedAt: new Date("2026-07-30T10:00:00+08:00"),
|
||||||
|
};
|
||||||
|
const firstRender = render(<AnalysisReport {...props} />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
|
||||||
|
"800 mm",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
firstRender.unmount();
|
||||||
|
render(<AnalysisReport {...props} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
|
||||||
|
"800 mm",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
GlobalStyles,
|
||||||
|
Paper,
|
||||||
|
Portal,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
DescriptionOutlined,
|
||||||
|
PrintOutlined,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { NETWORK_NAME } from "@config/config";
|
||||||
|
import {
|
||||||
|
clearPipeDiameterCache,
|
||||||
|
getCachedPipeDiameters,
|
||||||
|
getPipeDiameterDisplay,
|
||||||
|
getPipeDiameterQueryKey,
|
||||||
|
loadPipeDiameters,
|
||||||
|
type PipeDiameterMap,
|
||||||
|
} from "./schemePipeDiameters";
|
||||||
|
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
|
import { getAffectedNodeCount } from "./valveIsolationScope";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
|
interface AnalysisReportProps {
|
||||||
|
scheme: SchemeRecord | null;
|
||||||
|
valveResult: ValveIsolationResult | null;
|
||||||
|
disabledValves: string[];
|
||||||
|
generatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportDocumentProps extends AnalysisReportProps {
|
||||||
|
diameters: PipeDiameterMap;
|
||||||
|
loadingDiameters: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPORT_BLUE = "#0b4f87";
|
||||||
|
const REPORT_INK = "#172435";
|
||||||
|
const REPORT_MUTED = "#5f6f80";
|
||||||
|
const REPORT_LINE = "#d8e0e8";
|
||||||
|
export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
|
||||||
|
|
||||||
|
const formatDateTime = (value: Date | string | null | undefined) => {
|
||||||
|
if (!value) return "未记录";
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "未记录";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | undefined) => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds === undefined || seconds < 0) {
|
||||||
|
return "未记录";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return [
|
||||||
|
hours ? `${hours} 小时` : "",
|
||||||
|
minutes ? `${minutes} 分钟` : "",
|
||||||
|
remainingSeconds || (!hours && !minutes) ? `${remainingSeconds} 秒` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchesValveAnalysis = (
|
||||||
|
scheme: SchemeRecord | null,
|
||||||
|
valveResult: ValveIsolationResult | null,
|
||||||
|
) => {
|
||||||
|
const schemePipeIds = new Set(scheme?.schemeDetail?.burst_ID ?? []);
|
||||||
|
const accidentElements = valveResult?.accident_elements ?? [];
|
||||||
|
return (
|
||||||
|
valveResult !== null &&
|
||||||
|
accidentElements.length > 0 &&
|
||||||
|
accidentElements.every((pipeId) => schemePipeIds.has(pipeId))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }} />
|
||||||
|
<Typography
|
||||||
|
component="h2"
|
||||||
|
sx={{ color: REPORT_INK, fontSize: 17, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IdList = ({
|
||||||
|
values,
|
||||||
|
emptyText = "无",
|
||||||
|
}: {
|
||||||
|
values: string[] | undefined;
|
||||||
|
emptyText?: string;
|
||||||
|
}) =>
|
||||||
|
values?.length ? (
|
||||||
|
<Box className="flex flex-wrap gap-1.5">
|
||||||
|
{values.map((value) => (
|
||||||
|
<Chip
|
||||||
|
key={value}
|
||||||
|
label={value}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ borderColor: REPORT_LINE, color: REPORT_INK }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: REPORT_MUTED }}>
|
||||||
|
{emptyText}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ReportDocument: React.FC<ReportDocumentProps> = ({
|
||||||
|
scheme,
|
||||||
|
valveResult,
|
||||||
|
disabledValves,
|
||||||
|
generatedAt,
|
||||||
|
diameters,
|
||||||
|
loadingDiameters,
|
||||||
|
}) => {
|
||||||
|
if (!scheme) return null;
|
||||||
|
|
||||||
|
const pipeIds = scheme.schemeDetail?.burst_ID ?? [];
|
||||||
|
const burstSizes = scheme.schemeDetail?.burst_size ?? [];
|
||||||
|
const matchedValveResult = matchesValveAnalysis(scheme, valveResult)
|
||||||
|
? valveResult
|
||||||
|
: null;
|
||||||
|
const duration = scheme.schemeDetail?.modify_total_duration;
|
||||||
|
const valveDetailRows: Array<[string, string[] | undefined]> =
|
||||||
|
matchedValveResult
|
||||||
|
? [
|
||||||
|
["已分析事故管段", matchedValveResult.accident_elements],
|
||||||
|
["必关阀门", matchedValveResult.must_close_valves],
|
||||||
|
["可选阀门", matchedValveResult.optional_valves],
|
||||||
|
["不可用阀门", disabledValves],
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
if (matchedValveResult?.isolatable) {
|
||||||
|
valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: "100%",
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: REPORT_INK,
|
||||||
|
p: { xs: 2, sm: 3 },
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderBottom: `3px solid ${REPORT_BLUE}`,
|
||||||
|
pb: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component="h1"
|
||||||
|
sx={{ color: REPORT_BLUE, fontSize: 26, fontWeight: 800 }}
|
||||||
|
>
|
||||||
|
爆管分析报告
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ mt: 0.75, color: REPORT_MUTED, fontSize: 13 }}>
|
||||||
|
报告编号:BA-{scheme.id} 生成时间:{formatDateTime(generatedAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 3, breakInside: "avoid" }}>
|
||||||
|
<SectionTitle>方案概况</SectionTitle>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||||
|
borderTop: `1px solid ${REPORT_LINE}`,
|
||||||
|
borderLeft: `1px solid ${REPORT_LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
["管网", NETWORK_NAME],
|
||||||
|
["方案名称", scheme.schemeName],
|
||||||
|
["方案创建人", scheme.username || "未记录"],
|
||||||
|
["方案创建时间", formatDateTime(scheme.create_time)],
|
||||||
|
["模拟开始时间", formatDateTime(scheme.startTime)],
|
||||||
|
["模拟持续时间", formatDuration(duration)],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<Box
|
||||||
|
key={label}
|
||||||
|
sx={{
|
||||||
|
p: 1.25,
|
||||||
|
borderRight: `1px solid ${REPORT_LINE}`,
|
||||||
|
borderBottom: `1px solid ${REPORT_LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ mt: 0.3, fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 3, breakInside: "avoid" }}>
|
||||||
|
<SectionTitle>爆管模拟参数</SectionTitle>
|
||||||
|
<TableContainer component={Paper} variant="outlined" elevation={0}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: "#f3f7fa" }}>
|
||||||
|
<TableCell>序号</TableCell>
|
||||||
|
<TableCell>爆管管段</TableCell>
|
||||||
|
<TableCell>管径</TableCell>
|
||||||
|
<TableCell align="right">爆管面积</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{pipeIds.length ? (
|
||||||
|
pipeIds.map((pipeId, index) => (
|
||||||
|
<TableRow key={`${pipeId}-${index}`}>
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{pipeId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{getPipeDiameterDisplay(
|
||||||
|
[pipeId],
|
||||||
|
diameters,
|
||||||
|
loadingDiameters,
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{Number.isFinite(burstSizes[index])
|
||||||
|
? `${burstSizes[index]} cm²`
|
||||||
|
: "未记录"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} align="center">
|
||||||
|
未记录爆管管段
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 3, breakInside: "avoid" }}>
|
||||||
|
<SectionTitle>分析结论</SectionTitle>
|
||||||
|
<Alert
|
||||||
|
severity={matchedValveResult?.isolatable === false ? "warning" : "info"}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
本方案记录了 {pipeIds.length} 条爆管管段,模拟持续时间为
|
||||||
|
{formatDuration(duration)}。
|
||||||
|
{matchedValveResult
|
||||||
|
? ` 当前关阀分析判定事故管段${
|
||||||
|
matchedValveResult.isolatable ? "可以" : "无法"
|
||||||
|
}有效隔离。`
|
||||||
|
: " 当前会话尚未对本方案执行匹配的关阀分析。"}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<SectionTitle>关阀分析</SectionTitle>
|
||||||
|
{matchedValveResult ? (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
|
||||||
|
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`],
|
||||||
|
["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<Paper
|
||||||
|
key={label}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ p: 1.5, textAlign: "center", breakInside: "avoid" }}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ mt: 0.5, fontWeight: 700 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{valveDetailRows.map(([label, values]) => (
|
||||||
|
<Box key={label} sx={{ breakInside: "avoid" }}>
|
||||||
|
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<IdList values={values} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{!matchedValveResult.isolatable && (
|
||||||
|
<Alert severity="info" variant="outlined">
|
||||||
|
不可隔离,未生成受影响节点清单。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info" variant="outlined">
|
||||||
|
本方案尚未执行关阀分析。可在“关阀分析”页签完成分析后重新查看报告。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnalysisReport: React.FC<AnalysisReportProps> = ({
|
||||||
|
scheme,
|
||||||
|
valveResult,
|
||||||
|
disabledValves,
|
||||||
|
generatedAt,
|
||||||
|
}) => {
|
||||||
|
const pipeIds = useMemo(
|
||||||
|
() => scheme?.schemeDetail?.burst_ID ?? [],
|
||||||
|
[scheme],
|
||||||
|
);
|
||||||
|
const diameterQueryKey = getPipeDiameterQueryKey(pipeIds);
|
||||||
|
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||||
|
const [diameterState, setDiameterState] = useState<{
|
||||||
|
queryKey: string | null;
|
||||||
|
values: PipeDiameterMap;
|
||||||
|
}>({ queryKey: null, values: {} });
|
||||||
|
const [printReady, setPrintReady] = useState(false);
|
||||||
|
const printStateRef = useRef<{ title: string } | null>(null);
|
||||||
|
const diameters = useMemo(
|
||||||
|
() =>
|
||||||
|
diameterState.queryKey === diameterQueryKey
|
||||||
|
? diameterState.values
|
||||||
|
: cachedDiameters ?? {},
|
||||||
|
[cachedDiameters, diameterQueryKey, diameterState],
|
||||||
|
);
|
||||||
|
const loadingDiameters =
|
||||||
|
pipeIds.length > 0 &&
|
||||||
|
diameterState.queryKey !== diameterQueryKey &&
|
||||||
|
!cachedDiameters;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pipeIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getCachedPipeDiameters(pipeIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadPipeDiameters(pipeIds)
|
||||||
|
.then((nextDiameters) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setDiameterState({
|
||||||
|
queryKey: diameterQueryKey,
|
||||||
|
values: nextDiameters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("查询分析报告管径失败:", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setDiameterState({
|
||||||
|
queryKey: diameterQueryKey,
|
||||||
|
values: Object.fromEntries(
|
||||||
|
pipeIds.map((pipeId) => [pipeId, null]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [diameterQueryKey, pipeIds]);
|
||||||
|
|
||||||
|
const restoreAfterPrint = useCallback(() => {
|
||||||
|
if (printStateRef.current) {
|
||||||
|
document.title = printStateRef.current.title;
|
||||||
|
printStateRef.current = null;
|
||||||
|
document.body.classList.remove("burst-analysis-report-printing");
|
||||||
|
}
|
||||||
|
setPrintReady(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
restoreAfterPrint();
|
||||||
|
},
|
||||||
|
[restoreAfterPrint],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
if (!scheme || printStateRef.current) return;
|
||||||
|
printStateRef.current = { title: document.title };
|
||||||
|
document.title = `爆管分析报告-${scheme.schemeName}`;
|
||||||
|
document.body.classList.add("burst-analysis-report-printing");
|
||||||
|
setPrintReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!printReady) return;
|
||||||
|
|
||||||
|
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
|
||||||
|
try {
|
||||||
|
window.print();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("打印爆管分析报告失败:", error);
|
||||||
|
window.setTimeout(restoreAfterPrint, 0);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("afterprint", restoreAfterPrint);
|
||||||
|
};
|
||||||
|
}, [printReady, restoreAfterPrint]);
|
||||||
|
|
||||||
|
const reportDocument = useMemo(
|
||||||
|
() => (
|
||||||
|
<ReportDocument
|
||||||
|
scheme={scheme}
|
||||||
|
valveResult={valveResult}
|
||||||
|
disabledValves={disabledValves}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
diameters={diameters}
|
||||||
|
loadingDiameters={loadingDiameters}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
diameters,
|
||||||
|
disabledValves,
|
||||||
|
generatedAt,
|
||||||
|
loadingDiameters,
|
||||||
|
scheme,
|
||||||
|
valveResult,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!scheme) {
|
||||||
|
return (
|
||||||
|
<PanelEmptyState
|
||||||
|
icon={<DescriptionOutlined />}
|
||||||
|
title="尚未选择分析方案"
|
||||||
|
description="请在“方案查询”中打开一个方案,再查看分析报告。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlobalStyles
|
||||||
|
styles={{
|
||||||
|
".burst-analysis-report-print-root": { display: "none" },
|
||||||
|
"@page": { size: "A4 portrait", margin: 0 },
|
||||||
|
"@media print": {
|
||||||
|
"html, body": {
|
||||||
|
width: "210mm",
|
||||||
|
minHeight: "297mm",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"body.burst-analysis-report-printing > *:not(.burst-analysis-report-print-root)":
|
||||||
|
{ display: "none !important" },
|
||||||
|
".burst-analysis-report-print-root": {
|
||||||
|
display: "block !important",
|
||||||
|
width: "210mm !important",
|
||||||
|
minHeight: "297mm !important",
|
||||||
|
backgroundColor: "#fff !important",
|
||||||
|
},
|
||||||
|
".burst-analysis-report-print-root > div": {
|
||||||
|
padding: "14mm 16mm !important",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box className="space-y-3">
|
||||||
|
<Box className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<PrintOutlined />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
>
|
||||||
|
打印/保存 PDF
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Paper
|
||||||
|
data-testid="burst-analysis-report-preview"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{reportDocument}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
{printReady && (
|
||||||
|
<Portal>
|
||||||
|
<Box
|
||||||
|
className="burst-analysis-report-print-root"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{reportDocument}
|
||||||
|
</Box>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalysisReport;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import BurstPipeAnalysisPanel from "./BurstPipeAnalysisPanel";
|
||||||
|
|
||||||
|
jest.mock("./AnalysisParameters", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div>analysis parameters</div>,
|
||||||
|
createBurstAnalysisParametersState: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./SchemeQuery", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
onViewReport,
|
||||||
|
}: {
|
||||||
|
onViewReport: (scheme: Record<string, unknown>) => void;
|
||||||
|
}) => {
|
||||||
|
const createScheme = (id: number, schemeName: string) => ({
|
||||||
|
id,
|
||||||
|
schemeName,
|
||||||
|
type: "burst_analysis",
|
||||||
|
username: "operator",
|
||||||
|
create_time: "2026-07-30T08:00:00+08:00",
|
||||||
|
startTime: "2026-07-30T09:00:00+08:00",
|
||||||
|
schemeDetail: {
|
||||||
|
burst_ID: ["P-1", "P-2"],
|
||||||
|
burst_size: [120, 80],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => onViewReport(createScheme(1, "selected-scheme"))}>
|
||||||
|
mock report action
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onViewReport(createScheme(2, "other-scheme"))}>
|
||||||
|
mock other report action
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createBurstSchemeQueryState: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AnalysisReport", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
scheme,
|
||||||
|
valveResult,
|
||||||
|
}: {
|
||||||
|
scheme: { schemeName: string } | null;
|
||||||
|
valveResult: { accident_elements: string[] } | null;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
report:{scheme?.schemeName ?? "empty"}:valve:
|
||||||
|
{valveResult?.accident_elements.join(",") ?? "none"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
matchesValveAnalysis: (
|
||||||
|
scheme: { schemeDetail: { burst_ID: string[] } },
|
||||||
|
valveResult: { accident_elements: string[] } | null,
|
||||||
|
) =>
|
||||||
|
valveResult !== null &&
|
||||||
|
valveResult.accident_elements.every((pipeId) =>
|
||||||
|
scheme.schemeDetail.burst_ID.includes(pipeId),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./ValveIsolation", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
allowedPipeIds,
|
||||||
|
sourceSchemeName,
|
||||||
|
onResultChange,
|
||||||
|
}: {
|
||||||
|
allowedPipeIds?: string[];
|
||||||
|
sourceSchemeName?: string;
|
||||||
|
onResultChange: (result: Record<string, unknown>) => void;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<div data-testid="valve-scope">
|
||||||
|
valve scope:{sourceSchemeName ?? "independent"}:
|
||||||
|
{allowedPipeIds?.join(",") ?? "any"}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onResultChange({
|
||||||
|
accident_elements: ["P-1"],
|
||||||
|
affected_nodes: ["J-1"],
|
||||||
|
must_close_valves: ["V-1"],
|
||||||
|
optional_valves: [],
|
||||||
|
isolatable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
mock valve result
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
createValveIsolationState: () => ({
|
||||||
|
selectedPipeId: null,
|
||||||
|
activeStep: 0,
|
||||||
|
expandedResult: true,
|
||||||
|
disabledValves: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("BurstPipeAnalysisPanel", () => {
|
||||||
|
it("replaces the obsolete location tab and opens the selected report", () => {
|
||||||
|
render(<BurstPipeAnalysisPanel />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("tab", { name: /定位结果/ }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("tab", { name: /分析报告/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "mock report action" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("report:selected-scheme:valve:none"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
|
||||||
|
expect(screen.getByTestId("valve-scope")).toHaveTextContent(
|
||||||
|
"valve scope:selected-scheme:P-1,P-2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reuse valve results across schemes with the same pipe", () => {
|
||||||
|
render(<BurstPipeAnalysisPanel />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "mock report action" }),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "mock valve result" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "mock other report action" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("report:other-scheme:valve:none"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Analytics as AnalyticsIcon,
|
Analytics as AnalyticsIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
MyLocation as MyLocationIcon,
|
DescriptionOutlined as ReportIcon,
|
||||||
Handyman as HandymanIcon,
|
Handyman as HandymanIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import AnalysisParameters, {
|
import AnalysisParameters, {
|
||||||
@@ -26,15 +26,12 @@ import SchemeQuery, {
|
|||||||
createBurstSchemeQueryState,
|
createBurstSchemeQueryState,
|
||||||
type BurstSchemeQueryState,
|
type BurstSchemeQueryState,
|
||||||
} from "./SchemeQuery";
|
} from "./SchemeQuery";
|
||||||
import LocationResults from "./LocationResults";
|
import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
|
||||||
import ValveIsolation, {
|
import ValveIsolation, {
|
||||||
createValveIsolationState,
|
createValveIsolationState,
|
||||||
type ValveIsolationState,
|
type ValveIsolationState,
|
||||||
} from "./ValveIsolation";
|
} from "./ValveIsolation";
|
||||||
import { api } from "@/lib/api";
|
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
import { config } from "@config/config";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
|
||||||
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
|
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
|
|
||||||
// 持久化方案查询结果
|
// 持久化方案查询结果
|
||||||
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||||
// 定位结果数据
|
const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
|
||||||
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
|
const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
|
||||||
// 关阀分析结果和加载状态
|
// 关阀分析结果和加载状态
|
||||||
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
||||||
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
||||||
|
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
|
||||||
|
useState<string | null>(null);
|
||||||
const [valveIsolationState, setValveIsolationState] =
|
const [valveIsolationState, setValveIsolationState] =
|
||||||
useState<ValveIsolationState>(createValveIsolationState);
|
useState<ValveIsolationState>(createValveIsolationState);
|
||||||
|
|
||||||
const { open } = useNotification();
|
|
||||||
|
|
||||||
// 使用受控或非受控状态
|
// 使用受控或非受控状态
|
||||||
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
@@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
setCurrentTab(newValue);
|
setCurrentTab(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLocateScheme = async (scheme: SchemeRecord) => {
|
const handleViewReport = (scheme: SchemeRecord) => {
|
||||||
try {
|
if (
|
||||||
const response = await api.get(
|
valveAnalysisSchemeName !== scheme.schemeName ||
|
||||||
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
|
!matchesValveAnalysis(scheme, valveAnalysisResult)
|
||||||
);
|
) {
|
||||||
setLocationResults(response.data);
|
setValveAnalysisResult(null);
|
||||||
setCurrentTab(2); // 切换到定位结果标签页
|
setValveAnalysisSchemeName(null);
|
||||||
} catch (error) {
|
setValveIsolationState(createValveIsolationState());
|
||||||
console.error("获取定位结果失败:", error);
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "获取定位结果失败",
|
|
||||||
description: "无法从服务器获取该方案的定位结果",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setReportScheme(scheme);
|
||||||
|
setReportGeneratedAt(new Date());
|
||||||
|
setCurrentTab(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValveAnalysisResultChange = (
|
||||||
|
result: ValveIsolationResult | null,
|
||||||
|
) => {
|
||||||
|
setValveAnalysisResult(result);
|
||||||
|
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawerWidth = 520;
|
const drawerWidth = 520;
|
||||||
@@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
label="方案查询"
|
label="方案查询"
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
icon={<MyLocationIcon fontSize="small" />}
|
icon={<ReportIcon fontSize="small" />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
label="定位结果"
|
label="分析报告"
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
icon={<HandymanIcon fontSize="small" />}
|
icon={<HandymanIcon fontSize="small" />}
|
||||||
@@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
onLocate={handleLocateScheme}
|
onViewReport={handleViewReport}
|
||||||
state={queryState}
|
state={queryState}
|
||||||
onStateChange={setQueryState}
|
onStateChange={setQueryState}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={2}>
|
<TabPanel value={currentTab} index={2}>
|
||||||
<LocationResults
|
<AnalysisReport
|
||||||
results={locationResults}
|
scheme={reportScheme}
|
||||||
|
valveResult={valveAnalysisResult}
|
||||||
|
disabledValves={valveIsolationState.disabledValves}
|
||||||
|
generatedAt={reportGeneratedAt}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
loading={valveAnalysisLoading}
|
loading={valveAnalysisLoading}
|
||||||
result={valveAnalysisResult}
|
result={valveAnalysisResult}
|
||||||
onLoadingChange={setValveAnalysisLoading}
|
onLoadingChange={setValveAnalysisLoading}
|
||||||
onResultChange={setValveAnalysisResult}
|
onResultChange={handleValveAnalysisResultChange}
|
||||||
state={valveIsolationState}
|
state={valveIsolationState}
|
||||||
onStateChange={setValveIsolationState}
|
onStateChange={setValveIsolationState}
|
||||||
|
allowedPipeIds={
|
||||||
|
reportScheme?.schemeDetail?.burst_ID
|
||||||
|
}
|
||||||
|
sourceSchemeName={reportScheme?.schemeName}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,416 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Chip,
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
Link,
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
LocationOn as LocationIcon,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
|
||||||
import { GeoJSON } from "ol/format";
|
|
||||||
import VectorLayer from "ol/layer/Vector";
|
|
||||||
import VectorSource from "ol/source/Vector";
|
|
||||||
import { Stroke, Style, Icon } from "ol/style";
|
|
||||||
import Feature, { FeatureLike } from "ol/Feature";
|
|
||||||
import {
|
|
||||||
along,
|
|
||||||
lineString,
|
|
||||||
length,
|
|
||||||
toMercator,
|
|
||||||
bbox,
|
|
||||||
featureCollection,
|
|
||||||
} from "@turf/turf";
|
|
||||||
import { Point } from "ol/geom";
|
|
||||||
import { toLonLat } from "ol/proj";
|
|
||||||
import moment from "moment";
|
|
||||||
import "moment-timezone";
|
|
||||||
import { LocationResult } from "./types";
|
|
||||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
|
||||||
|
|
||||||
interface LocationResultsProps {
|
|
||||||
results?: LocationResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationResults: React.FC<LocationResultsProps> = ({
|
|
||||||
results = [],
|
|
||||||
}) => {
|
|
||||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
|
||||||
const map = useMap();
|
|
||||||
|
|
||||||
// 格式化时间为 UTC+8
|
|
||||||
const formatTime = (timeStr: string) => {
|
|
||||||
return moment(timeStr).utcOffset(8).format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLocatePipes = (pipeIds: string[]) => {
|
|
||||||
if (pipeIds.length > 0) {
|
|
||||||
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
|
|
||||||
if (features.length > 0) {
|
|
||||||
// 设置高亮要素
|
|
||||||
setHighlightFeatures(features);
|
|
||||||
// 将 OpenLayers Feature 转换为 GeoJSON Feature
|
|
||||||
const geojsonFormat = new GeoJSON();
|
|
||||||
const geojsonFeatures = features.map((feature) =>
|
|
||||||
geojsonFormat.writeFeatureObject(feature),
|
|
||||||
);
|
|
||||||
|
|
||||||
const extent = bbox(featureCollection(geojsonFeatures as any));
|
|
||||||
|
|
||||||
if (extent) {
|
|
||||||
map?.getView().fit(extent, { maxZoom: 18, duration: 1000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化管道图层和高亮图层
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const burstPipeStyle = function (feature: FeatureLike) {
|
|
||||||
const styles = [];
|
|
||||||
// 线条样式(底层发光,主线条,内层高亮线)
|
|
||||||
styles.push(
|
|
||||||
new Style({
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: "rgba(255, 0, 0, 0.3)",
|
|
||||||
width: 12,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
new Style({
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: "rgba(255, 0, 0, 1)",
|
|
||||||
width: 6,
|
|
||||||
lineDash: [15, 10],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
new Style({
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: "rgba(255, 102, 102, 1)",
|
|
||||||
width: 3,
|
|
||||||
lineDash: [15, 10],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const geometry = feature.getGeometry();
|
|
||||||
const lineCoords =
|
|
||||||
geometry?.getType() === "LineString"
|
|
||||||
? (geometry as any).getCoordinates()
|
|
||||||
: null;
|
|
||||||
if (geometry && lineCoords) {
|
|
||||||
const lineCoordsWGS84 = lineCoords.map((coord: []) => {
|
|
||||||
const [lon, lat] = toLonLat(coord);
|
|
||||||
return [lon, lat];
|
|
||||||
});
|
|
||||||
// 计算中点
|
|
||||||
const lineStringFeature = lineString(lineCoordsWGS84);
|
|
||||||
const lineLength = length(lineStringFeature);
|
|
||||||
const midPoint = along(lineStringFeature, lineLength / 2).geometry
|
|
||||||
.coordinates;
|
|
||||||
// 在中点添加 icon 样式
|
|
||||||
const midPointMercator = toMercator(midPoint);
|
|
||||||
styles.push(
|
|
||||||
new Style({
|
|
||||||
geometry: new Point(midPointMercator),
|
|
||||||
image: new Icon({
|
|
||||||
src: "/icons/burst_pipe.svg",
|
|
||||||
scale: 0.2,
|
|
||||||
anchor: [0.5, 1],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return styles;
|
|
||||||
};
|
|
||||||
// 创建高亮图层
|
|
||||||
const highlightLayer = new VectorLayer({
|
|
||||||
source: new VectorSource(),
|
|
||||||
style: burstPipeStyle,
|
|
||||||
maxZoom: 24,
|
|
||||||
minZoom: 12,
|
|
||||||
properties: {
|
|
||||||
name: "爆管管段高亮",
|
|
||||||
value: "burst_pipe_highlight",
|
|
||||||
queryable: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
map.addLayer(highlightLayer);
|
|
||||||
highlightLayerRef.current = highlightLayer;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
highlightLayerRef.current = null;
|
|
||||||
map.removeLayer(highlightLayer);
|
|
||||||
};
|
|
||||||
}, [map]);
|
|
||||||
|
|
||||||
// 高亮要素的函数
|
|
||||||
useEffect(() => {
|
|
||||||
const source = highlightLayerRef.current?.getSource();
|
|
||||||
if (!source) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 清除之前的高亮
|
|
||||||
source.clear();
|
|
||||||
// 添加新的高亮要素
|
|
||||||
highlightFeatures.forEach((feature) => {
|
|
||||||
if (feature instanceof Feature) {
|
|
||||||
source.addFeature(feature);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [highlightFeatures]);
|
|
||||||
|
|
||||||
// 取第一条记录或空对象
|
|
||||||
const result = results.length > 0 ? results[0] : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="flex flex-col h-full">
|
|
||||||
{/* 结果展示 */}
|
|
||||||
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200">
|
|
||||||
{!result ? (
|
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
|
|
||||||
<Box className="mb-4">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="40"
|
|
||||||
cy="40"
|
|
||||||
r="25"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<circle cx="40" cy="40" r="5" fill="currentColor" />
|
|
||||||
<line
|
|
||||||
x1="40"
|
|
||||||
y1="15"
|
|
||||||
x2="40"
|
|
||||||
y2="25"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="40"
|
|
||||||
y1="55"
|
|
||||||
x2="40"
|
|
||||||
y2="65"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="15"
|
|
||||||
y1="40"
|
|
||||||
x2="25"
|
|
||||||
y2="40"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="55"
|
|
||||||
y1="40"
|
|
||||||
x2="65"
|
|
||||||
y2="40"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">暂无定位结果</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
请先执行方案分析
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box className="p-5 h-full overflow-auto">
|
|
||||||
{/* 头部:标识信息 */}
|
|
||||||
<Box className="mb-5">
|
|
||||||
<Box className="flex items-center gap-2 mb-1">
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
className="font-bold text-gray-900"
|
|
||||||
title={result.burst_incident}
|
|
||||||
>
|
|
||||||
{result.burst_incident}
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label={
|
|
||||||
result.type === "burst_analysis" ? "爆管模拟" : result.type
|
|
||||||
}
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
height: "24px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" className="text-gray-500">
|
|
||||||
ID: {result.id}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 主要信息:三栏卡片布局 */}
|
|
||||||
<Box className="grid grid-cols-3 gap-3 mb-5">
|
|
||||||
{/* 检测时间卡片 */}
|
|
||||||
<Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
<Box className="flex items-center gap-1.5 mb-2">
|
|
||||||
<Box className="w-1.5 h-1.5 rounded-full bg-blue-600"></Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
className="text-blue-700 font-semibold uppercase tracking-wide"
|
|
||||||
sx={{ fontSize: "0.7rem" }}
|
|
||||||
>
|
|
||||||
检测时间
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
className="font-bold text-blue-900 leading-tight"
|
|
||||||
sx={{ fontSize: "0.875rem" }}
|
|
||||||
>
|
|
||||||
{formatTime(result.detect_time)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 漏损量卡片 */}
|
|
||||||
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
<Box className="flex items-center gap-1.5 mb-2">
|
|
||||||
<Box className="w-1.5 h-1.5 rounded-full bg-orange-600"></Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
className="text-orange-700 font-semibold uppercase tracking-wide"
|
|
||||||
sx={{ fontSize: "0.7rem" }}
|
|
||||||
>
|
|
||||||
漏损量
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
className="font-bold text-orange-900"
|
|
||||||
sx={{ fontSize: "0.875rem" }}
|
|
||||||
>
|
|
||||||
{result.leakage !== null
|
|
||||||
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
|
|
||||||
: "N/A"}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 定位管段数量卡片 */}
|
|
||||||
<Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
<Box className="flex items-center gap-1.5 mb-2">
|
|
||||||
<Box className="w-1.5 h-1.5 rounded-full bg-green-600"></Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
className="text-green-700 font-semibold uppercase tracking-wide"
|
|
||||||
sx={{ fontSize: "0.7rem" }}
|
|
||||||
>
|
|
||||||
定位管段
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
className="font-bold text-green-900"
|
|
||||||
sx={{ fontSize: "0.875rem" }}
|
|
||||||
>
|
|
||||||
{result.locate_result ? result.locate_result.length : 0}{" "}
|
|
||||||
个管段
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 定位管段详细列表 */}
|
|
||||||
{result.locate_result && result.locate_result.length > 0 && (
|
|
||||||
<Box className="bg-white rounded-lg p-4 border-2 border-blue-200 shadow-sm">
|
|
||||||
<Box className="flex items-center justify-between mb-3">
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
className="text-gray-900 font-bold"
|
|
||||||
sx={{ fontSize: "0.95rem" }}
|
|
||||||
>
|
|
||||||
管段列表
|
|
||||||
</Typography>
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Tooltip title="定位所有管道">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleLocatePipes(result.locate_result!)}
|
|
||||||
color="primary"
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "rgba(37, 125, 212, 0.1)",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "rgba(37, 125, 212, 0.2)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LocationIcon sx={{ fontSize: "1.2rem" }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box className="grid grid-cols-2 gap-2">
|
|
||||||
{result.locate_result.map((pipeId, idx) => (
|
|
||||||
<Box
|
|
||||||
key={idx}
|
|
||||||
className="bg-gradient-to-r from-blue-50 to-white rounded-lg px-3 py-2 border border-blue-200 hover:border-blue-400 hover:shadow-md transition-all cursor-pointer group"
|
|
||||||
onClick={() => handleLocatePipes([pipeId])}
|
|
||||||
sx={{
|
|
||||||
"&:active": {
|
|
||||||
transform: "scale(0.98)",
|
|
||||||
boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box className="flex items-center justify-between">
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
className="font-semibold text-blue-700 group-hover:text-blue-900"
|
|
||||||
>
|
|
||||||
{pipeId}
|
|
||||||
</Typography>
|
|
||||||
<Box className="flex items-center gap-1">
|
|
||||||
{/* <Tooltip title="定位管段">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleLocatePipes([pipeId]);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "rgba(37, 125, 212, 0.1)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LocationIcon sx={{ fontSize: "1rem" }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip> */}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LocationResults;
|
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
|
DescriptionOutlined as ReportIcon,
|
||||||
Info as InfoIcon,
|
Info as InfoIcon,
|
||||||
LocationOn as LocationIcon,
|
LocationOn as LocationIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
@@ -52,14 +53,18 @@ import { toLonLat } from "ol/proj";
|
|||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { SchemaItem, SchemeRecord } from "./types";
|
import { SchemaItem, SchemeRecord } from "./types";
|
||||||
import {
|
import {
|
||||||
|
getCachedPipeDiameters,
|
||||||
getPipeDiameterDisplay,
|
getPipeDiameterDisplay,
|
||||||
|
loadPipeDiameters,
|
||||||
type PipeDiameterMap,
|
type PipeDiameterMap,
|
||||||
} from "./schemePipeDiameters";
|
} from "./schemePipeDiameters";
|
||||||
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||||
onLocate?: (scheme: SchemeRecord) => void;
|
onViewReport?: (scheme: SchemeRecord) => void;
|
||||||
network?: string;
|
network?: string;
|
||||||
state?: BurstSchemeQueryState;
|
state?: BurstSchemeQueryState;
|
||||||
onStateChange?: (state: BurstSchemeQueryState) => void;
|
onStateChange?: (state: BurstSchemeQueryState) => void;
|
||||||
@@ -74,6 +79,7 @@ export interface BurstSchemeQueryState {
|
|||||||
selectedDate: Date | undefined;
|
selectedDate: Date | undefined;
|
||||||
timeRange: { start: Date; end: Date } | undefined;
|
timeRange: { start: Date; end: Date } | undefined;
|
||||||
expandedId: number | null;
|
expandedId: number | null;
|
||||||
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
|
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
|
||||||
@@ -83,12 +89,13 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
|
|||||||
selectedDate: undefined,
|
selectedDate: undefined,
|
||||||
timeRange: undefined,
|
timeRange: undefined,
|
||||||
expandedId: null,
|
expandedId: null,
|
||||||
|
hasQueried: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||||
schemes: externalSchemes,
|
schemes: externalSchemes,
|
||||||
onSchemesChange,
|
onSchemesChange,
|
||||||
onLocate,
|
onViewReport,
|
||||||
network = NETWORK_NAME,
|
network = NETWORK_NAME,
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
@@ -105,6 +112,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
selectedDate,
|
selectedDate,
|
||||||
timeRange,
|
timeRange,
|
||||||
expandedId,
|
expandedId,
|
||||||
|
hasQueried,
|
||||||
} = queryState;
|
} = queryState;
|
||||||
const [highlightLayer, setHighlightLayer] =
|
const [highlightLayer, setHighlightLayer] =
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
@@ -112,6 +120,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const creatorName = useSchemeCreatorName();
|
||||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
||||||
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
||||||
Record<number, PipeDiameterMap>
|
Record<number, PipeDiameterMap>
|
||||||
@@ -152,9 +161,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const params: Record<string, string> = {
|
||||||
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
scheme_type: SCHEME_TYPE,
|
||||||
);
|
};
|
||||||
|
if (!queryAll && queryDate) {
|
||||||
|
params.query_date = queryDate.startOf("day").toISOString();
|
||||||
|
}
|
||||||
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|
||||||
if (!queryAll) {
|
if (!queryAll) {
|
||||||
@@ -169,16 +184,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
id: item.scheme_id,
|
id: item.scheme_id,
|
||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
type: item.scheme_type,
|
type: item.scheme_type,
|
||||||
user: item.username,
|
username: item.username,
|
||||||
create_time: item.create_time,
|
create_time: item.create_time,
|
||||||
startTime: item.scheme_start_time,
|
startTime: item.scheme_start_time,
|
||||||
schemeDetail: item.scheme_detail,
|
schemeDetail: item.scheme_detail,
|
||||||
}));
|
}));
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
|
setQueryField("hasQueried", true);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "success",
|
||||||
message: "查询结果",
|
message: "查询结果",
|
||||||
description: queryAll
|
description: queryAll
|
||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
@@ -236,40 +252,23 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||||
|
if (cachedDiameters) {
|
||||||
|
setPipeDiametersByScheme((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[expandedId]: cachedDiameters,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingDiameterByScheme((previous) => ({
|
setLoadingDiameterByScheme((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
[expandedId]: true,
|
[expandedId]: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const loadPipeDiameters = async () => {
|
const applyPipeDiameters = async () => {
|
||||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
const nextDiameters = await loadPipeDiameters(pipeIds);
|
||||||
const foundPipeIds = new Set(
|
|
||||||
features.map((feature) => String(feature.getProperties().id)),
|
|
||||||
);
|
|
||||||
const missingPipeIds = pipeIds.filter(
|
|
||||||
(pipeId) => !foundPipeIds.has(pipeId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingPipeIds.length > 0) {
|
|
||||||
const fallbackFeatures = await queryFeaturesByIds(
|
|
||||||
missingPipeIds,
|
|
||||||
"geo_pipes",
|
|
||||||
);
|
|
||||||
features = [...features, ...fallbackFeatures];
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDiameters: PipeDiameterMap = Object.fromEntries(
|
|
||||||
pipeIds.map((pipeId) => [pipeId, null]),
|
|
||||||
);
|
|
||||||
|
|
||||||
features.forEach((feature) => {
|
|
||||||
const properties = feature.getProperties();
|
|
||||||
const pipeId = String(properties.id);
|
|
||||||
const diameter = Number(properties.diameter);
|
|
||||||
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -284,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPipeDiameters().catch((error) => {
|
applyPipeDiameters().catch((error) => {
|
||||||
console.error("查询管径失败:", error);
|
console.error("查询管径失败:", error);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLoadingDiameterByScheme((previous) => ({
|
setLoadingDiameterByScheme((previous) => ({
|
||||||
@@ -452,7 +451,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={queryAll}
|
checked={queryAll}
|
||||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setQueryField("queryAll", e.target.checked);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -465,9 +467,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={queryDate}
|
value={queryDate}
|
||||||
onChange={(value) =>
|
onChange={(value) => {
|
||||||
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
|
if (value && dayjs.isDayjs(value)) {
|
||||||
|
setQueryField("queryDate", value);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
disabled={queryAll}
|
disabled={queryAll}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -495,39 +500,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
{/* 结果列表 */}
|
{/* 结果列表 */}
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{filteredSchemes.length === 0 ? (
|
{filteredSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||||
<Box className="mb-4">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="20"
|
|
||||||
width="60"
|
|
||||||
height="45"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="10"
|
|
||||||
y1="30"
|
|
||||||
x2="70"
|
|
||||||
y2="30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">总共 0 条</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
No data
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
@@ -592,14 +565,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
<InfoIcon fontSize="small" />
|
<InfoIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="查看定位结果">
|
<Tooltip title="查看分析报告">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onLocate?.(scheme)}
|
onClick={() => onViewReport?.(scheme)}
|
||||||
color="primary"
|
color="primary"
|
||||||
className="p-1"
|
className="p-1"
|
||||||
>
|
>
|
||||||
<LocationIcon fontSize="small" />
|
<ReportIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -715,7 +688,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
className="font-medium text-gray-900"
|
className="font-medium text-gray-900"
|
||||||
>
|
>
|
||||||
{scheme.user}
|
{creatorName(scheme.username)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -52,9 +52,16 @@ import {
|
|||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import {
|
||||||
|
getAffectedNodeCount,
|
||||||
|
isAllowedAccidentPipe,
|
||||||
|
normalizeValveIsolationResult,
|
||||||
|
} from "./valveIsolationScope";
|
||||||
|
|
||||||
interface ValveIsolationProps {
|
interface ValveIsolationProps {
|
||||||
initialPipeIds?: string[];
|
initialPipeIds?: string[];
|
||||||
|
allowedPipeIds?: string[];
|
||||||
|
sourceSchemeName?: string;
|
||||||
shouldFetch?: boolean;
|
shouldFetch?: boolean;
|
||||||
onFetchComplete?: () => void;
|
onFetchComplete?: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -81,6 +88,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({
|
|||||||
|
|
||||||
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||||
initialPipeIds = [],
|
initialPipeIds = [],
|
||||||
|
allowedPipeIds,
|
||||||
|
sourceSchemeName,
|
||||||
shouldFetch = false,
|
shouldFetch = false,
|
||||||
onFetchComplete,
|
onFetchComplete,
|
||||||
loading: externalLoading,
|
loading: externalLoading,
|
||||||
@@ -160,14 +169,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedPipeId(pipeId);
|
const normalizedPipeId = String(pipeId);
|
||||||
|
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "请选择当前爆管方案中的事故管段",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedPipeId(normalizedPipeId);
|
||||||
setHighlightFeature(feature);
|
setHighlightFeature(feature);
|
||||||
setIsSelecting(false);
|
setIsSelecting(false);
|
||||||
setResult(null); // 清除旧结果
|
setResult(null); // 清除旧结果
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isSelecting, map, open, setResult, setSelectedPipeId],
|
[
|
||||||
|
allowedPipeIds,
|
||||||
|
isSelecting,
|
||||||
|
map,
|
||||||
|
open,
|
||||||
|
setResult,
|
||||||
|
setSelectedPipeId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,6 +232,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
"must_close" | "optional" | "affected_node" | "pipe"
|
"must_close" | "optional" | "affected_node" | "pipe"
|
||||||
>("affected_node");
|
>("affected_node");
|
||||||
|
|
||||||
|
const selectSchemeAccidentPipe = (pipeId: string) => {
|
||||||
|
if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return;
|
||||||
|
setSelectedPipeId(pipeId);
|
||||||
|
setHighlightFeature(null);
|
||||||
|
setHighlightFeatures([]);
|
||||||
|
setResult(null);
|
||||||
|
setActiveStep(0);
|
||||||
|
setExpandedResult(true);
|
||||||
|
setDisabledValves([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
||||||
if (pipeIds.length > 0) {
|
if (pipeIds.length > 0) {
|
||||||
@@ -311,6 +346,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
|
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "关阀分析仅限当前爆管方案中的事故管段",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const isExpandSearch = disabled.length > 0;
|
const isExpandSearch = disabled.length > 0;
|
||||||
if (!isExpandSearch) {
|
if (!isExpandSearch) {
|
||||||
@@ -319,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
network: NETWORK_NAME,
|
|
||||||
accident_element: ids,
|
accident_element: ids,
|
||||||
};
|
};
|
||||||
if (disabled.length > 0) {
|
if (disabled.length > 0) {
|
||||||
params.disabled_valves = disabled;
|
params.disabled_valves = disabled;
|
||||||
}
|
}
|
||||||
const response = await api.get(
|
const response = await api.post<ValveIsolationResult>(
|
||||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
|
||||||
|
undefined,
|
||||||
{
|
{
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
@@ -334,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setResult(response.data);
|
setResult(normalizeValveIsolationResult(response.data));
|
||||||
if (!isExpandSearch) {
|
if (!isExpandSearch) {
|
||||||
setActiveStep(1);
|
setActiveStep(1);
|
||||||
} else {
|
} else {
|
||||||
@@ -352,7 +394,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[open, setActiveStep, setDisabledValves, setLoading, setResult],
|
[
|
||||||
|
allowedPipeIds,
|
||||||
|
open,
|
||||||
|
setActiveStep,
|
||||||
|
setDisabledValves,
|
||||||
|
setLoading,
|
||||||
|
setResult,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 监听外部传入的分析请求
|
// 监听外部传入的分析请求
|
||||||
@@ -666,7 +715,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
{[
|
{[
|
||||||
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
|
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
|
||||||
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
|
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
|
||||||
{ label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
|
{ label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
|
||||||
].map((item, index) => (
|
].map((item, index) => (
|
||||||
<Box
|
<Box
|
||||||
key={index}
|
key={index}
|
||||||
@@ -832,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!result.isolatable && (
|
||||||
|
<Alert severity="info" variant="outlined">
|
||||||
|
不可隔离,未生成受影响节点清单。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 受影响节点 */}
|
{/* 受影响节点 */}
|
||||||
{result.affected_nodes && result.affected_nodes.length > 0 && (
|
{result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Box className="flex items-center justify-between mb-2">
|
<Box className="flex items-center justify-between mb-2">
|
||||||
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
|
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
|
||||||
@@ -912,6 +967,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
</StepLabel>
|
</StepLabel>
|
||||||
<StepContent>
|
<StepContent>
|
||||||
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
||||||
|
{allowedPipeIds !== undefined ? (
|
||||||
|
<Alert severity="info" variant="outlined">
|
||||||
|
已绑定爆管方案“{sourceSchemeName || "未命名方案"}”。关阀分析仅限该方案中的事故管段,分析结果将写入对应报告。
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert severity="warning" variant="outlined">
|
||||||
|
当前为独立关阀分析,结果不会自动写入爆管分析报告。请先从“方案查询”打开对应分析报告以建立关联。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 选择管段 */}
|
{/* 选择管段 */}
|
||||||
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
|
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
|
||||||
<Box className="flex items-center justify-between mb-2">
|
<Box className="flex items-center justify-between mb-2">
|
||||||
@@ -955,6 +1020,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{allowedPipeIds !== undefined && (
|
||||||
|
<Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2">
|
||||||
|
<Box className="mb-2 flex flex-wrap gap-1.5">
|
||||||
|
{allowedPipeIds.map((pipeId) => (
|
||||||
|
<Chip
|
||||||
|
key={pipeId}
|
||||||
|
label={pipeId}
|
||||||
|
size="small"
|
||||||
|
color={selectedPipeId === pipeId ? "primary" : "default"}
|
||||||
|
variant={selectedPipeId === pipeId ? "filled" : "outlined"}
|
||||||
|
onClick={() => selectSchemeAccidentPipe(pipeId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
disabled={allowedPipeIds.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
allowedPipeIds[0] &&
|
||||||
|
selectSchemeAccidentPipe(allowedPipeIds[0])
|
||||||
|
}
|
||||||
|
startIcon={<CheckCircleIcon />}
|
||||||
|
>
|
||||||
|
选择当前爆管管段
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSelecting && (
|
{isSelecting && (
|
||||||
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||||||
💡 点击地图上的管道添加爆管点
|
💡 点击地图上的管道添加爆管点
|
||||||
|
|||||||
@@ -1,4 +1,48 @@
|
|||||||
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import {
|
||||||
|
clearPipeDiameterCache,
|
||||||
|
getPipeDiameterDisplay,
|
||||||
|
loadPipeDiameters,
|
||||||
|
} from "./schemePipeDiameters";
|
||||||
|
|
||||||
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
|
queryFeaturesByIds: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const feature = (id: string, diameter: number) => ({
|
||||||
|
getProperties: () => ({ id, diameter }),
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearPipeDiameterCache();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadPipeDiameters", () => {
|
||||||
|
it("shares an in-flight WFS request and reuses its resolved result", async () => {
|
||||||
|
let resolveFeatures: ((features: ReturnType<typeof feature>[]) => void) | undefined;
|
||||||
|
const pendingFeatures = new Promise<ReturnType<typeof feature>[]>((resolve) => {
|
||||||
|
resolveFeatures = resolve;
|
||||||
|
});
|
||||||
|
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
||||||
|
async () => pendingFeatures,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstRequest = loadPipeDiameters(["P-1"]);
|
||||||
|
const duplicateRequest = loadPipeDiameters(["P-1"]);
|
||||||
|
|
||||||
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveFeatures?.([feature("P-1", 315)]);
|
||||||
|
await expect(firstRequest).resolves.toEqual({ "P-1": 315 });
|
||||||
|
await expect(duplicateRequest).resolves.toEqual({ "P-1": 315 });
|
||||||
|
|
||||||
|
await expect(loadPipeDiameters(["P-1"])).resolves.toEqual({
|
||||||
|
"P-1": 315,
|
||||||
|
});
|
||||||
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getPipeDiameterDisplay", () => {
|
describe("getPipeDiameterDisplay", () => {
|
||||||
it("shows the actual diameter for one burst pipe", () => {
|
it("shows the actual diameter for one burst pipe", () => {
|
||||||
|
|||||||
@@ -1,5 +1,94 @@
|
|||||||
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import config from "@config/config";
|
||||||
|
|
||||||
export type PipeDiameterMap = Record<string, number | null | undefined>;
|
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 = (
|
export const getPipeDiameterDisplay = (
|
||||||
pipeIds: string[] | undefined,
|
pipeIds: string[] | undefined,
|
||||||
diameters: PipeDiameterMap | undefined,
|
diameters: PipeDiameterMap | undefined,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface SchemeRecord {
|
|||||||
id: number;
|
id: number;
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
type: string;
|
type: string;
|
||||||
user: string;
|
username: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
// 详情信息
|
// 详情信息
|
||||||
@@ -28,18 +28,10 @@ export interface SchemaItem {
|
|||||||
scheme_detail?: SchemeDetail;
|
scheme_detail?: SchemeDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocationResult {
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
burst_incident: string;
|
|
||||||
leakage: number | null;
|
|
||||||
detect_time: string;
|
|
||||||
locate_result: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValveIsolationResult {
|
export interface ValveIsolationResult {
|
||||||
accident_elements: string[];
|
accident_elements: string[];
|
||||||
affected_nodes: string[];
|
affected_nodes: string[];
|
||||||
|
affected_node_count?: number;
|
||||||
must_close_valves: string[];
|
must_close_valves: string[];
|
||||||
optional_valves: string[];
|
optional_valves: string[];
|
||||||
isolatable: boolean;
|
isolatable: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ValveIsolationResult } from "./types";
|
||||||
|
|
||||||
|
export const isAllowedAccidentPipe = (
|
||||||
|
pipeId: string,
|
||||||
|
allowedPipeIds: string[] | undefined,
|
||||||
|
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);
|
||||||
|
|
||||||
|
export const getAffectedNodeCount = (result: ValveIsolationResult) =>
|
||||||
|
result.affected_node_count ?? result.affected_nodes?.length ?? 0;
|
||||||
|
|
||||||
|
export const normalizeValveIsolationResult = (
|
||||||
|
result: ValveIsolationResult,
|
||||||
|
): ValveIsolationResult => ({
|
||||||
|
...result,
|
||||||
|
affected_nodes: result.isolatable ? result.affected_nodes ?? [] : [],
|
||||||
|
affected_node_count: getAffectedNodeCount(result),
|
||||||
|
});
|
||||||
@@ -228,7 +228,6 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
console.log("默认设置 pattern 为 CONSTANT");
|
console.log("默认设置 pattern 为 CONSTANT");
|
||||||
}
|
}
|
||||||
const params = {
|
const params = {
|
||||||
network,
|
|
||||||
start_time: start_time,
|
start_time: start_time,
|
||||||
source: sourceNode,
|
source: sourceNode,
|
||||||
concentration,
|
concentration,
|
||||||
@@ -237,7 +236,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, {
|
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import Feature from "ol/Feature";
|
|||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
|
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
|
||||||
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: ContaminantSchemeRecord[];
|
schemes?: ContaminantSchemeRecord[];
|
||||||
@@ -59,6 +61,7 @@ export interface ContaminantSchemeQueryState {
|
|||||||
selectedDate: Date | undefined;
|
selectedDate: Date | undefined;
|
||||||
timeRange: { start: Date; end: Date } | undefined;
|
timeRange: { start: Date; end: Date } | undefined;
|
||||||
expandedId: number | null;
|
expandedId: number | null;
|
||||||
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createContaminantSchemeQueryState =
|
export const createContaminantSchemeQueryState =
|
||||||
@@ -69,6 +72,7 @@ export const createContaminantSchemeQueryState =
|
|||||||
selectedDate: undefined,
|
selectedDate: undefined,
|
||||||
timeRange: undefined,
|
timeRange: undefined,
|
||||||
expandedId: null,
|
expandedId: null,
|
||||||
|
hasQueried: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||||
@@ -91,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
selectedDate,
|
selectedDate,
|
||||||
timeRange,
|
timeRange,
|
||||||
expandedId,
|
expandedId,
|
||||||
|
hasQueried,
|
||||||
} = queryState;
|
} = queryState;
|
||||||
const [highlightLayer, setHighlightLayer] =
|
const [highlightLayer, setHighlightLayer] =
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
@@ -101,6 +106,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
>([]);
|
>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
|
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
|
||||||
|
const creatorName = useSchemeCreatorName();
|
||||||
|
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -216,9 +222,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
if (!queryAll && !queryDate) return;
|
if (!queryAll && !queryDate) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const params: Record<string, string> = {
|
||||||
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
scheme_type: SCHEME_TYPE,
|
||||||
);
|
};
|
||||||
|
if (!queryAll && queryDate) {
|
||||||
|
params.query_date = queryDate.startOf("day").toISOString();
|
||||||
|
}
|
||||||
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|
||||||
if (!queryAll) {
|
if (!queryAll) {
|
||||||
@@ -235,16 +247,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
id: item.scheme_id,
|
id: item.scheme_id,
|
||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
type: item.scheme_type,
|
type: item.scheme_type,
|
||||||
user: item.username,
|
username: item.username,
|
||||||
create_time: item.create_time,
|
create_time: item.create_time,
|
||||||
startTime: item.scheme_start_time,
|
startTime: item.scheme_start_time,
|
||||||
schemeDetail: item.scheme_detail,
|
schemeDetail: item.scheme_detail,
|
||||||
}));
|
}));
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
|
setQueryField("hasQueried", true);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "success",
|
||||||
message: "查询结果",
|
message: "查询结果",
|
||||||
description: queryAll
|
description: queryAll
|
||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
@@ -342,7 +355,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={queryAll}
|
checked={queryAll}
|
||||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setQueryField("queryAll", e.target.checked);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -355,9 +371,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={queryDate}
|
value={queryDate}
|
||||||
onChange={(value) =>
|
onChange={(value) => {
|
||||||
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
|
if (value && dayjs.isDayjs(value)) {
|
||||||
|
setQueryField("queryDate", value);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
disabled={queryAll}
|
disabled={queryAll}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -384,39 +403,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{filteredSchemes.length === 0 ? (
|
{filteredSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||||
<Box className="mb-4">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="20"
|
|
||||||
width="60"
|
|
||||||
height="45"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="10"
|
|
||||||
y1="30"
|
|
||||||
x2="70"
|
|
||||||
y2="30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">总共 0 条</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
No data
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
@@ -591,7 +578,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
className="font-medium text-gray-900"
|
className="font-medium text-gray-900"
|
||||||
>
|
>
|
||||||
{scheme.user}
|
{creatorName(scheme.username)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ContaminantSchemeRecord {
|
|||||||
id: number;
|
id: number;
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
type: string;
|
type: string;
|
||||||
user: string;
|
username: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
schemeDetail?: ContaminantSchemeDetail;
|
schemeDetail?: ContaminantSchemeDetail;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import dayjs, { Dayjs } from "dayjs";
|
|||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { LeakageResultDetail } from "./types";
|
import { LeakageResultDetail } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||||
@@ -82,7 +82,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
|
|
||||||
const parsedQSum = Number(qSumInput);
|
const parsedQSum = Number(qSumInput);
|
||||||
const qSumIsValid =
|
const qSumIsValid =
|
||||||
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 360;
|
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0;
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
if (!schemeName.trim() || !startTime || !endTime) return false;
|
if (!schemeName.trim() || !startTime || !endTime) return false;
|
||||||
@@ -94,7 +94,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "请完善参数并确认时间范围合法",
|
message: "请完善参数并确认时间范围合法",
|
||||||
description: !qSumIsValid ? `总漏损流量需不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined,
|
description: !qSumIsValid ? `总漏损流量不能小于 0 ${FLOW_DISPLAY_UNIT}` : undefined,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,9 +108,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/leakage/identify/`,
|
`${config.BACKEND_URL}/api/v1/leakage-identifications`,
|
||||||
{
|
{
|
||||||
network: NETWORK_NAME,
|
|
||||||
scheme_name: schemeName.trim(),
|
scheme_name: schemeName.trim(),
|
||||||
dma_count: dmaCount,
|
dma_count: dmaCount,
|
||||||
scada_start: startTime.toISOString(),
|
scada_start: startTime.toISOString(),
|
||||||
@@ -235,11 +234,11 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
setFormField("qSum", value);
|
setFormField("qSum", value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
inputProps={{ min: 360, step: 10 }}
|
inputProps={{ min: 0, step: 10 }}
|
||||||
error={qSumInput.trim() !== "" && !qSumIsValid}
|
error={qSumInput.trim() !== "" && !qSumIsValid}
|
||||||
helperText={
|
helperText={
|
||||||
qSumInput.trim() !== "" && !qSumIsValid
|
qSumInput.trim() !== "" && !qSumIsValid
|
||||||
? `需不小于 360 ${FLOW_DISPLAY_UNIT}`
|
? `不能小于 0 ${FLOW_DISPLAY_UNIT}`
|
||||||
: " "
|
: " "
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { FormatListBulleted } from "@mui/icons-material";
|
import {
|
||||||
|
FormatListBulleted,
|
||||||
|
TroubleshootOutlined,
|
||||||
|
} from "@mui/icons-material";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { getAreaColor } from "./utils";
|
import { getAreaColor } from "./utils";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||||
import { LeakageResultDetail } from "./types";
|
import { LeakageResultDetail } from "./types";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: LeakageResultDetail | null;
|
result: LeakageResultDetail | null;
|
||||||
@@ -31,39 +35,11 @@ const RecognitionResults: React.FC<Props> = ({ result }) => {
|
|||||||
|
|
||||||
if (!result || !sortedRows.length) {
|
if (!result || !sortedRows.length) {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
|
<PanelEmptyState
|
||||||
<Box className="mb-4">
|
icon={<TroubleshootOutlined />}
|
||||||
<svg
|
title="尚未生成识别结果"
|
||||||
width="80"
|
description="请在“识别参数”中运行分析,或在“方案查询”中打开历史结果。"
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="20"
|
|
||||||
width="60"
|
|
||||||
height="45"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
/>
|
||||||
<line
|
|
||||||
x1="10"
|
|
||||||
y1="30"
|
|
||||||
x2="70"
|
|
||||||
y2="30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">暂无识别结果</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
请先加载方案或执行识别分析
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { NETWORK_NAME, config } from "@config/config";
|
|||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||||
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: LeakageResultDetail) => void;
|
onViewResult: (result: LeakageResultDetail) => void;
|
||||||
@@ -39,12 +41,14 @@ export interface DMALeakSchemeQueryState {
|
|||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: number | null;
|
||||||
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({
|
export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({
|
||||||
queryAll: true,
|
queryAll: true,
|
||||||
queryDate: dayjs(),
|
queryDate: dayjs(),
|
||||||
expandedId: null,
|
expandedId: null,
|
||||||
|
hasQueried: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<Props> = ({
|
const SchemeQuery: React.FC<Props> = ({
|
||||||
@@ -60,9 +64,10 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
createDMALeakSchemeQueryState(),
|
createDMALeakSchemeQueryState(),
|
||||||
);
|
);
|
||||||
const { queryAll, queryDate, expandedId } = queryState;
|
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||||
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const creatorName = useSchemeCreatorName();
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
const sortedSchemes = useMemo(
|
const sortedSchemes = useMemo(
|
||||||
@@ -79,18 +84,21 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = { network: NETWORK_NAME };
|
const params: Record<string, string> = {
|
||||||
|
scheme_type: "dma_leak_identification",
|
||||||
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
params.query_date = queryDate.startOf("day").toISOString();
|
||||||
}
|
}
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
const nextSchemes = response.data as LeakageSchemeRecord[];
|
const nextSchemes = response.data as LeakageSchemeRecord[];
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
|
setQueryField("hasQueried", true);
|
||||||
if (nextSchemes.length === 0) {
|
if (nextSchemes.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "success",
|
||||||
message: "查询结果",
|
message: "查询结果",
|
||||||
description: queryAll
|
description: queryAll
|
||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
@@ -117,8 +125,12 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleViewSchemeResult = async (schemeName: string) => {
|
const handleViewSchemeResult = async (schemeName: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/leakage/schemes/${encodeURIComponent(schemeName)}`,
|
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||||
{ params: { network: NETWORK_NAME } },
|
{
|
||||||
|
params: {
|
||||||
|
scheme_type: "dma_leak_identification",
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
onViewResult(response.data as LeakageResultDetail);
|
onViewResult(response.data as LeakageResultDetail);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -140,7 +152,10 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
size="small"
|
size="small"
|
||||||
checked={queryAll}
|
checked={queryAll}
|
||||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setQueryField("queryAll", e.target.checked);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={<Typography variant="body2">查询全部</Typography>}
|
label={<Typography variant="body2">查询全部</Typography>}
|
||||||
@@ -149,7 +164,10 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={queryDate}
|
value={queryDate}
|
||||||
onChange={(value) => setQueryField("queryDate", value)}
|
onChange={(value) => {
|
||||||
|
setQueryField("queryDate", value);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
disabled={queryAll}
|
disabled={queryAll}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
|
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
|
||||||
@@ -170,39 +188,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{sortedSchemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||||
<Box className="mb-4">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="20"
|
|
||||||
width="60"
|
|
||||||
height="45"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="10"
|
|
||||||
y1="30"
|
|
||||||
x2="70"
|
|
||||||
y2="30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">总共 0 条</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
No data
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
@@ -257,7 +243,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
用户:
|
用户:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
<Typography variant="caption" className="font-medium text-gray-900">
|
||||||
{scheme.username || "-"}
|
{creatorName(scheme.username)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Divider,
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Close as CloseIcon } from "@mui/icons-material";
|
import {
|
||||||
|
AdjustOutlined,
|
||||||
|
Close as CloseIcon,
|
||||||
|
WaterDropOutlined,
|
||||||
|
} from "@mui/icons-material";
|
||||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||||
@@ -31,6 +35,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
export interface ValveItem {
|
export interface ValveItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -302,11 +307,10 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
network: NETWORK_NAME,
|
|
||||||
start_time: formattedTime,
|
start_time: formattedTime,
|
||||||
valves: valves.map(v => v.id),
|
valves: valves.map(v => v.id),
|
||||||
valves_k: valves.map(v => v.k),
|
valves_k: valves.map(v => v.k),
|
||||||
drainage_node_ID: drainageNode,
|
drainage_node_id: drainageNode,
|
||||||
flush_flow: flushFlow,
|
flush_flow: flushFlow,
|
||||||
duration: duration
|
duration: duration
|
||||||
};
|
};
|
||||||
@@ -315,7 +319,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
// but axios usually handles array as valves[]=1&valves[]=2
|
// but axios usually handles array as valves[]=1&valves[]=2
|
||||||
// FastAPI default expects repeated query params.
|
// FastAPI default expects repeated query params.
|
||||||
|
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, {
|
const response = await api.post(`${config.BACKEND_URL}/api/v1/flushing-analyses`, undefined, {
|
||||||
params,
|
params,
|
||||||
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
@@ -383,9 +387,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{valves.length === 0 && (
|
{valves.length === 0 && (
|
||||||
<Typography variant="caption" className="text-gray-400 text-center py-20">
|
<PanelEmptyState
|
||||||
暂无选中阀门
|
variant="compact"
|
||||||
</Typography>
|
icon={<AdjustOutlined />}
|
||||||
|
title="尚未选择阀门"
|
||||||
|
description="点击“选择阀门”,然后在地图上添加。"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -412,7 +419,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
💡 点击地图上的节点作为排水点
|
💡 点击地图上的节点作为排水点
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Stack spacing={1} className="h-12 overflow-auto">
|
<Stack spacing={1} className="min-h-16 overflow-auto">
|
||||||
{drainageNode && (
|
{drainageNode && (
|
||||||
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||||
<Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography>
|
<Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography>
|
||||||
@@ -428,9 +435,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{!drainageNode && (
|
{!drainageNode && (
|
||||||
<Typography variant="caption" className="text-gray-400 text-center py-2">
|
<PanelEmptyState
|
||||||
暂无选中排水节点
|
variant="compact"
|
||||||
</Typography>
|
icon={<WaterDropOutlined />}
|
||||||
|
title="尚未选择排水节点"
|
||||||
|
description="点击“选择节点”,然后在地图上指定排水点。"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import { bbox, featureCollection } from "@turf/turf";
|
|||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { SchemeRecord, SchemaItem } from "./types";
|
import { SchemeRecord, SchemaItem } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
||||||
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
@@ -60,6 +62,7 @@ export interface FlushingSchemeQueryState {
|
|||||||
showTimeline: boolean;
|
showTimeline: boolean;
|
||||||
selectedDate: Date | undefined;
|
selectedDate: Date | undefined;
|
||||||
timeRange: { start: Date; end: Date } | undefined;
|
timeRange: { start: Date; end: Date } | undefined;
|
||||||
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createFlushingSchemeQueryState =
|
export const createFlushingSchemeQueryState =
|
||||||
@@ -70,6 +73,7 @@ export const createFlushingSchemeQueryState =
|
|||||||
showTimeline: false,
|
showTimeline: false,
|
||||||
selectedDate: undefined,
|
selectedDate: undefined,
|
||||||
timeRange: undefined,
|
timeRange: undefined,
|
||||||
|
hasQueried: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||||
@@ -91,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
showTimeline,
|
showTimeline,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
hasQueried,
|
||||||
} = queryState;
|
} = queryState;
|
||||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
@@ -100,6 +105,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
|
|
||||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
|
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
|
||||||
|
const creatorName = useSchemeCreatorName();
|
||||||
|
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -262,9 +268,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const params: Record<string, string> = {
|
||||||
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
|
scheme_type: SCHEME_TYPE,
|
||||||
);
|
};
|
||||||
|
if (!queryAll && queryDate) {
|
||||||
|
params.query_date = queryDate.startOf("day").toISOString();
|
||||||
|
}
|
||||||
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|
||||||
@@ -283,16 +295,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
id: item.scheme_id,
|
id: item.scheme_id,
|
||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
type: item.scheme_type,
|
type: item.scheme_type,
|
||||||
user: item.username,
|
username: item.username,
|
||||||
create_time: item.create_time,
|
create_time: item.create_time,
|
||||||
startTime: item.scheme_start_time,
|
startTime: item.scheme_start_time,
|
||||||
schemeDetail: item.scheme_detail,
|
schemeDetail: item.scheme_detail,
|
||||||
}));
|
}));
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
|
setQueryField("hasQueried", true);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "success",
|
||||||
message: "未找到相关方案",
|
message: "未找到相关方案",
|
||||||
description: "请尝试更改查询条件",
|
description: "请尝试更改查询条件",
|
||||||
});
|
});
|
||||||
@@ -362,7 +375,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={queryAll}
|
checked={queryAll}
|
||||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setQueryField("queryAll", e.target.checked);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -375,9 +391,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={queryDate}
|
value={queryDate}
|
||||||
onChange={(value) =>
|
onChange={(value) => {
|
||||||
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
|
if (value && dayjs.isDayjs(value)) {
|
||||||
|
setQueryField("queryDate", value);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
disabled={queryAll}
|
disabled={queryAll}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -405,39 +424,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
{/* Results List */}
|
{/* Results List */}
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{sortedSchemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||||
<Box className="mb-4">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="20"
|
|
||||||
width="60"
|
|
||||||
height="45"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="10"
|
|
||||||
y1="30"
|
|
||||||
x2="70"
|
|
||||||
y2="30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">总共 0 条</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
No data
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
@@ -472,7 +459,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
className="text-gray-500 block"
|
className="text-gray-500 block"
|
||||||
>
|
>
|
||||||
用户: {scheme.user} · 时间: {formatTime(scheme.create_time)}
|
用户: {creatorName(scheme.username)} · 时间:{" "}
|
||||||
|
{formatTime(scheme.create_time)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex gap-1 ml-2">
|
<Box className="flex gap-1 ml-2">
|
||||||
@@ -572,7 +560,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
用户:
|
用户:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
<Typography variant="caption" className="font-medium text-gray-900">
|
||||||
{scheme.user}
|
{creatorName(scheme.username)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface SchemeRecord {
|
|||||||
id: number;
|
id: number;
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
type: string;
|
type: string;
|
||||||
user: string;
|
username: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
// 详情信息
|
// 详情信息
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface HealthRiskContextType {
|
|||||||
setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>;
|
setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>;
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
setCurrentYear: Dispatch<SetStateAction<number>>;
|
setCurrentYear: Dispatch<SetStateAction<number>>;
|
||||||
|
analysisQueryTime: string | null;
|
||||||
|
setAnalysisQueryTime: Dispatch<SetStateAction<string | null>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined);
|
const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined);
|
||||||
@@ -15,6 +17,7 @@ const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undef
|
|||||||
export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]);
|
const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]);
|
||||||
const [currentYear, setCurrentYear] = useState<number>(4);
|
const [currentYear, setCurrentYear] = useState<number>(4);
|
||||||
|
const [analysisQueryTime, setAnalysisQueryTime] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HealthRiskContext.Provider
|
<HealthRiskContext.Provider
|
||||||
@@ -23,6 +26,8 @@ export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
setPredictionResults,
|
setPredictionResults,
|
||||||
currentYear,
|
currentYear,
|
||||||
setCurrentYear,
|
setCurrentYear,
|
||||||
|
analysisQueryTime,
|
||||||
|
setAnalysisQueryTime,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import HealthRiskReport from "./HealthRiskReport";
|
||||||
|
import { PredictionResult } from "./types";
|
||||||
|
|
||||||
|
jest.mock("echarts-for-react", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div data-testid="report-chart" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const predictionResults: PredictionResult[] = [
|
||||||
|
{
|
||||||
|
link_id: "PIPE-007",
|
||||||
|
survival_function: {
|
||||||
|
x: [4, 5],
|
||||||
|
y: [0.2, 0.15],
|
||||||
|
a: 0,
|
||||||
|
b: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
link_id: "PIPE-021",
|
||||||
|
survival_function: {
|
||||||
|
x: [4, 5],
|
||||||
|
y: [0.8, 0.7],
|
||||||
|
a: 0,
|
||||||
|
b: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("HealthRiskReport", () => {
|
||||||
|
it("renders automatic report identity and priority results", () => {
|
||||||
|
render(
|
||||||
|
<HealthRiskReport
|
||||||
|
open
|
||||||
|
onClose={jest.fn()}
|
||||||
|
predictionResults={predictionResults}
|
||||||
|
currentYear={4}
|
||||||
|
analysisQueryTime="2026-07-30T01:02:03.000Z"
|
||||||
|
networkName="fengyang"
|
||||||
|
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "管网健康风险体检报告" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("fengyang")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("第 4 年")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("PIPE-007")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByTestId("report-chart")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the report and restores document state after printing", () => {
|
||||||
|
const originalTitle = document.title;
|
||||||
|
const print = jest
|
||||||
|
.spyOn(window, "print")
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<HealthRiskReport
|
||||||
|
open
|
||||||
|
onClose={jest.fn()}
|
||||||
|
predictionResults={predictionResults}
|
||||||
|
currentYear={4}
|
||||||
|
analysisQueryTime="2026-07-30T01:02:03.000Z"
|
||||||
|
networkName="fengyang"
|
||||||
|
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "打印" }));
|
||||||
|
|
||||||
|
expect(print).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.body).toHaveClass("health-risk-report-printing");
|
||||||
|
expect(document.title).toContain("管网健康风险体检报告-fengyang");
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event("afterprint"));
|
||||||
|
|
||||||
|
expect(document.body).not.toHaveClass("health-risk-report-printing");
|
||||||
|
expect(document.title).toBe(originalTitle);
|
||||||
|
print.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,746 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
GlobalStyles,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Close,
|
||||||
|
PrintOutlined,
|
||||||
|
WaterDropOutlined,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { PredictionResult } from "./types";
|
||||||
|
import { buildHealthRiskReportData } from "./healthRiskReport";
|
||||||
|
|
||||||
|
interface HealthRiskReportProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
predictionResults: PredictionResult[];
|
||||||
|
currentYear: number;
|
||||||
|
analysisQueryTime: string | null;
|
||||||
|
networkName: string;
|
||||||
|
generatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPORT_BLUE = "#0b4f87";
|
||||||
|
const REPORT_INK = "#172435";
|
||||||
|
const REPORT_MUTED = "#5f6f80";
|
||||||
|
const REPORT_LINE = "#d8e0e8";
|
||||||
|
|
||||||
|
const reportFontFamily =
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||||
|
|
||||||
|
const formatDateTime = (value: Date | string | null) => {
|
||||||
|
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 formatPercent = (value: number | null, fractionDigits = 1) =>
|
||||||
|
value === null ? "无有效数据" : `${(value * 100).toFixed(fractionDigits)}%`;
|
||||||
|
|
||||||
|
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1.25}
|
||||||
|
sx={{ mb: 1.5 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
color: REPORT_INK,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HealthRiskReport: React.FC<HealthRiskReportProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
predictionResults,
|
||||||
|
currentYear,
|
||||||
|
analysisQueryTime,
|
||||||
|
networkName,
|
||||||
|
generatedAt,
|
||||||
|
}) => {
|
||||||
|
const printStateRef = useRef<{ title: string } | null>(null);
|
||||||
|
const reportData = useMemo(
|
||||||
|
() => buildHealthRiskReportData(predictionResults, currentYear),
|
||||||
|
[currentYear, predictionResults],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (printStateRef.current) {
|
||||||
|
document.title = printStateRef.current.title;
|
||||||
|
document.body.classList.remove("health-risk-report-printing");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const restoreAfterPrint = () => {
|
||||||
|
if (!printStateRef.current) return;
|
||||||
|
document.title = printStateRef.current.title;
|
||||||
|
printStateRef.current = null;
|
||||||
|
document.body.classList.remove("health-risk-report-printing");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
if (printStateRef.current) return;
|
||||||
|
printStateRef.current = { title: document.title };
|
||||||
|
document.title = `管网健康风险体检报告-${networkName || "未命名管网"}-${new Intl.DateTimeFormat(
|
||||||
|
"zh-CN",
|
||||||
|
{ year: "numeric", month: "2-digit", day: "2-digit" },
|
||||||
|
)
|
||||||
|
.format(generatedAt)
|
||||||
|
.replaceAll("/", "-")}`;
|
||||||
|
document.body.classList.add("health-risk-report-printing");
|
||||||
|
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const distributionOption = {
|
||||||
|
animation: false,
|
||||||
|
grid: { top: 4, right: 38, bottom: 28, left: 148 },
|
||||||
|
xAxis: {
|
||||||
|
type: "value",
|
||||||
|
name: "管段数",
|
||||||
|
minInterval: 1,
|
||||||
|
axisLine: { lineStyle: { color: "#8b99a8" } },
|
||||||
|
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
|
||||||
|
splitLine: { lineStyle: { color: "#e7edf2" } },
|
||||||
|
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "category",
|
||||||
|
inverse: true,
|
||||||
|
data: reportData.distribution.map((item) => item.label),
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: REPORT_INK, fontSize: 10 },
|
||||||
|
},
|
||||||
|
tooltip: { show: false },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "bar",
|
||||||
|
barMaxWidth: 14,
|
||||||
|
data: reportData.distribution.map((item) => ({
|
||||||
|
value: item.count,
|
||||||
|
itemStyle: { color: item.color },
|
||||||
|
})),
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: "right",
|
||||||
|
color: REPORT_INK,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendOption = {
|
||||||
|
animation: false,
|
||||||
|
grid: { top: 24, right: 24, bottom: 38, left: 48 },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
name: "预测年份",
|
||||||
|
boundaryGap: false,
|
||||||
|
data: reportData.trend.map((item) => item.year),
|
||||||
|
axisLine: { lineStyle: { color: "#8b99a8" } },
|
||||||
|
axisLabel: {
|
||||||
|
color: REPORT_MUTED,
|
||||||
|
fontSize: 10,
|
||||||
|
interval: Math.max(0, Math.ceil(reportData.trend.length / 9) - 1),
|
||||||
|
},
|
||||||
|
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
name: "较高及以上风险管段数",
|
||||||
|
minInterval: 1,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
|
||||||
|
splitLine: { lineStyle: { color: "#e7edf2" } },
|
||||||
|
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
|
||||||
|
},
|
||||||
|
tooltip: { show: false },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
symbol: "none",
|
||||||
|
lineStyle: { color: "#c83d3d", width: 2.5 },
|
||||||
|
areaStyle: { color: "rgba(200, 61, 61, 0.08)" },
|
||||||
|
data: reportData.trend.map((item) => item.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricItems = [
|
||||||
|
{ label: "分析管段", value: reportData.totalCount.toLocaleString("zh-CN") },
|
||||||
|
{ label: "有效结果", value: reportData.validCount.toLocaleString("zh-CN") },
|
||||||
|
{
|
||||||
|
label: "平均生存概率",
|
||||||
|
value: formatPercent(reportData.averageProbability),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "较高及以上风险",
|
||||||
|
value: `${reportData.highRiskCount.toLocaleString("zh-CN")} 条`,
|
||||||
|
detail: formatPercent(reportData.highRiskRatio),
|
||||||
|
danger: reportData.highRiskCount > 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlobalStyles
|
||||||
|
styles={{
|
||||||
|
"@page": { size: "A4 portrait", margin: 0 },
|
||||||
|
"@media print": {
|
||||||
|
"html, body": {
|
||||||
|
width: "210mm",
|
||||||
|
minHeight: "297mm",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
"body.health-risk-report-printing > *:not(.health-risk-report-dialog)":
|
||||||
|
{
|
||||||
|
display: "none !important",
|
||||||
|
},
|
||||||
|
".health-risk-report-dialog": {
|
||||||
|
position: "static !important",
|
||||||
|
display: "block !important",
|
||||||
|
},
|
||||||
|
".health-risk-report-dialog .MuiBackdrop-root, .health-risk-report-actions":
|
||||||
|
{
|
||||||
|
display: "none !important",
|
||||||
|
},
|
||||||
|
".health-risk-report-dialog .MuiDialog-container": {
|
||||||
|
display: "block !important",
|
||||||
|
height: "auto !important",
|
||||||
|
},
|
||||||
|
".health-risk-report-dialog .MuiDialog-paper": {
|
||||||
|
width: "210mm !important",
|
||||||
|
maxWidth: "none !important",
|
||||||
|
minHeight: "297mm !important",
|
||||||
|
maxHeight: "none !important",
|
||||||
|
margin: "0 !important",
|
||||||
|
overflow: "visible !important",
|
||||||
|
boxShadow: "none !important",
|
||||||
|
},
|
||||||
|
".health-risk-report-preview": {
|
||||||
|
padding: "0 !important",
|
||||||
|
overflow: "visible !important",
|
||||||
|
backgroundColor: "#fff !important",
|
||||||
|
},
|
||||||
|
"#health-risk-print-root": {
|
||||||
|
width: "210mm !important",
|
||||||
|
minHeight: "297mm !important",
|
||||||
|
padding: "12mm !important",
|
||||||
|
boxShadow: "none !important",
|
||||||
|
printColorAdjust: "exact",
|
||||||
|
WebkitPrintColorAdjust: "exact",
|
||||||
|
},
|
||||||
|
".health-risk-report-section, .health-risk-report-metrics": {
|
||||||
|
breakInside: "avoid",
|
||||||
|
pageBreakInside: "avoid",
|
||||||
|
},
|
||||||
|
".health-risk-report-table-row": {
|
||||||
|
breakInside: "avoid",
|
||||||
|
pageBreakInside: "avoid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth={false}
|
||||||
|
fullWidth
|
||||||
|
className="health-risk-report-dialog"
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
m: 0,
|
||||||
|
borderRadius: 0,
|
||||||
|
bgcolor: "#e8edf2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
className="health-risk-report-actions"
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
minHeight: 64,
|
||||||
|
px: { xs: 1.5, sm: 3 },
|
||||||
|
bgcolor: "#fff",
|
||||||
|
boxShadow: "0 1px 4px rgba(23, 36, 53, 0.16)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭报告预览"
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, color: REPORT_INK }}>
|
||||||
|
报告预览
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: REPORT_MUTED }}>
|
||||||
|
A4 纵向,可在打印窗口另存为 PDF
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PrintOutlined />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
sx={{
|
||||||
|
minHeight: 40,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: REPORT_BLUE,
|
||||||
|
"&:hover": { bgcolor: "#083f6d" },
|
||||||
|
"&:active": { transform: "scale(0.96)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
打印
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
className="health-risk-report-preview"
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
px: { xs: 0, sm: 2 },
|
||||||
|
py: { xs: 0, sm: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
id="health-risk-print-root"
|
||||||
|
lang="zh-CN"
|
||||||
|
sx={{
|
||||||
|
boxSizing: "border-box",
|
||||||
|
width: { xs: "100%", sm: "min(210mm, 100%)" },
|
||||||
|
minHeight: "297mm",
|
||||||
|
mx: "auto",
|
||||||
|
p: { xs: 2, sm: "12mm" },
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: REPORT_INK,
|
||||||
|
boxShadow: { xs: "none", sm: "0 12px 32px rgba(23,36,53,0.16)" },
|
||||||
|
fontFamily: reportFontFamily,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
bgcolor: REPORT_BLUE,
|
||||||
|
color: "#f4f8fb",
|
||||||
|
px: { xs: 2.5, sm: 4 },
|
||||||
|
py: { xs: 2.5, sm: 3.5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: "flex-start", sm: "center" }}
|
||||||
|
spacing={2}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<WaterDropOutlined aria-hidden="true" />
|
||||||
|
<Typography
|
||||||
|
lang="en"
|
||||||
|
sx={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.08em" }}
|
||||||
|
>
|
||||||
|
TJWATER
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
fontSize: { xs: 24, sm: 30 },
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
textWrap: "balance",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管网健康风险体检报告
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 0.75,
|
||||||
|
color: "#cfe1ef",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pipeline health risk assessment
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: { sm: 168 },
|
||||||
|
width: { xs: "100%", sm: "auto" },
|
||||||
|
pt: { xs: 2, sm: 0 },
|
||||||
|
pl: { xs: 0, sm: 3 },
|
||||||
|
borderTop: {
|
||||||
|
xs: "1px solid rgba(224, 238, 248, 0.28)",
|
||||||
|
sm: "none",
|
||||||
|
},
|
||||||
|
borderLeft: {
|
||||||
|
xs: "none",
|
||||||
|
sm: "1px solid rgba(224, 238, 248, 0.28)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.8}>
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor:
|
||||||
|
reportData.highRiskCount > 0 ? "#ffb3a7" : "#9dd7bd",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography sx={{ color: "#cfe1ef", fontSize: 11 }}>
|
||||||
|
本期提示
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 0.75,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reportData.highRiskCount > 0 ? "需重点关注" : "持续监测"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="dl"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" },
|
||||||
|
borderBottom: `1px solid ${REPORT_LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
["管网项目", networkName || "未命名管网"],
|
||||||
|
["预测时点", `第 ${currentYear} 年`],
|
||||||
|
["分析数据时间", formatDateTime(analysisQueryTime)],
|
||||||
|
["报告生成时间", formatDateTime(generatedAt)],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<Box
|
||||||
|
key={label}
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "96px 1fr",
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
borderBottom: { xs: `1px solid ${REPORT_LINE}`, sm: "none" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component="dt"
|
||||||
|
sx={{ color: REPORT_MUTED, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="dd"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
color: REPORT_INK,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="section"
|
||||||
|
className="health-risk-report-section"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
>
|
||||||
|
<SectionTitle>总体结论</SectionTitle>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor:
|
||||||
|
reportData.highRiskCount > 0 ? "#fff4f1" : "#eff8f4",
|
||||||
|
px: 2.25,
|
||||||
|
py: 1.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: 13, lineHeight: 1.8, color: REPORT_INK }}>
|
||||||
|
{reportData.conclusion}
|
||||||
|
</Typography>
|
||||||
|
{reportData.invalidCount > 0 && (
|
||||||
|
<Typography sx={{ mt: 0.5, fontSize: 11, color: REPORT_MUTED }}>
|
||||||
|
另有 {reportData.invalidCount} 条管段在当前预测年份缺少有效结果,未纳入本期统计。
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
className="health-risk-report-metrics"
|
||||||
|
sx={{
|
||||||
|
mt: 2.25,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "repeat(2, 1fr)", sm: "repeat(4, 1fr)" },
|
||||||
|
borderTop: `1px solid ${REPORT_LINE}`,
|
||||||
|
borderBottom: `1px solid ${REPORT_LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metricItems.map((item, index) => (
|
||||||
|
<Box
|
||||||
|
key={item.label}
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.75,
|
||||||
|
borderRight:
|
||||||
|
index < metricItems.length - 1
|
||||||
|
? `1px solid ${REPORT_LINE}`
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: REPORT_MUTED, fontSize: 11 }}>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
color: item.danger ? "#a62f2f" : REPORT_INK,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Typography>
|
||||||
|
{item.detail && (
|
||||||
|
<Typography sx={{ mt: 0.25, color: REPORT_MUTED, fontSize: 10 }}>
|
||||||
|
占有效结果 {item.detail}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="section"
|
||||||
|
className="health-risk-report-section"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
>
|
||||||
|
<SectionTitle>当前年份风险分布</SectionTitle>
|
||||||
|
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
|
||||||
|
生存概率越低,表示模型预测风险越高。统计口径与地图风险图例一致。
|
||||||
|
</Typography>
|
||||||
|
<ReactECharts
|
||||||
|
option={distributionOption}
|
||||||
|
style={{ width: "100%", height: 300 }}
|
||||||
|
opts={{ renderer: "svg" }}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="section"
|
||||||
|
className="health-risk-report-section"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
>
|
||||||
|
<SectionTitle>重点风险趋势</SectionTitle>
|
||||||
|
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
|
||||||
|
展示各预测年份中生存概率不高于 0.3 的管段数量。
|
||||||
|
</Typography>
|
||||||
|
<ReactECharts
|
||||||
|
option={trendOption}
|
||||||
|
style={{ width: "100%", height: 240 }}
|
||||||
|
opts={{ renderer: "svg" }}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box component="section" sx={{ mt: 3 }}>
|
||||||
|
<SectionTitle>重点风险管段</SectionTitle>
|
||||||
|
<Typography sx={{ mb: 1.25, color: REPORT_MUTED, fontSize: 11 }}>
|
||||||
|
按当前预测年份的生存概率从低到高排列,最多展示 20 条。
|
||||||
|
</Typography>
|
||||||
|
{reportData.priorityPipes.length > 0 ? (
|
||||||
|
<TableContainer>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
aria-label="重点风险管段"
|
||||||
|
sx={{ tableLayout: "fixed" }}
|
||||||
|
>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: "#edf3f7" }}>
|
||||||
|
<TableCell sx={{ width: 52, fontWeight: 700 }}>序号</TableCell>
|
||||||
|
<TableCell sx={{ width: "34%", fontWeight: 700 }}>
|
||||||
|
管段编号
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ width: 110, fontWeight: 700 }}>
|
||||||
|
生存概率
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>风险等级</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{reportData.priorityPipes.map((pipe, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={`${pipe.linkId}-${index}`}
|
||||||
|
className="health-risk-report-table-row"
|
||||||
|
>
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
sx={{ fontWeight: 600, overflowWrap: "anywhere" }}
|
||||||
|
>
|
||||||
|
{pipe.linkId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{formatPercent(pipe.probability, 2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
flexShrink: 0,
|
||||||
|
bgcolor: pipe.color,
|
||||||
|
borderRadius: "50%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography sx={{ fontSize: 12 }}>
|
||||||
|
{pipe.riskLabel}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ bgcolor: "#f4f7f9", px: 2, py: 2 }}>
|
||||||
|
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
|
||||||
|
当前预测年份没有较高及以上风险管段。
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="section"
|
||||||
|
className="health-risk-report-section"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
>
|
||||||
|
<SectionTitle>处置建议</SectionTitle>
|
||||||
|
<Box component="ol" sx={{ m: 0, pl: 3 }}>
|
||||||
|
{reportData.recommendations.map((recommendation) => (
|
||||||
|
<Typography
|
||||||
|
component="li"
|
||||||
|
key={recommendation}
|
||||||
|
sx={{
|
||||||
|
pl: 0.5,
|
||||||
|
mb: 0.75,
|
||||||
|
color: REPORT_INK,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recommendation}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
mt: 3.5,
|
||||||
|
pt: 1.5,
|
||||||
|
borderTop: `1px solid ${REPORT_LINE}`,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 2,
|
||||||
|
color: REPORT_MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: 10 }}>TJWater 管网分析平台</Typography>
|
||||||
|
<Typography sx={{ fontSize: 10, textAlign: "right" }}>
|
||||||
|
风险阈值:生存概率 ≤ 0.3
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HealthRiskReport;
|
||||||
@@ -11,10 +11,19 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Slide,
|
Slide,
|
||||||
Fade,
|
Fade,
|
||||||
|
Button,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { ChevronLeft, ChevronRight, BarChart } from "@mui/icons-material";
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
BarChart,
|
||||||
|
DescriptionOutlined,
|
||||||
|
} from "@mui/icons-material";
|
||||||
import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types";
|
import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types";
|
||||||
import { useHealthRisk } from "./HealthRiskContext";
|
import { useHealthRisk } from "./HealthRiskContext";
|
||||||
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
|
import HealthRiskReport from "./HealthRiskReport";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
const SIMPLE_LABELS = [
|
const SIMPLE_LABELS = [
|
||||||
"0.0 - 0.1",
|
"0.0 - 0.1",
|
||||||
@@ -30,8 +39,13 @@ const SIMPLE_LABELS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const HealthRiskStatistics: React.FC = () => {
|
const HealthRiskStatistics: React.FC = () => {
|
||||||
const { predictionResults, currentYear } = useHealthRisk();
|
const { predictionResults, currentYear, analysisQueryTime } = useHealthRisk();
|
||||||
|
const project = useProject();
|
||||||
const [isExpanded, setIsExpanded] = React.useState<boolean>(true);
|
const [isExpanded, setIsExpanded] = React.useState<boolean>(true);
|
||||||
|
const [isReportOpen, setIsReportOpen] = React.useState<boolean>(false);
|
||||||
|
const [reportGeneratedAt, setReportGeneratedAt] = React.useState(
|
||||||
|
() => new Date(),
|
||||||
|
);
|
||||||
const [hoveredYearIndex, setHoveredYearIndex] = React.useState<number | null>(
|
const [hoveredYearIndex, setHoveredYearIndex] = React.useState<number | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -216,24 +230,11 @@ const HealthRiskStatistics: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderEmpty = () => (
|
const renderEmpty = () => (
|
||||||
<Box
|
<PanelEmptyState
|
||||||
sx={{
|
icon={<BarChart />}
|
||||||
display: "flex",
|
title="暂无风险预测结果"
|
||||||
flexDirection: "column",
|
description="请先运行健康风险预测,完成后将在这里展示风险分布统计。"
|
||||||
alignItems: "center",
|
/>
|
||||||
justifyContent: "center",
|
|
||||||
height: "100%",
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BarChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
|
|
||||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
|
|
||||||
暂无预测数据
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
请先进行健康风险分析预测,以生成风险分布数据
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -278,6 +279,41 @@ const HealthRiskStatistics: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
predictionResults.length > 0
|
||||||
|
? "生成健康风险体检报告"
|
||||||
|
: "请先完成健康风险分析"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DescriptionOutlined fontSize="small" />}
|
||||||
|
disabled={predictionResults.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setReportGeneratedAt(new Date());
|
||||||
|
setIsReportOpen(true);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
minHeight: 32,
|
||||||
|
color: "white",
|
||||||
|
borderColor: "rgba(255,255,255,0.62)",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "white",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
color: "rgba(255,255,255,0.48)",
|
||||||
|
borderColor: "rgba(255,255,255,0.24)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
生成报告
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="收起">
|
<Tooltip title="收起">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -308,6 +344,15 @@ const HealthRiskStatistics: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Slide>
|
</Slide>
|
||||||
|
<HealthRiskReport
|
||||||
|
open={isReportOpen}
|
||||||
|
onClose={() => setIsReportOpen(false)}
|
||||||
|
predictionResults={predictionResults}
|
||||||
|
currentYear={currentYear}
|
||||||
|
analysisQueryTime={analysisQueryTime}
|
||||||
|
networkName={project?.networkName || "未命名管网"}
|
||||||
|
generatedAt={reportGeneratedAt}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ShowChart } from "@mui/icons-material";
|
|||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import { useHealthRisk } from "./HealthRiskContext";
|
import { useHealthRisk } from "./HealthRiskContext";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
export interface PredictDataPanelProps {
|
export interface PredictDataPanelProps {
|
||||||
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
||||||
@@ -58,26 +59,11 @@ const PredictDataPanel: React.FC<PredictDataPanelProps> = ({
|
|||||||
}, [filteredResults]);
|
}, [filteredResults]);
|
||||||
|
|
||||||
const renderEmpty = () => (
|
const renderEmpty = () => (
|
||||||
<Box
|
<PanelEmptyState
|
||||||
sx={{
|
icon={<ShowChart />}
|
||||||
flex: 1,
|
title="暂无可展示的管道预测"
|
||||||
display: "flex",
|
description="请先在地图上选择已完成健康风险分析的管道。"
|
||||||
flexDirection: "column",
|
/>
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
py: 8,
|
|
||||||
color: "text.secondary",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
|
|
||||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
|
|
||||||
暂无预测数据
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
请在地图上选择已分析的管道
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderChart = () => {
|
const renderChart = () => {
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
setPredictionResults,
|
setPredictionResults,
|
||||||
currentYear,
|
currentYear,
|
||||||
setCurrentYear,
|
setCurrentYear,
|
||||||
|
setAnalysisQueryTime,
|
||||||
} = useHealthRisk();
|
} = useHealthRisk();
|
||||||
|
|
||||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||||
@@ -391,12 +392,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
`${config.BACKEND_URL}/api/v1/pipeline-health-predictions?query_time=${query_time}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const results: PredictionResult[] = await response.json();
|
const results: PredictionResult[] = await response.json();
|
||||||
setPredictionResults(results);
|
setPredictionResults(results);
|
||||||
|
setAnalysisQueryTime(calculationDateTime.toISOString());
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
|
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
|
||||||
@@ -404,7 +406,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
// 读取后端 HTTPException 返回的 detail 信息
|
// 读取后端 HTTPException 返回的 detail 信息
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
const errorMessage = errorData.detail || "模拟预测失败";
|
const errorMessage =
|
||||||
|
typeof errorData.detail === "string" &&
|
||||||
|
errorData.detail.includes("未找到流速数据")
|
||||||
|
? "所选时间暂无可用的管网模拟数据,请确认该时刻已完成模拟计算并生成流速结果后重试。"
|
||||||
|
: errorData.detail || "模拟预测失败";
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
buildHealthRiskReportData,
|
||||||
|
getProbabilityAtYear,
|
||||||
|
getRiskBandIndex,
|
||||||
|
PRIORITY_PIPE_LIMIT,
|
||||||
|
} from "./healthRiskReport";
|
||||||
|
import { PredictionResult } from "./types";
|
||||||
|
|
||||||
|
const createResult = (
|
||||||
|
linkId: string,
|
||||||
|
years: number[],
|
||||||
|
probabilities: number[],
|
||||||
|
): PredictionResult => ({
|
||||||
|
link_id: linkId,
|
||||||
|
survival_function: {
|
||||||
|
x: years,
|
||||||
|
y: probabilities,
|
||||||
|
a: 0,
|
||||||
|
b: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("healthRiskReport", () => {
|
||||||
|
it("matches probability by the exact forecast year", () => {
|
||||||
|
const result = createResult("P-1", [4, 6], [0.8, 0.2]);
|
||||||
|
|
||||||
|
expect(getProbabilityAtYear(result, 6)).toBe(0.2);
|
||||||
|
expect(getProbabilityAtYear(result, 5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing and out-of-range probabilities", () => {
|
||||||
|
expect(
|
||||||
|
getProbabilityAtYear(createResult("P-1", [4], [Number.NaN]), 4),
|
||||||
|
).toBeNull();
|
||||||
|
expect(getProbabilityAtYear(createResult("P-2", [4], [1.1]), 4)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps risk break boundaries consistent with the existing legend", () => {
|
||||||
|
expect(getRiskBandIndex(0)).toBe(0);
|
||||||
|
expect(getRiskBandIndex(0.1)).toBe(0);
|
||||||
|
expect(getRiskBandIndex(0.10001)).toBe(1);
|
||||||
|
expect(getRiskBandIndex(1)).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the current snapshot and high-risk trend", () => {
|
||||||
|
const report = buildHealthRiskReportData(
|
||||||
|
[
|
||||||
|
createResult("P-1", [4, 5], [0.2, 0.1]),
|
||||||
|
createResult("P-2", [4, 5], [0.5, 0.25]),
|
||||||
|
createResult("P-3", [4, 5], [0.9, 0.8]),
|
||||||
|
createResult("P-4", [5], [0.4]),
|
||||||
|
],
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.totalCount).toBe(4);
|
||||||
|
expect(report.validCount).toBe(3);
|
||||||
|
expect(report.invalidCount).toBe(1);
|
||||||
|
expect(report.highRiskCount).toBe(1);
|
||||||
|
expect(report.highRiskRatio).toBeCloseTo(1 / 3);
|
||||||
|
expect(report.averageProbability).toBeCloseTo(0.533333);
|
||||||
|
expect(report.distribution.reduce((sum, item) => sum + item.count, 0)).toBe(
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
expect(report.trend).toEqual([
|
||||||
|
{ year: 4, count: 1, validCount: 3, ratio: 1 / 3 },
|
||||||
|
{ year: 5, count: 2, validCount: 4, ratio: 0.5 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts priority pipes and limits the printed detail", () => {
|
||||||
|
const results = Array.from({ length: PRIORITY_PIPE_LIMIT + 5 }, (_, index) =>
|
||||||
|
createResult(`P-${PRIORITY_PIPE_LIMIT + 5 - index}`, [4], [0.2]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = buildHealthRiskReportData(results, 4);
|
||||||
|
|
||||||
|
expect(report.priorityPipes).toHaveLength(PRIORITY_PIPE_LIMIT);
|
||||||
|
expect(report.priorityPipes[0].linkId).toBe("P-1");
|
||||||
|
expect(report.priorityPipes.at(-1)?.linkId).toBe(
|
||||||
|
`P-${PRIORITY_PIPE_LIMIT}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
PredictionResult,
|
||||||
|
RAINBOW_COLORS,
|
||||||
|
RISK_BREAKS,
|
||||||
|
RISK_LABELS,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const HIGH_RISK_MAX_PROBABILITY = 0.3;
|
||||||
|
export const PRIORITY_PIPE_LIMIT = 20;
|
||||||
|
|
||||||
|
export interface RiskDistributionItem {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HighRiskTrendPoint {
|
||||||
|
year: number;
|
||||||
|
count: number;
|
||||||
|
validCount: number;
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriorityPipeItem {
|
||||||
|
linkId: string;
|
||||||
|
probability: number;
|
||||||
|
riskLabel: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthRiskReportData {
|
||||||
|
totalCount: number;
|
||||||
|
validCount: number;
|
||||||
|
invalidCount: number;
|
||||||
|
averageProbability: number | null;
|
||||||
|
highRiskCount: number;
|
||||||
|
highRiskRatio: number;
|
||||||
|
distribution: RiskDistributionItem[];
|
||||||
|
trend: HighRiskTrendPoint[];
|
||||||
|
priorityPipes: PriorityPipeItem[];
|
||||||
|
conclusion: string;
|
||||||
|
recommendations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidProbability = (value: unknown): value is number =>
|
||||||
|
typeof value === "number" &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
value >= 0 &&
|
||||||
|
value <= 1;
|
||||||
|
|
||||||
|
export const getProbabilityAtYear = (
|
||||||
|
result: PredictionResult,
|
||||||
|
year: number,
|
||||||
|
): number | null => {
|
||||||
|
const yearIndex = result.survival_function.x.findIndex(
|
||||||
|
(value) => value === year,
|
||||||
|
);
|
||||||
|
if (yearIndex < 0) return null;
|
||||||
|
|
||||||
|
const probability = result.survival_function.y[yearIndex];
|
||||||
|
return isValidProbability(probability) ? probability : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRiskBandIndex = (probability: number): number => {
|
||||||
|
const index = RISK_BREAKS.findIndex((upperBound) => probability <= upperBound);
|
||||||
|
return index >= 0 ? index : RISK_BREAKS.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildHealthRiskReportData = (
|
||||||
|
results: PredictionResult[],
|
||||||
|
currentYear: number,
|
||||||
|
): HealthRiskReportData => {
|
||||||
|
const currentValues = results.flatMap((result) => {
|
||||||
|
const probability = getProbabilityAtYear(result, currentYear);
|
||||||
|
return probability === null ? [] : [{ result, probability }];
|
||||||
|
});
|
||||||
|
const validCount = currentValues.length;
|
||||||
|
const highRiskCount = currentValues.filter(
|
||||||
|
({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const distribution = RISK_LABELS.map((label, index) => {
|
||||||
|
const count = currentValues.filter(
|
||||||
|
({ probability }) => getRiskBandIndex(probability) === index,
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
color: RAINBOW_COLORS[index],
|
||||||
|
count,
|
||||||
|
ratio: validCount > 0 ? count / validCount : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const priorityPipes = currentValues
|
||||||
|
.filter(
|
||||||
|
({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.probability - right.probability ||
|
||||||
|
left.result.link_id.localeCompare(right.result.link_id, "zh-CN", {
|
||||||
|
numeric: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.slice(0, PRIORITY_PIPE_LIMIT)
|
||||||
|
.map(({ result, probability }) => {
|
||||||
|
const bandIndex = getRiskBandIndex(probability);
|
||||||
|
return {
|
||||||
|
linkId: result.link_id,
|
||||||
|
probability,
|
||||||
|
riskLabel: RISK_LABELS[bandIndex],
|
||||||
|
color: RAINBOW_COLORS[bandIndex],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const trendByYear = new Map<
|
||||||
|
number,
|
||||||
|
{ count: number; validCount: number }
|
||||||
|
>();
|
||||||
|
results.forEach((result) => {
|
||||||
|
const seenYears = new Set<number>();
|
||||||
|
result.survival_function.x.forEach((year, index) => {
|
||||||
|
if (
|
||||||
|
typeof year !== "number" ||
|
||||||
|
!Number.isFinite(year) ||
|
||||||
|
seenYears.has(year)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenYears.add(year);
|
||||||
|
const probability = result.survival_function.y[index];
|
||||||
|
if (!isValidProbability(probability)) return;
|
||||||
|
|
||||||
|
const point = trendByYear.get(year) ?? { count: 0, validCount: 0 };
|
||||||
|
point.validCount += 1;
|
||||||
|
if (probability <= HIGH_RISK_MAX_PROBABILITY) point.count += 1;
|
||||||
|
trendByYear.set(year, point);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const trend = Array.from(trendByYear, ([year, point]) => ({
|
||||||
|
year,
|
||||||
|
count: point.count,
|
||||||
|
validCount: point.validCount,
|
||||||
|
ratio: point.validCount > 0 ? point.count / point.validCount : 0,
|
||||||
|
})).sort((left, right) => left.year - right.year);
|
||||||
|
|
||||||
|
const averageProbability =
|
||||||
|
validCount > 0
|
||||||
|
? currentValues.reduce(
|
||||||
|
(total, { probability }) => total + probability,
|
||||||
|
0,
|
||||||
|
) / validCount
|
||||||
|
: null;
|
||||||
|
const highRiskRatio = validCount > 0 ? highRiskCount / validCount : 0;
|
||||||
|
const mediumRiskCount = currentValues.filter(
|
||||||
|
({ probability }) =>
|
||||||
|
probability > HIGH_RISK_MAX_PROBABILITY && probability <= 0.6,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const conclusion =
|
||||||
|
validCount === 0
|
||||||
|
? `预测第 ${currentYear} 年没有可用于报告的有效生存概率数据。`
|
||||||
|
: highRiskCount > 0
|
||||||
|
? `预测第 ${currentYear} 年发现 ${highRiskCount} 条较高及以上风险管段,占有效结果的 ${(highRiskRatio * 100).toFixed(1)}%。建议优先核查重点管段,并结合现场检测确认处置顺序。`
|
||||||
|
: `预测第 ${currentYear} 年未发现较高及以上风险管段。当前结果仍需结合巡检记录持续跟踪。`;
|
||||||
|
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
if (highRiskCount > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
"优先核查重点风险管段的运行状态、历史故障和周边施工情况,必要时安排现场检测。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mediumRiskCount > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
`对其余 ${mediumRiskCount} 条中等风险管段提高监测频率,关注压力波动和风险趋势变化。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (highRiskCount === 0 && mediumRiskCount === 0 && validCount > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
"保持现有巡检和监测频率,在新的在线模拟数据产生后重新评估。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recommendations.push(
|
||||||
|
"本报告为模型预测结果,仅用于辅助决策,不能替代现场检测、专业鉴定和安全处置流程。",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCount: results.length,
|
||||||
|
validCount,
|
||||||
|
invalidCount: results.length - validCount,
|
||||||
|
averageProbability,
|
||||||
|
highRiskCount,
|
||||||
|
highRiskRatio,
|
||||||
|
distribution,
|
||||||
|
trend,
|
||||||
|
priorityPipes,
|
||||||
|
conclusion,
|
||||||
|
recommendations,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -7,9 +7,9 @@ export interface SurvivalFunction {
|
|||||||
|
|
||||||
export interface PredictionResult {
|
export interface PredictionResult {
|
||||||
link_id: string;
|
link_id: string;
|
||||||
diameter: number;
|
diameter?: number;
|
||||||
velocity: number;
|
velocity?: number;
|
||||||
pressure: number;
|
pressure?: number;
|
||||||
survival_function: SurvivalFunction;
|
survival_function: SurvivalFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import MonitoringPlaceOptimizationPanel, {
|
||||||
|
getTabIndicatorSx,
|
||||||
|
getTabIndicatorTransform,
|
||||||
|
} from "./MonitoringPlaceOptimizationPanel";
|
||||||
|
import { getSensorPlacementScheme } from "./schemeApi";
|
||||||
|
import type { SensorPlacementScheme } from "./types";
|
||||||
|
|
||||||
|
const mockSchemeEditorRender = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useNotification: () => ({ open: jest.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./OptimizationParameters", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div>optimization parameters</div>,
|
||||||
|
createOptimizationParametersState: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./SchemeQuery", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => (
|
||||||
|
<button onClick={() => onEdit(7)}>打开测试方案</button>
|
||||||
|
),
|
||||||
|
createMonitoringSchemeQueryState: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./SchemeEditor", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
scheme,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
scheme: SensorPlacementScheme;
|
||||||
|
active: boolean;
|
||||||
|
}) => {
|
||||||
|
mockSchemeEditorRender(active);
|
||||||
|
return <div data-testid="scheme-editor">{scheme.scheme_name}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./schemeApi", () => ({
|
||||||
|
getSensorPlacementScheme: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@components/olmap/common/PanelEmptyState", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div>empty state</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetSensorPlacementScheme = jest.mocked(getSensorPlacementScheme);
|
||||||
|
|
||||||
|
const scheme: SensorPlacementScheme = {
|
||||||
|
id: 7,
|
||||||
|
scheme_name: "测试方案",
|
||||||
|
sensor_number: 1,
|
||||||
|
min_diameter: 100,
|
||||||
|
username: "operator",
|
||||||
|
create_time: "2026-07-30T08:00:00+08:00",
|
||||||
|
sensor_location: ["J-1"],
|
||||||
|
sensor_points: [],
|
||||||
|
can_edit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("MonitoringPlaceOptimizationPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSchemeEditorRender.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies equal-width positioning to the rendered tab indicator", () => {
|
||||||
|
expect(getTabIndicatorTransform(0)).toBe("translateX(0%)");
|
||||||
|
expect(getTabIndicatorTransform(1)).toBe("translateX(100%)");
|
||||||
|
expect(getTabIndicatorTransform(2)).toBe("translateX(200%)");
|
||||||
|
expect(getTabIndicatorSx(1)).toMatchObject({
|
||||||
|
left: "0 !important",
|
||||||
|
width: "33.333333% !important",
|
||||||
|
transform: "translateX(100%)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<MonitoringPlaceOptimizationPanel />);
|
||||||
|
const indicator = container.querySelector<HTMLElement>(
|
||||||
|
".MuiTabs-indicator",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(indicator).not.toBeNull();
|
||||||
|
expect(indicator).toHaveStyle({ transform: "translateX(0%)" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||||
|
expect(indicator).toHaveStyle({ transform: "translateX(200%)" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepares a queried scheme before switching to the result editor", async () => {
|
||||||
|
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
|
||||||
|
mockGetSensorPlacementScheme.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveScheme = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<MonitoringPlaceOptimizationPanel />);
|
||||||
|
|
||||||
|
const queryTab = screen.getByRole("tab", { name: /方案查询/ });
|
||||||
|
const editorTab = screen.getByRole("tab", { name: /结果编辑/ });
|
||||||
|
fireEvent.click(queryTab);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "打开测试方案" }));
|
||||||
|
|
||||||
|
expect(queryTab).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(editorTab).toHaveAttribute("aria-selected", "false");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveScheme(scheme);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editorTab).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByTestId("scheme-editor")).toBeVisible();
|
||||||
|
expect(mockSchemeEditorRender.mock.calls.map(([active]) => active)).toEqual([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+153
-22
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Sensors as SensorsIcon,
|
Sensors as SensorsIcon,
|
||||||
Analytics as AnalyticsIcon,
|
Analytics as AnalyticsIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
|
TableView as TableIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import OptimizationParameters, {
|
import OptimizationParameters, {
|
||||||
createOptimizationParametersState,
|
createOptimizationParametersState,
|
||||||
@@ -25,16 +27,12 @@ import SchemeQuery, {
|
|||||||
createMonitoringSchemeQueryState,
|
createMonitoringSchemeQueryState,
|
||||||
type MonitoringSchemeQueryState,
|
type MonitoringSchemeQueryState,
|
||||||
} from "./SchemeQuery";
|
} from "./SchemeQuery";
|
||||||
|
import SchemeEditor from "./SchemeEditor";
|
||||||
interface SchemeRecord {
|
import { getSensorPlacementScheme } from "./schemeApi";
|
||||||
id: number;
|
import type { SchemeRecord, SensorPlacementScheme } from "./types";
|
||||||
schemeName: string;
|
import { NETWORK_NAME } from "@/config/config";
|
||||||
sensorNumber: number;
|
import { useNotification } from "@refinedev/core";
|
||||||
minDiameter: number;
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
user: string;
|
|
||||||
create_time: string;
|
|
||||||
sensorLocation?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -49,13 +47,44 @@ const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
|||||||
hidden={value !== index}
|
hidden={value !== index}
|
||||||
className="flex-1 overflow-hidden flex flex-col"
|
className="flex-1 overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
{value === index && (
|
|
||||||
<Box className="flex-1 overflow-auto p-4">{children}</Box>
|
<Box className="flex-1 overflow-auto p-4">{children}</Box>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PANEL_MOTION_DURATION_MS = 280;
|
||||||
|
const PANEL_MOTION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||||
|
|
||||||
|
export const getTabIndicatorTransform = (tabIndex: number) =>
|
||||||
|
`translateX(${tabIndex * 100}%)`;
|
||||||
|
|
||||||
|
export const getTabIndicatorSx = (tabIndex: number) => ({
|
||||||
|
backgroundColor: "#257DD4",
|
||||||
|
left: "0 !important",
|
||||||
|
width: "33.333333% !important",
|
||||||
|
transform: getTabIndicatorTransform(tabIndex),
|
||||||
|
transition: `transform ${PANEL_MOTION_DURATION_MS}ms ${PANEL_MOTION_EASING}`,
|
||||||
|
"@media (prefers-reduced-motion: reduce)": {
|
||||||
|
transition: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PreparedSchemeEditorProps
|
||||||
|
extends React.ComponentProps<typeof SchemeEditor> {
|
||||||
|
onReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreparedSchemeEditor: React.FC<PreparedSchemeEditorProps> = ({
|
||||||
|
onReady,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
onReady?.();
|
||||||
|
}, [onReady]);
|
||||||
|
|
||||||
|
return <SchemeEditor {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
interface MonitoringPlaceOptimizationPanelProps {
|
interface MonitoringPlaceOptimizationPanelProps {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
@@ -66,6 +95,13 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
> = ({ open: controlledOpen, onToggle }) => {
|
> = ({ open: controlledOpen, onToggle }) => {
|
||||||
const [internalOpen, setInternalOpen] = useState(true);
|
const [internalOpen, setInternalOpen] = useState(true);
|
||||||
const [currentTab, setCurrentTab] = useState(0);
|
const [currentTab, setCurrentTab] = useState(0);
|
||||||
|
const [activeScheme, setActiveScheme] =
|
||||||
|
useState<SensorPlacementScheme | null>(null);
|
||||||
|
const [loadingScheme, setLoadingScheme] = useState(false);
|
||||||
|
const [pendingOpenSchemeId, setPendingOpenSchemeId] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const { open: notify } = useNotification();
|
||||||
|
|
||||||
// 持久化方案查询结果
|
// 持久化方案查询结果
|
||||||
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||||
@@ -89,7 +125,47 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
setCurrentTab(newValue);
|
setCurrentTab(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawerWidth = 520;
|
const drawerWidth = currentTab === 1 ? 820 : 520;
|
||||||
|
|
||||||
|
const handleSchemeEditorReady = useCallback(() => {
|
||||||
|
setCurrentTab(1);
|
||||||
|
setPendingOpenSchemeId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenScheme = async (schemeId: number) => {
|
||||||
|
setLoadingScheme(true);
|
||||||
|
try {
|
||||||
|
const scheme = await getSensorPlacementScheme(schemeId);
|
||||||
|
setActiveScheme(scheme);
|
||||||
|
setLoadingScheme(false);
|
||||||
|
setPendingOpenSchemeId(scheme.id);
|
||||||
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
|
(error as { response?: { data?: { detail?: string } } }).response?.data
|
||||||
|
?.detail || "无法读取方案详情";
|
||||||
|
notify?.({
|
||||||
|
type: "error",
|
||||||
|
message: "方案加载失败",
|
||||||
|
description: detail,
|
||||||
|
});
|
||||||
|
setLoadingScheme(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchemeSaved = (scheme: SensorPlacementScheme) => {
|
||||||
|
setActiveScheme(scheme);
|
||||||
|
setSchemes((current) =>
|
||||||
|
current.map((record) =>
|
||||||
|
record.id === scheme.id
|
||||||
|
? {
|
||||||
|
...record,
|
||||||
|
sensorNumber: scheme.sensor_number,
|
||||||
|
sensorLocation: scheme.sensor_location,
|
||||||
|
}
|
||||||
|
: record,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -132,16 +208,23 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
right: 16,
|
right: 16,
|
||||||
height: "calc(100vh - 32px)",
|
height: "calc(100vh - 32px)",
|
||||||
maxHeight: "850px",
|
maxHeight: "850px",
|
||||||
|
maxWidth: "calc(100vw - 32px)",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||||
backdropFilter: "blur(8px)",
|
backdropFilter: "blur(8px)",
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
|
transition:
|
||||||
|
`width ${PANEL_MOTION_DURATION_MS}ms ${PANEL_MOTION_EASING}, ` +
|
||||||
|
"transform 300ms ease-in-out, opacity 300ms ease-in-out",
|
||||||
|
willChange: "width, transform, opacity",
|
||||||
border: "none",
|
border: "none",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
"@media (prefers-reduced-motion: reduce)": {
|
||||||
|
transition: "none",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -183,9 +266,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
"& .Mui-selected": {
|
"& .Mui-selected": {
|
||||||
color: "#257DD4",
|
color: "#257DD4",
|
||||||
},
|
},
|
||||||
"& .MuiTabs-indicator": {
|
"& .MuiTabs-indicator": getTabIndicatorSx(currentTab),
|
||||||
backgroundColor: "#257DD4",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab
|
<Tab
|
||||||
@@ -193,6 +274,11 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
label="优化要件"
|
label="优化要件"
|
||||||
/>
|
/>
|
||||||
|
<Tab
|
||||||
|
icon={<TableIcon fontSize="small" />}
|
||||||
|
iconPosition="start"
|
||||||
|
label="结果编辑"
|
||||||
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
icon={<SearchIcon fontSize="small" />}
|
icon={<SearchIcon fontSize="small" />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
@@ -206,19 +292,64 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
<OptimizationParameters
|
<OptimizationParameters
|
||||||
state={optimizationState}
|
state={optimizationState}
|
||||||
onStateChange={setOptimizationState}
|
onStateChange={setOptimizationState}
|
||||||
|
onSchemeCreated={(scheme) => {
|
||||||
|
setActiveScheme(scheme);
|
||||||
|
setCurrentTab(1);
|
||||||
|
setSchemes((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: scheme.id,
|
||||||
|
schemeName: scheme.scheme_name,
|
||||||
|
sensorNumber: scheme.sensor_number,
|
||||||
|
minDiameter: scheme.min_diameter,
|
||||||
|
username: scheme.username,
|
||||||
|
create_time: scheme.create_time,
|
||||||
|
sensorLocation: scheme.sensor_location,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={1}>
|
<TabPanel value={currentTab} index={1}>
|
||||||
|
{loadingScheme ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: 320,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={30} />
|
||||||
|
</Box>
|
||||||
|
) : activeScheme ? (
|
||||||
|
<PreparedSchemeEditor
|
||||||
|
scheme={activeScheme}
|
||||||
|
network={NETWORK_NAME}
|
||||||
|
active={isOpen && currentTab === 1}
|
||||||
|
onSaved={handleSchemeSaved}
|
||||||
|
onReady={
|
||||||
|
pendingOpenSchemeId === activeScheme.id
|
||||||
|
? handleSchemeEditorReady
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PanelEmptyState
|
||||||
|
icon={<TableIcon />}
|
||||||
|
title="暂无可编辑结果"
|
||||||
|
description="请先在“优化要件”中创建方案,或在“方案查询”中打开已有方案。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={currentTab} index={2}>
|
||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
state={queryState}
|
state={queryState}
|
||||||
onStateChange={setQueryState}
|
onStateChange={setQueryState}
|
||||||
onLocate={(id) => {
|
onEdit={handleOpenScheme}
|
||||||
console.log("定位方案:", id);
|
|
||||||
// TODO: 在地图上定位
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,22 +7,15 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Typography,
|
Typography,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Stack,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { useGetIdentity } from "@refinedev/core";
|
import { NETWORK_NAME } from "@/config/config";
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { optimizeSensorPlacement } from "./schemeApi";
|
||||||
type IUser = {
|
import type { SensorPlacementScheme } from "./types";
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OptimizationParametersState {
|
export interface OptimizationParametersState {
|
||||||
sensorType: string;
|
|
||||||
method: string;
|
method: string;
|
||||||
sensorCount: number;
|
sensorCount: number;
|
||||||
minDiameter: number;
|
minDiameter: number;
|
||||||
@@ -31,7 +24,6 @@ export interface OptimizationParametersState {
|
|||||||
|
|
||||||
export const createOptimizationParametersState =
|
export const createOptimizationParametersState =
|
||||||
(): OptimizationParametersState => ({
|
(): OptimizationParametersState => ({
|
||||||
sensorType: "pressure",
|
|
||||||
method: "kmeans",
|
method: "kmeans",
|
||||||
sensorCount: 5,
|
sensorCount: 5,
|
||||||
minDiameter: 5,
|
minDiameter: 5,
|
||||||
@@ -41,45 +33,32 @@ export const createOptimizationParametersState =
|
|||||||
interface OptimizationParametersProps {
|
interface OptimizationParametersProps {
|
||||||
state?: OptimizationParametersState;
|
state?: OptimizationParametersState;
|
||||||
onStateChange?: (state: OptimizationParametersState) => void;
|
onStateChange?: (state: OptimizationParametersState) => void;
|
||||||
|
onSchemeCreated?: (scheme: SensorPlacementScheme) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
|
onSchemeCreated,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
|
||||||
|
|
||||||
const [parametersState, , setFormField] = useControllableObjectState(
|
const [parametersState, , setFormField] = useControllableObjectState(
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
createOptimizationParametersState(),
|
createOptimizationParametersState(),
|
||||||
);
|
);
|
||||||
const { sensorType, method, sensorCount, minDiameter, schemeName } =
|
const { method, sensorCount, minDiameter, schemeName } =
|
||||||
parametersState;
|
parametersState;
|
||||||
const [network] = useState<string>(NETWORK_NAME);
|
const network = NETWORK_NAME;
|
||||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||||
|
|
||||||
|
|
||||||
// 传感器类型选项
|
|
||||||
const sensorTypeOptions = [
|
|
||||||
{ value: "pressure", label: "压力" },
|
|
||||||
{ value: "flow", label: "流量" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 方法选项
|
// 方法选项
|
||||||
const methodOptions = [
|
const methodOptions = [
|
||||||
{ value: "kmeans", label: "聚类分析" },
|
{ value: "kmeans", label: "聚类分析" },
|
||||||
{ value: "sensitivity", label: "灵敏度分析" },
|
{ value: "sensitivity", label: "灵敏度分析" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 获取传感器类型的中文标签
|
|
||||||
const getSensorTypeLabel = (value: string) => {
|
|
||||||
return (
|
|
||||||
sensorTypeOptions.find((option) => option.value === value)?.label || value
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建方案
|
// 创建方案
|
||||||
const handleCreateScheme = async () => {
|
const handleCreateScheme = async () => {
|
||||||
// 验证输入
|
// 验证输入
|
||||||
@@ -109,48 +88,21 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
|||||||
|
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
|
|
||||||
if (!user || !user.id) {
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "用户信息无效",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 发送优化请求
|
const created = await optimizeSensorPlacement({
|
||||||
const response = await api.post(
|
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
network: network,
|
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
sensor_type: sensorType,
|
sensor_type: "pressure",
|
||||||
method: method,
|
method: method as "sensitivity" | "kmeans",
|
||||||
sensor_count: sensorCount,
|
sensor_count: sensorCount,
|
||||||
min_diameter: minDiameter,
|
min_diameter: minDiameter,
|
||||||
user_id: user.id,
|
});
|
||||||
user_name: user.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("响应数据:", response.data); // 添加日志以便调试
|
|
||||||
|
|
||||||
// 兼容后端返回字符串 "success" 或对象 { success: true }
|
|
||||||
if (response.data.success === true || response.data === "success") {
|
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "方案创建成功",
|
message: "方案创建成功",
|
||||||
description: `方案 "${schemeName}" 已完成优化分析`,
|
description: `方案 "${schemeName}" 已完成优化分析`,
|
||||||
});
|
});
|
||||||
|
onSchemeCreated?.(created);
|
||||||
// 重置方案名称
|
|
||||||
setFormField("schemeName", "Fangan" + new Date().getTime());
|
setFormField("schemeName", "Fangan" + new Date().getTime());
|
||||||
} else {
|
|
||||||
throw new Error(response.data?.message || "创建失败");
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("创建方案失败:", error);
|
console.error("创建方案失败:", error);
|
||||||
open?.({
|
open?.({
|
||||||
@@ -175,11 +127,10 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
|||||||
类型
|
类型
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
value={sensorType}
|
value="压力"
|
||||||
onChange={(e) => setFormField("sensorType", e.target.value)}
|
slotProps={{ input: { readOnly: true } }}
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
"&:hover fieldset": {
|
"&:hover fieldset": {
|
||||||
@@ -190,13 +141,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{sensorTypeOptions.map((option) => (
|
|
||||||
<MenuItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 方法选择 */}
|
{/* 方法选择 */}
|
||||||
@@ -270,7 +215,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
|||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
className="mb-2 font-semibold text-gray-700"
|
className="mb-2 font-semibold text-gray-700"
|
||||||
>
|
>
|
||||||
{getSensorTypeLabel(sensorType)}监测点安装最小管径(可选)
|
压力监测点安装最小管径(可选)
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Download as DownloadIcon,
|
||||||
|
Print as PrintIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import OlMap from "ol/Map";
|
||||||
|
import View from "ol/View";
|
||||||
|
import VectorTileLayer from "ol/layer/VectorTile";
|
||||||
|
import type VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import type { StyleLike } from "ol/style/Style";
|
||||||
|
import {
|
||||||
|
TileFeatureIndex,
|
||||||
|
clipLineStringPartsToExtent,
|
||||||
|
lineStringFromFlatCoordinates,
|
||||||
|
} from "@components/olmap/core/tileFeatureIndex";
|
||||||
|
import {
|
||||||
|
A3_LANDSCAPE_HEIGHT,
|
||||||
|
A3_LANDSCAPE_WIDTH,
|
||||||
|
canvasToPngBlob,
|
||||||
|
getPaddedDrawingExtent,
|
||||||
|
renderEngineeringDrawing,
|
||||||
|
} from "./engineeringDrawing";
|
||||||
|
import type {
|
||||||
|
NetworkDrawingData,
|
||||||
|
SensorPlacementScheme,
|
||||||
|
SensorPointRow,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface SchemeDrawingDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
scheme: SensorPlacementScheme;
|
||||||
|
rows: SensorPointRow[];
|
||||||
|
network: string;
|
||||||
|
map: OlMap | null;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAWING_MAP_RENDER_TIMEOUT_MS = 10_000;
|
||||||
|
const DRAWING_MAP_SIZE: [number, number] = [
|
||||||
|
A3_LANDSCAPE_WIDTH - 300,
|
||||||
|
A3_LANDSCAPE_HEIGHT - 650,
|
||||||
|
];
|
||||||
|
|
||||||
|
type DrawingPipeLayer = {
|
||||||
|
getSource?: () => VectorTileSource | null;
|
||||||
|
getExtent?: () => number[] | undefined;
|
||||||
|
getStyle?: () => StyleLike | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDrawingPipeLayer = (map: OlMap) =>
|
||||||
|
map.getAllLayers().find((layer) => layer.get("value") === "pipes") as
|
||||||
|
DrawingPipeLayer | undefined;
|
||||||
|
|
||||||
|
export const fitMapToFullNetwork = (
|
||||||
|
map: OlMap,
|
||||||
|
): [number, number, number, number] => {
|
||||||
|
const extent = getDrawingPipeLayer(map)?.getExtent?.();
|
||||||
|
if (
|
||||||
|
!extent ||
|
||||||
|
extent.length !== 4 ||
|
||||||
|
!extent.every(Number.isFinite) ||
|
||||||
|
extent[0] >= extent[2] ||
|
||||||
|
extent[1] >= extent[3]
|
||||||
|
) {
|
||||||
|
throw new Error("主地图中未配置有效的管网完整范围");
|
||||||
|
}
|
||||||
|
const size = map.getSize();
|
||||||
|
if (!size) throw new Error("地图尺寸不可用");
|
||||||
|
const networkExtent: [number, number, number, number] = [
|
||||||
|
extent[0],
|
||||||
|
extent[1],
|
||||||
|
extent[2],
|
||||||
|
extent[3],
|
||||||
|
];
|
||||||
|
const view = map.getView();
|
||||||
|
const drawingExtent = getPaddedDrawingExtent(
|
||||||
|
networkExtent,
|
||||||
|
size[0] / size[1],
|
||||||
|
);
|
||||||
|
view.cancelAnimations();
|
||||||
|
view.fit(drawingExtent, {
|
||||||
|
padding: [0, 0, 0, 0],
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
return networkExtent;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DrawingMapSession {
|
||||||
|
map: OlMap;
|
||||||
|
extent: [number, number, number, number];
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDrawingMapSession = (mainMap: OlMap): DrawingMapSession => {
|
||||||
|
const mainPipeLayer = getDrawingPipeLayer(mainMap);
|
||||||
|
const source = mainPipeLayer?.getSource?.();
|
||||||
|
const extent = mainPipeLayer?.getExtent?.();
|
||||||
|
if (!source) throw new Error("主地图中未找到管网图层");
|
||||||
|
if (
|
||||||
|
!extent ||
|
||||||
|
extent.length !== 4 ||
|
||||||
|
!extent.every(Number.isFinite) ||
|
||||||
|
extent[0] >= extent[2] ||
|
||||||
|
extent[1] >= extent[3]
|
||||||
|
) {
|
||||||
|
throw new Error("主地图中未配置有效的管网完整范围");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = document.createElement("div");
|
||||||
|
target.setAttribute("aria-hidden", "true");
|
||||||
|
Object.assign(target.style, {
|
||||||
|
position: "fixed",
|
||||||
|
left: "-100000px",
|
||||||
|
top: "0",
|
||||||
|
width: `${DRAWING_MAP_SIZE[0]}px`,
|
||||||
|
height: `${DRAWING_MAP_SIZE[1]}px`,
|
||||||
|
pointerEvents: "none",
|
||||||
|
});
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
let drawingMap: OlMap | null = null;
|
||||||
|
try {
|
||||||
|
const networkExtent: [number, number, number, number] = [
|
||||||
|
extent[0],
|
||||||
|
extent[1],
|
||||||
|
extent[2],
|
||||||
|
extent[3],
|
||||||
|
];
|
||||||
|
const pipeLayer = new VectorTileLayer({
|
||||||
|
source,
|
||||||
|
style: mainPipeLayer?.getStyle?.(),
|
||||||
|
});
|
||||||
|
pipeLayer.set("value", "pipes");
|
||||||
|
pipeLayer.setExtent(networkExtent);
|
||||||
|
drawingMap = new OlMap({
|
||||||
|
target,
|
||||||
|
pixelRatio: 1,
|
||||||
|
view: new View({
|
||||||
|
projection: mainMap.getView().getProjection(),
|
||||||
|
}),
|
||||||
|
layers: [pipeLayer],
|
||||||
|
controls: [],
|
||||||
|
interactions: [],
|
||||||
|
});
|
||||||
|
drawingMap.setSize(DRAWING_MAP_SIZE);
|
||||||
|
fitMapToFullNetwork(drawingMap);
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
return {
|
||||||
|
map: drawingMap,
|
||||||
|
extent: networkExtent,
|
||||||
|
dispose: () => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
drawingMap?.setTarget(undefined);
|
||||||
|
drawingMap?.getLayers().clear();
|
||||||
|
target.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (reason) {
|
||||||
|
drawingMap?.setTarget(undefined);
|
||||||
|
target.remove();
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForDrawingMapRender = (map: OlMap) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
map.un("moveend", requestRender);
|
||||||
|
map.un("rendercomplete", handleRenderComplete);
|
||||||
|
};
|
||||||
|
const handleRenderComplete = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const requestRender = () => {
|
||||||
|
map.once("rendercomplete", handleRenderComplete);
|
||||||
|
map.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("工程图管网加载超时,请稍后重试"));
|
||||||
|
}, DRAWING_MAP_RENDER_TIMEOUT_MS);
|
||||||
|
|
||||||
|
if (map.getView().getAnimating()) {
|
||||||
|
map.once("moveend", requestRender);
|
||||||
|
} else {
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const readNetworkDataFromMap = (
|
||||||
|
map: OlMap,
|
||||||
|
extent: [number, number, number, number],
|
||||||
|
): NetworkDrawingData => {
|
||||||
|
const pipeLayer = getDrawingPipeLayer(map);
|
||||||
|
const source = pipeLayer?.getSource?.();
|
||||||
|
if (!source) throw new Error("工程图中未找到管网图层");
|
||||||
|
|
||||||
|
const index = new TileFeatureIndex("engineering-drawing-pipes", source);
|
||||||
|
index.scanLoadedTiles();
|
||||||
|
const snapshot = index.getSnapshot(map, map.getView().getZoom() ?? 0);
|
||||||
|
const paths = snapshot.instances.flatMap((instance) => {
|
||||||
|
if (!instance.geometryType.includes("LineString")) return [];
|
||||||
|
return clipLineStringPartsToExtent(
|
||||||
|
lineStringFromFlatCoordinates(instance.flatCoordinates, instance.stride),
|
||||||
|
instance.tileExtent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!paths.length) throw new Error("工程图管网尚未加载完成,请稍后重试");
|
||||||
|
|
||||||
|
const nodes: string[] = [];
|
||||||
|
const links: string[] = [];
|
||||||
|
const nodeIds = new globalThis.Map<string, string>();
|
||||||
|
const getNodeId = ([x, y]: number[]) => {
|
||||||
|
const key = `${x}:${y}`;
|
||||||
|
const existing = nodeIds.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const nodeId = `drawing_node_${nodeIds.size + 1}`;
|
||||||
|
nodeIds.set(key, nodeId);
|
||||||
|
nodes.push(`${nodeId}:junction:${x}:${y}`);
|
||||||
|
return nodeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
paths.forEach((path) => {
|
||||||
|
for (let index = 1; index < path.length; index += 1) {
|
||||||
|
const startId = getNodeId(path[index - 1]);
|
||||||
|
const endId = getNodeId(path[index]);
|
||||||
|
links.push(`drawing_link_${links.length + 1}:pipe:${startId}:${endId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { nodes, links, extent };
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBlob = (blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value.replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(character) =>
|
||||||
|
({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
})[character] ?? character,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DrawingPreview = ({
|
||||||
|
src,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
label: string;
|
||||||
|
}) => (
|
||||||
|
<Box
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundImage: `url("${src}")`,
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network,
|
||||||
|
map,
|
||||||
|
dirty,
|
||||||
|
}) => {
|
||||||
|
const [networkData, setNetworkData] = useState<NetworkDrawingData | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const replacePreviewUrl = useCallback((nextUrl: string | null) => {
|
||||||
|
const previousUrl = previewUrlRef.current;
|
||||||
|
previewUrlRef.current = nextUrl;
|
||||||
|
setPreviewUrl(nextUrl);
|
||||||
|
if (previousUrl && previousUrl !== nextUrl) {
|
||||||
|
URL.revokeObjectURL(previousUrl);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const filename = useMemo(
|
||||||
|
() => `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_布置图.png`,
|
||||||
|
[dirty, scheme.scheme_name],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setNetworkData(null);
|
||||||
|
replacePreviewUrl(null);
|
||||||
|
if (!map) {
|
||||||
|
setError("主地图尚未初始化,请稍后重试");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let drawingSession: DrawingMapSession;
|
||||||
|
try {
|
||||||
|
drawingSession = createDrawingMapSession(map);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "无法适配完整管网范围",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
waitForDrawingMapRender(drawingSession.map)
|
||||||
|
.then(() =>
|
||||||
|
readNetworkDataFromMap(
|
||||||
|
drawingSession.map,
|
||||||
|
drawingSession.extent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setNetworkData(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "无法读取工程图管网数据",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
drawingSession.dispose();
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
drawingSession.dispose();
|
||||||
|
};
|
||||||
|
}, [map, network, open, replacePreviewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !networkData) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
renderEngineeringDrawing({
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network,
|
||||||
|
networkData,
|
||||||
|
dirty,
|
||||||
|
width: 1600,
|
||||||
|
})
|
||||||
|
.then(canvasToPngBlob)
|
||||||
|
.then((blob) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
replacePreviewUrl(URL.createObjectURL(blob));
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!cancelled)
|
||||||
|
setError(reason instanceof Error ? reason.message : "出图失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
dirty,
|
||||||
|
network,
|
||||||
|
networkData,
|
||||||
|
open,
|
||||||
|
rows,
|
||||||
|
scheme,
|
||||||
|
replacePreviewUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const createFullResolutionBlob = async () => {
|
||||||
|
if (!networkData) throw new Error("管网数据尚未加载");
|
||||||
|
const canvas = await renderEngineeringDrawing({
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network,
|
||||||
|
networkData,
|
||||||
|
dirty,
|
||||||
|
width: A3_LANDSCAPE_WIDTH,
|
||||||
|
});
|
||||||
|
return canvasToPngBlob(canvas);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
downloadBlob(await createFullResolutionBlob(), filename);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "PNG 下载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = async () => {
|
||||||
|
const printWindow = window.open("", "_blank");
|
||||||
|
if (!printWindow) {
|
||||||
|
setError("浏览器阻止了打印窗口,请允许本站打开新窗口");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printWindow.opener = null;
|
||||||
|
printWindow.document.write(
|
||||||
|
'<!doctype html><html lang="zh-CN"><body style="font-family: sans-serif; padding: 24px;">正在生成 A3 工程图...</body></html>',
|
||||||
|
);
|
||||||
|
printWindow.document.close();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const blob = await createFullResolutionBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const safeSchemeName = escapeHtml(scheme.scheme_name);
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<title>${safeSchemeName} 布置图</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A3 landscape; margin: 0; }
|
||||||
|
html, body { margin: 0; width: 100%; height: 100%; }
|
||||||
|
img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><img src="${url}" alt="${safeSchemeName} 压力监测点布置图"></body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
const image = printWindow.document.querySelector("img");
|
||||||
|
image?.addEventListener("load", () => {
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
} catch (reason) {
|
||||||
|
printWindow.close();
|
||||||
|
setError(reason instanceof Error ? reason.message : "打印失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="lg"
|
||||||
|
aria-labelledby="scheme-drawing-title"
|
||||||
|
PaperProps={{ sx: { borderRadius: 3, overflow: "hidden" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle id="scheme-drawing-title" sx={{ pb: 1 }}>
|
||||||
|
工程布置图预览
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ bgcolor: "#eef2f5", p: 0 }}>
|
||||||
|
<Box data-testid="drawing-content-spacing" sx={{ px: 3, pt: 1.5, pb: 2 }}>
|
||||||
|
<Box
|
||||||
|
data-testid="drawing-format-summary"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
columnGap: 2,
|
||||||
|
rowGap: 0.75,
|
||||||
|
mb: 1.5,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
minHeight: 44,
|
||||||
|
bgcolor: "rgba(37, 125, 212, 0.06)",
|
||||||
|
border: "1px solid rgba(37, 125, 212, 0.14)",
|
||||||
|
borderRadius: 2,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ lineHeight: 1.5 }}>
|
||||||
|
<Box component="span" sx={{ color: "text.secondary", mr: 1 }}>
|
||||||
|
输出样式
|
||||||
|
</Box>
|
||||||
|
<Box component="span" sx={{ color: "text.primary", fontWeight: 700 }}>
|
||||||
|
简化管网线稿
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
divider={
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{ width: 1, height: 14, bgcolor: "rgba(15, 23, 42, 0.14)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">A3 横向</Typography>
|
||||||
|
<Typography variant="caption">300 DPI</Typography>
|
||||||
|
<Typography variant="caption">4961 × 3508</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 1.5 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
aspectRatio: `${4961} / ${3508}`,
|
||||||
|
bgcolor: "#fff",
|
||||||
|
boxShadow: "0 8px 28px rgba(15, 23, 42, 0.14)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewUrl && (
|
||||||
|
<DrawingPreview
|
||||||
|
src={previewUrl}
|
||||||
|
label={`${scheme.scheme_name} 压力监测点布置图预览`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
bgcolor: "rgba(255,255,255,0.72)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={30} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button onClick={onClose}>关闭</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={loading || !networkData}
|
||||||
|
>
|
||||||
|
下载 PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PrintIcon />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={loading || !networkData}
|
||||||
|
>
|
||||||
|
打印
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SchemeDrawingDialog;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import SchemeEditor from "./SchemeEditor";
|
||||||
|
import type { SensorPlacementScheme } from "./types";
|
||||||
|
|
||||||
|
const mockOpen = jest.fn();
|
||||||
|
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
||||||
|
|
||||||
|
const mockMap = {
|
||||||
|
addLayer: jest.fn(),
|
||||||
|
removeLayer: jest.fn(),
|
||||||
|
on: jest.fn((eventName: string, handler: (event: unknown) => void) => {
|
||||||
|
if (eventName === "singleclick") {
|
||||||
|
mockSingleClickHandler = handler;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
un: jest.fn(),
|
||||||
|
forEachFeatureAtPixel: jest.fn(() => undefined),
|
||||||
|
getView: jest.fn(() => ({
|
||||||
|
animate: jest.fn(),
|
||||||
|
getZoom: jest.fn(() => 16),
|
||||||
|
getResolution: jest.fn(() => 1),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useNotification: () => ({ open: mockOpen }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||||
|
useMap: () => mockMap,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@mui/x-data-grid", () => ({
|
||||||
|
DataGrid: (props: {
|
||||||
|
density?: string;
|
||||||
|
initialState?: { density?: string };
|
||||||
|
rowHeight?: number;
|
||||||
|
columnHeaderHeight?: number;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="scheme-grid"
|
||||||
|
data-density={props.density ?? "uncontrolled"}
|
||||||
|
data-initial-density={props.initialState?.density ?? ""}
|
||||||
|
data-row-height={props.rowHeight ?? "automatic"}
|
||||||
|
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
GridToolbar: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@mui/x-data-grid/locales", () => ({
|
||||||
|
zhCN: {
|
||||||
|
components: {
|
||||||
|
MuiDataGrid: {
|
||||||
|
defaultProps: {
|
||||||
|
localeText: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/Feature", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
private values: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(values: Record<string, unknown>) {
|
||||||
|
this.values = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string) {
|
||||||
|
return this.values[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
setId() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/geom/Point", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
constructor(_coordinates: number[]) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/source/Vector", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
clear() {}
|
||||||
|
addFeature() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/layer/Vector", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
private source: { clear: () => void; addFeature: () => void };
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
source: { clear: () => void; addFeature: () => void };
|
||||||
|
}) {
|
||||||
|
this.source = options.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSource() {
|
||||||
|
return this.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/style", () => ({
|
||||||
|
Circle: class {},
|
||||||
|
Fill: class {},
|
||||||
|
Stroke: class {},
|
||||||
|
Style: class {},
|
||||||
|
Text: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/proj", () => ({
|
||||||
|
fromLonLat: (coordinates: number[]) => coordinates,
|
||||||
|
toLonLat: (coordinates: number[]) => coordinates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
|
handleMapClickSelectFeatures: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./SchemeDrawingDialog", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./schemeApi", () => ({
|
||||||
|
exportSensorPlacementExcel: jest.fn(),
|
||||||
|
overwriteSensorPlacementScheme: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scheme: SensorPlacementScheme = {
|
||||||
|
id: 1,
|
||||||
|
scheme_name: "测试方案",
|
||||||
|
sensor_number: 1,
|
||||||
|
min_diameter: 300,
|
||||||
|
username: "operator",
|
||||||
|
create_time: "2026-07-30T08:00:00+08:00",
|
||||||
|
sensor_location: ["J1"],
|
||||||
|
sensor_points: [
|
||||||
|
{
|
||||||
|
node_id: "J1",
|
||||||
|
project_x: 10,
|
||||||
|
project_y: 20,
|
||||||
|
map_x: 13500010,
|
||||||
|
map_y: 3600020,
|
||||||
|
longitude: 121,
|
||||||
|
latitude: 31,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
can_edit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SchemeEditor notifications", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockSingleClickHandler = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
|
||||||
|
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "替换" }));
|
||||||
|
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
mockSingleClickHandler?.({
|
||||||
|
pixel: [0, 0],
|
||||||
|
coordinate: [13500000, 3600000],
|
||||||
|
stopPropagation: jest.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith({
|
||||||
|
type: "error",
|
||||||
|
message: "请先点击要替换的监测点",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the Data Grid density selector control row sizing", () => {
|
||||||
|
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
|
||||||
|
"data-density",
|
||||||
|
"uncontrolled",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
|
||||||
|
"data-initial-density",
|
||||||
|
"compact",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
|
||||||
|
"data-row-height",
|
||||||
|
"automatic",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
|
||||||
|
"data-column-header-height",
|
||||||
|
"automatic",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
AddLocationAlt as AddIcon,
|
||||||
|
DeleteOutline as DeleteIcon,
|
||||||
|
Download as DownloadIcon,
|
||||||
|
EditLocationAlt as ReplaceIcon,
|
||||||
|
LocationOn as LocateIcon,
|
||||||
|
Map as MapIcon,
|
||||||
|
Redo as RedoIcon,
|
||||||
|
RestartAlt as ResetIcon,
|
||||||
|
Save as SaveIcon,
|
||||||
|
Undo as UndoIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
GridToolbar,
|
||||||
|
type GridColDef,
|
||||||
|
type GridRowSelectionModel,
|
||||||
|
} from "@mui/x-data-grid";
|
||||||
|
import { zhCN } from "@mui/x-data-grid/locales";
|
||||||
|
import Feature, { type FeatureLike } from "ol/Feature";
|
||||||
|
import Point from "ol/geom/Point";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
|
||||||
|
import { fromLonLat, toLonLat } from "ol/proj";
|
||||||
|
import { useNotification } from "@refinedev/core";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { config } from "@/config/config";
|
||||||
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
|
import {
|
||||||
|
addSensorPoint,
|
||||||
|
createSchemeEditorState,
|
||||||
|
deleteSensorPoint,
|
||||||
|
isSchemeDirty,
|
||||||
|
redoSchemeEdit,
|
||||||
|
replaceSensorPoint,
|
||||||
|
resetSchemeEdit,
|
||||||
|
summarizeChanges,
|
||||||
|
toSensorPointRows,
|
||||||
|
undoSchemeEdit,
|
||||||
|
} from "./schemeEditor";
|
||||||
|
import {
|
||||||
|
exportSensorPlacementExcel,
|
||||||
|
overwriteSensorPlacementScheme,
|
||||||
|
} from "./schemeApi";
|
||||||
|
import SchemeDrawingDialog from "./SchemeDrawingDialog";
|
||||||
|
import type {
|
||||||
|
AdjustmentStatus,
|
||||||
|
SensorPlacementScheme,
|
||||||
|
SensorPoint,
|
||||||
|
SensorPointRow,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type EditMode = "idle" | "add" | "replace" | "delete";
|
||||||
|
|
||||||
|
interface SchemeEditorProps {
|
||||||
|
scheme: SensorPlacementScheme;
|
||||||
|
network: string;
|
||||||
|
active?: boolean;
|
||||||
|
onSaved?: (scheme: SensorPlacementScheme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<AdjustmentStatus, string> = {
|
||||||
|
current: "当前方案",
|
||||||
|
original: "原方案",
|
||||||
|
added: "新增",
|
||||||
|
replaced: "替换",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<
|
||||||
|
AdjustmentStatus,
|
||||||
|
"default" | "success" | "warning" | "info"
|
||||||
|
> = {
|
||||||
|
current: "default",
|
||||||
|
original: "info",
|
||||||
|
added: "success",
|
||||||
|
replaced: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
const markerStyle = (feature: FeatureLike) => {
|
||||||
|
const status = feature.get("adjustment_status") as AdjustmentStatus;
|
||||||
|
const selected = Boolean(feature.get("selected"));
|
||||||
|
const color =
|
||||||
|
status === "added" ? "#16865b" : status === "replaced" ? "#d06b16" : "#c9252d";
|
||||||
|
return new Style({
|
||||||
|
image: new Circle({
|
||||||
|
radius: selected ? 11 : 9,
|
||||||
|
fill: new Fill({ color: "#ffffff" }),
|
||||||
|
stroke: new Stroke({ color, width: selected ? 4 : 3 }),
|
||||||
|
}),
|
||||||
|
text: new Text({
|
||||||
|
text: String(feature.get("sequence") ?? ""),
|
||||||
|
font: '700 11px -apple-system, "PingFang SC", sans-serif',
|
||||||
|
fill: new Fill({ color }),
|
||||||
|
offsetY: 0.5,
|
||||||
|
}),
|
||||||
|
zIndex: selected ? 20 : 10,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorDescription = (error: unknown) => {
|
||||||
|
const candidate = error as {
|
||||||
|
response?: { data?: { detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
return candidate.response?.data?.detail || candidate.message || "请求失败";
|
||||||
|
};
|
||||||
|
|
||||||
|
const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||||
|
scheme,
|
||||||
|
network,
|
||||||
|
active = true,
|
||||||
|
onSaved,
|
||||||
|
}) => {
|
||||||
|
const map = useMap();
|
||||||
|
const { open } = useNotification();
|
||||||
|
const [editor, setEditor] = useState(() => createSchemeEditorState(scheme));
|
||||||
|
const [mode, setMode] = useState<EditMode>("idle");
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
const [replaceSourceId, setReplaceSourceId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
|
const [drawingOpen, setDrawingOpen] = useState(false);
|
||||||
|
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditor(createSchemeEditorState(scheme));
|
||||||
|
setMode("idle");
|
||||||
|
setReplaceSourceId(null);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
}, [scheme]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => toSensorPointRows(editor), [editor]);
|
||||||
|
const dirty = isSchemeDirty(editor);
|
||||||
|
const changes = summarizeChanges(editor);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const layer = new VectorLayer({
|
||||||
|
source: new VectorSource(),
|
||||||
|
style: markerStyle,
|
||||||
|
properties: {
|
||||||
|
name: "监测点方案编辑",
|
||||||
|
value: "sensor_scheme_editor",
|
||||||
|
queryable: false,
|
||||||
|
},
|
||||||
|
zIndex: 120,
|
||||||
|
});
|
||||||
|
markerLayerRef.current = layer;
|
||||||
|
map.addLayer(layer);
|
||||||
|
return () => {
|
||||||
|
markerLayerRef.current = null;
|
||||||
|
map.removeLayer(layer);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
markerLayerRef.current?.setVisible(active);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = markerLayerRef.current?.getSource();
|
||||||
|
if (!source) return;
|
||||||
|
source.clear();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: new Point([row.map_x, row.map_y]),
|
||||||
|
node_id: row.node_id,
|
||||||
|
sequence: row.sequence,
|
||||||
|
adjustment_status: row.adjustment_status,
|
||||||
|
selected: row.node_id === selectedNodeId,
|
||||||
|
});
|
||||||
|
feature.setId(`sensor-scheme-${row.node_id}`);
|
||||||
|
source.addFeature(feature);
|
||||||
|
});
|
||||||
|
}, [rows, selectedNodeId]);
|
||||||
|
|
||||||
|
const resolveJunction = useCallback(
|
||||||
|
async (event: any): Promise<SensorPoint | null> => {
|
||||||
|
if (!map) return null;
|
||||||
|
const feature = await handleMapClickSelectFeatures(event, map);
|
||||||
|
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
|
||||||
|
if (!nodeId) return null;
|
||||||
|
const featureGeometry = feature?.getGeometry();
|
||||||
|
const featureCoordinate =
|
||||||
|
featureGeometry instanceof Point
|
||||||
|
? featureGeometry.getCoordinates()
|
||||||
|
: event.coordinate;
|
||||||
|
const projectedFeatureCoordinate =
|
||||||
|
Math.abs(featureCoordinate[0]) <= 180 &&
|
||||||
|
Math.abs(featureCoordinate[1]) <= 90
|
||||||
|
? fromLonLat(featureCoordinate)
|
||||||
|
: featureCoordinate;
|
||||||
|
const resolution = map.getView().getResolution() ?? 1;
|
||||||
|
const isAlignedWithMap =
|
||||||
|
Math.hypot(
|
||||||
|
projectedFeatureCoordinate[0] - event.coordinate[0],
|
||||||
|
projectedFeatureCoordinate[1] - event.coordinate[1],
|
||||||
|
) <=
|
||||||
|
resolution * 20;
|
||||||
|
const [mapX, mapY] = isAlignedWithMap
|
||||||
|
? projectedFeatureCoordinate
|
||||||
|
: event.coordinate;
|
||||||
|
try {
|
||||||
|
const response = await api.get<{
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
elevation: number;
|
||||||
|
}>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
|
||||||
|
params: { junction: nodeId },
|
||||||
|
});
|
||||||
|
if (!response.data?.id) return null;
|
||||||
|
const [longitude, latitude] = toLonLat([mapX, mapY]);
|
||||||
|
return {
|
||||||
|
node_id: String(response.data.id),
|
||||||
|
project_x: Number(response.data.x),
|
||||||
|
project_y: Number(response.data.y),
|
||||||
|
map_x: mapX,
|
||||||
|
map_y: mapY,
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
elevation: Number(response.data.elevation),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[map],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !map || mode === "idle" || !scheme.can_edit) return;
|
||||||
|
let handling = false;
|
||||||
|
const processClick = async (event: any) => {
|
||||||
|
if (handling) return;
|
||||||
|
event.stopPropagation?.();
|
||||||
|
handling = true;
|
||||||
|
try {
|
||||||
|
const markerLayer = markerLayerRef.current;
|
||||||
|
const marker = markerLayer
|
||||||
|
? map.forEachFeatureAtPixel(
|
||||||
|
event.pixel,
|
||||||
|
(feature) => feature as Feature,
|
||||||
|
{
|
||||||
|
hitTolerance: 8,
|
||||||
|
layerFilter: (layer) => layer === markerLayer,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const markerNodeId = marker ? String(marker.get("node_id")) : null;
|
||||||
|
|
||||||
|
if (mode === "delete") {
|
||||||
|
if (!markerNodeId) {
|
||||||
|
open?.({ type: "error", message: "请点击要删除的监测点" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditor((current) => deleteSensorPoint(current, markerNodeId));
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "replace" && !replaceSourceId) {
|
||||||
|
if (!markerNodeId) {
|
||||||
|
open?.({ type: "error", message: "请先点击要替换的监测点" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReplaceSourceId(markerNodeId);
|
||||||
|
setSelectedNodeId(markerNodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = await resolveJunction(event);
|
||||||
|
if (!candidate) {
|
||||||
|
open?.({ type: "error", message: "请选择有效的管网节点" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "add") {
|
||||||
|
const exists = editor.points.some(
|
||||||
|
(point) => point.node_id === candidate.node_id,
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
open?.({ type: "error", message: "该节点已在当前方案中" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditor((current) => addSensorPoint(current, candidate));
|
||||||
|
setSelectedNodeId(candidate.node_id);
|
||||||
|
} else if (mode === "replace" && replaceSourceId) {
|
||||||
|
const duplicate = editor.points.some(
|
||||||
|
(point) =>
|
||||||
|
point.node_id === candidate.node_id &&
|
||||||
|
point.node_id !== replaceSourceId,
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
open?.({ type: "error", message: "目标节点已在当前方案中" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditor((current) =>
|
||||||
|
replaceSensorPoint(current, replaceSourceId, candidate),
|
||||||
|
);
|
||||||
|
setSelectedNodeId(candidate.node_id);
|
||||||
|
setReplaceSourceId(null);
|
||||||
|
setMode("idle");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
handling = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleClick = (event: any) => {
|
||||||
|
void processClick(event);
|
||||||
|
};
|
||||||
|
map.on("singleclick", handleClick);
|
||||||
|
return () => {
|
||||||
|
map.un("singleclick", handleClick);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
editor.points,
|
||||||
|
active,
|
||||||
|
map,
|
||||||
|
mode,
|
||||||
|
network,
|
||||||
|
open,
|
||||||
|
replaceSourceId,
|
||||||
|
resolveJunction,
|
||||||
|
scheme.can_edit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activateMode = useCallback(
|
||||||
|
(nextMode: EditMode, sourceNodeId?: string) => {
|
||||||
|
setMode((current) =>
|
||||||
|
current === nextMode && !sourceNodeId ? "idle" : nextMode,
|
||||||
|
);
|
||||||
|
setReplaceSourceId(sourceNodeId ?? null);
|
||||||
|
if (sourceNodeId) setSelectedNodeId(sourceNodeId);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback((nodeId: string) => {
|
||||||
|
setEditor((current) => deleteSensorPoint(current, nodeId));
|
||||||
|
if (selectedNodeId === nodeId) setSelectedNodeId(null);
|
||||||
|
}, [selectedNodeId]);
|
||||||
|
|
||||||
|
const locateRow = useCallback((row: SensorPointRow) => {
|
||||||
|
setSelectedNodeId(row.node_id);
|
||||||
|
if (!map) return;
|
||||||
|
map.getView().animate({
|
||||||
|
center: [row.map_x, row.map_y],
|
||||||
|
zoom: Math.max(map.getView().getZoom() ?? 16, 18),
|
||||||
|
duration: 300,
|
||||||
|
});
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await overwriteSensorPlacementScheme(
|
||||||
|
scheme.id,
|
||||||
|
editor.baseline.map((point) => point.node_id),
|
||||||
|
editor.points.map((point) => point.node_id),
|
||||||
|
);
|
||||||
|
setEditor(createSchemeEditorState(updated));
|
||||||
|
onSaved?.(updated);
|
||||||
|
setSaveDialogOpen(false);
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "方案已覆盖保存",
|
||||||
|
description: `当前共 ${updated.sensor_number} 个监测点`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
(error as { response?: { status?: number } }).response?.status === 409
|
||||||
|
? "方案已发生变化"
|
||||||
|
: "方案保存失败",
|
||||||
|
description: errorDescription(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const blob = await exportSensorPlacementExcel(
|
||||||
|
scheme.id,
|
||||||
|
editor.points.map((point) => point.node_id),
|
||||||
|
editor.statuses,
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_监测点清单.xlsx`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "Excel 导出失败",
|
||||||
|
description: errorDescription(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<GridColDef<SensorPointRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
field: "sequence",
|
||||||
|
headerName: "序号",
|
||||||
|
width: 64,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
|
||||||
|
{
|
||||||
|
field: "longitude",
|
||||||
|
headerName: "经度",
|
||||||
|
minWidth: 125,
|
||||||
|
flex: 1,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => Number(value).toFixed(6),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "latitude",
|
||||||
|
headerName: "纬度",
|
||||||
|
minWidth: 125,
|
||||||
|
flex: 1,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => Number(value).toFixed(6),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "project_x",
|
||||||
|
headerName: "工程 X",
|
||||||
|
minWidth: 130,
|
||||||
|
flex: 1,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => Number(value).toFixed(3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "project_y",
|
||||||
|
headerName: "工程 Y",
|
||||||
|
minWidth: 130,
|
||||||
|
flex: 1,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => Number(value).toFixed(3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "map_x",
|
||||||
|
headerName: "地图 X",
|
||||||
|
minWidth: 130,
|
||||||
|
flex: 1,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => Number(value).toFixed(3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "map_y",
|
||||||
|
headerName: "地图 Y",
|
||||||
|
minWidth: 130,
|
||||||
|
flex: 1,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => Number(value).toFixed(3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "elevation",
|
||||||
|
headerName: "高程",
|
||||||
|
width: 92,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => Number(value).toFixed(3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "adjustment_status",
|
||||||
|
headerName: "调整状态",
|
||||||
|
width: 108,
|
||||||
|
renderCell: ({ value }) => {
|
||||||
|
const status = value as AdjustmentStatus;
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={STATUS_LABELS[status]}
|
||||||
|
color={STATUS_COLORS[status]}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "操作",
|
||||||
|
width: scheme.can_edit ? 138 : 54,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
renderCell: ({ row }) => (
|
||||||
|
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
|
||||||
|
<Tooltip title="地图定位">
|
||||||
|
<IconButton
|
||||||
|
aria-label={`定位节点 ${row.node_id}`}
|
||||||
|
size="small"
|
||||||
|
onClick={() => locateRow(row)}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<LocateIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{scheme.can_edit && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="替换节点">
|
||||||
|
<IconButton
|
||||||
|
aria-label={`替换节点 ${row.node_id}`}
|
||||||
|
size="small"
|
||||||
|
onClick={() => activateMode("replace", row.node_id)}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<ReplaceIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除节点">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
aria-label={`删除节点 ${row.node_id}`}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDelete(row.node_id)}
|
||||||
|
disabled={rows.length <= 1}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const instruction =
|
||||||
|
mode === "add"
|
||||||
|
? "点击地图中的管网节点添加监测点"
|
||||||
|
: mode === "replace"
|
||||||
|
? replaceSourceId
|
||||||
|
? `已选择 ${replaceSourceId},请点击新的管网节点`
|
||||||
|
: "请先点击要替换的监测点"
|
||||||
|
: mode === "delete"
|
||||||
|
? "点击地图中的监测点删除"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", gap: 1.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 1.5,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
|
||||||
|
{scheme.scheme_name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{scheme.username} · {rows.length} 个监测点
|
||||||
|
{dirty ? " · 未保存草稿" : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
{exporting ? "导出中" : "Excel"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<MapIcon />}
|
||||||
|
onClick={() => setDrawingOpen(true)}
|
||||||
|
>
|
||||||
|
工程图
|
||||||
|
</Button>
|
||||||
|
{scheme.can_edit && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
disabled={!dirty}
|
||||||
|
onClick={() => setSaveDialogOpen(true)}
|
||||||
|
>
|
||||||
|
覆盖保存
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!scheme.can_edit && (
|
||||||
|
<Alert severity="info">当前为只读方案,创建人或管理员可以微调并保存。</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scheme.can_edit && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
p: 1,
|
||||||
|
bgcolor: "#f3f6f8",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={mode === "add" ? "contained" : "text"}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => activateMode("add")}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={mode === "replace" ? "contained" : "text"}
|
||||||
|
startIcon={<ReplaceIcon />}
|
||||||
|
onClick={() => activateMode("replace")}
|
||||||
|
>
|
||||||
|
替换
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant={mode === "delete" ? "contained" : "text"}
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={() => activateMode("delete")}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<Tooltip title="撤销上一步">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="撤销上一步"
|
||||||
|
size="small"
|
||||||
|
disabled={!editor.history.length}
|
||||||
|
onClick={() => setEditor((current) => undoSchemeEdit(current))}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<UndoIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重做下一步">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="重做下一步"
|
||||||
|
size="small"
|
||||||
|
disabled={!editor.future.length}
|
||||||
|
onClick={() => setEditor((current) => redoSchemeEdit(current))}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<RedoIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重置为服务器版本">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="重置为服务器版本"
|
||||||
|
size="small"
|
||||||
|
disabled={!dirty}
|
||||||
|
onClick={() => setEditor((current) => resetSchemeEdit(current))}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<ResetIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{instruction && (
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setMode("idle");
|
||||||
|
setReplaceSourceId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{instruction}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minHeight: 320 }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
getRowId={(row) => row.node_id}
|
||||||
|
initialState={{ density: "compact" }}
|
||||||
|
disableRowSelectionOnClick={false}
|
||||||
|
rowSelectionModel={selectedNodeId ? [selectedNodeId] : []}
|
||||||
|
onRowSelectionModelChange={(selection: GridRowSelectionModel) =>
|
||||||
|
setSelectedNodeId(selection.length ? String(selection[0]) : null)
|
||||||
|
}
|
||||||
|
onRowDoubleClick={({ row }) => locateRow(row)}
|
||||||
|
slots={{ toolbar: GridToolbar }}
|
||||||
|
slotProps={{
|
||||||
|
toolbar: {
|
||||||
|
showQuickFilter: true,
|
||||||
|
printOptions: { disableToolbarButton: true },
|
||||||
|
csvOptions: { disableToolbarButton: true },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
borderColor: "rgba(15, 23, 42, 0.10)",
|
||||||
|
"& .MuiDataGrid-columnHeaders": { bgcolor: "#edf3f7" },
|
||||||
|
"& .MuiDataGrid-cell": {
|
||||||
|
borderColor: "rgba(15, 23, 42, 0.06)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
},
|
||||||
|
"& .MuiDataGrid-row.Mui-selected": {
|
||||||
|
bgcolor: "rgba(37, 125, 212, 0.10)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={saveDialogOpen}
|
||||||
|
onClose={() => !saving && setSaveDialogOpen(false)}
|
||||||
|
aria-labelledby="overwrite-scheme-title"
|
||||||
|
>
|
||||||
|
<DialogTitle id="overwrite-scheme-title">覆盖当前方案</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" sx={{ lineHeight: 1.8 }}>
|
||||||
|
保存后将覆盖原方案节点。此次调整包含:新增 {changes.added} 个,替换{" "}
|
||||||
|
{changes.replaced} 个,删除 {changes.removed} 个,保存后共 {rows.length} 个。
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setSaveDialogOpen(false)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "保存中" : "确认覆盖"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<SchemeDrawingDialog
|
||||||
|
open={drawingOpen}
|
||||||
|
onClose={() => setDrawingOpen(false)}
|
||||||
|
scheme={scheme}
|
||||||
|
rows={rows}
|
||||||
|
network={network}
|
||||||
|
map={map ?? null}
|
||||||
|
dirty={dirty}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SchemeEditor;
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Info as InfoIcon,
|
Info as InfoIcon,
|
||||||
LocationOn as LocationIcon,
|
EditLocationAlt as EditIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
@@ -37,16 +37,9 @@ import VectorSource from "ol/source/Vector";
|
|||||||
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
|
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
|
||||||
import Feature, { FeatureLike } from "ol/Feature";
|
import Feature, { FeatureLike } from "ol/Feature";
|
||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
|
import type { SchemeRecord } from "./types";
|
||||||
interface SchemeRecord {
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
id: number;
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
schemeName: string;
|
|
||||||
sensorNumber: number;
|
|
||||||
minDiameter: number;
|
|
||||||
user: string;
|
|
||||||
create_time: string;
|
|
||||||
sensorLocation?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchemaItem {
|
interface SchemaItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -61,7 +54,7 @@ interface SchemaItem {
|
|||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||||
onLocate?: (id: number) => void;
|
onEdit?: (id: number) => void;
|
||||||
network?: string;
|
network?: string;
|
||||||
state?: MonitoringSchemeQueryState;
|
state?: MonitoringSchemeQueryState;
|
||||||
onStateChange?: (state: MonitoringSchemeQueryState) => void;
|
onStateChange?: (state: MonitoringSchemeQueryState) => void;
|
||||||
@@ -71,6 +64,7 @@ export interface MonitoringSchemeQueryState {
|
|||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: number | null;
|
||||||
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createMonitoringSchemeQueryState =
|
export const createMonitoringSchemeQueryState =
|
||||||
@@ -78,12 +72,13 @@ export const createMonitoringSchemeQueryState =
|
|||||||
queryAll: true,
|
queryAll: true,
|
||||||
queryDate: dayjs(new Date()),
|
queryDate: dayjs(new Date()),
|
||||||
expandedId: null,
|
expandedId: null,
|
||||||
|
hasQueried: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||||
schemes: externalSchemes,
|
schemes: externalSchemes,
|
||||||
onSchemesChange,
|
onSchemesChange,
|
||||||
onLocate,
|
onEdit,
|
||||||
network = NETWORK_NAME,
|
network = NETWORK_NAME,
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
@@ -93,9 +88,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
createMonitoringSchemeQueryState(),
|
createMonitoringSchemeQueryState(),
|
||||||
);
|
);
|
||||||
const { queryAll, queryDate, expandedId } = queryState;
|
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
|
||||||
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const creatorName = useSchemeCreatorName();
|
||||||
|
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -181,7 +177,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
@@ -200,15 +196,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
sensorNumber: item.sensor_number,
|
sensorNumber: item.sensor_number,
|
||||||
minDiameter: item.min_diameter,
|
minDiameter: item.min_diameter,
|
||||||
user: item.username,
|
username: item.username,
|
||||||
create_time: item.create_time,
|
create_time: item.create_time,
|
||||||
sensorLocation: item.sensor_location,
|
sensorLocation: item.sensor_location,
|
||||||
}));
|
}));
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
|
setQueryField("hasQueried", true);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "success",
|
||||||
message: "查询结果",
|
message: "查询结果",
|
||||||
description: queryAll
|
description: queryAll
|
||||||
? "没有找到任何方案"
|
? "没有找到任何方案"
|
||||||
@@ -269,15 +266,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setQueryField("expandedId", expandedId === id ? null : id);
|
setQueryField("expandedId", expandedId === id ? null : id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存方案(示例功能)
|
|
||||||
const handleSaveScheme = (scheme: SchemeRecord) => {
|
|
||||||
open?.({
|
|
||||||
type: "success",
|
|
||||||
message: "保存成功",
|
|
||||||
description: `方案 "${scheme.schemeName}" 已保存`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col h-full">
|
<Box className="flex flex-col h-full">
|
||||||
{/* 查询条件 - 单行布局 */}
|
{/* 查询条件 - 单行布局 */}
|
||||||
@@ -288,7 +276,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={queryAll}
|
checked={queryAll}
|
||||||
onChange={(e) => setQueryField("queryAll", e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setQueryField("queryAll", e.target.checked);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -301,9 +292,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={queryDate}
|
value={queryDate}
|
||||||
onChange={(value) =>
|
onChange={(value) => {
|
||||||
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
|
if (value && dayjs.isDayjs(value)) {
|
||||||
|
setQueryField("queryDate", value);
|
||||||
|
setQueryField("hasQueried", false);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
disabled={queryAll}
|
disabled={queryAll}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -331,39 +325,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
{/* 结果列表 */}
|
{/* 结果列表 */}
|
||||||
<Box className="flex-1 overflow-auto">
|
<Box className="flex-1 overflow-auto">
|
||||||
{sortedSchemes.length === 0 ? (
|
{sortedSchemes.length === 0 ? (
|
||||||
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
|
<SchemeQueryEmptyState hasQueried={hasQueried} />
|
||||||
<Box className="mb-4">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 80 80"
|
|
||||||
fill="none"
|
|
||||||
className="opacity-40"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="20"
|
|
||||||
width="60"
|
|
||||||
height="45"
|
|
||||||
rx="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="10"
|
|
||||||
y1="30"
|
|
||||||
x2="70"
|
|
||||||
y2="30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2">总共 0 条</Typography>
|
|
||||||
<Typography variant="body2" className="mt-1">
|
|
||||||
No data
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Box className="space-y-2 p-2">
|
<Box className="space-y-2 p-2">
|
||||||
<Typography variant="caption" className="text-gray-500 px-2">
|
<Typography variant="caption" className="text-gray-500 px-2">
|
||||||
@@ -399,8 +361,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
className="text-gray-500 block"
|
className="text-gray-500 block"
|
||||||
>
|
>
|
||||||
最小半径: {scheme.minDiameter} · 用户: {scheme.user} ·
|
最小管径: {scheme.minDiameter} · 用户:{" "}
|
||||||
日期: {formatShortDate(scheme.create_time)}
|
{creatorName(scheme.username)} · 日期:{" "}
|
||||||
|
{formatShortDate(scheme.create_time)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
@@ -424,16 +387,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
<InfoIcon fontSize="small" />
|
<InfoIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* <Tooltip title="定位">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => onLocate?.(scheme.id)}
|
|
||||||
color="primary"
|
|
||||||
className="p-1"
|
|
||||||
>
|
|
||||||
<LocationIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip> */}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -464,7 +417,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
className="text-gray-600 min-w-[70px]"
|
className="text-gray-600 min-w-[70px]"
|
||||||
>
|
>
|
||||||
最小半径:
|
最小管径:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
@@ -490,7 +443,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
className="font-medium text-gray-900"
|
className="font-medium text-gray-900"
|
||||||
>
|
>
|
||||||
{scheme.user}
|
{creatorName(scheme.username)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
@@ -571,13 +524,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
onClick={() => handleSaveScheme(scheme)}
|
startIcon={<EditIcon />}
|
||||||
|
onClick={() => onEdit?.(scheme.id)}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
保存方案
|
打开结果编辑
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
jest.mock("@components/olmap/core/tileFeatureIndex", () => ({
|
||||||
|
TileFeatureIndex: jest.fn(),
|
||||||
|
clipLineStringPartsToExtent: jest.fn(),
|
||||||
|
lineStringFromFlatCoordinates: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/View", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn().mockImplementation(() => ({
|
||||||
|
cancelAnimations: jest.fn(),
|
||||||
|
fit: jest.fn(),
|
||||||
|
getAnimating: () => false,
|
||||||
|
getProjection: () => "EPSG:3857",
|
||||||
|
getZoom: () => 12,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/layer/VectorTile", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn().mockImplementation(({ source }) => {
|
||||||
|
let extent: number[] | undefined;
|
||||||
|
const properties = new Map<string, unknown>();
|
||||||
|
return {
|
||||||
|
get: (key: string) => properties.get(key),
|
||||||
|
getExtent: () => extent,
|
||||||
|
getSource: () => source,
|
||||||
|
set: (key: string, value: unknown) => properties.set(key, value),
|
||||||
|
setExtent: (value: number[]) => {
|
||||||
|
extent = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/layer/Group", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/layer/Tile", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn().mockImplementation(({ source }) => ({
|
||||||
|
getOpacity: () => 1,
|
||||||
|
getSource: () => source,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/Map", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn().mockImplementation(({ layers, view }) => {
|
||||||
|
let size: number[] | undefined;
|
||||||
|
let renderComplete: (() => void) | undefined;
|
||||||
|
return {
|
||||||
|
getAllLayers: () => layers,
|
||||||
|
getLayers: () => ({ clear: jest.fn() }),
|
||||||
|
getSize: () => size,
|
||||||
|
getView: () => view,
|
||||||
|
once: (event: string, callback: () => void) => {
|
||||||
|
if (event === "rendercomplete") renderComplete = callback;
|
||||||
|
},
|
||||||
|
render: () => renderComplete?.(),
|
||||||
|
setSize: (value: number[]) => {
|
||||||
|
size = value;
|
||||||
|
},
|
||||||
|
setTarget: jest.fn(),
|
||||||
|
un: jest.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import {
|
||||||
|
getPaddedDrawingExtent,
|
||||||
|
renderEngineeringDrawing,
|
||||||
|
} from "./engineeringDrawing";
|
||||||
|
import SchemeDrawingDialog, {
|
||||||
|
DrawingPreview,
|
||||||
|
fitMapToFullNetwork,
|
||||||
|
} from "./SchemeDrawingDialog";
|
||||||
|
import type { SensorPlacementScheme, SensorPointRow } from "./types";
|
||||||
|
|
||||||
|
const createContext = () => ({
|
||||||
|
arc: jest.fn(),
|
||||||
|
beginPath: jest.fn(),
|
||||||
|
clip: jest.fn(),
|
||||||
|
closePath: jest.fn(),
|
||||||
|
drawImage: jest.fn(),
|
||||||
|
fill: jest.fn(),
|
||||||
|
fillRect: jest.fn(),
|
||||||
|
fillText: jest.fn(),
|
||||||
|
lineTo: jest.fn(),
|
||||||
|
moveTo: jest.fn(),
|
||||||
|
rect: jest.fn(),
|
||||||
|
restore: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
setTransform: jest.fn(),
|
||||||
|
stroke: jest.fn(),
|
||||||
|
strokeRect: jest.fn(),
|
||||||
|
strokeText: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const point = (
|
||||||
|
node_id: string,
|
||||||
|
sequence: number,
|
||||||
|
map_x: number,
|
||||||
|
map_y: number,
|
||||||
|
): SensorPointRow => ({
|
||||||
|
node_id,
|
||||||
|
sequence,
|
||||||
|
map_x,
|
||||||
|
map_y,
|
||||||
|
project_x: map_x,
|
||||||
|
project_y: map_y,
|
||||||
|
longitude: 121,
|
||||||
|
latitude: 31,
|
||||||
|
elevation: 5,
|
||||||
|
adjustment_status: "current",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)];
|
||||||
|
|
||||||
|
const scheme: SensorPlacementScheme = {
|
||||||
|
id: 1,
|
||||||
|
scheme_name: "测试方案",
|
||||||
|
sensor_number: rows.length,
|
||||||
|
min_diameter: 300,
|
||||||
|
username: "tester",
|
||||||
|
create_time: "2026-07-30T08:00:00+08:00",
|
||||||
|
sensor_location: rows.map((row) => row.node_id),
|
||||||
|
sensor_points: rows,
|
||||||
|
can_edit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("engineering drawing map framing", () => {
|
||||||
|
it("does not change the visible main-map view while opening the drawing", async () => {
|
||||||
|
const cancelAnimations = jest.fn();
|
||||||
|
const fit = jest.fn();
|
||||||
|
const setCenter = jest.fn();
|
||||||
|
const setResolution = jest.fn();
|
||||||
|
const setRotation = jest.fn();
|
||||||
|
const getAllLayers = jest.fn(() => [
|
||||||
|
{
|
||||||
|
get: (key: string) => (key === "value" ? "pipes" : undefined),
|
||||||
|
getExtent: () => [0, 0, 1000, 1000],
|
||||||
|
getSource: () => ({}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let renderComplete: (() => void) | undefined;
|
||||||
|
const map = {
|
||||||
|
getAllLayers,
|
||||||
|
getSize: () => [1200, 800],
|
||||||
|
getView: () => ({
|
||||||
|
cancelAnimations,
|
||||||
|
fit,
|
||||||
|
getAnimating: () => false,
|
||||||
|
getCenter: () => [500, 500],
|
||||||
|
getResolution: () => 10,
|
||||||
|
getRotation: () => 0,
|
||||||
|
getProjection: () => "EPSG:3857",
|
||||||
|
getZoom: () => 12,
|
||||||
|
setCenter,
|
||||||
|
setResolution,
|
||||||
|
setRotation,
|
||||||
|
}),
|
||||||
|
once: (event: string, callback: () => void) => {
|
||||||
|
if (event === "rendercomplete") renderComplete = callback;
|
||||||
|
},
|
||||||
|
un: jest.fn(),
|
||||||
|
render: jest.fn(() => renderComplete?.()),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
createElement(SchemeDrawingDialog, {
|
||||||
|
open: true,
|
||||||
|
onClose: jest.fn(),
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network: "test",
|
||||||
|
map: map as never,
|
||||||
|
dirty: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(getAllLayers).toHaveBeenCalled());
|
||||||
|
expect(screen.getByText(/简化管网线稿/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("地图底图 + 管网")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("HubOutlinedIcon")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("drawing-content-spacing")).toHaveStyle({
|
||||||
|
paddingTop: "12px",
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("drawing-format-summary")).toHaveStyle({
|
||||||
|
marginBottom: "12px",
|
||||||
|
});
|
||||||
|
expect(fit).not.toHaveBeenCalled();
|
||||||
|
expect(setCenter).not.toHaveBeenCalled();
|
||||||
|
expect(setResolution).not.toHaveBeenCalled();
|
||||||
|
expect(setRotation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not use a native image fallback for the generated preview", () => {
|
||||||
|
render(
|
||||||
|
createElement(DrawingPreview, {
|
||||||
|
src: "blob:engineering-drawing-preview",
|
||||||
|
label: "测试方案压力监测点布置图预览",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = screen.getByRole("img", {
|
||||||
|
name: "测试方案压力监测点布置图预览",
|
||||||
|
});
|
||||||
|
expect(preview.tagName).toBe("DIV");
|
||||||
|
expect(preview).not.toHaveAttribute("src");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits the OpenLayers map to the complete pipe layer extent", () => {
|
||||||
|
const extent = [13500000, 3600000, 13600000, 3700000];
|
||||||
|
const cancelAnimations = jest.fn();
|
||||||
|
const fit = jest.fn();
|
||||||
|
const map = {
|
||||||
|
getAllLayers: () => [
|
||||||
|
{
|
||||||
|
get: (key: string) => (key === "value" ? "pipes" : undefined),
|
||||||
|
getExtent: () => extent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getSize: () => [1200, 800],
|
||||||
|
getView: () => ({ cancelAnimations, fit }),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(fitMapToFullNetwork(map as never)).toEqual(extent);
|
||||||
|
expect(cancelAnimations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fit).toHaveBeenCalledWith(
|
||||||
|
getPaddedDrawingExtent(extent, 1200 / 800),
|
||||||
|
{
|
||||||
|
padding: [0, 0, 0, 0],
|
||||||
|
size: [1200, 800],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(cancelAnimations.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
fit.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("engineering drawing", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws the complete network even when sensors occupy a local area", async () => {
|
||||||
|
const context = createContext();
|
||||||
|
jest
|
||||||
|
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||||
|
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
|
||||||
|
|
||||||
|
await renderEngineeringDrawing({
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network: "test",
|
||||||
|
networkData: {
|
||||||
|
nodes: ["A:junction:8000:4000", "B:junction:9000:4500"],
|
||||||
|
links: ["P1:pipe:A:B"],
|
||||||
|
extent: [0, 0, 10000, 5000],
|
||||||
|
},
|
||||||
|
dirty: false,
|
||||||
|
width: 1600,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The first path is the remote pipe. A sensor-derived extent would skip it,
|
||||||
|
// leaving the title-block divider as the first path near the sheet bottom.
|
||||||
|
const [pipeStartX, pipeStartY] = context.moveTo.mock.calls[0];
|
||||||
|
const [pipeEndX, pipeEndY] = context.lineTo.mock.calls[0];
|
||||||
|
expect(pipeStartX).toBeGreaterThan(1200);
|
||||||
|
expect(pipeStartY).toBeLessThan(400);
|
||||||
|
expect(pipeEndX).toBeGreaterThan(pipeStartX);
|
||||||
|
expect(pipeEndY).toBeLessThan(pipeStartY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one scale for linework coordinates", async () => {
|
||||||
|
const context = createContext();
|
||||||
|
jest
|
||||||
|
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||||
|
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
|
||||||
|
|
||||||
|
await renderEngineeringDrawing({
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network: "test",
|
||||||
|
networkData: {
|
||||||
|
nodes: ["A:junction:400:200", "B:junction:500:300"],
|
||||||
|
links: ["P1:pipe:A:B"],
|
||||||
|
extent: [0, 0, 1000, 1000],
|
||||||
|
},
|
||||||
|
dirty: false,
|
||||||
|
width: 1600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [startX, startY] = context.moveTo.mock.calls[0];
|
||||||
|
const [endX, endY] = context.lineTo.mock.calls[0];
|
||||||
|
expect(Math.abs(endX - startX)).toBeCloseTo(Math.abs(endY - startY), 6);
|
||||||
|
expect(context.drawImage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps visible space on the left and right of the complete network", async () => {
|
||||||
|
const context = createContext();
|
||||||
|
jest
|
||||||
|
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||||
|
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
|
||||||
|
|
||||||
|
await renderEngineeringDrawing({
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network: "test",
|
||||||
|
networkData: {
|
||||||
|
nodes: ["A:junction:0:0", "B:junction:1631:1000"],
|
||||||
|
links: ["P1:pipe:A:B"],
|
||||||
|
extent: [0, 0, 1631, 1000],
|
||||||
|
},
|
||||||
|
dirty: false,
|
||||||
|
width: 1600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [startX] = context.moveTo.mock.calls[0];
|
||||||
|
const [endX] = context.lineTo.mock.calls[0];
|
||||||
|
expect(startX).toBeGreaterThan(100);
|
||||||
|
expect(endX).toBeLessThan(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import type {
|
||||||
|
NetworkDrawingData,
|
||||||
|
SensorPlacementScheme,
|
||||||
|
SensorPointRow,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const A3_LANDSCAPE_WIDTH = 4961;
|
||||||
|
export const A3_LANDSCAPE_HEIGHT = 3508;
|
||||||
|
|
||||||
|
interface DrawingOptions {
|
||||||
|
scheme: SensorPlacementScheme;
|
||||||
|
rows: SensorPointRow[];
|
||||||
|
network: string;
|
||||||
|
networkData: NetworkDrawingData;
|
||||||
|
dirty: boolean;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedNode {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawingExtent = [number, number, number, number];
|
||||||
|
export const DRAWING_CONTENT_PADDING_RATIO = 0.04;
|
||||||
|
|
||||||
|
const toDrawingExtent = (extent: number[]): DrawingExtent => {
|
||||||
|
if (
|
||||||
|
extent.length !== 4 ||
|
||||||
|
!extent.every(Number.isFinite) ||
|
||||||
|
extent[0] >= extent[2] ||
|
||||||
|
extent[1] >= extent[3]
|
||||||
|
) {
|
||||||
|
throw new Error("地图范围不可用");
|
||||||
|
}
|
||||||
|
return [extent[0], extent[1], extent[2], extent[3]];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNode = (value: string): ParsedNode | null => {
|
||||||
|
const [id, , x, y] = value.split(":");
|
||||||
|
const parsedX = Number(x);
|
||||||
|
const parsedY = Number(y);
|
||||||
|
if (!id || !Number.isFinite(parsedX) || !Number.isFinite(parsedY))
|
||||||
|
return null;
|
||||||
|
return { id, x: parsedX, y: parsedY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitExtentToAspectRatio = (
|
||||||
|
extent: DrawingExtent,
|
||||||
|
targetAspectRatio: number,
|
||||||
|
): DrawingExtent => {
|
||||||
|
const [minX, minY, maxX, maxY] = extent;
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
let spanX = maxX - minX;
|
||||||
|
let spanY = maxY - minY;
|
||||||
|
|
||||||
|
if (spanX / spanY > targetAspectRatio) {
|
||||||
|
spanY = spanX / targetAspectRatio;
|
||||||
|
} else {
|
||||||
|
spanX = spanY * targetAspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
centerX - spanX / 2,
|
||||||
|
centerY - spanY / 2,
|
||||||
|
centerX + spanX / 2,
|
||||||
|
centerY + spanY / 2,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPaddedDrawingExtent = (
|
||||||
|
extent: number[],
|
||||||
|
targetAspectRatio: number,
|
||||||
|
): DrawingExtent => {
|
||||||
|
const fitted = fitExtentToAspectRatio(
|
||||||
|
toDrawingExtent(extent),
|
||||||
|
targetAspectRatio,
|
||||||
|
);
|
||||||
|
const [minX, minY, maxX, maxY] = fitted;
|
||||||
|
const horizontalPadding =
|
||||||
|
((maxX - minX) * DRAWING_CONTENT_PADDING_RATIO) /
|
||||||
|
(1 - DRAWING_CONTENT_PADDING_RATIO * 2);
|
||||||
|
const verticalPadding =
|
||||||
|
((maxY - minY) * DRAWING_CONTENT_PADDING_RATIO) /
|
||||||
|
(1 - DRAWING_CONTENT_PADDING_RATIO * 2);
|
||||||
|
return [
|
||||||
|
minX - horizontalPadding,
|
||||||
|
minY - verticalPadding,
|
||||||
|
maxX + horizontalPadding,
|
||||||
|
maxY + verticalPadding,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawText = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
value: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
size: number,
|
||||||
|
weight = 400,
|
||||||
|
align: CanvasTextAlign = "left",
|
||||||
|
outlineColor?: string,
|
||||||
|
outlineWidth = 0,
|
||||||
|
) => {
|
||||||
|
context.font = `${weight} ${size}px -apple-system, "PingFang SC", "Noto Sans SC", sans-serif`;
|
||||||
|
context.textAlign = align;
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
if (outlineColor && outlineWidth > 0) {
|
||||||
|
context.strokeStyle = outlineColor;
|
||||||
|
context.lineWidth = outlineWidth;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.strokeText(value, x, y);
|
||||||
|
}
|
||||||
|
context.fillText(value, x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderEngineeringDrawing = async ({
|
||||||
|
scheme,
|
||||||
|
rows,
|
||||||
|
network,
|
||||||
|
networkData,
|
||||||
|
dirty,
|
||||||
|
width = A3_LANDSCAPE_WIDTH,
|
||||||
|
}: DrawingOptions): Promise<HTMLCanvasElement> => {
|
||||||
|
if (!rows.length) throw new Error("方案没有可出图的监测点");
|
||||||
|
const height = Math.round((width * A3_LANDSCAPE_HEIGHT) / A3_LANDSCAPE_WIDTH);
|
||||||
|
const scale = width / A3_LANDSCAPE_WIDTH;
|
||||||
|
const px = (value: number) => value * scale;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("无法创建工程图画布");
|
||||||
|
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
context.strokeStyle = "#111827";
|
||||||
|
context.lineWidth = px(8);
|
||||||
|
context.strokeRect(px(70), px(70), width - px(140), height - px(140));
|
||||||
|
|
||||||
|
const mapBox = {
|
||||||
|
x: px(150),
|
||||||
|
y: px(150),
|
||||||
|
width: width - px(300),
|
||||||
|
height: height - px(650),
|
||||||
|
};
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(mapBox.x, mapBox.y, mapBox.width, mapBox.height);
|
||||||
|
context.clip();
|
||||||
|
|
||||||
|
const extent = getPaddedDrawingExtent(
|
||||||
|
networkData.extent,
|
||||||
|
mapBox.width / mapBox.height,
|
||||||
|
);
|
||||||
|
context.fillStyle = "#fbfcfd";
|
||||||
|
context.fillRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height);
|
||||||
|
|
||||||
|
const nodes = networkData.nodes
|
||||||
|
.map(parseNode)
|
||||||
|
.filter((node): node is ParsedNode => Boolean(node));
|
||||||
|
const nodesById = new globalThis.Map(
|
||||||
|
nodes.map((node) => [node.id, node] as const),
|
||||||
|
);
|
||||||
|
const [minX, minY, maxX, maxY] = extent;
|
||||||
|
const toCanvas = (x: number, y: number) => ({
|
||||||
|
x: mapBox.x + ((x - minX) / (maxX - minX)) * mapBox.width,
|
||||||
|
y:
|
||||||
|
mapBox.y + mapBox.height - ((y - minY) / (maxY - minY)) * mapBox.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
context.strokeStyle = "#45677a";
|
||||||
|
context.lineWidth = px(5);
|
||||||
|
for (const link of networkData.links) {
|
||||||
|
const [, , startId, endId] = link.split(":");
|
||||||
|
const start = nodesById.get(startId);
|
||||||
|
const end = nodesById.get(endId);
|
||||||
|
if (!start || !end) continue;
|
||||||
|
if (
|
||||||
|
Math.max(start.x, end.x) < minX ||
|
||||||
|
Math.min(start.x, end.x) > maxX ||
|
||||||
|
Math.max(start.y, end.y) < minY ||
|
||||||
|
Math.min(start.y, end.y) > maxY
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const startPoint = toCanvas(start.x, start.y);
|
||||||
|
const endPoint = toCanvas(end.x, end.y);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(startPoint.x, startPoint.y);
|
||||||
|
context.lineTo(endPoint.x, endPoint.y);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillStyle = "#668697";
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.x < minX || node.x > maxX || node.y < minY || node.y > maxY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const point = toCanvas(node.x, node.y);
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(point.x, point.y, px(4), 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const point = toCanvas(row.map_x, row.map_y);
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(point.x, point.y, px(36), 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
context.strokeStyle = "rgba(255,255,255,0.96)";
|
||||||
|
context.lineWidth = px(14);
|
||||||
|
context.stroke();
|
||||||
|
context.strokeStyle = "#c9252d";
|
||||||
|
context.lineWidth = px(5);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.fillStyle = "#a51f28";
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
String(row.sequence),
|
||||||
|
point.x,
|
||||||
|
point.y,
|
||||||
|
px(32),
|
||||||
|
700,
|
||||||
|
"center",
|
||||||
|
);
|
||||||
|
|
||||||
|
context.fillStyle = "#273746";
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
row.node_id,
|
||||||
|
point.x,
|
||||||
|
point.y + px(62),
|
||||||
|
px(25),
|
||||||
|
650,
|
||||||
|
"center",
|
||||||
|
"rgba(255,255,255,0.98)",
|
||||||
|
px(9),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
context.strokeStyle = "#111827";
|
||||||
|
context.lineWidth = px(4);
|
||||||
|
context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height);
|
||||||
|
|
||||||
|
for (let index = 0; index <= 4; index += 1) {
|
||||||
|
const ratio = index / 4;
|
||||||
|
const x = mapBox.x + ratio * mapBox.width;
|
||||||
|
const y = mapBox.y + mapBox.height - ratio * mapBox.height;
|
||||||
|
context.fillStyle = "#334155";
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
(minX + ratio * (maxX - minX)).toFixed(0),
|
||||||
|
x,
|
||||||
|
mapBox.y + mapBox.height + px(32),
|
||||||
|
px(20),
|
||||||
|
500,
|
||||||
|
"center",
|
||||||
|
);
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
(minY + ratio * (maxY - minY)).toFixed(0),
|
||||||
|
mapBox.x - px(18),
|
||||||
|
y,
|
||||||
|
px(20),
|
||||||
|
500,
|
||||||
|
"right",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleTop = height - px(420);
|
||||||
|
context.strokeStyle = "#111827";
|
||||||
|
context.lineWidth = px(4);
|
||||||
|
context.strokeRect(px(150), titleTop, width - px(300), px(270));
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(width - px(1700), titleTop);
|
||||||
|
context.lineTo(width - px(1700), titleTop + px(270));
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.fillStyle = "#111827";
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
`${scheme.scheme_name} 压力监测点布置图`,
|
||||||
|
px(220),
|
||||||
|
titleTop + px(70),
|
||||||
|
px(48),
|
||||||
|
700,
|
||||||
|
);
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
`项目:${network} 监测点:${rows.length} 个 地图:EPSG:3857 经纬度:WGS84`,
|
||||||
|
px(220),
|
||||||
|
titleTop + px(145),
|
||||||
|
px(26),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
dirty ? "状态:未保存草稿" : "状态:当前方案",
|
||||||
|
px(220),
|
||||||
|
titleTop + px(215),
|
||||||
|
px(26),
|
||||||
|
dirty ? 700 : 500,
|
||||||
|
);
|
||||||
|
|
||||||
|
const infoX = width - px(1620);
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
`创建人:${scheme.username}`,
|
||||||
|
infoX,
|
||||||
|
titleTop + px(55),
|
||||||
|
px(24),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
`创建时间:${new Date(scheme.create_time).toLocaleString("zh-CN")}`,
|
||||||
|
infoX,
|
||||||
|
titleTop + px(115),
|
||||||
|
px(24),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
`制图时间:${new Date().toLocaleString("zh-CN")}`,
|
||||||
|
infoX,
|
||||||
|
titleTop + px(175),
|
||||||
|
px(24),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
drawText(
|
||||||
|
context,
|
||||||
|
"图幅:A3 横向 300 DPI",
|
||||||
|
infoX,
|
||||||
|
titleTop + px(235),
|
||||||
|
px(24),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.strokeStyle = "#111827";
|
||||||
|
context.lineWidth = px(5);
|
||||||
|
const northX = width - px(260);
|
||||||
|
const northY = px(270);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(northX, northY - px(70));
|
||||||
|
context.lineTo(northX - px(32), northY + px(35));
|
||||||
|
context.lineTo(northX, northY + px(15));
|
||||||
|
context.lineTo(northX + px(32), northY + px(35));
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.fillStyle = "#111827";
|
||||||
|
drawText(context, "N", northX, northY - px(110), px(30), 700, "center");
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canvasToPngBlob = (canvas: HTMLCanvasElement) =>
|
||||||
|
new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) resolve(blob);
|
||||||
|
else reject(new Error("PNG 生成失败"));
|
||||||
|
}, "image/png");
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { config } from "@/config/config";
|
||||||
|
import type {
|
||||||
|
AdjustmentStatus,
|
||||||
|
SensorPlacementScheme,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export interface OptimizeSchemeInput {
|
||||||
|
scheme_name: string;
|
||||||
|
sensor_type: "pressure";
|
||||||
|
method: "sensitivity" | "kmeans";
|
||||||
|
sensor_count: number;
|
||||||
|
min_diameter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const optimizeSensorPlacement = async (
|
||||||
|
input: OptimizeSchemeInput,
|
||||||
|
): Promise<SensorPlacementScheme> => {
|
||||||
|
const response = await api.post<SensorPlacementScheme>(
|
||||||
|
`${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSensorPlacementScheme = async (
|
||||||
|
schemeId: number,
|
||||||
|
): Promise<SensorPlacementScheme> => {
|
||||||
|
const response = await api.get<SensorPlacementScheme>(
|
||||||
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const overwriteSensorPlacementScheme = async (
|
||||||
|
schemeId: number,
|
||||||
|
expectedSensorLocation: string[],
|
||||||
|
sensorLocation: string[],
|
||||||
|
): Promise<SensorPlacementScheme> => {
|
||||||
|
const response = await api.put<SensorPlacementScheme>(
|
||||||
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
|
||||||
|
{
|
||||||
|
expected_sensor_location: expectedSensorLocation,
|
||||||
|
sensor_location: sensorLocation,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportSensorPlacementExcel = async (
|
||||||
|
schemeId: number,
|
||||||
|
sensorLocation: string[],
|
||||||
|
adjustmentStatus: Record<string, AdjustmentStatus>,
|
||||||
|
) => {
|
||||||
|
const response = await api.post<Blob>(
|
||||||
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}/exports/excel`,
|
||||||
|
{
|
||||||
|
sensor_location: sensorLocation,
|
||||||
|
adjustment_status: adjustmentStatus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
responseType: "blob",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
addSensorPoint,
|
||||||
|
createSchemeEditorState,
|
||||||
|
deleteSensorPoint,
|
||||||
|
isSchemeDirty,
|
||||||
|
redoSchemeEdit,
|
||||||
|
replaceSensorPoint,
|
||||||
|
resetSchemeEdit,
|
||||||
|
summarizeChanges,
|
||||||
|
undoSchemeEdit,
|
||||||
|
} from "./schemeEditor";
|
||||||
|
import type { SensorPlacementScheme, SensorPoint } from "./types";
|
||||||
|
|
||||||
|
const point = (node_id: string): SensorPoint => ({
|
||||||
|
node_id,
|
||||||
|
project_x: Number(node_id.slice(1)) * 10,
|
||||||
|
project_y: Number(node_id.slice(1)) * 20,
|
||||||
|
map_x: 13500000 + Number(node_id.slice(1)) * 10,
|
||||||
|
map_y: 3600000 + Number(node_id.slice(1)) * 20,
|
||||||
|
longitude: 121,
|
||||||
|
latitude: 31,
|
||||||
|
elevation: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheme: SensorPlacementScheme = {
|
||||||
|
id: 1,
|
||||||
|
scheme_name: "测试方案",
|
||||||
|
sensor_number: 2,
|
||||||
|
min_diameter: 300,
|
||||||
|
username: "alice",
|
||||||
|
create_time: "2026-07-30T08:00:00+08:00",
|
||||||
|
sensor_location: ["J1", "J2"],
|
||||||
|
sensor_points: [point("J1"), point("J2")],
|
||||||
|
can_edit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("scheme editor", () => {
|
||||||
|
it("adds unique nodes and can undo", () => {
|
||||||
|
const initial = createSchemeEditorState(scheme);
|
||||||
|
const added = addSensorPoint(initial, point("J3"));
|
||||||
|
|
||||||
|
expect(added.points.map((item) => item.node_id)).toEqual(["J1", "J2", "J3"]);
|
||||||
|
expect(added.statuses.J3).toBe("added");
|
||||||
|
expect(isSchemeDirty(added)).toBe(true);
|
||||||
|
expect(undoSchemeEdit(added).points).toEqual(initial.points);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can redo an undone edit and clears redo history after a new edit", () => {
|
||||||
|
const initial = createSchemeEditorState(scheme);
|
||||||
|
const added = addSensorPoint(initial, point("J3"));
|
||||||
|
const undone = undoSchemeEdit(added);
|
||||||
|
const redone = redoSchemeEdit(undone);
|
||||||
|
|
||||||
|
expect(redone.points.map((item) => item.node_id)).toEqual([
|
||||||
|
"J1",
|
||||||
|
"J2",
|
||||||
|
"J3",
|
||||||
|
]);
|
||||||
|
expect(redone.history).toHaveLength(1);
|
||||||
|
expect(redone.future).toEqual([]);
|
||||||
|
|
||||||
|
const editedAfterUndo = replaceSensorPoint(undone, "J1", point("J4"));
|
||||||
|
expect(editedAfterUndo.future).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a node without changing row order", () => {
|
||||||
|
const initial = createSchemeEditorState(scheme);
|
||||||
|
const replaced = replaceSensorPoint(initial, "J1", point("J3"));
|
||||||
|
|
||||||
|
expect(replaced.points.map((item) => item.node_id)).toEqual(["J3", "J2"]);
|
||||||
|
expect(replaced.statuses.J3).toBe("replaced");
|
||||||
|
expect(summarizeChanges(replaced)).toEqual({
|
||||||
|
added: 0,
|
||||||
|
removed: 0,
|
||||||
|
replaced: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an added status when replacing a newly added node", () => {
|
||||||
|
const added = addSensorPoint(createSchemeEditorState(scheme), point("J3"));
|
||||||
|
const replaced = replaceSensorPoint(added, "J3", point("J4"));
|
||||||
|
|
||||||
|
expect(replaced.statuses.J3).toBeUndefined();
|
||||||
|
expect(replaced.statuses.J4).toBe("added");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate replacements and deleting the final row", () => {
|
||||||
|
const initial = createSchemeEditorState(scheme);
|
||||||
|
expect(replaceSensorPoint(initial, "J1", point("J2"))).toBe(initial);
|
||||||
|
|
||||||
|
const oneLeft = deleteSensorPoint(initial, "J1");
|
||||||
|
expect(deleteSensorPoint(oneLeft, "J2")).toBe(oneLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets to the loaded baseline", () => {
|
||||||
|
const edited = addSensorPoint(createSchemeEditorState(scheme), point("J3"));
|
||||||
|
const reset = resetSchemeEdit(edited);
|
||||||
|
|
||||||
|
expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]);
|
||||||
|
expect(isSchemeDirty(reset)).toBe(false);
|
||||||
|
expect(reset.history).toEqual([]);
|
||||||
|
expect(reset.future).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type {
|
||||||
|
AdjustmentStatus,
|
||||||
|
SensorPlacementScheme,
|
||||||
|
SensorPoint,
|
||||||
|
SensorPointRow,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export interface SchemeEditorSnapshot {
|
||||||
|
points: SensorPoint[];
|
||||||
|
statuses: Record<string, AdjustmentStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemeEditorState extends SchemeEditorSnapshot {
|
||||||
|
baseline: SensorPoint[];
|
||||||
|
history: SchemeEditorSnapshot[];
|
||||||
|
future: SchemeEditorSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point }));
|
||||||
|
|
||||||
|
const currentStatuses = (points: SensorPoint[]) =>
|
||||||
|
Object.fromEntries(points.map((point) => [point.node_id, "current"])) as Record<
|
||||||
|
string,
|
||||||
|
AdjustmentStatus
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const createSchemeEditorState = (
|
||||||
|
scheme: SensorPlacementScheme,
|
||||||
|
): SchemeEditorState => ({
|
||||||
|
baseline: clonePoints(scheme.sensor_points),
|
||||||
|
points: clonePoints(scheme.sensor_points),
|
||||||
|
statuses: currentStatuses(scheme.sensor_points),
|
||||||
|
history: [],
|
||||||
|
future: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({
|
||||||
|
points: clonePoints(state.points),
|
||||||
|
statuses: { ...state.statuses },
|
||||||
|
});
|
||||||
|
|
||||||
|
const withHistory = (
|
||||||
|
state: SchemeEditorState,
|
||||||
|
points: SensorPoint[],
|
||||||
|
statuses: Record<string, AdjustmentStatus>,
|
||||||
|
): SchemeEditorState => ({
|
||||||
|
...state,
|
||||||
|
points,
|
||||||
|
statuses,
|
||||||
|
history: [...state.history, snapshot(state)],
|
||||||
|
future: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addSensorPoint = (
|
||||||
|
state: SchemeEditorState,
|
||||||
|
point: SensorPoint,
|
||||||
|
): SchemeEditorState => {
|
||||||
|
if (state.points.some((item) => item.node_id === point.node_id)) return state;
|
||||||
|
return withHistory(
|
||||||
|
state,
|
||||||
|
[...state.points, { ...point }],
|
||||||
|
{ ...state.statuses, [point.node_id]: "added" },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceSensorPoint = (
|
||||||
|
state: SchemeEditorState,
|
||||||
|
sourceNodeId: string,
|
||||||
|
point: SensorPoint,
|
||||||
|
): SchemeEditorState => {
|
||||||
|
const sourceIndex = state.points.findIndex(
|
||||||
|
(item) => item.node_id === sourceNodeId,
|
||||||
|
);
|
||||||
|
if (sourceIndex < 0) return state;
|
||||||
|
if (
|
||||||
|
sourceNodeId !== point.node_id &&
|
||||||
|
state.points.some((item) => item.node_id === point.node_id)
|
||||||
|
) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPoints = clonePoints(state.points);
|
||||||
|
nextPoints[sourceIndex] = { ...point };
|
||||||
|
const nextStatuses = { ...state.statuses };
|
||||||
|
const sourceStatus = nextStatuses[sourceNodeId] ?? "current";
|
||||||
|
delete nextStatuses[sourceNodeId];
|
||||||
|
const baselineIds = new Set(state.baseline.map((item) => item.node_id));
|
||||||
|
nextStatuses[point.node_id] =
|
||||||
|
sourceStatus === "added"
|
||||||
|
? "added"
|
||||||
|
: baselineIds.has(point.node_id)
|
||||||
|
? "original"
|
||||||
|
: "replaced";
|
||||||
|
return withHistory(state, nextPoints, nextStatuses);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteSensorPoint = (
|
||||||
|
state: SchemeEditorState,
|
||||||
|
nodeId: string,
|
||||||
|
): SchemeEditorState => {
|
||||||
|
if (state.points.length <= 1) return state;
|
||||||
|
if (!state.points.some((item) => item.node_id === nodeId)) return state;
|
||||||
|
const nextStatuses = { ...state.statuses };
|
||||||
|
delete nextStatuses[nodeId];
|
||||||
|
return withHistory(
|
||||||
|
state,
|
||||||
|
state.points.filter((item) => item.node_id !== nodeId),
|
||||||
|
nextStatuses,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
|
||||||
|
const previous = state.history[state.history.length - 1];
|
||||||
|
if (!previous) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
points: clonePoints(previous.points),
|
||||||
|
statuses: { ...previous.statuses },
|
||||||
|
history: state.history.slice(0, -1),
|
||||||
|
future: [...state.future, snapshot(state)],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
|
||||||
|
const next = state.future[state.future.length - 1];
|
||||||
|
if (!next) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
points: clonePoints(next.points),
|
||||||
|
statuses: { ...next.statuses },
|
||||||
|
history: [...state.history, snapshot(state)],
|
||||||
|
future: state.future.slice(0, -1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState => ({
|
||||||
|
...state,
|
||||||
|
points: clonePoints(state.baseline),
|
||||||
|
statuses: currentStatuses(state.baseline),
|
||||||
|
history: [],
|
||||||
|
future: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isSchemeDirty = (state: SchemeEditorState) => {
|
||||||
|
const baselineIds = state.baseline.map((point) => point.node_id);
|
||||||
|
const currentIds = state.points.map((point) => point.node_id);
|
||||||
|
return (
|
||||||
|
baselineIds.length !== currentIds.length ||
|
||||||
|
baselineIds.some((nodeId, index) => nodeId !== currentIds[index])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toSensorPointRows = (
|
||||||
|
state: SchemeEditorState,
|
||||||
|
): SensorPointRow[] =>
|
||||||
|
state.points.map((point, index) => ({
|
||||||
|
...point,
|
||||||
|
sequence: index + 1,
|
||||||
|
adjustment_status: state.statuses[point.node_id] ?? "current",
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const summarizeChanges = (state: SchemeEditorState) => {
|
||||||
|
const baselineIds = new Set(state.baseline.map((point) => point.node_id));
|
||||||
|
const currentIds = new Set(state.points.map((point) => point.node_id));
|
||||||
|
const added = [...currentIds].filter((nodeId) => !baselineIds.has(nodeId)).length;
|
||||||
|
const removed = [...baselineIds].filter(
|
||||||
|
(nodeId) => !currentIds.has(nodeId),
|
||||||
|
).length;
|
||||||
|
const replaced = Math.min(added, removed);
|
||||||
|
return {
|
||||||
|
added: Math.max(0, added - replaced),
|
||||||
|
removed: Math.max(0, removed - replaced),
|
||||||
|
replaced,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type AdjustmentStatus = "current" | "original" | "added" | "replaced";
|
||||||
|
|
||||||
|
export interface SensorPoint {
|
||||||
|
node_id: string;
|
||||||
|
project_x: number;
|
||||||
|
project_y: number;
|
||||||
|
map_x: number;
|
||||||
|
map_y: number;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
elevation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorPlacementScheme {
|
||||||
|
id: number;
|
||||||
|
scheme_name: string;
|
||||||
|
sensor_number: number;
|
||||||
|
min_diameter: number;
|
||||||
|
username: string;
|
||||||
|
create_time: string;
|
||||||
|
sensor_location: string[];
|
||||||
|
sensor_points: SensorPoint[];
|
||||||
|
can_edit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemeRecord {
|
||||||
|
id: number;
|
||||||
|
schemeName: string;
|
||||||
|
sensorNumber: number;
|
||||||
|
minDiameter: number;
|
||||||
|
username: string;
|
||||||
|
create_time: string;
|
||||||
|
sensorLocation?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorPointRow extends SensorPoint {
|
||||||
|
sequence: number;
|
||||||
|
adjustment_status: AdjustmentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkDrawingData {
|
||||||
|
nodes: string[];
|
||||||
|
links: string[];
|
||||||
|
extent: [number, number, number, number];
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import { useGetIdentity } from "@refinedev/core";
|
|||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -104,11 +105,11 @@ const fetchFromBackend = async (
|
|||||||
const start_time = dayjs(range.from).toISOString();
|
const start_time = dayjs(range.from).toISOString();
|
||||||
const end_time = dayjs(range.to).toISOString();
|
const end_time = dayjs(range.to).toISOString();
|
||||||
// 清洗数据接口
|
// 清洗数据接口
|
||||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 原始数据
|
// 原始数据
|
||||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 模拟数据接口
|
// 模拟数据接口
|
||||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||||
try {
|
try {
|
||||||
// 优先查询清洗数据和模拟数据
|
// 优先查询清洗数据和模拟数据
|
||||||
const [cleaningRes, simulationRes] = await Promise.all([
|
const [cleaningRes, simulationRes] = await Promise.all([
|
||||||
@@ -314,11 +315,11 @@ const emptyStateMessages: Record<
|
|||||||
> = {
|
> = {
|
||||||
chart: {
|
chart: {
|
||||||
title: "暂无时序数据",
|
title: "暂无时序数据",
|
||||||
subtitle: "请切换时间段来获取曲线",
|
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
title: "暂无表格数据",
|
title: "暂无表格数据",
|
||||||
subtitle: "请切换时间段来获取记录",
|
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -350,11 +351,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const end_time = dayjs(range.to).toISOString();
|
const end_time = dayjs(range.to).toISOString();
|
||||||
|
|
||||||
// 清洗数据接口
|
// 清洗数据接口
|
||||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 原始数据
|
// 原始数据
|
||||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 模拟数据接口
|
// 模拟数据接口
|
||||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||||
try {
|
try {
|
||||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||||
apiFetch(cleaningDataUrl)
|
apiFetch(cleaningDataUrl)
|
||||||
@@ -529,7 +530,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${
|
`${
|
||||||
config.BACKEND_URL
|
config.BACKEND_URL
|
||||||
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join(
|
}/api/v1/timeseries/scada-cleaning-runs?device_ids=${deviceIds.join(
|
||||||
",",
|
",",
|
||||||
)}&start_time=${startTime}&end_time=${endTime}`,
|
)}&start_time=${startTime}&end_time=${endTime}`,
|
||||||
);
|
);
|
||||||
@@ -721,28 +722,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderEmpty = () => {
|
const renderEmpty = () => {
|
||||||
|
if (!hasDevices) {
|
||||||
|
return (
|
||||||
|
<PanelEmptyState
|
||||||
|
icon={<ShowChart />}
|
||||||
|
title="尚未选择设备"
|
||||||
|
description="请先在设备列表中选择需要查看的 SCADA 设备。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const message = emptyStateMessages[activeTab];
|
const message = emptyStateMessages[activeTab];
|
||||||
return (
|
return (
|
||||||
<Box
|
<PanelEmptyState
|
||||||
sx={{
|
icon={activeTab === "chart" ? <ShowChart /> : <TableChart />}
|
||||||
flex: 1,
|
title={message.title}
|
||||||
display: "flex",
|
description={message.subtitle}
|
||||||
flexDirection: "column",
|
/>
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
py: 8,
|
|
||||||
color: "text.secondary",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
|
|
||||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
|
|
||||||
{message.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{message.subtitle}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1254,15 +1250,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
|
|
||||||
{!hasDevices && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="warning.main"
|
|
||||||
sx={{ mt: 1, display: "block" }}
|
|
||||||
>
|
|
||||||
未选择任何设备,无法获取数据。
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{error && (
|
{error && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import dayjs, { Dayjs } from "dayjs";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||||
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
const STATUS_OPTIONS: {
|
const STATUS_OPTIONS: {
|
||||||
value: "online" | "offline" | "warning" | "error";
|
value: "online" | "offline" | "warning" | "error";
|
||||||
@@ -623,7 +624,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
|
|
||||||
// 调用后端清洗接口
|
// 调用后端清洗接口
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/scada-cleaning-runs?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理成功响应
|
// 处理成功响应
|
||||||
@@ -1067,22 +1068,23 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
) : filteredDevices.length === 0 ? (
|
) : filteredDevices.length === 0 ? (
|
||||||
<Box
|
<PanelEmptyState
|
||||||
sx={{
|
icon={<DeviceHub />}
|
||||||
p: 4,
|
title={
|
||||||
textAlign: "center",
|
searchQuery ||
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
|
|
||||||
<Typography variant="body2">
|
|
||||||
{searchQuery ||
|
|
||||||
selectedType !== "all" ||
|
selectedType !== "all" ||
|
||||||
selectedStatus !== "all"
|
selectedStatus !== "all"
|
||||||
? "未找到匹配的设备"
|
? "未找到匹配设备"
|
||||||
: "暂无 SCADA 设备"}
|
: "暂无 SCADA 设备"
|
||||||
</Typography>
|
}
|
||||||
</Box>
|
description={
|
||||||
|
searchQuery ||
|
||||||
|
selectedType !== "all" ||
|
||||||
|
selectedStatus !== "all"
|
||||||
|
? "请清除关键词,或调整设备类型和状态筛选条件。"
|
||||||
|
: "请确认设备已接入当前管网后刷新列表。"
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FixedSizeList
|
<FixedSizeList
|
||||||
height={listHeight}
|
height={listHeight}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user