fix(api): align frontend with REST contracts
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"contract_version": "1.0.0",
|
||||||
|
"contracts": {
|
||||||
|
"agent": {
|
||||||
|
"file": "agent-v1.openapi.json",
|
||||||
|
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"file": "server-v1.openapi.json",
|
||||||
|
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+242
-16
@@ -35,6 +35,7 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
@@ -5731,6 +5733,75 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@redocly/ajv": {
|
||||||
|
"version": "8.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||||
|
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2",
|
||||||
|
"uri-js-replace": "^1.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/config": {
|
||||||
|
"version": "0.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||||
|
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core": {
|
||||||
|
"version": "1.34.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.18.tgz",
|
||||||
|
"integrity": "sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/ajv": "8.11.2",
|
||||||
|
"@redocly/config": "0.22.0",
|
||||||
|
"colorette": "1.4.0",
|
||||||
|
"https-proxy-agent": "7.0.6",
|
||||||
|
"js-levenshtein": "1.1.6",
|
||||||
|
"js-yaml": "4.3.0",
|
||||||
|
"minimatch": "5.1.9",
|
||||||
|
"pluralize": "8.0.0",
|
||||||
|
"yaml-ast-parser": "0.0.43"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0",
|
||||||
|
"npm": ">=9.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||||
|
"version": "5.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@refinedev/core": {
|
"node_modules/@refinedev/core": {
|
||||||
"version": "5.0.12",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz",
|
||||||
@@ -10466,6 +10537,16 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-colors": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-escapes": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||||
@@ -11260,6 +11341,13 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/change-case": {
|
||||||
|
"version": "5.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||||
|
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/char-regex": {
|
"node_modules/char-regex": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
|
||||||
@@ -11430,6 +11518,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -14363,6 +14458,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/index-to-position": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@@ -16106,6 +16214,16 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-levenshtein": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -16113,10 +16231,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -16230,6 +16358,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -18369,6 +18504,73 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openapi-fetch": {
|
||||||
|
"version": "0.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
|
||||||
|
"integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"openapi-typescript-helpers": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript": {
|
||||||
|
"version": "7.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||||
|
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@redocly/openapi-core": "^1.34.6",
|
||||||
|
"ansi-colors": "^4.1.3",
|
||||||
|
"change-case": "^5.4.4",
|
||||||
|
"parse-json": "^8.3.0",
|
||||||
|
"supports-color": "^10.2.2",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openapi-typescript": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript-helpers": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript/node_modules/parse-json": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.26.2",
|
||||||
|
"index-to-position": "^1.1.0",
|
||||||
|
"type-fest": "^4.39.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openid-client": {
|
"node_modules/openid-client": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
@@ -19355,6 +19557,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reselect": {
|
"node_modules/reselect": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
@@ -20712,19 +20924,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-jest/node_modules/type-fest": {
|
|
||||||
"version": "4.41.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
|
||||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
@@ -20780,6 +20979,19 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typed-array-buffer": {
|
"node_modules/typed-array-buffer": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||||
@@ -21162,6 +21374,13 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uri-js-replace": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
@@ -21579,6 +21798,13 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml-ast-parser": {
|
||||||
|
"version": "0.0.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||||
|
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
|
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
|
||||||
|
"api:check": "node scripts/check-api-contracts.mjs",
|
||||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -75,6 +78,7 @@
|
|||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">TJWater</title>
|
||||||
|
<rect width="72" height="72" rx="18" fill="#1478d4"/>
|
||||||
|
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
|
||||||
|
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
|
||||||
|
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||||
|
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<rect width="1440" height="900" fill="#edf5f5"/>
|
||||||
|
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
|
||||||
|
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
|
||||||
|
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
|
||||||
|
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
|
||||||
|
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
|
||||||
|
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
|
||||||
|
<circle cx="178" cy="608" r="10"/><circle cx="203" cy="386" r="9"/>
|
||||||
|
<circle cx="354" cy="294" r="9"/><circle cx="475" cy="469" r="11"/>
|
||||||
|
<circle cx="659" cy="536" r="10"/><circle cx="747" cy="652" r="9"/>
|
||||||
|
<circle cx="320" cy="659" r="8"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#0b8f82">
|
||||||
|
<circle cx="111" cy="291" r="6"/><circle cx="166" cy="130" r="6"/>
|
||||||
|
<circle cx="446" cy="153" r="7"/><circle cx="598" cy="211" r="6"/>
|
||||||
|
<circle cx="696" cy="105" r="6"/><circle cx="316" cy="791" r="6"/>
|
||||||
|
<circle cx="487" cy="872" r="6"/><circle cx="835" cy="331" r="7"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
|
||||||
|
<circle cx="615" cy="448" r="222"/><circle cx="615" cy="448" r="276"/>
|
||||||
|
<circle cx="615" cy="448" r="334"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#1478d4" opacity=".08">
|
||||||
|
<rect x="40" y="40" width="118" height="10" rx="5"/>
|
||||||
|
<rect x="40" y="62" width="72" height="6" rx="3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(new URL("../contracts/manifest.json", import.meta.url), "utf8"),
|
||||||
|
);
|
||||||
|
const run = promisify(execFile);
|
||||||
|
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
const generator = join(
|
||||||
|
projectRoot,
|
||||||
|
"node_modules",
|
||||||
|
"openapi-typescript",
|
||||||
|
"bin",
|
||||||
|
"cli.js",
|
||||||
|
);
|
||||||
|
const temporaryDirectory = await mkdtemp(
|
||||||
|
join(tmpdir(), "tjwater-api-contracts-"),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [name, contract] of Object.entries(manifest.contracts)) {
|
||||||
|
const contractPath = fileURLToPath(
|
||||||
|
new URL(`../contracts/${contract.file}`, import.meta.url),
|
||||||
|
);
|
||||||
|
const payload = await readFile(contractPath);
|
||||||
|
const actual = createHash("sha256").update(payload).digest("hex");
|
||||||
|
if (actual !== contract.sha256) {
|
||||||
|
throw new Error(
|
||||||
|
`${name} contract hash mismatch: expected ${contract.sha256}, got ${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedName = `${name}Api.ts`;
|
||||||
|
const temporaryOutput = join(temporaryDirectory, generatedName);
|
||||||
|
await run(process.execPath, [generator, contractPath, "-o", temporaryOutput]);
|
||||||
|
const expectedOutput = await readFile(
|
||||||
|
new URL(`../src/generated/${generatedName}`, import.meta.url),
|
||||||
|
);
|
||||||
|
const generatedOutput = await readFile(temporaryOutput);
|
||||||
|
if (!expectedOutput.equals(generatedOutput)) {
|
||||||
|
throw new Error(
|
||||||
|
`${basename(contract.file)} generated type is stale: run \`npm run api:generate\``,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`validated API contract mirrors and generated types for ${manifest.contract_version}`,
|
||||||
|
);
|
||||||
@@ -78,7 +78,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setAccessLoading(true);
|
setAccessLoading(true);
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, {
|
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
})
|
})
|
||||||
@@ -110,7 +110,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const auditKey = `tjwater-login-audit:${data.user.id}`;
|
const auditKey = `tjwater-login-audit:${data.user.id}`;
|
||||||
if (sessionStorage.getItem(auditKey)) return;
|
if (sessionStorage.getItem(auditKey)) return;
|
||||||
|
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
|
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ event: "login" }),
|
body: JSON.stringify({ event: "login" }),
|
||||||
@@ -137,7 +137,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
|
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ event: "logout" }),
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ export default async function RootLayout({
|
|||||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body>
|
<body>
|
||||||
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
<Script src="/runtime-config.js" strategy="beforeInteractive" />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import Login from "./page";
|
||||||
|
|
||||||
|
const mockLogin = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useLogin: () => ({ mutate: mockLogin }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Login", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockLogin.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts Keycloak login once when the page opens", () => {
|
||||||
|
const { rerender } = render(<Login />);
|
||||||
|
|
||||||
|
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogin).toHaveBeenCalledWith({});
|
||||||
|
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<Login />);
|
||||||
|
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a manual fallback when automatic redirect does not complete", () => {
|
||||||
|
render(<Login />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "继续登录" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
|
||||||
|
expect(mockLogin).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+239
-34
@@ -1,54 +1,259 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Container from "@mui/material/Container";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useLogin } from "@refinedev/core";
|
import { useLogin } from "@refinedev/core";
|
||||||
import { Title } from "@components/title";
|
import { PROJECT_TITLE } from "@config/config";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const FALLBACK_DELAY_MS = 4000;
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { mutate: login } = useLogin();
|
const { mutate: login } = useLogin();
|
||||||
|
const loginStartedRef = useRef(false);
|
||||||
|
const [showFallback, setShowFallback] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loginStartedRef.current) {
|
||||||
|
loginStartedRef.current = true;
|
||||||
|
login({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTimer = window.setTimeout(
|
||||||
|
() => setShowFallback(true),
|
||||||
|
FALLBACK_DELAY_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(fallbackTimer);
|
||||||
|
}, [login]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Box
|
||||||
style={{
|
component="main"
|
||||||
height: "100vh",
|
sx={{
|
||||||
display: "flex",
|
minHeight: "100vh",
|
||||||
justifyContent: "center",
|
"@supports (height: 100svh)": {
|
||||||
alignItems: "center",
|
minHeight: "100svh",
|
||||||
|
},
|
||||||
|
color: "oklch(0.3 0.055 215)",
|
||||||
|
bgcolor: "oklch(0.965 0.014 205)",
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 50%,
|
||||||
|
oklch(0.975 0.01 205 / 62%) 68%,
|
||||||
|
oklch(0.975 0.01 205 / 82%) 100%
|
||||||
|
),
|
||||||
|
url("/login-network-blueprint.svg")
|
||||||
|
`,
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
"@keyframes tjwaterEnter": {
|
||||||
|
from: { opacity: 0, transform: "translateY(12px)" },
|
||||||
|
to: { opacity: 1, transform: "translateY(0)" },
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
sx={{
|
||||||
gap="36px"
|
width: "100%",
|
||||||
justifyContent="center"
|
maxWidth: 1720,
|
||||||
flexDirection="column"
|
minHeight: "100vh",
|
||||||
|
"@supports (height: 100svh)": {
|
||||||
|
minHeight: "100svh",
|
||||||
|
},
|
||||||
|
mx: "auto",
|
||||||
|
px: {
|
||||||
|
xs: 1.75,
|
||||||
|
sm: 3,
|
||||||
|
md: "clamp(40px, 5vw, 88px)",
|
||||||
|
},
|
||||||
|
py: {
|
||||||
|
xs: "max(28px, env(safe-area-inset-top))",
|
||||||
|
md: "clamp(32px, 4.5vw, 76px)",
|
||||||
|
},
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "minmax(0, 1fr)",
|
||||||
|
md: "minmax(360px, 1fr) minmax(400px, 460px)",
|
||||||
|
},
|
||||||
|
gridTemplateAreas: {
|
||||||
|
xs: '"brand" "status"',
|
||||||
|
md: '"brand status"',
|
||||||
|
},
|
||||||
|
alignContent: { xs: "center", md: "stretch" },
|
||||||
|
alignItems: "center",
|
||||||
|
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box display="flex" justifyContent="center">
|
<Stack
|
||||||
<Title collapsed={false} />
|
spacing={{ xs: 0, md: 3 }}
|
||||||
</Box>
|
direction={{ xs: "row", md: "column" }}
|
||||||
<Button
|
alignItems={{ xs: "center", md: "flex-start" }}
|
||||||
style={{ width: "240px" }}
|
sx={{
|
||||||
size="large"
|
position: "relative",
|
||||||
variant="contained"
|
isolation: "isolate",
|
||||||
onClick={() => login({})}
|
gridArea: "brand",
|
||||||
|
width: "fit-content",
|
||||||
|
maxWidth: "100%",
|
||||||
|
animation:
|
||||||
|
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||||
|
"&::before": {
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: -1,
|
||||||
|
inset: { xs: "-24px -20px", md: "-54px -72px" },
|
||||||
|
background: {
|
||||||
|
xs: `radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
oklch(0.965 0.014 205 / 98%) 0%,
|
||||||
|
oklch(0.965 0.014 205 / 88%) 58%,
|
||||||
|
transparent 84%
|
||||||
|
)`,
|
||||||
|
md: `radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
oklch(0.925 0.018 205 / 98%) 0%,
|
||||||
|
oklch(0.925 0.018 205 / 94%) 48%,
|
||||||
|
oklch(0.925 0.018 205 / 62%) 65%,
|
||||||
|
transparent 82%
|
||||||
|
)`,
|
||||||
|
},
|
||||||
|
pointerEvents: "none",
|
||||||
|
content: '""',
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Sign in
|
<Box
|
||||||
</Button>
|
component="img"
|
||||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
src="/login-logo-mark.svg"
|
||||||
Powered by
|
alt=""
|
||||||
<Image
|
width={56}
|
||||||
style={{ padding: "0 5px" }}
|
height={56}
|
||||||
alt="Keycloak"
|
sx={{
|
||||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
width: { xs: 42, md: 56 },
|
||||||
width={18}
|
height: { xs: 42, md: 56 },
|
||||||
height={18}
|
flex: { xs: "0 0 42px", md: "0 0 auto" },
|
||||||
|
mr: { xs: 1.5, md: 0 },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
Keycloak
|
<Typography
|
||||||
</Typography>
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
maxWidth: 680,
|
||||||
|
m: 0,
|
||||||
|
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
|
||||||
|
fontWeight: 720,
|
||||||
|
lineHeight: { xs: 1.4, md: 1.28 },
|
||||||
|
textWrap: "balance",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PROJECT_TITLE}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
display: { xs: "none", md: "block" },
|
||||||
|
width: 64,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: "oklch(0.58 0.12 180)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
aria-live="polite"
|
||||||
|
sx={{
|
||||||
|
gridArea: "status",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 460,
|
||||||
|
justifySelf: "stretch",
|
||||||
|
p: { xs: 2.75, sm: 4.5 },
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: { xs: 4, sm: 4.5 },
|
||||||
|
bgcolor: "oklch(0.995 0.004 205 / 97%)",
|
||||||
|
boxShadow:
|
||||||
|
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
|
||||||
|
animation:
|
||||||
|
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<CircularProgress
|
||||||
|
size={30}
|
||||||
|
thickness={4.5}
|
||||||
|
aria-label="正在连接统一身份认证"
|
||||||
|
sx={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: "oklch(0.57 0.16 242)",
|
||||||
|
"@media (prefers-reduced-motion: reduce)": {
|
||||||
|
animation: "none",
|
||||||
|
"& .MuiCircularProgress-circle": {
|
||||||
|
animation: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography
|
||||||
|
component="h2"
|
||||||
|
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
|
||||||
|
>
|
||||||
|
正在进入账号登录
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "oklch(0.52 0.035 215)",
|
||||||
|
fontSize: { xs: 14, sm: 15 },
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
正在连接统一身份认证服务,请稍候。
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{showFallback ? (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => login({})}
|
||||||
|
sx={{
|
||||||
|
minHeight: 48,
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow:
|
||||||
|
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
|
||||||
|
transitionProperty:
|
||||||
|
"transform, background-color, box-shadow",
|
||||||
|
"&:active": { transform: "scale(0.96)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
继续登录
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "oklch(0.52 0.035 215)",
|
||||||
|
fontSize: { xs: 12, sm: 13 },
|
||||||
|
lineHeight: 1.7,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
账号与访问权限由统一身份认证服务管理
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ export const SystemAdminPanel = () => {
|
|||||||
|
|
||||||
const loadInitialState = async () => {
|
const loadInitialState = async () => {
|
||||||
try {
|
try {
|
||||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
||||||
projectHeaderMode: "omit",
|
projectHeaderMode: "omit",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
@@ -947,7 +947,7 @@ export const SystemAdminPanel = () => {
|
|||||||
const form = databaseForms[role];
|
const form = databaseForms[role];
|
||||||
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
|
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`,
|
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health-checks`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const defaultFilters: AuditFilters = {
|
|||||||
end_time: "",
|
end_time: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUDIT_LOGS_PATH = "/api/v1/audit/logs";
|
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
|
||||||
|
|
||||||
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
||||||
{ value: "all", label: "全部状态" },
|
{ value: "all", label: "全部状态" },
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
|
|||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
});
|
});
|
||||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
|
|||||||
isTitleManuallyEdited: true,
|
isTitleManuallyEdited: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||||
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||||
title: "新标题",
|
title: "新标题",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
|
|||||||
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||||
|
|
||||||
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
|||||||
|
|
||||||
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
|
|||||||
isTitleManuallyEdited?: boolean,
|
isTitleManuallyEdited?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
|
|||||||
|
|
||||||
const deleteBackendChatSession = async (sessionId: string) => {
|
const deleteBackendChatSession = async (sessionId: string) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ describe("burst detection request", () => {
|
|||||||
const state = createBurstDetectionAnalysisParametersState();
|
const state = createBurstDetectionAnalysisParametersState();
|
||||||
state.schemeName = " latest-case ";
|
state.schemeName = " latest-case ";
|
||||||
|
|
||||||
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
|
expect(buildBurstDetectionRequest(state)).toEqual({
|
||||||
network: "fengyang",
|
|
||||||
scheme_name: "latest-case",
|
scheme_name: "latest-case",
|
||||||
sampling_interval_minutes: 15,
|
sampling_interval_minutes: 15,
|
||||||
});
|
});
|
||||||
@@ -33,10 +32,8 @@ describe("burst detection request", () => {
|
|||||||
samplingIntervalMinutes: 30,
|
samplingIntervalMinutes: 30,
|
||||||
samplingIntervalSource: "manual",
|
samplingIntervalSource: "manual",
|
||||||
},
|
},
|
||||||
"fengyang",
|
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
network: "fengyang",
|
|
||||||
scheme_name: "history-case",
|
scheme_name: "history-case",
|
||||||
sampling_interval_minutes: 30,
|
sampling_interval_minutes: 30,
|
||||||
target_time: targetTime.toISOString(),
|
target_time: targetTime.toISOString(),
|
||||||
|
|||||||
@@ -92,9 +92,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
|||||||
|
|
||||||
export const buildBurstDetectionRequest = (
|
export const buildBurstDetectionRequest = (
|
||||||
parameters: BurstDetectionAnalysisParametersState,
|
parameters: BurstDetectionAnalysisParametersState,
|
||||||
network: string,
|
|
||||||
) => ({
|
) => ({
|
||||||
network,
|
|
||||||
scheme_name: parameters.schemeName.trim(),
|
scheme_name: parameters.schemeName.trim(),
|
||||||
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
||||||
...(parameters.detectionMode === "historical" && parameters.targetTime
|
...(parameters.detectionMode === "historical" && parameters.targetTime
|
||||||
@@ -129,7 +127,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
let active = true;
|
let active = true;
|
||||||
setFrequencyLoading(true);
|
setFrequencyLoading(true);
|
||||||
api
|
api
|
||||||
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
|
.get("/api/v1/scada-info")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const interval = resolvePressureSamplingInterval(
|
const interval = resolvePressureSamplingInterval(
|
||||||
@@ -186,8 +184,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
"/api/v1/burst-detection/detect/",
|
"/api/v1/burst-detections",
|
||||||
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
|
buildBurstDetectionRequest(parametersState),
|
||||||
);
|
);
|
||||||
onResult(response.data as BurstDetectionResult);
|
onResult(response.data as BurstDetectionResult);
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
network: NETWORK_NAME,
|
|
||||||
scheme_type: "burst_detection",
|
scheme_type: "burst_detection",
|
||||||
};
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
@@ -162,7 +161,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||||
{ params: { network: NETWORK_NAME, scheme_type: "burst_detection" } },
|
{ params: { scheme_type: "burst_detection" } },
|
||||||
);
|
);
|
||||||
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
||||||
result_payload?: BurstDetectionResult;
|
result_payload?: BurstDetectionResult;
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
setSchemeLoading(true);
|
setSchemeLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
params: { network: NETWORK_NAME, scheme_type: "burst_analysis" },
|
params: { scheme_type: "burst_analysis" },
|
||||||
});
|
});
|
||||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||||
@@ -222,9 +222,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
|
`${config.BACKEND_URL}/api/v1/burst-locations`,
|
||||||
{
|
{
|
||||||
network: NETWORK_NAME,
|
|
||||||
data_source: dataSource,
|
data_source: dataSource,
|
||||||
scheme_name: schemeName.trim() || undefined,
|
scheme_name: schemeName.trim() || undefined,
|
||||||
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
||||||
|
|||||||
@@ -228,7 +228,6 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
network: NETWORK_NAME,
|
|
||||||
scheme_type: "burst_location",
|
scheme_type: "burst_location",
|
||||||
};
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
@@ -238,7 +237,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const [response, simulationResponse] = await Promise.all([
|
const [response, simulationResponse] = await Promise.all([
|
||||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
|
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
|
||||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||||
params: { network: NETWORK_NAME, scheme_type: "burst_analysis" },
|
params: { scheme_type: "burst_analysis" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const nextSchemes = response.data as BurstSchemeRecord[];
|
const nextSchemes = response.data as BurstSchemeRecord[];
|
||||||
@@ -281,7 +280,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||||
{ params: { network: NETWORK_NAME, scheme_type: "burst_location" } },
|
{ params: { scheme_type: "burst_location" } },
|
||||||
);
|
);
|
||||||
const schemeRecord = response.data as BurstSchemeRecord & {
|
const schemeRecord = response.data as BurstSchemeRecord & {
|
||||||
result_payload?: BurstLocationResult;
|
result_payload?: BurstLocationResult;
|
||||||
|
|||||||
@@ -322,16 +322,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
: "";
|
: "";
|
||||||
const modify_total_duration = duration;
|
const modify_total_duration = duration;
|
||||||
const params = {
|
const params = {
|
||||||
network: network,
|
|
||||||
modify_pattern_start_time: modify_pattern_start_time,
|
modify_pattern_start_time: modify_pattern_start_time,
|
||||||
burst_ID: burst_ID,
|
burst_id: burst_ID,
|
||||||
burst_size: burst_size,
|
burst_size: burst_size,
|
||||||
modify_total_duration: modify_total_duration,
|
modify_total_duration: modify_total_duration,
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
|
await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ import {
|
|||||||
PrintOutlined,
|
PrintOutlined,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
|
||||||
import {
|
import {
|
||||||
|
clearPipeDiameterCache,
|
||||||
|
getCachedPipeDiameters,
|
||||||
getPipeDiameterDisplay,
|
getPipeDiameterDisplay,
|
||||||
|
getPipeDiameterQueryKey,
|
||||||
|
loadPipeDiameters,
|
||||||
type PipeDiameterMap,
|
type PipeDiameterMap,
|
||||||
} from "./schemePipeDiameters";
|
} from "./schemePipeDiameters";
|
||||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
@@ -54,66 +57,7 @@ const REPORT_BLUE = "#0b4f87";
|
|||||||
const REPORT_INK = "#172435";
|
const REPORT_INK = "#172435";
|
||||||
const REPORT_MUTED = "#5f6f80";
|
const REPORT_MUTED = "#5f6f80";
|
||||||
const REPORT_LINE = "#d8e0e8";
|
const REPORT_LINE = "#d8e0e8";
|
||||||
const diameterCache = new Map<string, PipeDiameterMap>();
|
export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
|
||||||
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
|
|
||||||
|
|
||||||
export const clearAnalysisReportDiameterCache = () => {
|
|
||||||
diameterCache.clear();
|
|
||||||
diameterRequestCache.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPipeDiameters = async (
|
|
||||||
pipeIds: string[],
|
|
||||||
queryKey: string,
|
|
||||||
): Promise<PipeDiameterMap> => {
|
|
||||||
const cachedDiameters = diameterCache.get(queryKey);
|
|
||||||
if (cachedDiameters) {
|
|
||||||
return cachedDiameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedRequest = diameterRequestCache.get(queryKey);
|
|
||||||
if (cachedRequest) {
|
|
||||||
return cachedRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = (async () => {
|
|
||||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
|
||||||
const foundIds = new Set(
|
|
||||||
features.map((feature) => String(feature.getProperties().id)),
|
|
||||||
);
|
|
||||||
const missingIds = pipeIds.filter((pipeId) => !foundIds.has(pipeId));
|
|
||||||
if (missingIds.length) {
|
|
||||||
features = [
|
|
||||||
...features,
|
|
||||||
...(await queryFeaturesByIds(missingIds, "geo_pipes")),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const diameters: PipeDiameterMap = Object.fromEntries(
|
|
||||||
pipeIds.map((pipeId) => [pipeId, null]),
|
|
||||||
);
|
|
||||||
features.forEach((feature) => {
|
|
||||||
const properties = feature.getProperties();
|
|
||||||
const pipeId = String(properties.id);
|
|
||||||
const diameter = Number(properties.diameter);
|
|
||||||
if (pipeIds.includes(pipeId)) {
|
|
||||||
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
diameterCache.set(queryKey, diameters);
|
|
||||||
return diameters;
|
|
||||||
})();
|
|
||||||
|
|
||||||
diameterRequestCache.set(queryKey, request);
|
|
||||||
try {
|
|
||||||
return await request;
|
|
||||||
} finally {
|
|
||||||
if (diameterRequestCache.get(queryKey) === request) {
|
|
||||||
diameterRequestCache.delete(queryKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (value: Date | string | null | undefined) => {
|
const formatDateTime = (value: Date | string | null | undefined) => {
|
||||||
if (!value) return "未记录";
|
if (!value) return "未记录";
|
||||||
@@ -411,8 +355,8 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
|
|||||||
() => scheme?.schemeDetail?.burst_ID ?? [],
|
() => scheme?.schemeDetail?.burst_ID ?? [],
|
||||||
[scheme],
|
[scheme],
|
||||||
);
|
);
|
||||||
const diameterQueryKey = pipeIds.join("\u0000");
|
const diameterQueryKey = getPipeDiameterQueryKey(pipeIds);
|
||||||
const cachedDiameters = diameterCache.get(diameterQueryKey);
|
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||||
const [diameterState, setDiameterState] = useState<{
|
const [diameterState, setDiameterState] = useState<{
|
||||||
queryKey: string | null;
|
queryKey: string | null;
|
||||||
values: PipeDiameterMap;
|
values: PipeDiameterMap;
|
||||||
@@ -435,13 +379,13 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
|
|||||||
if (!pipeIds.length) {
|
if (!pipeIds.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (diameterCache.has(diameterQueryKey)) {
|
if (getCachedPipeDiameters(pipeIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
loadPipeDiameters(pipeIds, diameterQueryKey)
|
loadPipeDiameters(pipeIds)
|
||||||
.then((nextDiameters) => {
|
.then((nextDiameters) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setDiameterState({
|
setDiameterState({
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ import { toLonLat } from "ol/proj";
|
|||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { SchemaItem, SchemeRecord } from "./types";
|
import { SchemaItem, SchemeRecord } from "./types";
|
||||||
import {
|
import {
|
||||||
|
getCachedPipeDiameters,
|
||||||
getPipeDiameterDisplay,
|
getPipeDiameterDisplay,
|
||||||
|
loadPipeDiameters,
|
||||||
type PipeDiameterMap,
|
type PipeDiameterMap,
|
||||||
} from "./schemePipeDiameters";
|
} from "./schemePipeDiameters";
|
||||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
@@ -160,7 +162,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
network,
|
|
||||||
scheme_type: SCHEME_TYPE,
|
scheme_type: SCHEME_TYPE,
|
||||||
};
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
@@ -251,40 +252,23 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||||
|
if (cachedDiameters) {
|
||||||
|
setPipeDiametersByScheme((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[expandedId]: cachedDiameters,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingDiameterByScheme((previous) => ({
|
setLoadingDiameterByScheme((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
[expandedId]: true,
|
[expandedId]: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const loadPipeDiameters = async () => {
|
const applyPipeDiameters = async () => {
|
||||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
const nextDiameters = await loadPipeDiameters(pipeIds);
|
||||||
const foundPipeIds = new Set(
|
|
||||||
features.map((feature) => String(feature.getProperties().id)),
|
|
||||||
);
|
|
||||||
const missingPipeIds = pipeIds.filter(
|
|
||||||
(pipeId) => !foundPipeIds.has(pipeId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingPipeIds.length > 0) {
|
|
||||||
const fallbackFeatures = await queryFeaturesByIds(
|
|
||||||
missingPipeIds,
|
|
||||||
"geo_pipes",
|
|
||||||
);
|
|
||||||
features = [...features, ...fallbackFeatures];
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDiameters: PipeDiameterMap = Object.fromEntries(
|
|
||||||
pipeIds.map((pipeId) => [pipeId, null]),
|
|
||||||
);
|
|
||||||
|
|
||||||
features.forEach((feature) => {
|
|
||||||
const properties = feature.getProperties();
|
|
||||||
const pipeId = String(properties.id);
|
|
||||||
const diameter = Number(properties.diameter);
|
|
||||||
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -299,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPipeDiameters().catch((error) => {
|
applyPipeDiameters().catch((error) => {
|
||||||
console.error("查询管径失败:", error);
|
console.error("查询管径失败:", error);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLoadingDiameterByScheme((previous) => ({
|
setLoadingDiameterByScheme((previous) => ({
|
||||||
|
|||||||
@@ -361,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
network: NETWORK_NAME,
|
|
||||||
accident_element: ids,
|
accident_element: ids,
|
||||||
};
|
};
|
||||||
if (disabled.length > 0) {
|
if (disabled.length > 0) {
|
||||||
params.disabled_valves = disabled;
|
params.disabled_valves = disabled;
|
||||||
}
|
}
|
||||||
const response = await api.get<ValveIsolationResult>(
|
const response = await api.post<ValveIsolationResult>(
|
||||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
|
||||||
|
undefined,
|
||||||
{
|
{
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
|
|||||||
@@ -1,4 +1,48 @@
|
|||||||
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import {
|
||||||
|
clearPipeDiameterCache,
|
||||||
|
getPipeDiameterDisplay,
|
||||||
|
loadPipeDiameters,
|
||||||
|
} from "./schemePipeDiameters";
|
||||||
|
|
||||||
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
|
queryFeaturesByIds: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const feature = (id: string, diameter: number) => ({
|
||||||
|
getProperties: () => ({ id, diameter }),
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearPipeDiameterCache();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadPipeDiameters", () => {
|
||||||
|
it("shares an in-flight WFS request and reuses its resolved result", async () => {
|
||||||
|
let resolveFeatures: ((features: ReturnType<typeof feature>[]) => void) | undefined;
|
||||||
|
const pendingFeatures = new Promise<ReturnType<typeof feature>[]>((resolve) => {
|
||||||
|
resolveFeatures = resolve;
|
||||||
|
});
|
||||||
|
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
||||||
|
async () => pendingFeatures,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstRequest = loadPipeDiameters(["P-1"]);
|
||||||
|
const duplicateRequest = loadPipeDiameters(["P-1"]);
|
||||||
|
|
||||||
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveFeatures?.([feature("P-1", 315)]);
|
||||||
|
await expect(firstRequest).resolves.toEqual({ "P-1": 315 });
|
||||||
|
await expect(duplicateRequest).resolves.toEqual({ "P-1": 315 });
|
||||||
|
|
||||||
|
await expect(loadPipeDiameters(["P-1"])).resolves.toEqual({
|
||||||
|
"P-1": 315,
|
||||||
|
});
|
||||||
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getPipeDiameterDisplay", () => {
|
describe("getPipeDiameterDisplay", () => {
|
||||||
it("shows the actual diameter for one burst pipe", () => {
|
it("shows the actual diameter for one burst pipe", () => {
|
||||||
|
|||||||
@@ -1,5 +1,94 @@
|
|||||||
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import config from "@config/config";
|
||||||
|
|
||||||
export type PipeDiameterMap = Record<string, number | null | undefined>;
|
export type PipeDiameterMap = Record<string, number | null | undefined>;
|
||||||
|
|
||||||
|
const diameterCache = new Map<string, PipeDiameterMap>();
|
||||||
|
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
|
||||||
|
|
||||||
|
const normalizePipeIds = (pipeIds: string[]) =>
|
||||||
|
[...new Set(pipeIds.map((pipeId) => String(pipeId).trim()).filter(Boolean))];
|
||||||
|
|
||||||
|
export const getPipeDiameterQueryKey = (pipeIds: string[]) =>
|
||||||
|
[
|
||||||
|
config.MAP_URL,
|
||||||
|
config.MAP_WORKSPACE,
|
||||||
|
...normalizePipeIds(pipeIds).sort(),
|
||||||
|
].join("\u0000");
|
||||||
|
|
||||||
|
export const getCachedPipeDiameters = (pipeIds: string[]) =>
|
||||||
|
diameterCache.get(getPipeDiameterQueryKey(pipeIds));
|
||||||
|
|
||||||
|
export const clearPipeDiameterCache = () => {
|
||||||
|
diameterCache.clear();
|
||||||
|
diameterRequestCache.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadPipeDiameters = async (
|
||||||
|
pipeIds: string[],
|
||||||
|
): Promise<PipeDiameterMap> => {
|
||||||
|
const normalizedPipeIds = normalizePipeIds(pipeIds);
|
||||||
|
if (normalizedPipeIds.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryKey = getPipeDiameterQueryKey(normalizedPipeIds);
|
||||||
|
const cachedDiameters = diameterCache.get(queryKey);
|
||||||
|
if (cachedDiameters) {
|
||||||
|
return cachedDiameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedRequest = diameterRequestCache.get(queryKey);
|
||||||
|
if (cachedRequest) {
|
||||||
|
return cachedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
let features = await queryFeaturesByIds(
|
||||||
|
normalizedPipeIds,
|
||||||
|
"geo_pipes_mat",
|
||||||
|
);
|
||||||
|
const foundPipeIds = new Set(
|
||||||
|
features.map((feature) => String(feature.getProperties().id)),
|
||||||
|
);
|
||||||
|
const missingPipeIds = normalizedPipeIds.filter(
|
||||||
|
(pipeId) => !foundPipeIds.has(pipeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingPipeIds.length > 0) {
|
||||||
|
const fallbackFeatures = await queryFeaturesByIds(
|
||||||
|
missingPipeIds,
|
||||||
|
"geo_pipes",
|
||||||
|
);
|
||||||
|
features = [...features, ...fallbackFeatures];
|
||||||
|
}
|
||||||
|
|
||||||
|
const diameters: PipeDiameterMap = Object.fromEntries(
|
||||||
|
normalizedPipeIds.map((pipeId) => [pipeId, null]),
|
||||||
|
);
|
||||||
|
features.forEach((feature) => {
|
||||||
|
const properties = feature.getProperties();
|
||||||
|
const pipeId = String(properties.id);
|
||||||
|
const diameter = Number(properties.diameter);
|
||||||
|
if (pipeId in diameters) {
|
||||||
|
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
diameterCache.set(queryKey, diameters);
|
||||||
|
return diameters;
|
||||||
|
})();
|
||||||
|
|
||||||
|
diameterRequestCache.set(queryKey, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
if (diameterRequestCache.get(queryKey) === request) {
|
||||||
|
diameterRequestCache.delete(queryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getPipeDiameterDisplay = (
|
export const getPipeDiameterDisplay = (
|
||||||
pipeIds: string[] | undefined,
|
pipeIds: string[] | undefined,
|
||||||
diameters: PipeDiameterMap | undefined,
|
diameters: PipeDiameterMap | undefined,
|
||||||
|
|||||||
@@ -228,7 +228,6 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
console.log("默认设置 pattern 为 CONSTANT");
|
console.log("默认设置 pattern 为 CONSTANT");
|
||||||
}
|
}
|
||||||
const params = {
|
const params = {
|
||||||
network,
|
|
||||||
start_time: start_time,
|
start_time: start_time,
|
||||||
source: sourceNode,
|
source: sourceNode,
|
||||||
concentration,
|
concentration,
|
||||||
@@ -237,7 +236,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, {
|
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
network,
|
|
||||||
scheme_type: SCHEME_TYPE,
|
scheme_type: SCHEME_TYPE,
|
||||||
};
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import dayjs, { Dayjs } from "dayjs";
|
|||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { LeakageResultDetail } from "./types";
|
import { LeakageResultDetail } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||||
@@ -108,9 +108,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/leakage/identify/`,
|
`${config.BACKEND_URL}/api/v1/leakage-identifications`,
|
||||||
{
|
{
|
||||||
network: NETWORK_NAME,
|
|
||||||
scheme_name: schemeName.trim(),
|
scheme_name: schemeName.trim(),
|
||||||
dma_count: dmaCount,
|
dma_count: dmaCount,
|
||||||
scada_start: startTime.toISOString(),
|
scada_start: startTime.toISOString(),
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
network: NETWORK_NAME,
|
|
||||||
scheme_type: "dma_leak_identification",
|
scheme_type: "dma_leak_identification",
|
||||||
};
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
@@ -129,7 +128,6 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
network: NETWORK_NAME,
|
|
||||||
scheme_type: "dma_leak_identification",
|
scheme_type: "dma_leak_identification",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -307,11 +307,10 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
network: NETWORK_NAME,
|
|
||||||
start_time: formattedTime,
|
start_time: formattedTime,
|
||||||
valves: valves.map(v => v.id),
|
valves: valves.map(v => v.id),
|
||||||
valves_k: valves.map(v => v.k),
|
valves_k: valves.map(v => v.k),
|
||||||
drainage_node_ID: drainageNode,
|
drainage_node_id: drainageNode,
|
||||||
flush_flow: flushFlow,
|
flush_flow: flushFlow,
|
||||||
duration: duration
|
duration: duration
|
||||||
};
|
};
|
||||||
@@ -320,7 +319,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
// but axios usually handles array as valves[]=1&valves[]=2
|
// but axios usually handles array as valves[]=1&valves[]=2
|
||||||
// FastAPI default expects repeated query params.
|
// FastAPI default expects repeated query params.
|
||||||
|
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, {
|
const response = await api.post(`${config.BACKEND_URL}/api/v1/flushing-analyses`, undefined, {
|
||||||
params,
|
params,
|
||||||
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
|
|||||||
@@ -269,7 +269,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
network,
|
|
||||||
scheme_type: SCHEME_TYPE,
|
scheme_type: SCHEME_TYPE,
|
||||||
};
|
};
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
`${config.BACKEND_URL}/api/v1/pipeline-health-predictions?query_time=${query_time}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
+1
-1
@@ -135,7 +135,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
const handleOpenScheme = async (schemeId: number) => {
|
const handleOpenScheme = async (schemeId: number) => {
|
||||||
setLoadingScheme(true);
|
setLoadingScheme(true);
|
||||||
try {
|
try {
|
||||||
const scheme = await getSensorPlacementScheme(NETWORK_NAME, schemeId);
|
const scheme = await getSensorPlacementScheme(schemeId);
|
||||||
setActiveScheme(scheme);
|
setActiveScheme(scheme);
|
||||||
setLoadingScheme(false);
|
setLoadingScheme(false);
|
||||||
setPendingOpenSchemeId(scheme.id);
|
setPendingOpenSchemeId(scheme.id);
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await optimizeSensorPlacement({
|
const created = await optimizeSensorPlacement({
|
||||||
network,
|
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
sensor_type: "pressure",
|
sensor_type: "pressure",
|
||||||
method: method as "sensitivity" | "kmeans",
|
method: method as "sensitivity" | "kmeans",
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import SchemeEditor from "./SchemeEditor";
|
||||||
|
import type { SensorPlacementScheme } from "./types";
|
||||||
|
|
||||||
|
const mockOpen = jest.fn();
|
||||||
|
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
||||||
|
|
||||||
|
const mockMap = {
|
||||||
|
addLayer: jest.fn(),
|
||||||
|
removeLayer: jest.fn(),
|
||||||
|
on: jest.fn((eventName: string, handler: (event: unknown) => void) => {
|
||||||
|
if (eventName === "singleclick") {
|
||||||
|
mockSingleClickHandler = handler;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
un: jest.fn(),
|
||||||
|
forEachFeatureAtPixel: jest.fn(() => undefined),
|
||||||
|
getView: jest.fn(() => ({
|
||||||
|
animate: jest.fn(),
|
||||||
|
getZoom: jest.fn(() => 16),
|
||||||
|
getResolution: jest.fn(() => 1),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useNotification: () => ({ open: mockOpen }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||||
|
useMap: () => mockMap,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@mui/x-data-grid", () => ({
|
||||||
|
DataGrid: () => <div data-testid="scheme-grid" />,
|
||||||
|
GridToolbar: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@mui/x-data-grid/locales", () => ({
|
||||||
|
zhCN: {
|
||||||
|
components: {
|
||||||
|
MuiDataGrid: {
|
||||||
|
defaultProps: {
|
||||||
|
localeText: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/Feature", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
private values: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(values: Record<string, unknown>) {
|
||||||
|
this.values = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string) {
|
||||||
|
return this.values[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
setId() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/geom/Point", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
constructor(_coordinates: number[]) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/source/Vector", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
clear() {}
|
||||||
|
addFeature() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/layer/Vector", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class {
|
||||||
|
private source: { clear: () => void; addFeature: () => void };
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
source: { clear: () => void; addFeature: () => void };
|
||||||
|
}) {
|
||||||
|
this.source = options.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSource() {
|
||||||
|
return this.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/style", () => ({
|
||||||
|
Circle: class {},
|
||||||
|
Fill: class {},
|
||||||
|
Stroke: class {},
|
||||||
|
Style: class {},
|
||||||
|
Text: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ol/proj", () => ({
|
||||||
|
fromLonLat: (coordinates: number[]) => coordinates,
|
||||||
|
toLonLat: (coordinates: number[]) => coordinates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
|
handleMapClickSelectFeatures: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./SchemeDrawingDialog", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./schemeApi", () => ({
|
||||||
|
exportSensorPlacementExcel: jest.fn(),
|
||||||
|
overwriteSensorPlacementScheme: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scheme: SensorPlacementScheme = {
|
||||||
|
id: 1,
|
||||||
|
scheme_name: "测试方案",
|
||||||
|
sensor_number: 1,
|
||||||
|
min_diameter: 300,
|
||||||
|
username: "operator",
|
||||||
|
create_time: "2026-07-30T08:00:00+08:00",
|
||||||
|
sensor_location: ["J1"],
|
||||||
|
sensor_points: [
|
||||||
|
{
|
||||||
|
node_id: "J1",
|
||||||
|
project_x: 10,
|
||||||
|
project_y: 20,
|
||||||
|
map_x: 13500010,
|
||||||
|
map_y: 3600020,
|
||||||
|
longitude: 121,
|
||||||
|
latitude: 31,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
can_edit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SchemeEditor notifications", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockSingleClickHandler = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
|
||||||
|
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "替换" }));
|
||||||
|
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
mockSingleClickHandler?.({
|
||||||
|
pixel: [0, 0],
|
||||||
|
coordinate: [13500000, 3600000],
|
||||||
|
stopPropagation: jest.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith({
|
||||||
|
type: "error",
|
||||||
|
message: "请先点击要替换的监测点",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
EditLocationAlt as ReplaceIcon,
|
EditLocationAlt as ReplaceIcon,
|
||||||
LocationOn as LocateIcon,
|
LocationOn as LocateIcon,
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
Redo as ResetIcon,
|
Redo as RedoIcon,
|
||||||
|
RestartAlt as ResetIcon,
|
||||||
Save as SaveIcon,
|
Save as SaveIcon,
|
||||||
Undo as UndoIcon,
|
Undo as UndoIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
createSchemeEditorState,
|
createSchemeEditorState,
|
||||||
deleteSensorPoint,
|
deleteSensorPoint,
|
||||||
isSchemeDirty,
|
isSchemeDirty,
|
||||||
|
redoSchemeEdit,
|
||||||
replaceSensorPoint,
|
replaceSensorPoint,
|
||||||
resetSchemeEdit,
|
resetSchemeEdit,
|
||||||
summarizeChanges,
|
summarizeChanges,
|
||||||
@@ -230,8 +232,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
elevation: number;
|
elevation: number;
|
||||||
}>(`${config.BACKEND_URL}/api/v1/getjunctionproperties/`, {
|
}>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
|
||||||
params: { network, junction: nodeId },
|
params: { junction: nodeId },
|
||||||
});
|
});
|
||||||
if (!response.data?.id) return null;
|
if (!response.data?.id) return null;
|
||||||
const [longitude, latitude] = toLonLat([mapX, mapY]);
|
const [longitude, latitude] = toLonLat([mapX, mapY]);
|
||||||
@@ -249,7 +251,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[map, network],
|
[map],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -275,7 +277,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
|
|
||||||
if (mode === "delete") {
|
if (mode === "delete") {
|
||||||
if (!markerNodeId) {
|
if (!markerNodeId) {
|
||||||
open?.({ type: "progress", message: "请点击要删除的监测点" });
|
open?.({ type: "error", message: "请点击要删除的监测点" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditor((current) => deleteSensorPoint(current, markerNodeId));
|
setEditor((current) => deleteSensorPoint(current, markerNodeId));
|
||||||
@@ -285,7 +287,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
|
|
||||||
if (mode === "replace" && !replaceSourceId) {
|
if (mode === "replace" && !replaceSourceId) {
|
||||||
if (!markerNodeId) {
|
if (!markerNodeId) {
|
||||||
open?.({ type: "progress", message: "请先点击要替换的监测点" });
|
open?.({ type: "error", message: "请先点击要替换的监测点" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReplaceSourceId(markerNodeId);
|
setReplaceSourceId(markerNodeId);
|
||||||
@@ -295,7 +297,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
|
|
||||||
const candidate = await resolveJunction(event);
|
const candidate = await resolveJunction(event);
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
open?.({ type: "progress", message: "请选择有效的管网节点" });
|
open?.({ type: "error", message: "请选择有效的管网节点" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +306,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
(point) => point.node_id === candidate.node_id,
|
(point) => point.node_id === candidate.node_id,
|
||||||
);
|
);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
open?.({ type: "progress", message: "该节点已在当前方案中" });
|
open?.({ type: "error", message: "该节点已在当前方案中" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditor((current) => addSensorPoint(current, candidate));
|
setEditor((current) => addSensorPoint(current, candidate));
|
||||||
@@ -316,7 +318,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
point.node_id !== replaceSourceId,
|
point.node_id !== replaceSourceId,
|
||||||
);
|
);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
open?.({ type: "progress", message: "目标节点已在当前方案中" });
|
open?.({ type: "error", message: "目标节点已在当前方案中" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditor((current) =>
|
setEditor((current) =>
|
||||||
@@ -379,7 +381,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const updated = await overwriteSensorPlacementScheme(
|
const updated = await overwriteSensorPlacementScheme(
|
||||||
network,
|
|
||||||
scheme.id,
|
scheme.id,
|
||||||
editor.baseline.map((point) => point.node_id),
|
editor.baseline.map((point) => point.node_id),
|
||||||
editor.points.map((point) => point.node_id),
|
editor.points.map((point) => point.node_id),
|
||||||
@@ -410,7 +411,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const blob = await exportSensorPlacementExcel(
|
const blob = await exportSensorPlacementExcel(
|
||||||
network,
|
|
||||||
scheme.id,
|
scheme.id,
|
||||||
editor.points.map((point) => point.node_id),
|
editor.points.map((point) => point.node_id),
|
||||||
editor.statuses,
|
editor.statuses,
|
||||||
@@ -693,6 +693,19 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="重做下一步">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="重做下一步"
|
||||||
|
size="small"
|
||||||
|
disabled={!editor.future.length}
|
||||||
|
onClick={() => setEditor((current) => redoSchemeEdit(current))}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<RedoIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="重置为服务器版本">
|
<Tooltip title="重置为服务器版本">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface OptimizeSchemeInput {
|
export interface OptimizeSchemeInput {
|
||||||
network: string;
|
|
||||||
scheme_name: string;
|
scheme_name: string;
|
||||||
sensor_type: "pressure";
|
sensor_type: "pressure";
|
||||||
method: "sensitivity" | "kmeans";
|
method: "sensitivity" | "kmeans";
|
||||||
@@ -18,25 +17,22 @@ export const optimizeSensorPlacement = async (
|
|||||||
input: OptimizeSchemeInput,
|
input: OptimizeSchemeInput,
|
||||||
): Promise<SensorPlacementScheme> => {
|
): Promise<SensorPlacementScheme> => {
|
||||||
const response = await api.post<SensorPlacementScheme>(
|
const response = await api.post<SensorPlacementScheme>(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/optimize`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`,
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSensorPlacementScheme = async (
|
export const getSensorPlacementScheme = async (
|
||||||
network: string,
|
|
||||||
schemeId: number,
|
schemeId: number,
|
||||||
): Promise<SensorPlacementScheme> => {
|
): Promise<SensorPlacementScheme> => {
|
||||||
const response = await api.get<SensorPlacementScheme>(
|
const response = await api.get<SensorPlacementScheme>(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
|
||||||
{ params: { network } },
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const overwriteSensorPlacementScheme = async (
|
export const overwriteSensorPlacementScheme = async (
|
||||||
network: string,
|
|
||||||
schemeId: number,
|
schemeId: number,
|
||||||
expectedSensorLocation: string[],
|
expectedSensorLocation: string[],
|
||||||
sensorLocation: string[],
|
sensorLocation: string[],
|
||||||
@@ -47,13 +43,11 @@ export const overwriteSensorPlacementScheme = async (
|
|||||||
expected_sensor_location: expectedSensorLocation,
|
expected_sensor_location: expectedSensorLocation,
|
||||||
sensor_location: sensorLocation,
|
sensor_location: sensorLocation,
|
||||||
},
|
},
|
||||||
{ params: { network } },
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exportSensorPlacementExcel = async (
|
export const exportSensorPlacementExcel = async (
|
||||||
network: string,
|
|
||||||
schemeId: number,
|
schemeId: number,
|
||||||
sensorLocation: string[],
|
sensorLocation: string[],
|
||||||
adjustmentStatus: Record<string, AdjustmentStatus>,
|
adjustmentStatus: Record<string, AdjustmentStatus>,
|
||||||
@@ -65,7 +59,6 @@ export const exportSensorPlacementExcel = async (
|
|||||||
adjustment_status: adjustmentStatus,
|
adjustment_status: adjustmentStatus,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: { network },
|
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
createSchemeEditorState,
|
createSchemeEditorState,
|
||||||
deleteSensorPoint,
|
deleteSensorPoint,
|
||||||
isSchemeDirty,
|
isSchemeDirty,
|
||||||
|
redoSchemeEdit,
|
||||||
replaceSensorPoint,
|
replaceSensorPoint,
|
||||||
resetSchemeEdit,
|
resetSchemeEdit,
|
||||||
summarizeChanges,
|
summarizeChanges,
|
||||||
@@ -44,6 +45,24 @@ describe("scheme editor", () => {
|
|||||||
expect(undoSchemeEdit(added).points).toEqual(initial.points);
|
expect(undoSchemeEdit(added).points).toEqual(initial.points);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can redo an undone edit and clears redo history after a new edit", () => {
|
||||||
|
const initial = createSchemeEditorState(scheme);
|
||||||
|
const added = addSensorPoint(initial, point("J3"));
|
||||||
|
const undone = undoSchemeEdit(added);
|
||||||
|
const redone = redoSchemeEdit(undone);
|
||||||
|
|
||||||
|
expect(redone.points.map((item) => item.node_id)).toEqual([
|
||||||
|
"J1",
|
||||||
|
"J2",
|
||||||
|
"J3",
|
||||||
|
]);
|
||||||
|
expect(redone.history).toHaveLength(1);
|
||||||
|
expect(redone.future).toEqual([]);
|
||||||
|
|
||||||
|
const editedAfterUndo = replaceSensorPoint(undone, "J1", point("J4"));
|
||||||
|
expect(editedAfterUndo.future).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("replaces a node without changing row order", () => {
|
it("replaces a node without changing row order", () => {
|
||||||
const initial = createSchemeEditorState(scheme);
|
const initial = createSchemeEditorState(scheme);
|
||||||
const replaced = replaceSensorPoint(initial, "J1", point("J3"));
|
const replaced = replaceSensorPoint(initial, "J1", point("J3"));
|
||||||
@@ -80,5 +99,6 @@ describe("scheme editor", () => {
|
|||||||
expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]);
|
expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]);
|
||||||
expect(isSchemeDirty(reset)).toBe(false);
|
expect(isSchemeDirty(reset)).toBe(false);
|
||||||
expect(reset.history).toEqual([]);
|
expect(reset.history).toEqual([]);
|
||||||
|
expect(reset.future).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface SchemeEditorSnapshot {
|
|||||||
export interface SchemeEditorState extends SchemeEditorSnapshot {
|
export interface SchemeEditorState extends SchemeEditorSnapshot {
|
||||||
baseline: SensorPoint[];
|
baseline: SensorPoint[];
|
||||||
history: SchemeEditorSnapshot[];
|
history: SchemeEditorSnapshot[];
|
||||||
|
future: SchemeEditorSnapshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point }));
|
const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point }));
|
||||||
@@ -30,6 +31,7 @@ export const createSchemeEditorState = (
|
|||||||
points: clonePoints(scheme.sensor_points),
|
points: clonePoints(scheme.sensor_points),
|
||||||
statuses: currentStatuses(scheme.sensor_points),
|
statuses: currentStatuses(scheme.sensor_points),
|
||||||
history: [],
|
history: [],
|
||||||
|
future: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({
|
const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({
|
||||||
@@ -46,6 +48,7 @@ const withHistory = (
|
|||||||
points,
|
points,
|
||||||
statuses,
|
statuses,
|
||||||
history: [...state.history, snapshot(state)],
|
history: [...state.history, snapshot(state)],
|
||||||
|
future: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addSensorPoint = (
|
export const addSensorPoint = (
|
||||||
@@ -114,6 +117,19 @@ export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
|
|||||||
points: clonePoints(previous.points),
|
points: clonePoints(previous.points),
|
||||||
statuses: { ...previous.statuses },
|
statuses: { ...previous.statuses },
|
||||||
history: state.history.slice(0, -1),
|
history: state.history.slice(0, -1),
|
||||||
|
future: [...state.future, snapshot(state)],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
|
||||||
|
const next = state.future[state.future.length - 1];
|
||||||
|
if (!next) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
points: clonePoints(next.points),
|
||||||
|
statuses: { ...next.statuses },
|
||||||
|
history: [...state.history, snapshot(state)],
|
||||||
|
future: state.future.slice(0, -1),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,6 +138,7 @@ export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState =>
|
|||||||
points: clonePoints(state.baseline),
|
points: clonePoints(state.baseline),
|
||||||
statuses: currentStatuses(state.baseline),
|
statuses: currentStatuses(state.baseline),
|
||||||
history: [],
|
history: [],
|
||||||
|
future: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const isSchemeDirty = (state: SchemeEditorState) => {
|
export const isSchemeDirty = (state: SchemeEditorState) => {
|
||||||
|
|||||||
@@ -105,11 +105,11 @@ const fetchFromBackend = async (
|
|||||||
const start_time = dayjs(range.from).toISOString();
|
const start_time = dayjs(range.from).toISOString();
|
||||||
const end_time = dayjs(range.to).toISOString();
|
const end_time = dayjs(range.to).toISOString();
|
||||||
// 清洗数据接口
|
// 清洗数据接口
|
||||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 原始数据
|
// 原始数据
|
||||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 模拟数据接口
|
// 模拟数据接口
|
||||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||||
try {
|
try {
|
||||||
// 优先查询清洗数据和模拟数据
|
// 优先查询清洗数据和模拟数据
|
||||||
const [cleaningRes, simulationRes] = await Promise.all([
|
const [cleaningRes, simulationRes] = await Promise.all([
|
||||||
@@ -351,11 +351,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const end_time = dayjs(range.to).toISOString();
|
const end_time = dayjs(range.to).toISOString();
|
||||||
|
|
||||||
// 清洗数据接口
|
// 清洗数据接口
|
||||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 原始数据
|
// 原始数据
|
||||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 模拟数据接口
|
// 模拟数据接口
|
||||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||||
try {
|
try {
|
||||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||||
apiFetch(cleaningDataUrl)
|
apiFetch(cleaningDataUrl)
|
||||||
@@ -530,7 +530,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${
|
`${
|
||||||
config.BACKEND_URL
|
config.BACKEND_URL
|
||||||
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join(
|
}/api/v1/timeseries/scada-cleaning-runs?device_ids=${deviceIds.join(
|
||||||
",",
|
",",
|
||||||
)}&start_time=${startTime}&end_time=${endTime}`,
|
)}&start_time=${startTime}&end_time=${endTime}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
|
|
||||||
// 调用后端清洗接口
|
// 调用后端清洗接口
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/scada-cleaning-runs?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理成功响应
|
// 处理成功响应
|
||||||
|
|||||||
@@ -108,13 +108,13 @@ const fetchFromBackend = async (
|
|||||||
.join(",");
|
.join(",");
|
||||||
|
|
||||||
// 监测值数据接口(use_cleaned=false)
|
// 监测值数据接口(use_cleaned=false)
|
||||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`;
|
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`;
|
||||||
// 清洗数据接口(use_cleaned=true)
|
// 清洗数据接口(use_cleaned=true)
|
||||||
const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`;
|
const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`;
|
||||||
// 模拟数据接口
|
// 模拟数据接口
|
||||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
|
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
|
||||||
// 策略模拟数据接口
|
// 策略模拟数据接口
|
||||||
const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`;
|
const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === "none") {
|
if (type === "none") {
|
||||||
|
|||||||
@@ -241,11 +241,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
nodePromise =
|
nodePromise =
|
||||||
sourceType === "scheme" && schemeName
|
sourceType === "scheme" && schemeName
|
||||||
? apiFetch(
|
? apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
: apiFetch(
|
: apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/records?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
requests.push(nodePromise);
|
requests.push(nodePromise);
|
||||||
@@ -270,11 +270,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
linkPromise =
|
linkPromise =
|
||||||
sourceType === "scheme" && schemeName
|
sourceType === "scheme" && schemeName
|
||||||
? apiFetch(
|
? apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
: apiFetch(
|
: apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/records?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
requests.push(linkPromise);
|
requests.push(linkPromise);
|
||||||
@@ -717,13 +717,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
name: NETWORK_NAME,
|
|
||||||
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
|
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||||
duration: calculatedInterval,
|
duration: calculatedInterval,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/simulations/run-by-date`,
|
`${config.BACKEND_URL}/api/v1/simulation-runs`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -431,11 +431,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
network: networkName,
|
|
||||||
link: selectedValveId,
|
link: selectedValveId,
|
||||||
});
|
});
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/getstatus/?${params.toString()}`,
|
`${config.BACKEND_URL}/api/v1/status?${params.toString()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -484,11 +483,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
network: networkName,
|
|
||||||
valve: selectedValveId,
|
valve: selectedValveId,
|
||||||
});
|
});
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/getvalveproperties/?${params.toString()}`,
|
`${config.BACKEND_URL}/api/v1/valves/properties?${params.toString()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -534,13 +532,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setIsValveStatusSaving(true);
|
setIsValveStatusSaving(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
network: networkName,
|
|
||||||
link: selectedValveId,
|
link: selectedValveId,
|
||||||
});
|
});
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/setstatus/?${params.toString()}`,
|
`${config.BACKEND_URL}/api/v1/status-properties?${params.toString()}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -567,7 +564,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setIsValveStatusSaving(false);
|
setIsValveStatusSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[networkName, open, selectedValveId],
|
[open, selectedValveId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleValveSettingSave = useCallback(
|
const handleValveSettingSave = useCallback(
|
||||||
@@ -600,13 +597,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setIsValveSettingSaving(true);
|
setIsValveSettingSaving(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
network: networkName,
|
|
||||||
valve: selectedValveId,
|
valve: selectedValveId,
|
||||||
});
|
});
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/setvalveproperties/?${params.toString()}`,
|
`${config.BACKEND_URL}/api/v1/valves/properties?${params.toString()}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -637,7 +633,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
networkName,
|
|
||||||
open,
|
open,
|
||||||
selectedValveId,
|
selectedValveId,
|
||||||
selectedValveType,
|
selectedValveType,
|
||||||
@@ -679,12 +674,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
if (queryType === "scheme") {
|
if (queryType === "scheme") {
|
||||||
response = await apiFetch(
|
response = await apiFetch(
|
||||||
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
response = await apiFetch(
|
response = await apiFetch(
|
||||||
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
|
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?id=${id}&type=${type}&query_time=${querytime}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("useTimelineTimeConfig", () => {
|
|||||||
});
|
});
|
||||||
expect(result.current.durationMinutes).toBe(1440);
|
expect(result.current.durationMinutes).toBe(1440);
|
||||||
expect(String(apiFetch.mock.calls[0][0])).toContain(
|
expect(String(apiFetch.mock.calls[0][0])).toContain(
|
||||||
"/api/v1/gettimeproperties/?network=test-network",
|
"/api/v1/network-options/time",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,7 @@ export const useTimelineTimeConfig = (): TimelineTimeConfig => {
|
|||||||
const fetchTimeProperties = async () => {
|
const fetchTimeProperties = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/gettimeproperties/?network=${encodeURIComponent(
|
`${config.BACKEND_URL}/api/v1/network-options/time`,
|
||||||
networkName,
|
|
||||||
)}`,
|
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch time properties: ${response.status}`);
|
throw new Error(`Failed to fetch time properties: ${response.status}`);
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const useToolbarChatActions = ({
|
|||||||
? `?session_id=${encodeURIComponent(action.sessionId)}`
|
? `?session_id=${encodeURIComponent(action.sessionId)}`
|
||||||
: "";
|
: "";
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/render-ref/${encodeURIComponent(action.renderRef)}${query}`,
|
`${config.AGENT_URL}/api/v1/agent/render-references/${encodeURIComponent(action.renderRef)}${query}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
|||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/meta/projects`,
|
`${config.BACKEND_URL}/api/v1/projects`,
|
||||||
{ projectHeaderMode: "omit" },
|
{ projectHeaderMode: "omit" },
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -77,10 +77,11 @@ export const setMapExtent = (extent: number[]) => {
|
|||||||
|
|
||||||
export const MAPBOX_TOKEN = runtimeConfig.MAPBOX_TOKEN || "";
|
export const MAPBOX_TOKEN = runtimeConfig.MAPBOX_TOKEN || "";
|
||||||
export const TIANDITU_TOKEN = runtimeConfig.TIANDITU_TOKEN || "";
|
export const TIANDITU_TOKEN = runtimeConfig.TIANDITU_TOKEN || "";
|
||||||
export const PROJECT_TITLE = process.env.PROJECT_TITLE || "TJWater Project";
|
export const PROJECT_TITLE =
|
||||||
|
process.env.PROJECT_TITLE || "TJWater 智慧水务平台";
|
||||||
export const META_DATA = {
|
export const META_DATA = {
|
||||||
title: PROJECT_TITLE,
|
title: PROJECT_TITLE,
|
||||||
description: "Developed by TJWATER",
|
description: PROJECT_TITLE,
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.ico",
|
icon: "/favicon.ico",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { render, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { ProjectProvider } from "./ProjectContext";
|
||||||
|
|
||||||
|
const mockApiFetch = jest.fn();
|
||||||
|
const mockUseSession = jest.fn();
|
||||||
|
const mockSetCurrentProjectId = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("next-auth/react", () => ({
|
||||||
|
useSession: () => mockUseSession(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/login",
|
||||||
|
useRouter: () => ({ push: jest.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/components/project/ProjectSelector", () => ({
|
||||||
|
ProjectSelector: () => <div>project selector</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/config/config", () => ({
|
||||||
|
config: {
|
||||||
|
BACKEND_URL: "http://backend.test",
|
||||||
|
MAP_EXTENT: [1, 2, 3, 4],
|
||||||
|
MAP_WORKSPACE: "default-workspace",
|
||||||
|
},
|
||||||
|
NETWORK_NAME: "default-network",
|
||||||
|
setMapExtent: jest.fn(),
|
||||||
|
setMapWorkspace: jest.fn(),
|
||||||
|
setNetworkName: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/permissions", () => ({
|
||||||
|
permissionCodes: { environmentManage: "environment:manage" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/store/accessStore", () => ({
|
||||||
|
useAccessStore: (selector: (state: { permissions: string[] }) => unknown) =>
|
||||||
|
selector({ permissions: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/store/projectStore", () => ({
|
||||||
|
useProjectStore: (
|
||||||
|
selector: (state: { setCurrentProjectId: typeof mockSetCurrentProjectId }) => unknown,
|
||||||
|
) => selector({ setCurrentProjectId: mockSetCurrentProjectId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const seedSavedProject = () => {
|
||||||
|
localStorage.setItem("MAP_WORKSPACE", "fengyang");
|
||||||
|
localStorage.setItem("NETWORK_NAME", "fengyang");
|
||||||
|
localStorage.setItem("MAP_EXTENT", "1,2,3,4");
|
||||||
|
localStorage.setItem(
|
||||||
|
"active_project",
|
||||||
|
"a2d67c84-fd9d-4feb-a500-c357244b2760",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ProjectProvider authentication boundary", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
mockApiFetch.mockReset();
|
||||||
|
mockSetCurrentProjectId.mockReset();
|
||||||
|
mockApiFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore or open a saved project while unauthenticated", () => {
|
||||||
|
seedSavedProject();
|
||||||
|
mockUseSession.mockReturnValue({ status: "unauthenticated" });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProjectProvider>
|
||||||
|
<div>login page</div>
|
||||||
|
</ProjectProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockSetCurrentProjectId).not.toHaveBeenCalled();
|
||||||
|
expect(mockApiFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores and opens a saved project after authentication", async () => {
|
||||||
|
seedSavedProject();
|
||||||
|
mockUseSession.mockReturnValue({ status: "authenticated" });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProjectProvider>
|
||||||
|
<div>main page</div>
|
||||||
|
</ProjectProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockApiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(mockSetCurrentProjectId).toHaveBeenCalledWith(
|
||||||
|
"a2d67c84-fd9d-4feb-a500-c357244b2760",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,7 +74,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const openResponse = await apiFetch(
|
const openResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/projects/open?network=${networkName}`,
|
`${config.BACKEND_URL}/api/v1/projects/current`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
if (!openResponse.ok) {
|
if (!openResponse.ok) {
|
||||||
@@ -82,7 +82,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const infoResponse = await apiFetch(
|
const infoResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/project-info?network=${networkName}`,
|
`${config.BACKEND_URL}/api/v1/projects/current`,
|
||||||
);
|
);
|
||||||
if (!infoResponse.ok) {
|
if (!infoResponse.ok) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -118,6 +118,10 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (status !== "authenticated") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check localStorage
|
// Check localStorage
|
||||||
const savedWorkspace = localStorage.getItem(MAP_WORKSPACE_STORAGE_KEY);
|
const savedWorkspace = localStorage.getItem(MAP_WORKSPACE_STORAGE_KEY);
|
||||||
const savedNetwork = localStorage.getItem(NETWORK_NAME_STORAGE_KEY);
|
const savedNetwork = localStorage.getItem(NETWORK_NAME_STORAGE_KEY);
|
||||||
@@ -133,7 +137,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
|
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [applyConfig]);
|
}, [applyConfig, status]);
|
||||||
|
|
||||||
const isGlobalManagementRoute =
|
const isGlobalManagementRoute =
|
||||||
pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs");
|
pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
import { resolveRequestUrl } from "@/lib/api";
|
||||||
|
|
||||||
|
describe("resolveRequestUrl", () => {
|
||||||
|
it("does not prepend baseURL to an absolute request URL", () => {
|
||||||
|
expect(
|
||||||
|
resolveRequestUrl({
|
||||||
|
baseURL: "http://localhost:8000",
|
||||||
|
url: "http://localhost:8000/api/v1/schemes",
|
||||||
|
}),
|
||||||
|
).toBe("http://localhost:8000/api/v1/schemes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines baseURL with a relative request URL", () => {
|
||||||
|
expect(
|
||||||
|
resolveRequestUrl({
|
||||||
|
baseURL: "http://localhost:8000",
|
||||||
|
url: "/api/v1/schemes",
|
||||||
|
}),
|
||||||
|
).toBe("http://localhost:8000/api/v1/schemes");
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-3
@@ -15,10 +15,22 @@ export const api = axios.create({
|
|||||||
|
|
||||||
let isSigningOut = false;
|
let isSigningOut = false;
|
||||||
|
|
||||||
const resolveRequestUrl = (request: {
|
export const resolveRequestUrl = (request: {
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
}) => `${request.baseURL ?? ""}${request.url ?? ""}`;
|
}) => {
|
||||||
|
const requestUrl = request.url ?? "";
|
||||||
|
if (/^([a-z][a-z\d+\-.]*:)?\/\//i.test(requestUrl)) {
|
||||||
|
return requestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = request.baseURL ?? "";
|
||||||
|
if (!baseURL || !requestUrl) {
|
||||||
|
return `${baseURL}${requestUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseURL.replace(/\/+$/, "")}/${requestUrl.replace(/^\/+/, "")}`;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ApiRequestConfig
|
export interface ApiRequestConfig
|
||||||
extends InternalAxiosRequestConfig,
|
extends InternalAxiosRequestConfig,
|
||||||
@@ -38,7 +50,17 @@ api.interceptors.request.use(async (request: ApiRequestConfig) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
|
if (
|
||||||
|
response.data &&
|
||||||
|
typeof response.data === "object" &&
|
||||||
|
Array.isArray(response.data.items) &&
|
||||||
|
typeof response.data.total === "number"
|
||||||
|
) {
|
||||||
|
response.data = response.data.items;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
||||||
useAuthStore.getState().setAccessToken(null);
|
useAuthStore.getState().setAccessToken(null);
|
||||||
|
|||||||
+33
-3
@@ -7,6 +7,30 @@ import {
|
|||||||
|
|
||||||
let isSigningOut = false;
|
let isSigningOut = false;
|
||||||
|
|
||||||
|
const unwrapPage = async (response: Response) => {
|
||||||
|
if (
|
||||||
|
!response.headers.get("content-type")?.includes("application/json")
|
||||||
|
) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const payload = await response.clone().json().catch(() => null);
|
||||||
|
if (
|
||||||
|
!payload ||
|
||||||
|
typeof payload !== "object" ||
|
||||||
|
!Array.isArray(payload.items) ||
|
||||||
|
typeof payload.total !== "number"
|
||||||
|
) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.delete("content-length");
|
||||||
|
return new Response(JSON.stringify(payload.items), {
|
||||||
|
headers,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const resolveUrl = (input: RequestInfo | URL) => {
|
const resolveUrl = (input: RequestInfo | URL) => {
|
||||||
if (typeof input === "string") return input;
|
if (typeof input === "string") return input;
|
||||||
if (input instanceof URL) return input.toString();
|
if (input instanceof URL) return input.toString();
|
||||||
@@ -22,10 +46,16 @@ export const apiFetch = async (
|
|||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init: ApiFetchInit = {},
|
init: ApiFetchInit = {},
|
||||||
) => {
|
) => {
|
||||||
const headers = new Headers(init.headers ?? {});
|
const headers = new Headers(
|
||||||
|
init.headers ?? (input instanceof Request ? input.headers : undefined),
|
||||||
|
);
|
||||||
await applyAuthContextHeaders(resolveUrl(input), headers, init);
|
await applyAuthContextHeaders(resolveUrl(input), headers, init);
|
||||||
|
|
||||||
const response = await fetch(input, { ...init, headers });
|
const requestInit: RequestInit = {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
const response = await fetch(input, requestInit);
|
||||||
|
|
||||||
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
||||||
useAuthStore.getState().setAccessToken(null);
|
useAuthStore.getState().setAccessToken(null);
|
||||||
@@ -35,5 +65,5 @@ export const apiFetch = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return unwrapPage(response);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("fetchAgentModels", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/models"),
|
expect.stringContaining("/api/v1/agent/models"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
|
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
|
|||||||
+41
-30
@@ -41,20 +41,34 @@ const makeStream = (chunks: string[]) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockNewSessionStream = (response: unknown) => {
|
||||||
|
apiFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ session_id: "s1" }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(response);
|
||||||
|
};
|
||||||
|
|
||||||
describe("streamAgentChat", () => {
|
describe("streamAgentChat", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiFetch.mockReset();
|
apiFetch.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses token and done events from chunked SSE", async () => {
|
it("parses token and done events from chunked SSE", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch
|
||||||
ok: true,
|
.mockResolvedValueOnce({
|
||||||
body: makeStream([
|
ok: true,
|
||||||
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
|
json: async () => ({ session_id: "s1" }),
|
||||||
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
|
})
|
||||||
'event: done\ndata: {"session_id":"s1"}\n\n',
|
.mockResolvedValueOnce({
|
||||||
]),
|
ok: true,
|
||||||
});
|
body: makeStream([
|
||||||
|
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
|
||||||
|
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
|
||||||
|
'event: done\ndata: {"session_id":"s1"}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
const events: Array<{ type: string; content?: string; sessionId?: string }> = [];
|
const events: Array<{ type: string; content?: string; sessionId?: string }> = [];
|
||||||
|
|
||||||
@@ -65,14 +79,13 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/stream"),
|
expect.stringContaining("/api/v1/agent/sessions/s1/runs"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
session_id: undefined,
|
|
||||||
model: "provider/model",
|
model: "provider/model",
|
||||||
approval_mode: undefined,
|
approval_mode: undefined,
|
||||||
}),
|
}),
|
||||||
@@ -111,7 +124,7 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/session/s1/stream"),
|
expect.stringContaining("/api/v1/agent/sessions/s1/runs/current/events"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
@@ -132,7 +145,7 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses progress events", async () => {
|
it("parses progress events", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
mockNewSessionStream({
|
||||||
ok: true,
|
ok: true,
|
||||||
body: makeStream([
|
body: makeStream([
|
||||||
'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n',
|
'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n',
|
||||||
@@ -159,7 +172,7 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses tool_call arguments when params is empty", async () => {
|
it("parses tool_call arguments when params is empty", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
mockNewSessionStream({
|
||||||
ok: true,
|
ok: true,
|
||||||
body: makeStream([
|
body: makeStream([
|
||||||
'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
|
'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
|
||||||
@@ -183,7 +196,7 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses permission request and response events", async () => {
|
it("parses permission request and response events", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
mockNewSessionStream({
|
||||||
ok: true,
|
ok: true,
|
||||||
body: makeStream([
|
body: makeStream([
|
||||||
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n',
|
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n',
|
||||||
@@ -220,7 +233,7 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses question request, response, and todo update events", async () => {
|
it("parses question request, response, and todo update events", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
mockNewSessionStream({
|
||||||
ok: true,
|
ok: true,
|
||||||
body: makeStream([
|
body: makeStream([
|
||||||
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
|
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
|
||||||
@@ -283,7 +296,7 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("emits error when response is not ok", async () => {
|
it("emits error when response is not ok", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
mockNewSessionStream({
|
||||||
ok: false,
|
ok: false,
|
||||||
body: null,
|
body: null,
|
||||||
text: async () => "bad request",
|
text: async () => "bad request",
|
||||||
@@ -301,7 +314,7 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("emits re-login message on unauthorized response", async () => {
|
it("emits re-login message on unauthorized response", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
mockNewSessionStream({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 401,
|
status: 401,
|
||||||
body: null,
|
body: null,
|
||||||
@@ -343,14 +356,11 @@ describe("streamAgentChat", () => {
|
|||||||
await abortAgentChat("s1");
|
await abortAgentChat("s1");
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/abort"),
|
expect.stringContaining("/api/v1/agent/sessions/s1/runs/current"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "DELETE",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: "s1",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -365,13 +375,13 @@ describe("streamAgentChat", () => {
|
|||||||
await replyAgentPermission("s1", "perm-1", "once");
|
await replyAgentPermission("s1", "perm-1", "once");
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"),
|
expect.stringContaining("/api/v1/agent/sessions/s1/permission-responses"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: "s1",
|
request_id: "perm-1",
|
||||||
reply: "once",
|
reply: "once",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -389,25 +399,27 @@ describe("streamAgentChat", () => {
|
|||||||
await rejectAgentQuestion("s1", "q-2");
|
await rejectAgentQuestion("s1", "q-2");
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"),
|
expect.stringContaining("/api/v1/agent/sessions/s1/question-responses"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: "s1",
|
request_id: "q-1",
|
||||||
|
action: "reply",
|
||||||
answers: [["城区"]],
|
answers: [["城区"]],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"),
|
expect.stringContaining("/api/v1/agent/sessions/s1/question-responses"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: "s1",
|
request_id: "q-2",
|
||||||
|
action: "reject",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -425,11 +437,10 @@ describe("streamAgentChat", () => {
|
|||||||
|
|
||||||
expect(sessionId).toBe("forked-s1");
|
expect(sessionId).toBe("forked-s1");
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/agent/chat/fork"),
|
expect.stringContaining("/api/v1/agent/sessions/s1/forks"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: "s1",
|
|
||||||
keep_message_count: 3,
|
keep_message_count: 3,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+36
-19
@@ -466,6 +466,28 @@ const readStreamEvents = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureAgentSession = async (sessionId?: string) => {
|
||||||
|
if (sessionId) return sessionId;
|
||||||
|
|
||||||
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((await response.text()) || `session creation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { session_id?: string };
|
||||||
|
if (!payload.session_id) {
|
||||||
|
throw new Error("session creation returned no session_id");
|
||||||
|
}
|
||||||
|
return payload.session_id;
|
||||||
|
};
|
||||||
|
|
||||||
export const streamAgentChat = async ({
|
export const streamAgentChat = async ({
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -476,8 +498,9 @@ export const streamAgentChat = async ({
|
|||||||
}: StreamOptions) => {
|
}: StreamOptions) => {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
|
const effectiveSessionId = await ensureAgentSession(sessionId);
|
||||||
response = await apiFetch(
|
response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/stream`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(effectiveSessionId)}/runs`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
@@ -487,7 +510,6 @@ export const streamAgentChat = async ({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message,
|
message,
|
||||||
session_id: sessionId,
|
|
||||||
model,
|
model,
|
||||||
approval_mode: approvalMode,
|
approval_mode: approvalMode,
|
||||||
}),
|
}),
|
||||||
@@ -535,7 +557,7 @@ export const resumeAgentChatStream = async ({
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await apiFetch(
|
response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/stream`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current/events`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal,
|
signal,
|
||||||
@@ -576,14 +598,8 @@ export const abortAgentChat = async (sessionId?: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/abort`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current`, {
|
||||||
method: "POST",
|
method: "DELETE",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: sessionId,
|
|
||||||
}),
|
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
@@ -600,14 +616,14 @@ export const replyAgentPermission = async (
|
|||||||
reply: PermissionReply,
|
reply: PermissionReply,
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/permission-responses`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: sessionId,
|
request_id: requestId,
|
||||||
reply,
|
reply,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
@@ -627,14 +643,15 @@ export const replyAgentQuestion = async (
|
|||||||
answers: string[][],
|
answers: string[][],
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: sessionId,
|
request_id: requestId,
|
||||||
|
action: "reply",
|
||||||
answers,
|
answers,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
@@ -653,14 +670,15 @@ export const rejectAgentQuestion = async (
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`,
|
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: sessionId,
|
request_id: requestId,
|
||||||
|
action: "reject",
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
@@ -674,13 +692,12 @@ export const rejectAgentQuestion = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId ?? "")}/forks`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: sessionId,
|
|
||||||
keep_message_count: keepMessageCount,
|
keep_message_count: keepMessageCount,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import createClient from "openapi-fetch";
|
||||||
|
|
||||||
|
import { config } from "@config/config";
|
||||||
|
import type { paths as AgentPaths } from "@/generated/agentApi";
|
||||||
|
import type { paths as ServerPaths } from "@/generated/serverApi";
|
||||||
|
import { applyAuthContextHeaders } from "@/lib/requestHeaders";
|
||||||
|
|
||||||
|
const authMiddleware = {
|
||||||
|
async onRequest({ request }: { request: Request }) {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
await applyAuthContextHeaders(request.url, headers);
|
||||||
|
return new Request(request, { headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serverApi = createClient<ServerPaths>({
|
||||||
|
baseUrl: config.BACKEND_URL,
|
||||||
|
});
|
||||||
|
export const agentApi = createClient<AgentPaths>({
|
||||||
|
baseUrl: config.AGENT_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
serverApi.use(authMiddleware);
|
||||||
|
agentApi.use(authMiddleware);
|
||||||
@@ -21,7 +21,8 @@ const shouldIncludeProjectHeader = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !url.includes("/api/v1/meta/projects");
|
const pathname = new URL(url, "http://tjwater.local").pathname.replace(/\/+$/, "");
|
||||||
|
return pathname !== "/api/v1/projects";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyAuthContextHeaders = async (
|
export const applyAuthContextHeaders = async (
|
||||||
|
|||||||
Reference in New Issue
Block a user