Compare commits
14
Commits
v2026.08.11.5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6623f4f7fb | ||
|
|
d2ce1687f2 | ||
|
|
dca2817320 | ||
|
|
41bf7d71e3 | ||
|
|
5bea86dbf6 | ||
|
|
78a470e89e | ||
|
|
6d505ca461 | ||
|
|
f34d81c933 | ||
|
|
5053ddcd1f | ||
|
|
45def5bba3 | ||
|
|
9e79d52dc8 | ||
|
|
4a978de905 | ||
|
|
f5bcf28f7e | ||
|
|
ab45c8da8e |
@@ -4,6 +4,10 @@
|
||||
"agent": {
|
||||
"file": "agent-v1.openapi.json",
|
||||
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "ac9b6fac185dfd999f1791cba51eb482df17a427b361963250aafa5fb1a276b4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1859,7 +1859,7 @@
|
||||
"title": "PressureRegulationRest",
|
||||
"type": "object"
|
||||
},
|
||||
"PressureSensorPlacement": {
|
||||
"PressureSensorPlacementRest": {
|
||||
"properties": {
|
||||
"min_diameter": {
|
||||
"default": 0,
|
||||
@@ -1867,11 +1867,6 @@
|
||||
"title": "Min Diameter",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"description": "管网名称(或数据库名称)",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"scheme_name": {
|
||||
"description": "方案名称",
|
||||
"title": "Scheme Name",
|
||||
@@ -1881,20 +1876,13 @@
|
||||
"description": "传感器数量",
|
||||
"title": "Sensor Number",
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"description": "用户名",
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"scheme_name",
|
||||
"sensor_number",
|
||||
"username"
|
||||
"sensor_number"
|
||||
],
|
||||
"title": "PressureSensorPlacement",
|
||||
"title": "PressureSensorPlacementRest",
|
||||
"type": "object"
|
||||
},
|
||||
"ProblemDetails": {
|
||||
@@ -5239,97 +5227,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/all-redis": {
|
||||
"delete": {
|
||||
"description": "清空整个Redis数据库的所有缓存",
|
||||
"operationId": "delete_all_redis",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "清除所有缓存",
|
||||
"tags": [
|
||||
"Cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/all-scada-properties": {
|
||||
"get": {
|
||||
"description": "获取指定水网中所有SCADA点的属性信息",
|
||||
@@ -11038,7 +10935,7 @@
|
||||
},
|
||||
"/api/v1/flushing-analyses": {
|
||||
"post": {
|
||||
"description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。",
|
||||
"description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。",
|
||||
"operationId": "post_flushing_analyses",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -11053,31 +10950,92 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "要开启的阀门ID列表",
|
||||
"description": "参与控制的阀门ID列表(可选)",
|
||||
"in": "query",
|
||||
"name": "valves",
|
||||
"required": true,
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "要开启的阀门ID列表",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Valves",
|
||||
"type": "array"
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "参与控制的阀门ID列表(可选)",
|
||||
"title": "Valves"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "对应各阀门的开度列表(0-1)",
|
||||
"description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)",
|
||||
"in": "query",
|
||||
"name": "valves_k",
|
||||
"required": true,
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "对应各阀门的开度列表(0-1)",
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"title": "Valves K",
|
||||
"type": "array"
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)",
|
||||
"title": "Valves K"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)",
|
||||
"in": "query",
|
||||
"name": "valve_statuses",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"OPEN",
|
||||
"CLOSED",
|
||||
"ACTIVE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)",
|
||||
"title": "Valve Statuses"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "对应各阀门的设置值列表(ACTIVE状态下必填)",
|
||||
"in": "query",
|
||||
"name": "valve_settings",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "对应各阀门的设置值列表(ACTIVE状态下必填)",
|
||||
"title": "Valve Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -24833,7 +24791,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PressureSensorPlacement",
|
||||
"$ref": "#/components/schemas/PressureSensorPlacementRest",
|
||||
"description": "传感器放置分析参数"
|
||||
}
|
||||
}
|
||||
@@ -25073,7 +25031,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PressureSensorPlacement",
|
||||
"$ref": "#/components/schemas/PressureSensorPlacementRest",
|
||||
"description": "传感器放置分析参数"
|
||||
}
|
||||
}
|
||||
@@ -29403,308 +29361,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/redis": {
|
||||
"get": {
|
||||
"description": "获取Redis中所有的缓存键",
|
||||
"operationId": "get_redis",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "查询缓存键列表",
|
||||
"tags": [
|
||||
"Cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/redis-keys": {
|
||||
"delete": {
|
||||
"description": "根据模式清除匹配的Redis缓存键",
|
||||
"operationId": "delete_redis_keys",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "缓存键模式(支持通配符)",
|
||||
"in": "query",
|
||||
"name": "keys",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "缓存键模式(支持通配符)",
|
||||
"title": "Keys",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "清除匹配的缓存键",
|
||||
"tags": [
|
||||
"Cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/redis-keys/detail": {
|
||||
"delete": {
|
||||
"description": "根据键名清除单个Redis缓存",
|
||||
"operationId": "delete_redis_keys_detail",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "缓存键名",
|
||||
"in": "query",
|
||||
"name": "key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "缓存键名",
|
||||
"title": "Key",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "清除单个缓存键",
|
||||
"tags": [
|
||||
"Cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/redos": {
|
||||
"post": {
|
||||
"description": "重做网络上被撤销的操作",
|
||||
|
||||
Generated
+22
-9
@@ -5611,6 +5611,18 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -13640,9 +13652,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
|
||||
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.0.tgz",
|
||||
"integrity": "sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -13651,9 +13663,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.2"
|
||||
"@nodable/entities": "^2.1.0",
|
||||
"fast-xml-builder": "^1.1.5",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -18189,9 +18202,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "5.5.9",
|
||||
"fast-xml-parser": "5.7.0",
|
||||
"postcss": "8.5.25",
|
||||
"sharp": "0.35.3"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,15 @@ import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { BurstDetectionResult } from "./types";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstDetectionResult) => void;
|
||||
@@ -49,7 +57,7 @@ const currentQuarterHour = () => {
|
||||
|
||||
export const createBurstDetectionAnalysisParametersState =
|
||||
(): BurstDetectionAnalysisParametersState => ({
|
||||
schemeName: `Burst_Detection_${Date.now()}`,
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection),
|
||||
detectionMode: "latest",
|
||||
targetTime: currentQuarterHour(),
|
||||
samplingIntervalMinutes: 15,
|
||||
@@ -94,7 +102,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||
export const buildBurstDetectionRequest = (
|
||||
parameters: BurstDetectionAnalysisParametersState,
|
||||
) => ({
|
||||
scheme_name: parameters.schemeName.trim(),
|
||||
scheme_name: normalizeSchemeName(parameters.schemeName),
|
||||
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
||||
...(parameters.detectionMode === "historical" && parameters.targetTime
|
||||
? { target_time: parameters.targetTime.toISOString() }
|
||||
@@ -164,7 +172,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
|
||||
const isValid = useMemo(
|
||||
() =>
|
||||
schemeName.trim().length > 0 &&
|
||||
isSchemeNameValid(schemeName) &&
|
||||
samplingIntervalValid &&
|
||||
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
|
||||
[detectionMode, samplingIntervalValid, schemeName, targetTime],
|
||||
@@ -203,12 +211,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
? "目标时刻存在异常信号,请优先复核相关测点。"
|
||||
: "目标时刻未发现爆管异常。",
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
open?.({
|
||||
key: "burst-detection-analysis-error",
|
||||
type: "error",
|
||||
message: "侦测失败",
|
||||
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
|
||||
description: getApiErrorMessage(error, "爆管侦测请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -226,6 +234,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
value={schemeName}
|
||||
onChange={(event) => setFormField("schemeName", event.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
||||
helperText={
|
||||
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
||||
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
||||
: undefined
|
||||
}
|
||||
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -27,9 +27,17 @@ import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||
import { BurstLocationResult } from "./types";
|
||||
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstLocationResult) => void;
|
||||
@@ -67,7 +75,7 @@ export interface BurstLocationAnalysisParametersState {
|
||||
|
||||
export const createBurstLocationAnalysisParametersState =
|
||||
(): BurstLocationAnalysisParametersState => ({
|
||||
schemeName: `Burst_Locate_${Date.now()}`,
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstLocation),
|
||||
dataSource: "monitoring",
|
||||
schemes: [],
|
||||
selectedSchemeId: "",
|
||||
@@ -163,12 +171,14 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "刷新方案失败",
|
||||
description:
|
||||
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
||||
description: getApiErrorMessage(
|
||||
error,
|
||||
"无法获取爆管分析方案列表",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setSchemeLoading(false);
|
||||
@@ -193,6 +203,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!isSchemeNameValid(schemeName)) return false;
|
||||
if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false;
|
||||
if (!burstStartTime || !burstEndTime) {
|
||||
return false;
|
||||
@@ -204,6 +215,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
return burstStartTime.isBefore(burstEndTime);
|
||||
}, [
|
||||
burstLeakage,
|
||||
schemeName,
|
||||
burstStartTime,
|
||||
burstEndTime,
|
||||
dataSource,
|
||||
@@ -234,7 +246,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
`${config.BACKEND_URL}/api/v1/burst-locations`,
|
||||
{
|
||||
data_source: dataSource,
|
||||
scheme_name: schemeName.trim() || undefined,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
||||
min_dpressure: minDpressure,
|
||||
basic_pressure: basicPressure,
|
||||
@@ -270,7 +282,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
message: "爆管定位成功",
|
||||
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
const notice = getBurstLocationErrorNotice(error);
|
||||
open?.({
|
||||
key: "burst-location-analysis-error",
|
||||
@@ -294,6 +306,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
||||
helperText={
|
||||
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
||||
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
||||
: undefined
|
||||
}
|
||||
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
|
||||
export interface BurstLocationErrorNotice {
|
||||
message: string;
|
||||
description: string;
|
||||
@@ -6,26 +8,10 @@ export interface BurstLocationErrorNotice {
|
||||
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 detail = getApiErrorMessage(error);
|
||||
const match = detail.match(DATA_GAP_PATTERN);
|
||||
|
||||
if (!match) {
|
||||
|
||||
@@ -30,9 +30,17 @@ import { api } from "@/lib/api";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { along, lineString, length, toMercator } from "@turf/turf";
|
||||
import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
export interface PipePoint {
|
||||
id: string;
|
||||
@@ -52,7 +60,7 @@ export const createBurstAnalysisParametersState = (): BurstAnalysisParametersSta
|
||||
pipePoints: [],
|
||||
startTime: dayjs(new Date()),
|
||||
duration: 3600,
|
||||
schemeName: "FANGAN" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis),
|
||||
network: NETWORK_NAME,
|
||||
});
|
||||
|
||||
@@ -104,7 +112,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
pipePoints.length > 0 &&
|
||||
startTime !== null &&
|
||||
duration > 0 &&
|
||||
schemeName.trim() !== "";
|
||||
isSchemeNameValid(schemeName);
|
||||
|
||||
// 地图点击选择要素事件处理函数
|
||||
const handleMapClickSelectFeatures = useCallback(
|
||||
@@ -333,7 +341,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
burst_id: burst_ID,
|
||||
burst_size: burst_size,
|
||||
modify_total_duration: modify_total_duration,
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -358,8 +366,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
key: "burst-analysis",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description:
|
||||
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
|
||||
description: getApiErrorMessage(error, "爆管模拟请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
@@ -525,6 +532,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setParameterField("schemeName", e.target.value)}
|
||||
placeholder="输入方案名称"
|
||||
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
||||
helperText={
|
||||
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
||||
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
||||
: undefined
|
||||
}
|
||||
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -30,6 +30,14 @@ import {
|
||||
} from "@/utils/mapQueryService";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
export interface ContaminantAnalysisParametersState {
|
||||
schemeName: string;
|
||||
@@ -42,7 +50,7 @@ export interface ContaminantAnalysisParametersState {
|
||||
|
||||
export const createContaminantAnalysisParametersState =
|
||||
(): ContaminantAnalysisParametersState => ({
|
||||
schemeName: "WQ_" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.contaminantAnalysis),
|
||||
startTime: dayjs(new Date()),
|
||||
sourceNode: "",
|
||||
concentration: 100,
|
||||
@@ -98,7 +106,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
Boolean(sourceNode) &&
|
||||
concentration > 0 &&
|
||||
duration > 0 &&
|
||||
schemeName.trim() !== ""
|
||||
isSchemeNameValid(schemeName)
|
||||
);
|
||||
}, [network, startTime, sourceNode, concentration, duration, schemeName]);
|
||||
|
||||
@@ -240,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
concentration,
|
||||
duration,
|
||||
pattern: pattern || undefined,
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
};
|
||||
|
||||
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
|
||||
@@ -259,8 +267,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
key: "contaminant-analysis",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description:
|
||||
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
|
||||
description: getApiErrorMessage(error, "污染物模拟请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -391,6 +398,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
placeholder="输入方案名称"
|
||||
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
||||
helperText={
|
||||
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
||||
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
||||
: undefined
|
||||
}
|
||||
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -21,8 +21,16 @@ import { api } from "@/lib/api";
|
||||
import { config } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { LeakageResultDetail } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: LeakageResultDetail) => void;
|
||||
@@ -43,7 +51,7 @@ export interface DMALeakAnalysisParametersState {
|
||||
|
||||
export const createDMALeakAnalysisParametersState =
|
||||
(): DMALeakAnalysisParametersState => ({
|
||||
schemeName: `DMA_Leak_${Date.now()}`,
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.dmaLeakIdentification),
|
||||
dmaCount: 5,
|
||||
startTime: dayjs().subtract(2, "hour"),
|
||||
endTime: dayjs(),
|
||||
@@ -93,7 +101,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0;
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!schemeName.trim() || !startTime || !endTime) return false;
|
||||
if (!isSchemeNameValid(schemeName) || !startTime || !endTime) return false;
|
||||
return startTime.isBefore(endTime) && qSumIsValid;
|
||||
}, [schemeName, startTime, endTime, qSumIsValid]);
|
||||
|
||||
@@ -118,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/leakage-identifications`,
|
||||
{
|
||||
scheme_name: schemeName.trim(),
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
dma_count: dmaCount,
|
||||
scada_start: startTime.toISOString(),
|
||||
scada_end: endTime.toISOString(),
|
||||
@@ -136,12 +144,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
message: "方案分析成功",
|
||||
description: "DMA 漏损识别完成,请在方案查询中查看结果。",
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
open?.({
|
||||
key: "dma-leak-analysis-error",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error?.response?.data?.detail ?? "请求失败",
|
||||
description: getApiErrorMessage(error, "DMA 漏损识别请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -163,6 +171,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
||||
helperText={
|
||||
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
||||
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
||||
: undefined
|
||||
}
|
||||
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
Typography,
|
||||
IconButton,
|
||||
Stack,
|
||||
Alert,
|
||||
Divider,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
AdjustOutlined,
|
||||
@@ -33,14 +33,34 @@ import {
|
||||
import Feature, { FeatureLike } from "ol/Feature";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { config } from "@/config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
import {
|
||||
type LinkStatus,
|
||||
getValveSettingHelperText,
|
||||
isLinkStatus,
|
||||
normalizeValveSetting,
|
||||
VALVE_STATUS_OPTIONS,
|
||||
validateValveSetting,
|
||||
} from "@components/olmap/core/Controls/valveControl";
|
||||
import { getValveListClassName } from "./analysisParametersLayout";
|
||||
|
||||
export interface ValveItem {
|
||||
id: string;
|
||||
k: number;
|
||||
setting?: string | null;
|
||||
vType?: string | null;
|
||||
status?: LinkStatus | null;
|
||||
detailsLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface FlushingAnalysisParametersState {
|
||||
@@ -54,7 +74,7 @@ export interface FlushingAnalysisParametersState {
|
||||
|
||||
export const createFlushingAnalysisParametersState =
|
||||
(): FlushingAnalysisParametersState => ({
|
||||
schemeName: "Flushing_" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis),
|
||||
valves: [],
|
||||
drainageNode: null,
|
||||
startTime: dayjs(new Date()),
|
||||
@@ -92,6 +112,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none');
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||
const valveSettingRequests = useRef(new Set<string>());
|
||||
|
||||
const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null);
|
||||
|
||||
@@ -119,7 +140,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
if (!feature) return;
|
||||
|
||||
const layer = feature.getId()?.toString().split(".")[0];
|
||||
const featureId = feature.getProperties().id;
|
||||
const featureId = String(feature.getProperties().id);
|
||||
|
||||
if (selectionMode === 'valve') {
|
||||
if (layer !== 'geo_valves') {
|
||||
@@ -139,7 +160,16 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
return prev;
|
||||
}
|
||||
setValveFeatures((features) => [...features, feature]);
|
||||
return [...prev, { id: featureId, k: 1.0 }];
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: featureId,
|
||||
setting: undefined,
|
||||
vType: undefined,
|
||||
status: undefined,
|
||||
detailsLoaded: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
} else if (selectionMode === 'drainage') {
|
||||
@@ -253,6 +283,91 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
});
|
||||
}, [valveFeatures.length, valves]);
|
||||
|
||||
useEffect(() => {
|
||||
valves.forEach((valve) => {
|
||||
if (valve.detailsLoaded || valveSettingRequests.current.has(valve.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
valveSettingRequests.current.add(valve.id);
|
||||
void Promise.allSettled([
|
||||
api.get(`${config.BACKEND_URL}/api/v1/valves/properties`, {
|
||||
params: { valve: valve.id },
|
||||
}),
|
||||
api.get(`${config.BACKEND_URL}/api/v1/status`, {
|
||||
params: { link: valve.id },
|
||||
}),
|
||||
])
|
||||
.then(([propertiesResult, statusResult]) => {
|
||||
const properties =
|
||||
propertiesResult.status === "fulfilled"
|
||||
? propertiesResult.value.data
|
||||
: null;
|
||||
const rawSetting = properties?.setting;
|
||||
const rawStatus =
|
||||
statusResult.status === "fulfilled"
|
||||
? statusResult.value.data?.status
|
||||
: null;
|
||||
const status = isLinkStatus(rawStatus) ? rawStatus : null;
|
||||
|
||||
setValves((previous) =>
|
||||
previous.map((item) =>
|
||||
item.id === valve.id
|
||||
? {
|
||||
...item,
|
||||
setting: normalizeValveSetting(rawSetting),
|
||||
vType: properties?.v_type
|
||||
? String(properties.v_type)
|
||||
: null,
|
||||
status,
|
||||
detailsLoaded: true,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
propertiesResult.status === "rejected" ||
|
||||
statusResult.status === "rejected"
|
||||
) {
|
||||
console.error("读取阀门属性失败", {
|
||||
propertiesError:
|
||||
propertiesResult.status === "rejected"
|
||||
? propertiesResult.reason
|
||||
: undefined,
|
||||
statusError:
|
||||
statusResult.status === "rejected"
|
||||
? statusResult.reason
|
||||
: undefined,
|
||||
});
|
||||
open?.({
|
||||
type: "error",
|
||||
message: `阀门 ${valve.id} 的部分属性读取失败`,
|
||||
description: [
|
||||
propertiesResult.status === "rejected"
|
||||
? `阀门属性:${getApiErrorMessage(
|
||||
propertiesResult.reason,
|
||||
"读取失败",
|
||||
)}`
|
||||
: null,
|
||||
statusResult.status === "rejected"
|
||||
? `开关状态:${getApiErrorMessage(
|
||||
statusResult.reason,
|
||||
"读取失败",
|
||||
)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";"),
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
valveSettingRequests.current.delete(valve.id);
|
||||
});
|
||||
});
|
||||
}, [open, setValves, valves]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drainageNode) {
|
||||
setDrainageFeature(null);
|
||||
@@ -292,13 +407,25 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const handleValveKChange = (id: string, k: string) => {
|
||||
const numK = parseFloat(k);
|
||||
setValves(prev => prev.map(v => v.id === id ? { ...v, k: isNaN(numK) ? 0 : numK } : v));
|
||||
const handleValveStatusChange = (id: string, status: string) => {
|
||||
if (!isLinkStatus(status)) return;
|
||||
setValves((previous) =>
|
||||
previous.map((valve) =>
|
||||
valve.id === id ? { ...valve, status } : valve,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleValveSettingChange = (id: string, setting: string) => {
|
||||
setValves((previous) =>
|
||||
previous.map((valve) =>
|
||||
valve.id === id ? { ...valve, setting } : valve,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!startTime || !drainageNode || !schemeName.trim()) {
|
||||
if (!startTime || !drainageNode || !isSchemeNameValid(schemeName)) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请填写完整参数",
|
||||
@@ -307,16 +434,48 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (valves.some((valve) => !isLinkStatus(valve.status))) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "阀门开关状态未设置",
|
||||
description: "请为所有参与阀门选择开启、关闭或激活状态",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidActiveValve = valves.find(
|
||||
(valve) =>
|
||||
valve.status === "ACTIVE" &&
|
||||
validateValveSetting(valve.vType, valve.setting ?? ""),
|
||||
);
|
||||
if (invalidActiveValve) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: `阀门 ${invalidActiveValve.id} 的设置值无效`,
|
||||
description:
|
||||
validateValveSetting(
|
||||
invalidActiveValve.vType,
|
||||
invalidActiveValve.setting ?? "",
|
||||
) ?? undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAnalyzing(true);
|
||||
|
||||
try {
|
||||
const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00
|
||||
|
||||
const params = {
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
start_time: formattedTime,
|
||||
valves: valves.map(v => v.id),
|
||||
valves_k: valves.map(v => v.k),
|
||||
...(valves.length > 0 && {
|
||||
valves: valves.map(v => v.id),
|
||||
valve_statuses: valves.map((v) => v.status),
|
||||
valve_settings: valves.map((v) =>
|
||||
v.status === "ACTIVE" ? (v.setting ?? "").trim() : "",
|
||||
),
|
||||
}),
|
||||
drainage_node_id: drainageNode,
|
||||
flush_flow: flushFlow,
|
||||
duration: duration
|
||||
@@ -346,7 +505,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error instanceof Error ? error.message : "未知错误",
|
||||
description: getApiErrorMessage(error, "管道冲洗分析请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
@@ -355,62 +514,11 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col h-full gap-4 pb-4">
|
||||
{/* 1. Valve Selection */}
|
||||
{/* 1. Drainage Node Selection */}
|
||||
<Box>
|
||||
<Box className="flex items-center justify-between mb-2">
|
||||
<Typography variant="subtitle2" className="font-medium">
|
||||
参与阀门
|
||||
</Typography>
|
||||
<Button
|
||||
variant={selectionMode === 'valve' ? "contained" : "outlined"}
|
||||
color={selectionMode === 'valve' ? "error" : "primary"}
|
||||
size="small"
|
||||
onClick={() => toggleSelection('valve')}
|
||||
>
|
||||
{selectionMode === 'valve' ? "停止选择" : "选择阀门"}
|
||||
</Button>
|
||||
</Box>
|
||||
{selectionMode === 'valve' && (
|
||||
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
|
||||
💡 点击地图上的阀门进行添加
|
||||
</Box>
|
||||
)}
|
||||
<Stack spacing={1} className="max-h-50 h-48 overflow-auto">
|
||||
{valves.map((valve) => (
|
||||
<Box key={valve.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||
<Typography className="text-sm flex-1 pl-1">{valve.id}</Typography>
|
||||
<TextField
|
||||
label="开度"
|
||||
size="small"
|
||||
type="number"
|
||||
value={valve.k}
|
||||
onChange={(e) => handleValveKChange(valve.id, e.target.value)}
|
||||
className="w-20"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 0, max: 1 } }}
|
||||
/>
|
||||
<IconButton size="small" onClick={() => handleRemoveValve(valve.id)}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
{valves.length === 0 && (
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
icon={<AdjustOutlined />}
|
||||
title="尚未选择阀门"
|
||||
description="点击“选择阀门”,然后在地图上添加。"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 2. Drainage Node Selection */}
|
||||
<Box>
|
||||
<Box className="flex items-center justify-between mb-2">
|
||||
<Typography variant="subtitle2" className="font-medium">
|
||||
排水节点
|
||||
排水节点(必选)
|
||||
</Typography>
|
||||
<Button
|
||||
variant={selectionMode === 'drainage' ? "contained" : "outlined"}
|
||||
@@ -444,6 +552,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
{!drainageNode && (
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
horizontalAlign="start"
|
||||
icon={<WaterDropOutlined />}
|
||||
title="尚未选择排水节点"
|
||||
description="点击“选择节点”,然后在地图上指定排水点。"
|
||||
@@ -454,6 +563,110 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 2. Optional Valve Selection */}
|
||||
<Box>
|
||||
<Box className="flex items-center justify-between mb-2">
|
||||
<Typography variant="subtitle2" className="font-medium">
|
||||
参与阀门(可选)
|
||||
</Typography>
|
||||
<Button
|
||||
variant={selectionMode === 'valve' ? "contained" : "outlined"}
|
||||
color={selectionMode === 'valve' ? "error" : "primary"}
|
||||
size="small"
|
||||
onClick={() => toggleSelection('valve')}
|
||||
>
|
||||
{selectionMode === 'valve' ? "停止选择" : "选择阀门"}
|
||||
</Button>
|
||||
</Box>
|
||||
{selectionMode === 'valve' && (
|
||||
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
|
||||
💡 点击地图上的阀门进行添加
|
||||
</Box>
|
||||
)}
|
||||
<Stack
|
||||
spacing={1}
|
||||
className={getValveListClassName(valves.length)}
|
||||
>
|
||||
{valves.map((valve) => {
|
||||
const settingValidation =
|
||||
valve.status === "ACTIVE"
|
||||
? validateValveSetting(valve.vType, valve.setting ?? "")
|
||||
: null;
|
||||
const isSettingDisabled =
|
||||
!valve.detailsLoaded ||
|
||||
valve.status === "OPEN" ||
|
||||
valve.status === "CLOSED";
|
||||
|
||||
return (
|
||||
<Box key={valve.id} className="p-2 bg-gray-50 rounded">
|
||||
<Box className="flex items-center gap-2 mb-2">
|
||||
<Typography className="text-sm min-w-0 flex-1 pl-1">
|
||||
{valve.id}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`移除阀门 ${valve.id}`}
|
||||
onClick={() => handleRemoveValve(valve.id)}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
label="开关状态"
|
||||
value={valve.status ?? ""}
|
||||
disabled={!valve.detailsLoaded}
|
||||
onChange={(event) =>
|
||||
handleValveStatusChange(valve.id, event.target.value)
|
||||
}
|
||||
>
|
||||
{VALVE_STATUS_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="阀门设置值"
|
||||
value={valve.setting ?? ""}
|
||||
disabled={isSettingDisabled}
|
||||
error={Boolean(settingValidation)}
|
||||
helperText={
|
||||
valve.detailsLoaded
|
||||
? settingValidation ??
|
||||
getValveSettingHelperText(
|
||||
valve.vType,
|
||||
valve.status,
|
||||
)
|
||||
: "加载中"
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleValveSettingChange(valve.id, event.target.value)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{valves.length === 0 && (
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
horizontalAlign="start"
|
||||
icon={<AdjustOutlined />}
|
||||
title="未选择参与阀门"
|
||||
description="无需调整阀门时可跳过,也可点击“选择阀门”添加。"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 3. Parameters */}
|
||||
<Box className="flex flex-col gap-3">
|
||||
<Box>
|
||||
@@ -485,6 +698,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
||||
helperText={
|
||||
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
||||
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
||||
: undefined
|
||||
}
|
||||
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -527,7 +747,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
onClick={handleAnalyze}
|
||||
disabled={
|
||||
analyzing ||
|
||||
!schemeName.trim() ||
|
||||
!isSchemeNameValid(schemeName) ||
|
||||
!drainageNode ||
|
||||
!startTime ||
|
||||
// !flushFlow ||
|
||||
|
||||
@@ -44,6 +44,7 @@ import { SchemeRecord, SchemaItem } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
import { formatValveControlSummary } from "@components/olmap/core/Controls/toolbarFeatureHelpers";
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: SchemeRecord[];
|
||||
@@ -589,10 +590,22 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
{/* 阀门列表 */}
|
||||
<Box className="col-span-2 pl-2">
|
||||
<Typography variant="caption" className="text-gray-600 block mb-1">
|
||||
参与阀门及开度:
|
||||
参与阀门及设置:
|
||||
</Typography>
|
||||
<Box className="flex flex-wrap gap-2">
|
||||
{scheme.schemeDetail?.valve_opening && Object.entries(scheme.schemeDetail.valve_opening).length > 0 ? (
|
||||
{scheme.schemeDetail?.valve_control && Object.entries(scheme.schemeDetail.valve_control).length > 0 ? (
|
||||
Object.entries(scheme.schemeDetail.valve_control).map(([id, control]) => (
|
||||
<Tooltip key={id} title="点击定位阀门">
|
||||
<Chip
|
||||
label={formatValveControlSummary(id, control)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleLocateValves([id])}
|
||||
className="text-xs h-6 bg-gray-50 cursor-pointer hover:bg-orange-50 hover:border-orange-200"
|
||||
/>
|
||||
</Tooltip>
|
||||
))
|
||||
) : scheme.schemeDetail?.valve_opening && Object.entries(scheme.schemeDetail.valve_opening).length > 0 ? (
|
||||
Object.entries(scheme.schemeDetail.valve_opening).map(([id, k]) => (
|
||||
<Tooltip key={id} title="点击定位阀门">
|
||||
<Chip
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getValveListClassName } from "./analysisParametersLayout";
|
||||
|
||||
describe("getValveListClassName", () => {
|
||||
it("uses compact height when no valves are selected", () => {
|
||||
expect(getValveListClassName(0)).toBe("min-h-16 overflow-auto");
|
||||
});
|
||||
|
||||
it("restores the fixed scroll region when valves are selected", () => {
|
||||
expect(getValveListClassName(1)).toBe(
|
||||
"max-h-50 h-48 overflow-auto",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const getValveListClassName = (valveCount: number): string =>
|
||||
valveCount > 0
|
||||
? "max-h-50 h-48 overflow-auto"
|
||||
: "min-h-16 overflow-auto";
|
||||
@@ -1,5 +1,13 @@
|
||||
export interface SchemeDetail {
|
||||
valve_opening: Record<string, number>;
|
||||
valve_opening?: Record<string, number> | null;
|
||||
valve_control?: Record<
|
||||
string,
|
||||
{
|
||||
status?: "OPEN" | "CLOSED" | "ACTIVE";
|
||||
setting?: string | number;
|
||||
k?: number;
|
||||
}
|
||||
> | null;
|
||||
drainage_node_ID: string;
|
||||
flushing_flow: number;
|
||||
duration: number;
|
||||
|
||||
@@ -12,8 +12,16 @@ import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { NETWORK_NAME } from "@/config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { optimizeSensorPlacement } from "./schemeApi";
|
||||
import type { SensorPlacementScheme } from "./types";
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_MAX_LENGTH,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "@utils/schemeName";
|
||||
|
||||
export interface OptimizationParametersState {
|
||||
method: string;
|
||||
@@ -27,7 +35,7 @@ export const createOptimizationParametersState =
|
||||
method: "kmeans",
|
||||
sensorCount: 5,
|
||||
minDiameter: 5,
|
||||
schemeName: "Fangan" + new Date().getTime(),
|
||||
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
|
||||
});
|
||||
|
||||
interface OptimizationParametersProps {
|
||||
@@ -62,7 +70,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
// 创建方案
|
||||
const handleCreateScheme = async () => {
|
||||
// 验证输入
|
||||
if (!schemeName.trim()) {
|
||||
if (!isSchemeNameValid(schemeName)) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请输入方案名称",
|
||||
@@ -90,7 +98,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
|
||||
try {
|
||||
const created = await optimizeSensorPlacement({
|
||||
scheme_name: schemeName,
|
||||
scheme_name: normalizeSchemeName(schemeName),
|
||||
sensor_type: "pressure",
|
||||
method: method as "sensitivity" | "kmeans",
|
||||
sensor_count: sensorCount,
|
||||
@@ -102,14 +110,16 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
description: `方案 "${schemeName}" 已完成优化分析`,
|
||||
});
|
||||
onSchemeCreated?.(created);
|
||||
setFormField("schemeName", "Fangan" + new Date().getTime());
|
||||
} catch (error: any) {
|
||||
setFormField(
|
||||
"schemeName",
|
||||
createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("创建方案失败:", error);
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "创建方案失败",
|
||||
description:
|
||||
error.response?.data?.message || error.message || "未知错误",
|
||||
description: getApiErrorMessage(error, "监测点优化请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
@@ -255,6 +265,13 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
value={schemeName}
|
||||
onChange={(e) => setFormField("schemeName", e.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
||||
helperText={
|
||||
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
||||
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
||||
: undefined
|
||||
}
|
||||
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
"&:hover fieldset": {
|
||||
|
||||
@@ -36,11 +36,15 @@ describe("PanelEmptyState", () => {
|
||||
render(
|
||||
<PanelEmptyState
|
||||
variant="compact"
|
||||
horizontalAlign="start"
|
||||
title="尚未选择阀门"
|
||||
description="点击“选择阀门”,然后在地图上添加。"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("尚未选择阀门")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveStyle({
|
||||
justifyContent: "flex-start",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PanelEmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: "panel" | "compact";
|
||||
horizontalAlign?: "center" | "start";
|
||||
}
|
||||
|
||||
const PanelEmptyState = ({
|
||||
@@ -17,6 +18,7 @@ const PanelEmptyState = ({
|
||||
title,
|
||||
description,
|
||||
variant = "panel",
|
||||
horizontalAlign = "center",
|
||||
}: PanelEmptyStateProps) => {
|
||||
const compact = variant === "compact";
|
||||
|
||||
@@ -32,7 +34,8 @@ const PanelEmptyState = ({
|
||||
display: "flex",
|
||||
flexDirection: compact ? "row" : { xs: "column", sm: "row" },
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
justifyContent:
|
||||
horizontalAlign === "start" ? "flex-start" : "center",
|
||||
gap: compact ? 1.5 : { xs: 1.75, sm: 2.5 },
|
||||
px: compact ? 1.5 : { xs: 2, sm: 4 },
|
||||
py: compact ? 1.25 : { xs: 4, sm: 5 },
|
||||
|
||||
@@ -22,9 +22,17 @@ import { useNotification } from "@refinedev/core";
|
||||
import ToolbarHistoryPanel from "./ToolbarHistoryPanel";
|
||||
import {
|
||||
buildFeatureProperties,
|
||||
getSimulationElementType,
|
||||
getValvePropertySource,
|
||||
} from "./toolbarFeatureHelpers";
|
||||
import { useToolbarChatActions } from "./useToolbarChatActions";
|
||||
import { useStyleEditor } from "./useStyleEditor";
|
||||
import {
|
||||
type LinkStatus,
|
||||
isLinkStatus,
|
||||
normalizeValveSetting,
|
||||
validateValveSetting,
|
||||
} from "./valveControl";
|
||||
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { useProject } from "@/contexts/ProjectContext";
|
||||
@@ -41,51 +49,25 @@ interface ToolbarProps {
|
||||
enableCompare?: boolean;
|
||||
}
|
||||
|
||||
type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE";
|
||||
type ValveProperties = {
|
||||
vType: string | null;
|
||||
setting: string | null;
|
||||
};
|
||||
|
||||
type SchemeValveControl = {
|
||||
status?: string;
|
||||
setting?: string | number;
|
||||
k?: number;
|
||||
};
|
||||
|
||||
type ActiveSchemeDetail = {
|
||||
valve_control?: Record<string, SchemeValveControl> | null;
|
||||
valve_opening?: Record<string, number> | null;
|
||||
};
|
||||
|
||||
const isValveLayer = (layerId: string | undefined) =>
|
||||
layerId === "geo_valves_mat" || layerId === "geo_valves";
|
||||
|
||||
const isLinkStatus = (value: unknown): value is LinkStatus =>
|
||||
value === "OPEN" || value === "CLOSED" || value === "ACTIVE";
|
||||
|
||||
const normalizeValveSetting = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const validateValveSetting = (
|
||||
valveType: string | null,
|
||||
value: string,
|
||||
): string | null => {
|
||||
const normalizedType = valveType?.toUpperCase();
|
||||
const trimmedValue = value.trim();
|
||||
const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]);
|
||||
|
||||
if (normalizedType === "GPV") {
|
||||
return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。";
|
||||
}
|
||||
|
||||
if (numericTypes.has(normalizedType ?? "")) {
|
||||
if (!trimmedValue) {
|
||||
return "阀门设置值必须是 0 或正数。";
|
||||
}
|
||||
|
||||
const numericValue = Number(trimmedValue);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return "阀门设置值必须是有限数字,且大于或等于 0。";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmedValue ? null : "阀门设置值不能为空。";
|
||||
};
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
hiddenButtons,
|
||||
queryType,
|
||||
@@ -400,6 +382,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const [isValvePropertiesLoading, setIsValvePropertiesLoading] =
|
||||
useState(false);
|
||||
const [isValveSettingSaving, setIsValveSettingSaving] = useState(false);
|
||||
const [activeSchemeDetail, setActiveSchemeDetail] =
|
||||
useState<ActiveSchemeDetail | null>(null);
|
||||
|
||||
const selectedFeature = highlightFeatures[0];
|
||||
const selectedFeatureLayer = selectedFeature
|
||||
@@ -415,9 +399,57 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId
|
||||
? String(selectedFeatureId)
|
||||
: null;
|
||||
const isSimulationDataActive =
|
||||
getValvePropertySource(queryType, schemeName) === "simulation";
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValveId) {
|
||||
if (
|
||||
queryType !== "scheme" ||
|
||||
schemeType !== "flushing_analysis" ||
|
||||
!schemeName ||
|
||||
!selectedValveId
|
||||
) {
|
||||
setActiveSchemeDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSchemeDetail(null);
|
||||
let cancelled = false;
|
||||
|
||||
const querySchemeDetail = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (schemeType) params.set("scheme_type", schemeType);
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}?${params.toString()}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`getschemedetail failed: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (!cancelled) {
|
||||
setActiveSchemeDetail(payload?.scheme_detail ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error querying scheme valve settings:", error);
|
||||
if (!cancelled) {
|
||||
setActiveSchemeDetail(null);
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "读取方案阀门设置失败。",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void querySchemeDetail();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, queryType, schemeName, schemeType, selectedValveId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValveId || isSimulationDataActive) {
|
||||
setValveStatus(null);
|
||||
setIsValveStatusLoading(false);
|
||||
return;
|
||||
@@ -466,10 +498,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [networkName, open, selectedValveId]);
|
||||
}, [isSimulationDataActive, networkName, open, selectedValveId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValveId) {
|
||||
if (!selectedValveId || isSimulationDataActive) {
|
||||
setValveProperties({ vType: null, setting: null });
|
||||
setIsValvePropertiesLoading(false);
|
||||
return;
|
||||
@@ -521,7 +553,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [networkName, open, selectedValveId]);
|
||||
}, [isSimulationDataActive, networkName, open, selectedValveId]);
|
||||
|
||||
const handleValveStatusSave = useCallback(
|
||||
async (nextStatus: string) => {
|
||||
@@ -642,7 +674,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
// 添加 useEffect 来查询计算属性
|
||||
useEffect(() => {
|
||||
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
|
||||
const canQuerySimulation =
|
||||
getValvePropertySource(queryType, schemeName) === "simulation";
|
||||
if (
|
||||
highlightFeatures.length === 0 ||
|
||||
!selectedDate ||
|
||||
!showPropertyPanel ||
|
||||
!canQuerySimulation
|
||||
) {
|
||||
setComputedProperties({});
|
||||
return;
|
||||
}
|
||||
@@ -654,11 +693,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setComputedProperties({});
|
||||
|
||||
const queryComputedProperties = async () => {
|
||||
try {
|
||||
const properties = highlightFeature?.getProperties?.() || {};
|
||||
const type =
|
||||
properties.geometry?.getType?.() === "LineString" ? "link" : "node";
|
||||
const type = getSimulationElementType(highlightFeature);
|
||||
// selectedDate 格式化为 YYYY-MM-DD
|
||||
let dateObj: Date;
|
||||
if (selectedDate instanceof Date) {
|
||||
@@ -670,22 +710,33 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||
// 转为 UTC ISO 字符串
|
||||
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
||||
let response;
|
||||
let response: Response;
|
||||
if (queryType === "scheme") {
|
||||
const params = new URLSearchParams({
|
||||
scheme_type: schemeType ?? "",
|
||||
scheme_name: schemeName ?? "",
|
||||
id: String(id),
|
||||
type,
|
||||
query_time: querytime,
|
||||
});
|
||||
response = await apiFetch(
|
||||
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?${params.toString()}`,
|
||||
);
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
id: String(id),
|
||||
type,
|
||||
query_time: querytime,
|
||||
});
|
||||
response = await apiFetch(
|
||||
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?id=${id}&type=${type}&query_time=${querytime}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("API request failed");
|
||||
}
|
||||
const data = await response.json();
|
||||
if (cancelled) return;
|
||||
if (!data.result || data.result.length === 0) {
|
||||
setComputedProperties({});
|
||||
} else {
|
||||
@@ -694,32 +745,76 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error querying computed properties:", error);
|
||||
setComputedProperties({});
|
||||
if (!cancelled) {
|
||||
setComputedProperties({});
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "读取模拟属性失败,请检查方案、时间和数据源。",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// 仅当 currentTime 有效时查询
|
||||
if (currentTime !== -1 && queryType) queryComputedProperties();
|
||||
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
||||
if (currentTime !== -1 && queryType) void queryComputedProperties();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [highlightFeatures, currentTime, open, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
||||
|
||||
const displayedComputedProperties = useMemo(() => {
|
||||
if (!isSimulationDataActive || !selectedValveId) {
|
||||
return computedProperties;
|
||||
}
|
||||
|
||||
const control = activeSchemeDetail?.valve_control?.[selectedValveId];
|
||||
const legacyOpening = activeSchemeDetail?.valve_opening?.[selectedValveId];
|
||||
return {
|
||||
...computedProperties,
|
||||
...(control?.status !== undefined
|
||||
? { scheme_status: control.status }
|
||||
: {}),
|
||||
...(control
|
||||
? {
|
||||
scheme_setting:
|
||||
control.status === "OPEN" || control.status === "CLOSED"
|
||||
? "不适用"
|
||||
: (control.setting ?? "未设置"),
|
||||
}
|
||||
: {}),
|
||||
...(control?.k !== undefined
|
||||
? { scheme_opening: control.k }
|
||||
: legacyOpening !== undefined
|
||||
? { scheme_opening: legacyOpening }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
activeSchemeDetail,
|
||||
computedProperties,
|
||||
isSimulationDataActive,
|
||||
selectedValveId,
|
||||
]);
|
||||
|
||||
const propertyPanelData = useMemo(
|
||||
() =>
|
||||
buildFeatureProperties(
|
||||
selectedFeature,
|
||||
computedProperties,
|
||||
canEditNetwork && selectedValveId
|
||||
displayedComputedProperties,
|
||||
!isSimulationDataActive && selectedValveId
|
||||
? {
|
||||
value: valveStatus,
|
||||
loading: isValveStatusLoading,
|
||||
saving: isValveStatusSaving,
|
||||
disabled: !canEditNetwork,
|
||||
onSave: handleValveStatusSave,
|
||||
}
|
||||
: undefined,
|
||||
canEditNetwork && selectedValveId
|
||||
!isSimulationDataActive && selectedValveId
|
||||
? {
|
||||
value: valveProperties.setting,
|
||||
vType: selectedValveType,
|
||||
loading: isValvePropertiesLoading,
|
||||
saving: isValveSettingSaving,
|
||||
disabled: !canEditNetwork,
|
||||
status: valveStatus,
|
||||
onSave: handleValveSettingSave,
|
||||
}
|
||||
@@ -727,7 +822,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
),
|
||||
[
|
||||
selectedFeature,
|
||||
computedProperties,
|
||||
displayedComputedProperties,
|
||||
isSimulationDataActive,
|
||||
canEditNetwork,
|
||||
selectedValveId,
|
||||
valveStatus,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
jest.mock("ol/Feature", () => ({
|
||||
__esModule: true,
|
||||
default: class Feature {},
|
||||
}));
|
||||
|
||||
import type Feature from "ol/Feature";
|
||||
|
||||
import {
|
||||
buildFeatureProperties,
|
||||
formatValveControlSummary,
|
||||
getSimulationElementType,
|
||||
getValvePropertySource,
|
||||
} from "./toolbarFeatureHelpers";
|
||||
|
||||
const createValveFeature = () => {
|
||||
const properties = {
|
||||
id: "V1",
|
||||
node1: "J1",
|
||||
node2: "J2",
|
||||
diameter: 200,
|
||||
v_type: "PRV",
|
||||
minor_loss: 0,
|
||||
};
|
||||
return {
|
||||
getId: () => "geo_valves.V1",
|
||||
getProperties: () => properties,
|
||||
} as unknown as Feature;
|
||||
};
|
||||
|
||||
const createFeature = (
|
||||
layer: string,
|
||||
id: string,
|
||||
properties: Record<string, unknown>,
|
||||
) =>
|
||||
({
|
||||
getId: () => `${layer}.${id}`,
|
||||
getProperties: () => ({ id, ...properties }),
|
||||
}) as unknown as Feature;
|
||||
|
||||
describe("buildFeatureProperties valve simulation values", () => {
|
||||
it("shows TimescaleDB status and setting for a simulated valve", () => {
|
||||
const result = buildFeatureProperties(createValveFeature(), {
|
||||
status: 2,
|
||||
setting: 2.5,
|
||||
});
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "模拟开关状态", value: "激活" }),
|
||||
expect.objectContaining({ label: "模拟设置值", value: 2.5 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the other TimescaleDB link results for a simulated valve", () => {
|
||||
const result = buildFeatureProperties(createValveFeature(), {
|
||||
flow: 10,
|
||||
pressure: 999,
|
||||
status: 1,
|
||||
setting: 2.5,
|
||||
velocity: 0.75,
|
||||
});
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "流量", value: "36.000" }),
|
||||
expect.objectContaining({ label: "流速", value: "0.750" }),
|
||||
]),
|
||||
);
|
||||
expect(result.properties).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ label: "压力" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the selected scheme control next to TimescaleDB values", () => {
|
||||
const result = buildFeatureProperties(createValveFeature(), {
|
||||
status: 1,
|
||||
setting: 0,
|
||||
scheme_status: "CLOSED",
|
||||
scheme_setting: "不适用",
|
||||
});
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "模拟开关状态", value: "开启" }),
|
||||
expect.objectContaining({ label: "方案设置状态", value: "关闭" }),
|
||||
expect.objectContaining({ label: "方案设置值", value: "不适用" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps PostgreSQL valve controls when simulation values are absent", () => {
|
||||
const onStatusSave = jest.fn(async () => undefined);
|
||||
const onSettingSave = jest.fn(async () => undefined);
|
||||
const result = buildFeatureProperties(
|
||||
createValveFeature(),
|
||||
{},
|
||||
{ value: "ACTIVE", onSave: onStatusSave },
|
||||
{
|
||||
value: "2.5",
|
||||
vType: "PRV",
|
||||
status: "ACTIVE",
|
||||
onSave: onSettingSave,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
label: "开关状态",
|
||||
value: "ACTIVE",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
label: "阀门设置值",
|
||||
value: "2.5",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.properties).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "模拟开关状态" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValvePropertySource", () => {
|
||||
it("uses business PostgreSQL until simulation data is opened", () => {
|
||||
expect(getValvePropertySource(undefined, undefined)).toBe("business");
|
||||
expect(getValvePropertySource("scheme", "")).toBe("business");
|
||||
expect(getValvePropertySource("scheme", "flush_260817_153045123")).toBe(
|
||||
"simulation",
|
||||
);
|
||||
expect(getValvePropertySource("realtime", undefined)).toBe("simulation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatValveControlSummary", () => {
|
||||
it.each(["OPEN", "CLOSED"])(
|
||||
"marks the setting as not applicable when status is %s",
|
||||
(status) => {
|
||||
expect(
|
||||
formatValveControlSummary("V1", { status, setting: 2.5 }),
|
||||
).toBe(`V1: ${status === "OPEN" ? "开启" : "关闭"} / 不适用`);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the setting for an active valve", () => {
|
||||
expect(
|
||||
formatValveControlSummary("V1", { status: "ACTIVE", setting: 2.5 }),
|
||||
).toBe("V1: 激活 / 2.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSimulationElementType", () => {
|
||||
it("treats a point-rendered valve as a hydraulic link", () => {
|
||||
expect(getSimulationElementType(createValveFeature())).toBe("link");
|
||||
});
|
||||
|
||||
it("keeps point-rendered pumps on the same hydraulic-link path", () => {
|
||||
const pump = {
|
||||
getId: () => "geo_pumps.P1",
|
||||
getProperties: () => ({
|
||||
id: "P1",
|
||||
geometry: { getType: () => "Point" },
|
||||
}),
|
||||
} as unknown as Feature;
|
||||
|
||||
expect(getSimulationElementType(pump)).toBe("link");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureProperties simulation values by hydraulic type", () => {
|
||||
it("shows link simulation results for a point-rendered pump", () => {
|
||||
const pump = createFeature("geo_pumps", "P1", {
|
||||
node1: "J1",
|
||||
node2: "J2",
|
||||
geometry: { getType: () => "Point" },
|
||||
});
|
||||
|
||||
const result = buildFeatureProperties(pump, {
|
||||
flow: 10,
|
||||
status: 1,
|
||||
velocity: 0.75,
|
||||
});
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "流量", value: "36.000" }),
|
||||
expect.objectContaining({ label: "状态", value: "1.000" }),
|
||||
expect.objectContaining({ label: "流速", value: "0.750" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["geo_tanks", "T1", "水池"],
|
||||
["geo_reservoirs", "R1", "水库"],
|
||||
])("shows node simulation results for %s", (layer, id, type) => {
|
||||
const feature = createFeature(layer, id, {
|
||||
geometry: { getType: () => "Point" },
|
||||
});
|
||||
|
||||
const result = buildFeatureProperties(feature, {
|
||||
actual_demand: 5,
|
||||
total_head: 42,
|
||||
pressure: 18,
|
||||
});
|
||||
|
||||
expect(result.type).toBe(type);
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "实际需水量", value: "18.000" }),
|
||||
expect.objectContaining({ label: "水头", value: "42.000" }),
|
||||
expect.objectContaining({ label: "压力", value: "18.000" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import Feature from "ol/Feature";
|
||||
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import {
|
||||
getValveSettingHelperText,
|
||||
VALVE_STATUS_OPTIONS,
|
||||
} from "./valveControl";
|
||||
|
||||
type ToolbarBaseProperty = {
|
||||
label: string;
|
||||
@@ -54,6 +58,7 @@ export type ValveStatusPropertyOptions = {
|
||||
value: string | null;
|
||||
loading?: boolean;
|
||||
saving?: boolean;
|
||||
disabled?: boolean;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -62,32 +67,71 @@ export type ValveSettingPropertyOptions = {
|
||||
vType: string | null;
|
||||
loading?: boolean;
|
||||
saving?: boolean;
|
||||
disabled?: boolean;
|
||||
status?: string | null;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const getValveSettingHelperText = (
|
||||
vType: string | null,
|
||||
status?: string | null,
|
||||
) => {
|
||||
if (status === "OPEN" || status === "CLOSED") {
|
||||
return "开启/关闭状态下 EPANET 会忽略阀门设置值";
|
||||
export const getValvePropertySource = (
|
||||
queryType: string | undefined,
|
||||
schemeName: string | undefined,
|
||||
): "business" | "simulation" =>
|
||||
queryType === "realtime" || (queryType === "scheme" && Boolean(schemeName))
|
||||
? "simulation"
|
||||
: "business";
|
||||
|
||||
export const getSimulationElementType = (
|
||||
feature: Feature,
|
||||
): "link" | "node" => {
|
||||
const layerId = feature.getId()?.toString().split(".")[0] ?? "";
|
||||
if (
|
||||
layerId.includes("pipe") ||
|
||||
layerId.includes("pump") ||
|
||||
layerId.includes("valve")
|
||||
) {
|
||||
return "link";
|
||||
}
|
||||
return feature.getProperties().geometry?.getType?.() === "LineString"
|
||||
? "link"
|
||||
: "node";
|
||||
};
|
||||
|
||||
const formatValveStatus = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.toUpperCase();
|
||||
const option = VALVE_STATUS_OPTIONS.find(
|
||||
(candidate) => candidate.value === normalized,
|
||||
);
|
||||
if (option) return option.label;
|
||||
}
|
||||
|
||||
switch (vType?.toUpperCase()) {
|
||||
case "PRV":
|
||||
case "PSV":
|
||||
case "PBV":
|
||||
return "压力设置值,需为 0 或正数";
|
||||
case "FCV":
|
||||
return "流量设置值,需为 0 或正数";
|
||||
case "TCV":
|
||||
return "损失系数,需为 0 或正数";
|
||||
case "GPV":
|
||||
return "水头损失曲线 ID";
|
||||
default:
|
||||
return "阀门类型相关设置值";
|
||||
const numericStatus = Number(value);
|
||||
if (Number.isFinite(numericStatus)) {
|
||||
if (numericStatus === 0) return "关闭";
|
||||
if (numericStatus === 1) return "开启";
|
||||
if (numericStatus === 2) return "激活";
|
||||
}
|
||||
|
||||
return value === null || value === undefined || value === ""
|
||||
? "未返回"
|
||||
: String(value);
|
||||
};
|
||||
|
||||
const displayValue = (value: unknown): string | number =>
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? value
|
||||
: "未设置";
|
||||
|
||||
export const formatValveControlSummary = (
|
||||
id: string,
|
||||
control: { status?: string; setting?: unknown },
|
||||
): string => {
|
||||
const normalizedStatus = control.status?.toUpperCase();
|
||||
const setting =
|
||||
normalizedStatus === "OPEN" || normalizedStatus === "CLOSED"
|
||||
? "不适用"
|
||||
: displayValue(control.setting);
|
||||
return `${id}: ${formatValveStatus(control.status)} / ${setting}`;
|
||||
};
|
||||
|
||||
const getFeatureHistoryType = (feature: Feature): string | null => {
|
||||
@@ -147,6 +191,51 @@ export const buildFeatureProperties = (
|
||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
||||
];
|
||||
|
||||
const appendLinkComputedProperties = (
|
||||
result: ToolbarPropertyPanelData,
|
||||
excludedKeys: string[] = [],
|
||||
) => {
|
||||
pipeComputedFields.forEach(({ key, label, unit }) => {
|
||||
if (excludedKeys.includes(key)) return;
|
||||
|
||||
let value = computedProperties[key];
|
||||
if (key === "flow" && value !== undefined) {
|
||||
value = toM3h(value, "lps");
|
||||
}
|
||||
if (
|
||||
key === "unit_headloss" &&
|
||||
value === undefined &&
|
||||
computedProperties.headloss !== undefined &&
|
||||
properties.length
|
||||
) {
|
||||
value = (computedProperties.headloss / properties.length) * 1000;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
result.properties?.push({
|
||||
label,
|
||||
value: typeof value === "number" ? value.toFixed(3) : value,
|
||||
unit,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const appendNodeComputedProperties = (result: ToolbarPropertyPanelData) => {
|
||||
nodeComputedFields.forEach(({ key, label, unit }) => {
|
||||
if (computedProperties[key] === undefined) return;
|
||||
|
||||
let value = computedProperties[key];
|
||||
if (key === "actual_demand") {
|
||||
value = toM3h(value, "lps");
|
||||
}
|
||||
result.properties?.push({
|
||||
label,
|
||||
value: typeof value === "number" ? value.toFixed(3) : value,
|
||||
unit,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
@@ -166,30 +255,7 @@ export const buildFeatureProperties = (
|
||||
],
|
||||
};
|
||||
|
||||
pipeComputedFields.forEach(({ key, label, unit }) => {
|
||||
let value = computedProperties[key];
|
||||
|
||||
if (key === "flow" && value !== undefined) {
|
||||
value = toM3h(value, "lps");
|
||||
}
|
||||
|
||||
if (
|
||||
key === "unit_headloss" &&
|
||||
value === undefined &&
|
||||
computedProperties.headloss !== undefined &&
|
||||
properties.length
|
||||
) {
|
||||
value = (computedProperties.headloss / properties.length) * 1000;
|
||||
}
|
||||
|
||||
if (value !== undefined) {
|
||||
result.properties?.push({
|
||||
label,
|
||||
value: typeof value === "number" ? value.toFixed(3) : value,
|
||||
unit,
|
||||
});
|
||||
}
|
||||
});
|
||||
appendLinkComputedProperties(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -230,25 +296,13 @@ export const buildFeatureProperties = (
|
||||
],
|
||||
};
|
||||
|
||||
nodeComputedFields.forEach(({ key, label, unit }) => {
|
||||
if (computedProperties[key] !== undefined) {
|
||||
let value = computedProperties[key];
|
||||
if (key === "actual_demand") {
|
||||
value = toM3h(value, "lps");
|
||||
}
|
||||
result.properties?.push({
|
||||
label,
|
||||
value: value?.toFixed?.(3) || value,
|
||||
unit,
|
||||
});
|
||||
}
|
||||
});
|
||||
appendNodeComputedProperties(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "水池",
|
||||
properties: [
|
||||
@@ -288,24 +342,28 @@ export const buildFeatureProperties = (
|
||||
},
|
||||
],
|
||||
};
|
||||
appendNodeComputedProperties(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "水库",
|
||||
properties: [
|
||||
{
|
||||
label: "水头",
|
||||
label: "基础水头",
|
||||
value: properties.head?.toFixed?.(1),
|
||||
unit: "m",
|
||||
},
|
||||
],
|
||||
};
|
||||
appendNodeComputedProperties(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "水泵",
|
||||
properties: [
|
||||
@@ -332,16 +390,22 @@ export const buildFeatureProperties = (
|
||||
},
|
||||
],
|
||||
};
|
||||
appendLinkComputedProperties(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (layer === "geo_valves_mat" || layer === "geo_valves") {
|
||||
const valveType = valveSetting?.vType ?? properties.v_type;
|
||||
const hasSimulationValues =
|
||||
Object.hasOwn(computedProperties, "status") ||
|
||||
Object.hasOwn(computedProperties, "setting");
|
||||
const isValveSettingDisabled =
|
||||
valveSetting?.loading ||
|
||||
valveSetting?.disabled ||
|
||||
valveSetting?.status === "OPEN" ||
|
||||
valveSetting?.status === "CLOSED";
|
||||
|
||||
return {
|
||||
const result: ToolbarPropertyPanelData = {
|
||||
id: properties.id,
|
||||
type: "阀门",
|
||||
properties: [
|
||||
@@ -360,25 +424,57 @@ export const buildFeatureProperties = (
|
||||
label: "局部损失",
|
||||
value: properties.minor_loss?.toFixed?.(2),
|
||||
},
|
||||
...(valveStatus
|
||||
...(hasSimulationValues
|
||||
? [
|
||||
{
|
||||
label: "模拟开关状态",
|
||||
value: formatValveStatus(computedProperties.status),
|
||||
},
|
||||
{
|
||||
label: "模拟设置值",
|
||||
value: displayValue(computedProperties.setting),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(Object.hasOwn(computedProperties, "scheme_status")
|
||||
? [
|
||||
{
|
||||
label: "方案设置状态",
|
||||
value: formatValveStatus(computedProperties.scheme_status),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(Object.hasOwn(computedProperties, "scheme_setting")
|
||||
? [
|
||||
{
|
||||
label: "方案设置值",
|
||||
value: displayValue(computedProperties.scheme_setting),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(Object.hasOwn(computedProperties, "scheme_opening")
|
||||
? [
|
||||
{
|
||||
label: "方案开度",
|
||||
value: displayValue(computedProperties.scheme_opening),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!hasSimulationValues && valveStatus
|
||||
? [
|
||||
{
|
||||
type: "select" as const,
|
||||
label: "开关状态",
|
||||
value: valveStatus.value ?? "",
|
||||
options: [
|
||||
{ label: "开启", value: "OPEN" },
|
||||
{ label: "关闭", value: "CLOSED" },
|
||||
{ label: "激活", value: "ACTIVE" },
|
||||
],
|
||||
options: VALVE_STATUS_OPTIONS,
|
||||
placeholder: valveStatus.loading ? "加载中" : "未设置",
|
||||
disabled: valveStatus.loading,
|
||||
disabled: valveStatus.loading || valveStatus.disabled,
|
||||
saving: valveStatus.saving,
|
||||
onSave: valveStatus.onSave,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(valveSetting
|
||||
...(!hasSimulationValues && valveSetting
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
@@ -397,6 +493,8 @@ export const buildFeatureProperties = (
|
||||
: []),
|
||||
],
|
||||
};
|
||||
appendLinkComputedProperties(result, ["setting", "status"]);
|
||||
return result;
|
||||
}
|
||||
|
||||
const getTransmissionFrequency = (transmissionFrequency: string) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
getValveSettingHelperText,
|
||||
isLinkStatus,
|
||||
normalizeValveSetting,
|
||||
VALVE_STATUS_OPTIONS,
|
||||
validateValveSetting,
|
||||
} from "./valveControl";
|
||||
|
||||
describe("valveControl", () => {
|
||||
it("provides the status translations shared by valve editors", () => {
|
||||
expect(VALVE_STATUS_OPTIONS).toEqual([
|
||||
{ label: "开启", value: "OPEN" },
|
||||
{ label: "关闭", value: "CLOSED" },
|
||||
{ label: "激活", value: "ACTIVE" },
|
||||
]);
|
||||
expect(isLinkStatus("ACTIVE")).toBe(true);
|
||||
expect(isLinkStatus("UNKNOWN")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes setting values without dropping zero", () => {
|
||||
expect(normalizeValveSetting(0)).toBe("0");
|
||||
expect(normalizeValveSetting(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("validates numeric and curve settings by valve type", () => {
|
||||
expect(validateValveSetting("PRV", "2.5")).toBeNull();
|
||||
expect(validateValveSetting("PRV", "-1")).toContain("大于或等于 0");
|
||||
expect(validateValveSetting("GPV", "curve-1")).toBeNull();
|
||||
expect(validateValveSetting("GPV", " ")).toContain("曲线 ID");
|
||||
});
|
||||
|
||||
it("explains that OPEN and CLOSED ignore the setting", () => {
|
||||
expect(getValveSettingHelperText("PRV", "OPEN")).toContain("忽略");
|
||||
expect(getValveSettingHelperText("FCV", "ACTIVE")).toContain("流量");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE";
|
||||
|
||||
export const VALVE_STATUS_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: LinkStatus;
|
||||
}> = [
|
||||
{ label: "开启", value: "OPEN" },
|
||||
{ label: "关闭", value: "CLOSED" },
|
||||
{ label: "激活", value: "ACTIVE" },
|
||||
];
|
||||
|
||||
export const isLinkStatus = (value: unknown): value is LinkStatus =>
|
||||
value === "OPEN" || value === "CLOSED" || value === "ACTIVE";
|
||||
|
||||
export const normalizeValveSetting = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const validateValveSetting = (
|
||||
valveType: string | null | undefined,
|
||||
value: string,
|
||||
): string | null => {
|
||||
const normalizedType = valveType?.toUpperCase();
|
||||
const trimmedValue = value.trim();
|
||||
const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]);
|
||||
|
||||
if (normalizedType === "GPV") {
|
||||
return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。";
|
||||
}
|
||||
|
||||
if (numericTypes.has(normalizedType ?? "")) {
|
||||
if (!trimmedValue) {
|
||||
return "阀门设置值必须是 0 或正数。";
|
||||
}
|
||||
|
||||
const numericValue = Number(trimmedValue);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return "阀门设置值必须是有限数字,且大于或等于 0。";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmedValue ? null : "阀门设置值不能为空。";
|
||||
};
|
||||
|
||||
export const getValveSettingHelperText = (
|
||||
valveType: string | null | undefined,
|
||||
status?: LinkStatus | string | null,
|
||||
) => {
|
||||
if (status === "OPEN" || status === "CLOSED") {
|
||||
return "开启/关闭状态下 EPANET 会忽略阀门设置值";
|
||||
}
|
||||
|
||||
switch (valveType?.toUpperCase()) {
|
||||
case "PRV":
|
||||
case "PSV":
|
||||
case "PBV":
|
||||
return "压力设置值,需为 0 或正数";
|
||||
case "FCV":
|
||||
return "流量设置值,需为 0 或正数";
|
||||
case "TCV":
|
||||
return "损失系数,需为 0 或正数";
|
||||
case "GPV":
|
||||
return "水头损失曲线 ID";
|
||||
default:
|
||||
return "阀门类型相关设置值";
|
||||
}
|
||||
};
|
||||
+13
-403
@@ -305,26 +305,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/all-redis": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* 清除所有缓存
|
||||
* @description 清空整个Redis数据库的所有缓存
|
||||
*/
|
||||
delete: operations["delete_all_redis"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/all-scada-properties": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1121,7 +1101,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* 冲洗分析(高级)
|
||||
* @description 高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。
|
||||
* @description 高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。
|
||||
*/
|
||||
post: operations["post_flushing_analyses"];
|
||||
delete?: never;
|
||||
@@ -3836,66 +3816,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/redis": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 查询缓存键列表
|
||||
* @description 获取Redis中所有的缓存键
|
||||
*/
|
||||
get: operations["get_redis"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/redis-keys": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* 清除匹配的缓存键
|
||||
* @description 根据模式清除匹配的Redis缓存键
|
||||
*/
|
||||
delete: operations["delete_redis_keys"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/redis-keys/detail": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* 清除单个缓存键
|
||||
* @description 根据键名清除单个Redis缓存
|
||||
*/
|
||||
delete: operations["delete_redis_keys_detail"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/redos": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7810,19 +7730,14 @@ export interface components {
|
||||
*/
|
||||
tank_init_level?: Record<string, never> | null;
|
||||
};
|
||||
/** PressureSensorPlacement */
|
||||
PressureSensorPlacement: {
|
||||
/** PressureSensorPlacementRest */
|
||||
PressureSensorPlacementRest: {
|
||||
/**
|
||||
* Min Diameter
|
||||
* @description 最小管径限制
|
||||
* @default 0
|
||||
*/
|
||||
min_diameter: number;
|
||||
/**
|
||||
* Name
|
||||
* @description 管网名称(或数据库名称)
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Scheme Name
|
||||
* @description 方案名称
|
||||
@@ -7833,11 +7748,6 @@ export interface components {
|
||||
* @description 传感器数量
|
||||
*/
|
||||
sensor_number: number;
|
||||
/**
|
||||
* Username
|
||||
* @description 用户名
|
||||
*/
|
||||
username: string;
|
||||
};
|
||||
/**
|
||||
* ProblemDetails
|
||||
@@ -10041,80 +9951,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_all_redis: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_all_scada_properties: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -14004,10 +13840,14 @@ export interface operations {
|
||||
query: {
|
||||
/** @description 冲洗开始时间(ISO 8601格式) */
|
||||
start_time: string;
|
||||
/** @description 要开启的阀门ID列表 */
|
||||
valves: string[];
|
||||
/** @description 对应各阀门的开度列表(0-1) */
|
||||
valves_k: number[];
|
||||
/** @description 参与控制的阀门ID列表(可选) */
|
||||
valves?: string[] | null;
|
||||
/** @description 对应各阀门的开度列表(0-1,可选,与valves同时提供) */
|
||||
valves_k?: number[] | null;
|
||||
/** @description 对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选) */
|
||||
valve_statuses?: ("OPEN" | "CLOSED" | "ACTIVE")[] | null;
|
||||
/** @description 对应各阀门的设置值列表(ACTIVE状态下必填) */
|
||||
valve_settings?: string[] | null;
|
||||
/** @description 排污节点ID */
|
||||
drainage_node_id: string;
|
||||
/** @description 冲洗流量(L/s),0表示自动计算 */
|
||||
@@ -23877,7 +23717,7 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PressureSensorPlacement"];
|
||||
"application/json": components["schemas"]["PressureSensorPlacementRest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
@@ -24040,7 +23880,7 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PressureSensorPlacement"];
|
||||
"application/json": components["schemas"]["PressureSensorPlacementRest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
@@ -27220,236 +27060,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_redis: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["JsonValue"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_redis_keys: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description 缓存键模式(支持通配符) */
|
||||
keys: string;
|
||||
};
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_redis_keys_detail: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description 缓存键名 */
|
||||
key: string;
|
||||
};
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_redos: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { getApiErrorMessage } from "./apiError";
|
||||
|
||||
describe("getApiErrorMessage", () => {
|
||||
it("uses a business detail from Problem Details", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: "ACTIVE 状态的阀门 V1 必须提供设置值",
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("ACTIVE 状态的阀门 V1 必须提供设置值");
|
||||
});
|
||||
|
||||
it("formats field-level validation errors in Chinese", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: "Request validation failed",
|
||||
errors: [
|
||||
{
|
||||
type: "missing",
|
||||
loc: ["query", "drainage_node_id"],
|
||||
msg: "Field required",
|
||||
},
|
||||
{
|
||||
type: "literal_error",
|
||||
loc: ["query", "valve_statuses", 1],
|
||||
msg: "Input should be 'OPEN', 'CLOSED' or 'ACTIVE'",
|
||||
ctx: { expected: "'OPEN', 'CLOSED' or 'ACTIVE'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"排水节点:不能为空;阀门开关状态[2]:可选值为 'OPEN', 'CLOSED' 或 'ACTIVE'",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports legacy FastAPI validation details", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: [
|
||||
{
|
||||
type: "greater_than_equal",
|
||||
loc: ["body", "sensor_count"],
|
||||
msg: "Input should be greater than or equal to 1",
|
||||
ctx: { ge: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("监测点数量:必须大于或等于 1");
|
||||
});
|
||||
|
||||
it("limits long validation responses while preserving the remaining count", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: Array.from({ length: 5 }, (_, index) => ({
|
||||
type: "missing",
|
||||
loc: ["body", `field_${index}`],
|
||||
msg: "Field required",
|
||||
})),
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"field_0:不能为空;field_1:不能为空;field_2:不能为空;另有 2 项参数错误",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds hostile validation payloads before formatting", () => {
|
||||
const message = getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: Array.from({ length: 100_000 }, () => ({
|
||||
type: "custom_error",
|
||||
loc: ["body", "x".repeat(1_000)],
|
||||
msg: "y".repeat(10_000),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(message.length).toBeLessThan(600);
|
||||
expect(message).toContain("另有 99997 项参数错误");
|
||||
});
|
||||
|
||||
it("bounds validation constraint values", () => {
|
||||
const message = getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
type: "greater_than",
|
||||
loc: ["body", "duration"],
|
||||
ctx: { gt: "9".repeat(10_000) },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(message.length).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("distinguishes network and timeout failures", () => {
|
||||
expect(getApiErrorMessage({ code: "ERR_NETWORK" })).toContain("无法连接服务");
|
||||
expect(getApiErrorMessage({ code: "ECONNABORTED" })).toContain("请求超时");
|
||||
});
|
||||
|
||||
it("uses a localized message and trace id for server failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 503,
|
||||
data: {
|
||||
detail: "仿真服务暂时不可用",
|
||||
trace_id: "trace-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("服务暂时不可用,请稍后重试(追踪 ID:trace-123)");
|
||||
});
|
||||
|
||||
it("does not expose authentication details from the response", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { detail: "Not authenticated" },
|
||||
},
|
||||
}),
|
||||
).toBe("登录状态已失效,请重新登录");
|
||||
});
|
||||
|
||||
it("preserves a clear Chinese permission detail", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 403,
|
||||
data: { detail: "当前项目角色为只读,不能创建方案" },
|
||||
},
|
||||
}),
|
||||
).toBe("当前项目角色为只读,不能创建方案");
|
||||
});
|
||||
|
||||
it("localizes plain-text server failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 500,
|
||||
data: "Internal Server Error",
|
||||
},
|
||||
}),
|
||||
).toBe("服务处理失败,请稍后重试");
|
||||
});
|
||||
|
||||
it("does not misreport server error arrays as validation failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
errors: [{ loc: ["body", "scheme_name"], msg: "failed" }],
|
||||
trace_id: "trace-500",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("服务处理失败,请稍后重试(追踪 ID:trace-500)");
|
||||
});
|
||||
|
||||
it("does not display HTML gateway responses", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 502,
|
||||
data: "<!doctype html><html><body>Bad Gateway</body></html>",
|
||||
},
|
||||
}),
|
||||
).toBe("上游服务暂时不可用,请稍后重试");
|
||||
});
|
||||
|
||||
it("uses a localized fallback for an unknown HTTP status", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 418,
|
||||
},
|
||||
message: "Request failed with status code 418",
|
||||
}),
|
||||
).toBe("请求失败(HTTP 418)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
type ErrorResponse = {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
scheme_name: "方案名称",
|
||||
start_time: "开始时间",
|
||||
modify_pattern_start_time: "开始时间",
|
||||
target_time: "目标时间",
|
||||
sampling_interval_minutes: "采样间隔",
|
||||
duration: "持续时长",
|
||||
modify_total_duration: "持续时长",
|
||||
valves: "参与阀门",
|
||||
valve_statuses: "阀门开关状态",
|
||||
valve_settings: "阀门设置值",
|
||||
valves_k: "阀门开度",
|
||||
drainage_node_id: "排水节点",
|
||||
drainage_node_ID: "排水节点",
|
||||
burst_id: "爆管点",
|
||||
burst_ID: "爆管点",
|
||||
burst_size: "爆管流量",
|
||||
burst_leakage: "爆管流量",
|
||||
min_dpressure: "最小压降",
|
||||
basic_pressure: "基准压力",
|
||||
data_source: "数据来源",
|
||||
scada_burst_start: "爆管开始时间",
|
||||
scada_burst_end: "爆管结束时间",
|
||||
use_scada_flow: "使用流量监测数据",
|
||||
simulation_scheme_name: "模拟方案名称",
|
||||
simulation_scheme_type: "模拟方案类型",
|
||||
source: "污染源节点",
|
||||
concentration: "污染物浓度",
|
||||
pattern: "污染物注入模式",
|
||||
sensor_count: "监测点数量",
|
||||
sensor_type: "监测点类型",
|
||||
method: "优化方法",
|
||||
min_diameter: "最小管径",
|
||||
dma_count: "DMA 数量",
|
||||
scada_start: "监测开始时间",
|
||||
scada_end: "监测结束时间",
|
||||
q_sum: "总漏损流量",
|
||||
pop_size: "种群规模",
|
||||
max_gen: "最大迭代次数",
|
||||
flush_flow: "冲洗流量",
|
||||
};
|
||||
|
||||
const STATUS_MESSAGES: Record<number, string> = {
|
||||
400: "请求参数不正确",
|
||||
401: "登录状态已失效,请重新登录",
|
||||
403: "当前账号无权执行此操作",
|
||||
404: "请求的数据不存在",
|
||||
409: "当前数据已发生变化,请刷新后重试",
|
||||
422: "请求参数校验失败",
|
||||
429: "请求过于频繁,请稍后重试",
|
||||
500: "服务处理失败,请稍后重试",
|
||||
502: "上游服务暂时不可用,请稍后重试",
|
||||
503: "服务暂时不可用,请稍后重试",
|
||||
504: "服务响应超时,请稍后重试",
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): UnknownRecord | null =>
|
||||
value !== null && typeof value === "object"
|
||||
? (value as UnknownRecord)
|
||||
: null;
|
||||
|
||||
const nonEmptyString = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
|
||||
const safeServerMessage = (value: unknown): string | null => {
|
||||
const message = nonEmptyString(value);
|
||||
if (!message || message.length > 300) return null;
|
||||
if (/<!doctype|<html|<body|<script|<style|<[^>]+>/i.test(message)) {
|
||||
return null;
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
const boundedText = (value: unknown, maxLength: number): string | null => {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) return null;
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
};
|
||||
|
||||
const boundedConstraint = (value: unknown, fallback: string) =>
|
||||
value === null || value === undefined
|
||||
? fallback
|
||||
: (boundedText(String(value), 40) ?? fallback);
|
||||
|
||||
const getErrorResponse = (error: unknown): ErrorResponse | null => {
|
||||
const candidate = asRecord(error);
|
||||
const response = asRecord(candidate?.response);
|
||||
if (!response) return null;
|
||||
|
||||
return {
|
||||
status:
|
||||
typeof response.status === "number" ? response.status : undefined,
|
||||
data: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
const formatValidationLocation = (location: unknown): string => {
|
||||
if (!Array.isArray(location)) return "请求参数";
|
||||
|
||||
const segments = location.slice(0, 5).filter(
|
||||
(segment, index) =>
|
||||
!(
|
||||
index === 0 &&
|
||||
["body", "query", "path", "header"].includes(String(segment))
|
||||
),
|
||||
);
|
||||
if (segments.length === 0) return "请求参数";
|
||||
|
||||
return segments.reduce<string>((result, segment, index) => {
|
||||
if (typeof segment === "number") {
|
||||
return `${result}[${segment + 1}]`;
|
||||
}
|
||||
const rawSegment = String(segment);
|
||||
const label = FIELD_LABELS[rawSegment] ?? boundedText(rawSegment, 40) ?? "参数";
|
||||
return index === 0 ? label : `${result}.${label}`;
|
||||
}, "");
|
||||
};
|
||||
|
||||
const translateValidationMessage = (error: UnknownRecord): string => {
|
||||
const type = nonEmptyString(error.type);
|
||||
const message = nonEmptyString(error.msg);
|
||||
const context = asRecord(error.ctx);
|
||||
|
||||
switch (type) {
|
||||
case "missing":
|
||||
return "不能为空";
|
||||
case "literal_error": {
|
||||
const expected = boundedText(context?.expected, 100);
|
||||
return expected
|
||||
? `可选值为 ${expected.replace(/\s+or\s+/g, " 或 ")}`
|
||||
: "取值不在允许范围内";
|
||||
}
|
||||
case "greater_than":
|
||||
return `必须大于 ${boundedConstraint(context?.gt, "限定值")}`;
|
||||
case "greater_than_equal":
|
||||
return `必须大于或等于 ${boundedConstraint(context?.ge, "限定值")}`;
|
||||
case "less_than":
|
||||
return `必须小于 ${boundedConstraint(context?.lt, "限定值")}`;
|
||||
case "less_than_equal":
|
||||
return `必须小于或等于 ${boundedConstraint(context?.le, "限定值")}`;
|
||||
case "int_parsing":
|
||||
return "必须是整数";
|
||||
case "float_parsing":
|
||||
case "decimal_parsing":
|
||||
return "必须是数字";
|
||||
case "datetime_from_date_parsing":
|
||||
case "datetime_parsing":
|
||||
return "日期时间格式不正确";
|
||||
case "string_too_short":
|
||||
return `长度不能少于 ${boundedConstraint(context?.min_length, "要求的")} 个字符`;
|
||||
case "string_too_long":
|
||||
return `长度不能超过 ${boundedConstraint(context?.max_length, "允许的")} 个字符`;
|
||||
default:
|
||||
if (message === "Field required") return "不能为空";
|
||||
if (message?.startsWith("Input should be ")) {
|
||||
return boundedText(
|
||||
message.replace("Input should be ", "可选值为 "),
|
||||
120,
|
||||
)!;
|
||||
}
|
||||
return boundedText(message, 120) ?? "参数无效";
|
||||
}
|
||||
};
|
||||
|
||||
const formatValidationErrors = (value: unknown): string | null => {
|
||||
if (!Array.isArray(value) || value.length === 0) return null;
|
||||
|
||||
const messages = value
|
||||
.slice(0, 3)
|
||||
.map(asRecord)
|
||||
.filter((item): item is UnknownRecord => item !== null)
|
||||
.map(
|
||||
(item) =>
|
||||
`${formatValidationLocation(item.loc)}:${translateValidationMessage(item)}`,
|
||||
);
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
const visibleMessages = messages.join(";");
|
||||
const remainingCount = value.length - messages.length;
|
||||
return remainingCount > 0
|
||||
? `${visibleMessages};另有 ${remainingCount} 项参数错误`
|
||||
: visibleMessages;
|
||||
};
|
||||
|
||||
const appendTraceId = (message: string, traceId: string | null) =>
|
||||
traceId ? `${message}(追踪 ID:${traceId})` : message;
|
||||
|
||||
export const getApiErrorMessage = (
|
||||
error: unknown,
|
||||
fallback = "请求失败,请稍后重试",
|
||||
): string => {
|
||||
const candidate = asRecord(error);
|
||||
const response = getErrorResponse(error);
|
||||
const payload = asRecord(response?.data);
|
||||
const status = response?.status;
|
||||
const traceId = safeServerMessage(payload?.trace_id);
|
||||
const isValidationResponse =
|
||||
status === 400 ||
|
||||
status === 422 ||
|
||||
nonEmptyString(payload?.code) === "validation_error";
|
||||
const validationErrors = isValidationResponse
|
||||
? formatValidationErrors(
|
||||
payload?.errors ??
|
||||
(Array.isArray(payload?.detail) ? payload.detail : null),
|
||||
)
|
||||
: null;
|
||||
|
||||
if (validationErrors) {
|
||||
return validationErrors;
|
||||
}
|
||||
|
||||
const responseText = safeServerMessage(response?.data);
|
||||
const detail = safeServerMessage(payload?.detail);
|
||||
const responseMessage = safeServerMessage(payload?.message);
|
||||
const statusMessage = status ? STATUS_MESSAGES[status] : null;
|
||||
const unknownStatusMessage = status ? `请求失败(HTTP ${status})` : null;
|
||||
|
||||
if (status === 403) {
|
||||
const permissionDetail = detail ?? responseMessage;
|
||||
return permissionDetail && /[\u3400-\u9fff]/u.test(permissionDetail)
|
||||
? permissionDetail
|
||||
: STATUS_MESSAGES[403];
|
||||
}
|
||||
|
||||
if (status && (status === 401 || status >= 500)) {
|
||||
return appendTraceId(
|
||||
statusMessage ?? unknownStatusMessage ?? fallback,
|
||||
status >= 500 ? traceId : null,
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
detail ?? responseMessage ?? responseText ?? statusMessage ?? unknownStatusMessage;
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const code = nonEmptyString(candidate?.code);
|
||||
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
|
||||
return "请求超时,请稍后重试";
|
||||
}
|
||||
if (
|
||||
code === "ERR_NETWORK" ||
|
||||
(candidate?.isAxiosError === true && !response)
|
||||
) {
|
||||
return "无法连接服务,请检查网络连接或服务状态";
|
||||
}
|
||||
|
||||
return nonEmptyString(candidate?.message) ?? fallback;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
createSchemeName,
|
||||
isSchemeNameValid,
|
||||
normalizeSchemeName,
|
||||
SCHEME_NAME_PREFIXES,
|
||||
} from "./schemeName";
|
||||
|
||||
describe("createSchemeName", () => {
|
||||
it("combines the scheme type with a readable local timestamp", () => {
|
||||
const date = new Date(2026, 7, 17, 15, 30, 45, 123);
|
||||
|
||||
expect(createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, date)).toBe(
|
||||
"flush_260817_153045123",
|
||||
);
|
||||
});
|
||||
|
||||
it("pads single-digit date and time values", () => {
|
||||
const date = new Date(2026, 0, 2, 3, 4, 5, 6);
|
||||
|
||||
expect(createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis, date)).toBe(
|
||||
"burst_sim_260102_030405006",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps names distinct within the same second", () => {
|
||||
const first = new Date(2026, 7, 17, 15, 30, 45, 123);
|
||||
const second = new Date(2026, 7, 17, 15, 30, 45, 124);
|
||||
|
||||
expect(createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, first)).not.toBe(
|
||||
createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, second),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes and validates manually entered names", () => {
|
||||
expect(normalizeSchemeName(" cafe\u0301 ")).toBe("café");
|
||||
expect(normalizeSchemeName("方案\u200bA\u2060")).toBe("方案A");
|
||||
expect(isSchemeNameValid("x".repeat(32))).toBe(true);
|
||||
expect(isSchemeNameValid("x".repeat(33))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export const SCHEME_NAME_PREFIXES = {
|
||||
dmaLeakIdentification: "dma_leak",
|
||||
flushingAnalysis: "flush",
|
||||
burstDetection: "burst_detect",
|
||||
burstAnalysis: "burst_sim",
|
||||
burstLocation: "burst_locate",
|
||||
contaminantAnalysis: "water_quality",
|
||||
sensorPlacement: "sensor_place",
|
||||
} as const;
|
||||
|
||||
export type SchemeNamePrefix =
|
||||
(typeof SCHEME_NAME_PREFIXES)[keyof typeof SCHEME_NAME_PREFIXES];
|
||||
|
||||
export const SCHEME_NAME_MAX_LENGTH = 32;
|
||||
|
||||
export const normalizeSchemeName = (value: string) =>
|
||||
value
|
||||
.normalize("NFC")
|
||||
.replace(/\p{Default_Ignorable_Code_Point}/gu, "")
|
||||
.trim();
|
||||
|
||||
export const isSchemeNameValid = (value: string) => {
|
||||
const normalized = normalizeSchemeName(value);
|
||||
return normalized.length > 0 && normalized.length <= SCHEME_NAME_MAX_LENGTH;
|
||||
};
|
||||
|
||||
const padNumber = (value: number, length = 2) =>
|
||||
String(value).padStart(length, "0");
|
||||
|
||||
export const createSchemeName = (
|
||||
prefix: SchemeNamePrefix,
|
||||
date = new Date(),
|
||||
) => {
|
||||
const timestamp = [
|
||||
padNumber(date.getFullYear() % 100),
|
||||
padNumber(date.getMonth() + 1),
|
||||
padNumber(date.getDate()),
|
||||
].join("");
|
||||
const time = [
|
||||
padNumber(date.getHours()),
|
||||
padNumber(date.getMinutes()),
|
||||
padNumber(date.getSeconds()),
|
||||
padNumber(date.getMilliseconds(), 3),
|
||||
].join("");
|
||||
|
||||
return `${prefix}_${timestamp}_${time}`;
|
||||
};
|
||||
Reference in New Issue
Block a user