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