fix(api): align frontend with REST contracts

This commit is contained in:
2026-07-30 20:38:52 +08:00
parent b57e58ff87
commit 782363cfb6
65 changed files with 99362 additions and 318 deletions
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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
+242 -16
View File
@@ -35,6 +35,7 @@
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"ol": "^10.7.0", "ol": "^10.7.0",
"openapi-fetch": "^0.17.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -63,6 +64,7 @@
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"openapi-typescript": "^7.13.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
@@ -5731,6 +5733,75 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.18",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.18.tgz",
"integrity": "sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.3.0",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@refinedev/core": { "node_modules/@refinedev/core": {
"version": "5.0.12", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz", "resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz",
@@ -10466,6 +10537,16 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -11260,6 +11341,13 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/char-regex": { "node_modules/char-regex": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@@ -11430,6 +11518,13 @@
} }
] ]
}, },
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -14363,6 +14458,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -16106,6 +16214,16 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -16113,10 +16231,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -16230,6 +16358,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -18369,6 +18504,73 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/openapi-fetch": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
"integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.1.0"
}
},
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.34.6",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.3.0",
"supports-color": "^10.2.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/openapi-typescript-helpers": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
"license": "MIT"
},
"node_modules/openapi-typescript/node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-typescript/node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/openid-client": { "node_modules/openid-client": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -19355,6 +19557,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -20712,19 +20924,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/ts-jest/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -20780,6 +20979,19 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": { "node_modules/typed-array-buffer": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -21162,6 +21374,13 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -21579,6 +21798,13 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+4
View File
@@ -14,6 +14,8 @@
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
"api:check": "node scripts/check-api-contracts.mjs",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh" "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
}, },
"dependencies": { "dependencies": {
@@ -44,6 +46,7 @@
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"ol": "^10.7.0", "ol": "^10.7.0",
"openapi-fetch": "^0.17.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -75,6 +78,7 @@
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"openapi-typescript": "^7.13.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
+8
View File
@@ -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

+31
View File
@@ -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

+57
View File
@@ -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}`,
);
+3 -3
View File
@@ -78,7 +78,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
let cancelled = false; let cancelled = false;
setAccessLoading(true); setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, { apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit", projectHeaderMode: currentProjectId ? "include" : "omit",
skipAuthRedirect: true, skipAuthRedirect: true,
}) })
@@ -110,7 +110,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const auditKey = `tjwater-login-audit:${data.user.id}`; const auditKey = `tjwater-login-audit:${data.user.id}`;
if (sessionStorage.getItem(auditKey)) return; if (sessionStorage.getItem(auditKey)) return;
apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, { apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "login" }), body: JSON.stringify({ event: "login" }),
@@ -137,7 +137,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
}, },
logout: async () => { logout: async () => {
try { try {
await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, { await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "logout" }), body: JSON.stringify({ event: "logout" }),
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function RootLayout({
const defaultMode = theme?.value === "dark" ? "dark" : "light"; const defaultMode = theme?.value === "dark" ? "dark" : "light";
return ( return (
<html lang="en"> <html lang="zh-CN">
<body> <body>
<Script src="/runtime-config.js" strategy="beforeInteractive" /> <Script src="/runtime-config.js" strategy="beforeInteractive" />
<Suspense> <Suspense>
+45
View File
@@ -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
View File
@@ -1,54 +1,259 @@
"use client"; "use client";
import Image from "next/image";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Container from "@mui/material/Container"; import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useLogin } from "@refinedev/core"; import { useLogin } from "@refinedev/core";
import { Title } from "@components/title"; import { PROJECT_TITLE } from "@config/config";
import { useEffect, useRef, useState } from "react";
const FALLBACK_DELAY_MS = 4000;
export default function Login() { export default function Login() {
const { mutate: login } = useLogin(); const { mutate: login } = useLogin();
const loginStartedRef = useRef(false);
const [showFallback, setShowFallback] = useState(false);
useEffect(() => {
if (!loginStartedRef.current) {
loginStartedRef.current = true;
login({});
}
const fallbackTimer = window.setTimeout(
() => setShowFallback(true),
FALLBACK_DELAY_MS,
);
return () => window.clearTimeout(fallbackTimer);
}, [login]);
return ( return (
<Container <Box
style={{ component="main"
height: "100vh", sx={{
display: "flex", minHeight: "100vh",
justifyContent: "center", "@supports (height: 100svh)": {
alignItems: "center", minHeight: "100svh",
},
color: "oklch(0.3 0.055 215)",
bgcolor: "oklch(0.965 0.014 205)",
backgroundImage: `
linear-gradient(
90deg,
transparent 0%,
transparent 50%,
oklch(0.975 0.01 205 / 62%) 68%,
oklch(0.975 0.01 205 / 82%) 100%
),
url("/login-network-blueprint.svg")
`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
"@keyframes tjwaterEnter": {
from: { opacity: 0, transform: "translateY(12px)" },
to: { opacity: 1, transform: "translateY(0)" },
},
}} }}
> >
<Box <Box
display="flex" sx={{
gap="36px" width: "100%",
justifyContent="center" maxWidth: 1720,
flexDirection="column" minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
mx: "auto",
px: {
xs: 1.75,
sm: 3,
md: "clamp(40px, 5vw, 88px)",
},
py: {
xs: "max(28px, env(safe-area-inset-top))",
md: "clamp(32px, 4.5vw, 76px)",
},
display: "grid",
gridTemplateColumns: {
xs: "minmax(0, 1fr)",
md: "minmax(360px, 1fr) minmax(400px, 460px)",
},
gridTemplateAreas: {
xs: '"brand" "status"',
md: '"brand status"',
},
alignContent: { xs: "center", md: "stretch" },
alignItems: "center",
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
}}
> >
<Box display="flex" justifyContent="center"> <Stack
<Title collapsed={false} /> spacing={{ xs: 0, md: 3 }}
</Box> direction={{ xs: "row", md: "column" }}
<Button alignItems={{ xs: "center", md: "flex-start" }}
style={{ width: "240px" }} sx={{
size="large" position: "relative",
variant="contained" isolation: "isolate",
onClick={() => login({})} gridArea: "brand",
width: "fit-content",
maxWidth: "100%",
animation:
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
"&::before": {
position: "absolute",
zIndex: -1,
inset: { xs: "-24px -20px", md: "-54px -72px" },
background: {
xs: `radial-gradient(
ellipse at center,
oklch(0.965 0.014 205 / 98%) 0%,
oklch(0.965 0.014 205 / 88%) 58%,
transparent 84%
)`,
md: `radial-gradient(
ellipse at center,
oklch(0.925 0.018 205 / 98%) 0%,
oklch(0.925 0.018 205 / 94%) 48%,
oklch(0.925 0.018 205 / 62%) 65%,
transparent 82%
)`,
},
pointerEvents: "none",
content: '""',
},
}}
> >
Sign in <Box
</Button> component="img"
<Typography align="center" color={"text.secondary"} fontSize="12px"> src="/login-logo-mark.svg"
Powered by alt=""
<Image width={56}
style={{ padding: "0 5px" }} height={56}
alt="Keycloak" sx={{
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg" width: { xs: 42, md: 56 },
width={18} height: { xs: 42, md: 56 },
height={18} flex: { xs: "0 0 42px", md: "0 0 auto" },
mr: { xs: 1.5, md: 0 },
}}
/> />
Keycloak <Typography
</Typography> component="h1"
sx={{
maxWidth: 680,
m: 0,
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
fontWeight: 720,
lineHeight: { xs: 1.4, md: 1.28 },
textWrap: "balance",
}}
>
{PROJECT_TITLE}
</Typography>
<Box
aria-hidden="true"
sx={{
display: { xs: "none", md: "block" },
width: 64,
height: 3,
borderRadius: 999,
bgcolor: "oklch(0.58 0.12 180)",
}}
/>
</Stack>
<Box
aria-live="polite"
sx={{
gridArea: "status",
width: "100%",
maxWidth: 460,
justifySelf: "stretch",
p: { xs: 2.75, sm: 4.5 },
overflow: "hidden",
borderRadius: { xs: 4, sm: 4.5 },
bgcolor: "oklch(0.995 0.004 205 / 97%)",
boxShadow:
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
animation:
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
}}
>
<Stack spacing={3}>
<Stack direction="row" spacing={2} alignItems="center">
<CircularProgress
size={30}
thickness={4.5}
aria-label="正在连接统一身份认证"
sx={{
flex: "0 0 auto",
color: "oklch(0.57 0.16 242)",
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
"& .MuiCircularProgress-circle": {
animation: "none",
},
},
}}
/>
<Stack spacing={0.5}>
<Typography
component="h2"
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
>
</Typography>
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 14, sm: 15 },
lineHeight: 1.7,
}}
>
</Typography>
</Stack>
</Stack>
{showFallback ? (
<Button
fullWidth
size="large"
variant="contained"
onClick={() => login({})}
sx={{
minHeight: 48,
borderRadius: 3,
fontSize: 16,
fontWeight: 700,
textTransform: "none",
boxShadow:
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
transitionProperty:
"transform, background-color, box-shadow",
"&:active": { transform: "scale(0.96)" },
}}
>
</Button>
) : null}
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 12, sm: 13 },
lineHeight: 1.7,
textAlign: "center",
}}
>
访
</Typography>
</Stack>
</Box>
</Box> </Box>
</Container> </Box>
); );
} }
+2 -2
View File
@@ -640,7 +640,7 @@ export const SystemAdminPanel = () => {
const loadInitialState = async () => { const loadInitialState = async () => {
try { try {
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
projectHeaderMode: "omit", projectHeaderMode: "omit",
skipAuthRedirect: true, skipAuthRedirect: true,
}); });
@@ -947,7 +947,7 @@ export const SystemAdminPanel = () => {
const form = databaseForms[role]; const form = databaseForms[role];
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {}; const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`, `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health-checks`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
+1 -1
View File
@@ -107,7 +107,7 @@ const defaultFilters: AuditFilters = {
end_time: "", end_time: "",
}; };
const AUDIT_LOGS_PATH = "/api/v1/audit/logs"; const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
{ value: "all", label: "全部状态" }, { value: "all", label: "全部状态" },
+2 -2
View File
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
sessionId: "session-1", sessionId: "session-1",
messages: [{ id: "message-1", role: "user", content: "查压力" }], messages: [{ id: "message-1", role: "user", content: "查压力" }],
}); });
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1"); expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" }); expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
}); });
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
isTitleManuallyEdited: true, isTitleManuallyEdited: true,
}); });
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title"); expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" }); expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({ expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
title: "新标题", title: "新标题",
+4 -4
View File
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
const normalizeTitle = (value?: string) => value?.trim() || "新对话"; const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => { const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => { const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{ {
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
isTitleManuallyEdited?: boolean, isTitleManuallyEdited?: boolean,
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{ {
method: "PATCH", method: "PATCH",
headers: { headers: {
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
const deleteBackendChatSession = async (sessionId: string) => { const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{ {
method: "DELETE", method: "DELETE",
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -11,8 +11,7 @@ describe("burst detection request", () => {
const state = createBurstDetectionAnalysisParametersState(); const state = createBurstDetectionAnalysisParametersState();
state.schemeName = " latest-case "; state.schemeName = " latest-case ";
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({ expect(buildBurstDetectionRequest(state)).toEqual({
network: "fengyang",
scheme_name: "latest-case", scheme_name: "latest-case",
sampling_interval_minutes: 15, sampling_interval_minutes: 15,
}); });
@@ -33,10 +32,8 @@ describe("burst detection request", () => {
samplingIntervalMinutes: 30, samplingIntervalMinutes: 30,
samplingIntervalSource: "manual", samplingIntervalSource: "manual",
}, },
"fengyang",
), ),
).toEqual({ ).toEqual({
network: "fengyang",
scheme_name: "history-case", scheme_name: "history-case",
sampling_interval_minutes: 30, sampling_interval_minutes: 30,
target_time: targetTime.toISOString(), target_time: targetTime.toISOString(),
@@ -92,9 +92,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
export const buildBurstDetectionRequest = ( export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState, parameters: BurstDetectionAnalysisParametersState,
network: string,
) => ({ ) => ({
network,
scheme_name: parameters.schemeName.trim(), scheme_name: parameters.schemeName.trim(),
sampling_interval_minutes: parameters.samplingIntervalMinutes, sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime ...(parameters.detectionMode === "historical" && parameters.targetTime
@@ -129,7 +127,7 @@ const AnalysisParameters: React.FC<Props> = ({
let active = true; let active = true;
setFrequencyLoading(true); setFrequencyLoading(true);
api api
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } }) .get("/api/v1/scada-info")
.then((response) => { .then((response) => {
if (!active) return; if (!active) return;
const interval = resolvePressureSamplingInterval( const interval = resolvePressureSamplingInterval(
@@ -186,8 +184,8 @@ const AnalysisParameters: React.FC<Props> = ({
try { try {
const response = await api.post( const response = await api.post(
"/api/v1/burst-detection/detect/", "/api/v1/burst-detections",
buildBurstDetectionRequest(parametersState, NETWORK_NAME), buildBurstDetectionRequest(parametersState),
); );
onResult(response.data as BurstDetectionResult); onResult(response.data as BurstDetectionResult);
open?.({ open?.({
@@ -131,7 +131,6 @@ const SchemeQuery: React.FC<Props> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
network: NETWORK_NAME,
scheme_type: "burst_detection", scheme_type: "burst_detection",
}; };
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
@@ -162,7 +161,7 @@ const SchemeQuery: React.FC<Props> = ({
try { try {
const response = await api.get( const response = await api.get(
`/api/v1/schemes/${encodeURIComponent(schemeName)}`, `/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME, scheme_type: "burst_detection" } }, { params: { scheme_type: "burst_detection" } },
); );
const schemeRecord = response.data as BurstDetectionSchemeRecord & { const schemeRecord = response.data as BurstDetectionSchemeRecord & {
result_payload?: BurstDetectionResult; result_payload?: BurstDetectionResult;
@@ -125,7 +125,7 @@ const AnalysisParameters: React.FC<Props> = ({
setSchemeLoading(true); setSchemeLoading(true);
try { try {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME, scheme_type: "burst_analysis" }, params: { scheme_type: "burst_analysis" },
}); });
const burstSchemes = (response.data as SchemeItem[]).filter( const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis", (scheme) => scheme.scheme_type === "burst_analysis",
@@ -222,9 +222,8 @@ const AnalysisParameters: React.FC<Props> = ({
: undefined; : undefined;
const response = await api.post( const response = await api.post(
`${config.BACKEND_URL}/api/v1/burst-location/locate/`, `${config.BACKEND_URL}/api/v1/burst-locations`,
{ {
network: NETWORK_NAME,
data_source: dataSource, data_source: dataSource,
scheme_name: schemeName.trim() || undefined, scheme_name: schemeName.trim() || undefined,
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT), burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
@@ -228,7 +228,6 @@ const SchemeQuery: React.FC<Props> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
network: NETWORK_NAME,
scheme_type: "burst_location", scheme_type: "burst_location",
}; };
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
@@ -238,7 +237,7 @@ const SchemeQuery: React.FC<Props> = ({
const [response, simulationResponse] = await Promise.all([ const [response, simulationResponse] = await Promise.all([
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }), api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME, scheme_type: "burst_analysis" }, params: { scheme_type: "burst_analysis" },
}), }),
]); ]);
const nextSchemes = response.data as BurstSchemeRecord[]; const nextSchemes = response.data as BurstSchemeRecord[];
@@ -281,7 +280,7 @@ const SchemeQuery: React.FC<Props> = ({
try { try {
const response = await api.get( const response = await api.get(
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`, `${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME, scheme_type: "burst_location" } }, { params: { scheme_type: "burst_location" } },
); );
const schemeRecord = response.data as BurstSchemeRecord & { const schemeRecord = response.data as BurstSchemeRecord & {
result_payload?: BurstLocationResult; result_payload?: BurstLocationResult;
@@ -322,16 +322,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
: ""; : "";
const modify_total_duration = duration; const modify_total_duration = duration;
const params = { const params = {
network: network,
modify_pattern_start_time: modify_pattern_start_time, modify_pattern_start_time: modify_pattern_start_time,
burst_ID: burst_ID, burst_id: burst_ID,
burst_size: burst_size, burst_size: burst_size,
modify_total_duration: modify_total_duration, modify_total_duration: modify_total_duration,
scheme_name: schemeName, scheme_name: schemeName,
}; };
try { try {
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, { await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
params, params,
paramsSerializer: { paramsSerializer: {
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
@@ -29,9 +29,12 @@ import {
PrintOutlined, PrintOutlined,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { import {
clearPipeDiameterCache,
getCachedPipeDiameters,
getPipeDiameterDisplay, getPipeDiameterDisplay,
getPipeDiameterQueryKey,
loadPipeDiameters,
type PipeDiameterMap, type PipeDiameterMap,
} from "./schemePipeDiameters"; } from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types"; import { SchemeRecord, ValveIsolationResult } from "./types";
@@ -54,66 +57,7 @@ const REPORT_BLUE = "#0b4f87";
const REPORT_INK = "#172435"; const REPORT_INK = "#172435";
const REPORT_MUTED = "#5f6f80"; const REPORT_MUTED = "#5f6f80";
const REPORT_LINE = "#d8e0e8"; const REPORT_LINE = "#d8e0e8";
const diameterCache = new Map<string, PipeDiameterMap>(); export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
export const clearAnalysisReportDiameterCache = () => {
diameterCache.clear();
diameterRequestCache.clear();
};
const loadPipeDiameters = async (
pipeIds: string[],
queryKey: string,
): Promise<PipeDiameterMap> => {
const cachedDiameters = diameterCache.get(queryKey);
if (cachedDiameters) {
return cachedDiameters;
}
const cachedRequest = diameterRequestCache.get(queryKey);
if (cachedRequest) {
return cachedRequest;
}
const request = (async () => {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
const foundIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingIds = pipeIds.filter((pipeId) => !foundIds.has(pipeId));
if (missingIds.length) {
features = [
...features,
...(await queryFeaturesByIds(missingIds, "geo_pipes")),
];
}
const diameters: PipeDiameterMap = Object.fromEntries(
pipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
if (pipeIds.includes(pipeId)) {
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
}
});
diameterCache.set(queryKey, diameters);
return diameters;
})();
diameterRequestCache.set(queryKey, request);
try {
return await request;
} finally {
if (diameterRequestCache.get(queryKey) === request) {
diameterRequestCache.delete(queryKey);
}
}
};
const formatDateTime = (value: Date | string | null | undefined) => { const formatDateTime = (value: Date | string | null | undefined) => {
if (!value) return "未记录"; if (!value) return "未记录";
@@ -411,8 +355,8 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
() => scheme?.schemeDetail?.burst_ID ?? [], () => scheme?.schemeDetail?.burst_ID ?? [],
[scheme], [scheme],
); );
const diameterQueryKey = pipeIds.join("\u0000"); const diameterQueryKey = getPipeDiameterQueryKey(pipeIds);
const cachedDiameters = diameterCache.get(diameterQueryKey); const cachedDiameters = getCachedPipeDiameters(pipeIds);
const [diameterState, setDiameterState] = useState<{ const [diameterState, setDiameterState] = useState<{
queryKey: string | null; queryKey: string | null;
values: PipeDiameterMap; values: PipeDiameterMap;
@@ -435,13 +379,13 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
if (!pipeIds.length) { if (!pipeIds.length) {
return; return;
} }
if (diameterCache.has(diameterQueryKey)) { if (getCachedPipeDiameters(pipeIds)) {
return; return;
} }
let cancelled = false; let cancelled = false;
loadPipeDiameters(pipeIds, diameterQueryKey) loadPipeDiameters(pipeIds)
.then((nextDiameters) => { .then((nextDiameters) => {
if (!cancelled) { if (!cancelled) {
setDiameterState({ setDiameterState({
@@ -53,7 +53,9 @@ import { toLonLat } from "ol/proj";
import Timeline from "@components/olmap/core/Controls/Timeline"; import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemaItem, SchemeRecord } from "./types"; import { SchemaItem, SchemeRecord } from "./types";
import { import {
getCachedPipeDiameters,
getPipeDiameterDisplay, getPipeDiameterDisplay,
loadPipeDiameters,
type PipeDiameterMap, type PipeDiameterMap,
} from "./schemePipeDiameters"; } from "./schemePipeDiameters";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
@@ -160,7 +162,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
network,
scheme_type: SCHEME_TYPE, scheme_type: SCHEME_TYPE,
}; };
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
@@ -251,40 +252,23 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
return; return;
} }
const cachedDiameters = getCachedPipeDiameters(pipeIds);
if (cachedDiameters) {
setPipeDiametersByScheme((previous) => ({
...previous,
[expandedId]: cachedDiameters,
}));
return;
}
let cancelled = false; let cancelled = false;
setLoadingDiameterByScheme((previous) => ({ setLoadingDiameterByScheme((previous) => ({
...previous, ...previous,
[expandedId]: true, [expandedId]: true,
})); }));
const loadPipeDiameters = async () => { const applyPipeDiameters = async () => {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat"); const nextDiameters = await loadPipeDiameters(pipeIds);
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = pipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
const nextDiameters: PipeDiameterMap = Object.fromEntries(
pipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
});
if (cancelled) { if (cancelled) {
return; return;
} }
@@ -299,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
})); }));
}; };
loadPipeDiameters().catch((error) => { applyPipeDiameters().catch((error) => {
console.error("查询管径失败:", error); console.error("查询管径失败:", error);
if (!cancelled) { if (!cancelled) {
setLoadingDiameterByScheme((previous) => ({ setLoadingDiameterByScheme((previous) => ({
@@ -361,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
} }
try { try {
const params: any = { const params: any = {
network: NETWORK_NAME,
accident_element: ids, accident_element: ids,
}; };
if (disabled.length > 0) { if (disabled.length > 0) {
params.disabled_valves = disabled; params.disabled_valves = disabled;
} }
const response = await api.get<ValveIsolationResult>( const response = await api.post<ValveIsolationResult>(
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`, `${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
undefined,
{ {
params, params,
paramsSerializer: { paramsSerializer: {
@@ -1,4 +1,48 @@
import { getPipeDiameterDisplay } from "./schemePipeDiameters"; import { queryFeaturesByIds } from "@/utils/mapQueryService";
import {
clearPipeDiameterCache,
getPipeDiameterDisplay,
loadPipeDiameters,
} from "./schemePipeDiameters";
jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(),
}));
const feature = (id: string, diameter: number) => ({
getProperties: () => ({ id, diameter }),
});
afterEach(() => {
clearPipeDiameterCache();
jest.clearAllMocks();
});
describe("loadPipeDiameters", () => {
it("shares an in-flight WFS request and reuses its resolved result", async () => {
let resolveFeatures: ((features: ReturnType<typeof feature>[]) => void) | undefined;
const pendingFeatures = new Promise<ReturnType<typeof feature>[]>((resolve) => {
resolveFeatures = resolve;
});
(queryFeaturesByIds as jest.Mock).mockImplementation(
async () => pendingFeatures,
);
const firstRequest = loadPipeDiameters(["P-1"]);
const duplicateRequest = loadPipeDiameters(["P-1"]);
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
resolveFeatures?.([feature("P-1", 315)]);
await expect(firstRequest).resolves.toEqual({ "P-1": 315 });
await expect(duplicateRequest).resolves.toEqual({ "P-1": 315 });
await expect(loadPipeDiameters(["P-1"])).resolves.toEqual({
"P-1": 315,
});
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
});
});
describe("getPipeDiameterDisplay", () => { describe("getPipeDiameterDisplay", () => {
it("shows the actual diameter for one burst pipe", () => { it("shows the actual diameter for one burst pipe", () => {
@@ -1,5 +1,94 @@
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import config from "@config/config";
export type PipeDiameterMap = Record<string, number | null | undefined>; export type PipeDiameterMap = Record<string, number | null | undefined>;
const diameterCache = new Map<string, PipeDiameterMap>();
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
const normalizePipeIds = (pipeIds: string[]) =>
[...new Set(pipeIds.map((pipeId) => String(pipeId).trim()).filter(Boolean))];
export const getPipeDiameterQueryKey = (pipeIds: string[]) =>
[
config.MAP_URL,
config.MAP_WORKSPACE,
...normalizePipeIds(pipeIds).sort(),
].join("\u0000");
export const getCachedPipeDiameters = (pipeIds: string[]) =>
diameterCache.get(getPipeDiameterQueryKey(pipeIds));
export const clearPipeDiameterCache = () => {
diameterCache.clear();
diameterRequestCache.clear();
};
export const loadPipeDiameters = async (
pipeIds: string[],
): Promise<PipeDiameterMap> => {
const normalizedPipeIds = normalizePipeIds(pipeIds);
if (normalizedPipeIds.length === 0) {
return {};
}
const queryKey = getPipeDiameterQueryKey(normalizedPipeIds);
const cachedDiameters = diameterCache.get(queryKey);
if (cachedDiameters) {
return cachedDiameters;
}
const cachedRequest = diameterRequestCache.get(queryKey);
if (cachedRequest) {
return cachedRequest;
}
const request = (async () => {
let features = await queryFeaturesByIds(
normalizedPipeIds,
"geo_pipes_mat",
);
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = normalizedPipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
const diameters: PipeDiameterMap = Object.fromEntries(
normalizedPipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
if (pipeId in diameters) {
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
}
});
diameterCache.set(queryKey, diameters);
return diameters;
})();
diameterRequestCache.set(queryKey, request);
try {
return await request;
} finally {
if (diameterRequestCache.get(queryKey) === request) {
diameterRequestCache.delete(queryKey);
}
}
};
export const getPipeDiameterDisplay = ( export const getPipeDiameterDisplay = (
pipeIds: string[] | undefined, pipeIds: string[] | undefined,
diameters: PipeDiameterMap | undefined, diameters: PipeDiameterMap | undefined,
@@ -228,7 +228,6 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
console.log("默认设置 pattern 为 CONSTANT"); console.log("默认设置 pattern 为 CONSTANT");
} }
const params = { const params = {
network,
start_time: start_time, start_time: start_time,
source: sourceNode, source: sourceNode,
concentration, concentration,
@@ -237,7 +236,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
scheme_name: schemeName, scheme_name: schemeName,
}; };
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, { await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
params, params,
}); });
@@ -223,7 +223,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
network,
scheme_type: SCHEME_TYPE, scheme_type: SCHEME_TYPE,
}; };
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
@@ -18,7 +18,7 @@ import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config"; import { config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { LeakageResultDetail } from "./types"; import { LeakageResultDetail } from "./types";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
@@ -108,9 +108,8 @@ const AnalysisParameters: React.FC<Props> = ({
}); });
try { try {
const response = await api.post( const response = await api.post(
`${config.BACKEND_URL}/api/v1/leakage/identify/`, `${config.BACKEND_URL}/api/v1/leakage-identifications`,
{ {
network: NETWORK_NAME,
scheme_name: schemeName.trim(), scheme_name: schemeName.trim(),
dma_count: dmaCount, dma_count: dmaCount,
scada_start: startTime.toISOString(), scada_start: startTime.toISOString(),
@@ -85,7 +85,6 @@ const SchemeQuery: React.FC<Props> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
network: NETWORK_NAME,
scheme_type: "dma_leak_identification", scheme_type: "dma_leak_identification",
}; };
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
@@ -129,7 +128,6 @@ const SchemeQuery: React.FC<Props> = ({
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`, `${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ {
params: { params: {
network: NETWORK_NAME,
scheme_type: "dma_leak_identification", scheme_type: "dma_leak_identification",
}, },
}, },
@@ -307,11 +307,10 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
const params = { const params = {
scheme_name: schemeName, scheme_name: schemeName,
network: NETWORK_NAME,
start_time: formattedTime, start_time: formattedTime,
valves: valves.map(v => v.id), valves: valves.map(v => v.id),
valves_k: valves.map(v => v.k), valves_k: valves.map(v => v.k),
drainage_node_ID: drainageNode, drainage_node_id: drainageNode,
flush_flow: flushFlow, flush_flow: flushFlow,
duration: duration duration: duration
}; };
@@ -320,7 +319,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
// but axios usually handles array as valves[]=1&valves[]=2 // but axios usually handles array as valves[]=1&valves[]=2
// FastAPI default expects repeated query params. // FastAPI default expects repeated query params.
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, { const response = await api.post(`${config.BACKEND_URL}/api/v1/flushing-analyses`, undefined, {
params, params,
// Ensure arrays are sent as repeated keys: valves=1&valves=2 // Ensure arrays are sent as repeated keys: valves=1&valves=2
paramsSerializer: { paramsSerializer: {
@@ -269,7 +269,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
network,
scheme_type: SCHEME_TYPE, scheme_type: SCHEME_TYPE,
}; };
if (!queryAll && queryDate) { if (!queryAll && queryDate) {
@@ -392,7 +392,7 @@ const Timeline: React.FC<TimelineProps> = ({
}); });
try { try {
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`, `${config.BACKEND_URL}/api/v1/pipeline-health-predictions?query_time=${query_time}`,
); );
if (response.ok) { if (response.ok) {
@@ -135,7 +135,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
const handleOpenScheme = async (schemeId: number) => { const handleOpenScheme = async (schemeId: number) => {
setLoadingScheme(true); setLoadingScheme(true);
try { try {
const scheme = await getSensorPlacementScheme(NETWORK_NAME, schemeId); const scheme = await getSensorPlacementScheme(schemeId);
setActiveScheme(scheme); setActiveScheme(scheme);
setLoadingScheme(false); setLoadingScheme(false);
setPendingOpenSchemeId(scheme.id); setPendingOpenSchemeId(scheme.id);
@@ -90,7 +90,6 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
try { try {
const created = await optimizeSensorPlacement({ const created = await optimizeSensorPlacement({
network,
scheme_name: schemeName, scheme_name: schemeName,
sensor_type: "pressure", sensor_type: "pressure",
method: method as "sensitivity" | "kmeans", method: method as "sensitivity" | "kmeans",
@@ -0,0 +1,176 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import SchemeEditor from "./SchemeEditor";
import type { SensorPlacementScheme } from "./types";
const mockOpen = jest.fn();
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
const mockMap = {
addLayer: jest.fn(),
removeLayer: jest.fn(),
on: jest.fn((eventName: string, handler: (event: unknown) => void) => {
if (eventName === "singleclick") {
mockSingleClickHandler = handler;
}
}),
un: jest.fn(),
forEachFeatureAtPixel: jest.fn(() => undefined),
getView: jest.fn(() => ({
animate: jest.fn(),
getZoom: jest.fn(() => 16),
getResolution: jest.fn(() => 1),
})),
};
jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: mockOpen }),
}));
jest.mock("@components/olmap/core/MapComponent", () => ({
useMap: () => mockMap,
}));
jest.mock("@mui/x-data-grid", () => ({
DataGrid: () => <div data-testid="scheme-grid" />,
GridToolbar: () => null,
}));
jest.mock("@mui/x-data-grid/locales", () => ({
zhCN: {
components: {
MuiDataGrid: {
defaultProps: {
localeText: {},
},
},
},
},
}));
jest.mock("ol/Feature", () => ({
__esModule: true,
default: class {
private values: Record<string, unknown>;
constructor(values: Record<string, unknown>) {
this.values = values;
}
get(key: string) {
return this.values[key];
}
setId() {}
},
}));
jest.mock("ol/geom/Point", () => ({
__esModule: true,
default: class {
constructor(_coordinates: number[]) {}
},
}));
jest.mock("ol/source/Vector", () => ({
__esModule: true,
default: class {
clear() {}
addFeature() {}
},
}));
jest.mock("ol/layer/Vector", () => ({
__esModule: true,
default: class {
private source: { clear: () => void; addFeature: () => void };
constructor(options: {
source: { clear: () => void; addFeature: () => void };
}) {
this.source = options.source;
}
getSource() {
return this.source;
}
setVisible() {}
},
}));
jest.mock("ol/style", () => ({
Circle: class {},
Fill: class {},
Stroke: class {},
Style: class {},
Text: class {},
}));
jest.mock("ol/proj", () => ({
fromLonLat: (coordinates: number[]) => coordinates,
toLonLat: (coordinates: number[]) => coordinates,
}));
jest.mock("@/utils/mapQueryService", () => ({
handleMapClickSelectFeatures: jest.fn(),
}));
jest.mock("./SchemeDrawingDialog", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("./schemeApi", () => ({
exportSensorPlacementExcel: jest.fn(),
overwriteSensorPlacementScheme: jest.fn(),
}));
const scheme: SensorPlacementScheme = {
id: 1,
scheme_name: "测试方案",
sensor_number: 1,
min_diameter: 300,
username: "operator",
create_time: "2026-07-30T08:00:00+08:00",
sensor_location: ["J1"],
sensor_points: [
{
node_id: "J1",
project_x: 10,
project_y: 20,
map_x: 13500010,
map_y: 3600020,
longitude: 121,
latitude: 31,
elevation: 5,
},
],
can_edit: true,
};
describe("SchemeEditor notifications", () => {
beforeEach(() => {
jest.clearAllMocks();
mockSingleClickHandler = undefined;
});
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
render(<SchemeEditor scheme={scheme} network="fengyang" />);
fireEvent.click(screen.getByRole("button", { name: "替换" }));
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
act(() => {
mockSingleClickHandler?.({
pixel: [0, 0],
coordinate: [13500000, 3600000],
stopPropagation: jest.fn(),
});
});
expect(mockOpen).toHaveBeenCalledWith({
type: "error",
message: "请先点击要替换的监测点",
});
});
});
@@ -28,7 +28,8 @@ import {
EditLocationAlt as ReplaceIcon, EditLocationAlt as ReplaceIcon,
LocationOn as LocateIcon, LocationOn as LocateIcon,
Map as MapIcon, Map as MapIcon,
Redo as ResetIcon, Redo as RedoIcon,
RestartAlt as ResetIcon,
Save as SaveIcon, Save as SaveIcon,
Undo as UndoIcon, Undo as UndoIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
@@ -55,6 +56,7 @@ import {
createSchemeEditorState, createSchemeEditorState,
deleteSensorPoint, deleteSensorPoint,
isSchemeDirty, isSchemeDirty,
redoSchemeEdit,
replaceSensorPoint, replaceSensorPoint,
resetSchemeEdit, resetSchemeEdit,
summarizeChanges, summarizeChanges,
@@ -230,8 +232,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
x: number; x: number;
y: number; y: number;
elevation: number; elevation: number;
}>(`${config.BACKEND_URL}/api/v1/getjunctionproperties/`, { }>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
params: { network, junction: nodeId }, params: { junction: nodeId },
}); });
if (!response.data?.id) return null; if (!response.data?.id) return null;
const [longitude, latitude] = toLonLat([mapX, mapY]); const [longitude, latitude] = toLonLat([mapX, mapY]);
@@ -249,7 +251,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
return null; return null;
} }
}, },
[map, network], [map],
); );
useEffect(() => { useEffect(() => {
@@ -275,7 +277,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
if (mode === "delete") { if (mode === "delete") {
if (!markerNodeId) { if (!markerNodeId) {
open?.({ type: "progress", message: "请点击要删除的监测点" }); open?.({ type: "error", message: "请点击要删除的监测点" });
return; return;
} }
setEditor((current) => deleteSensorPoint(current, markerNodeId)); setEditor((current) => deleteSensorPoint(current, markerNodeId));
@@ -285,7 +287,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
if (mode === "replace" && !replaceSourceId) { if (mode === "replace" && !replaceSourceId) {
if (!markerNodeId) { if (!markerNodeId) {
open?.({ type: "progress", message: "请先点击要替换的监测点" }); open?.({ type: "error", message: "请先点击要替换的监测点" });
return; return;
} }
setReplaceSourceId(markerNodeId); setReplaceSourceId(markerNodeId);
@@ -295,7 +297,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
const candidate = await resolveJunction(event); const candidate = await resolveJunction(event);
if (!candidate) { if (!candidate) {
open?.({ type: "progress", message: "请选择有效的管网节点" }); open?.({ type: "error", message: "请选择有效的管网节点" });
return; return;
} }
@@ -304,7 +306,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
(point) => point.node_id === candidate.node_id, (point) => point.node_id === candidate.node_id,
); );
if (exists) { if (exists) {
open?.({ type: "progress", message: "该节点已在当前方案中" }); open?.({ type: "error", message: "该节点已在当前方案中" });
return; return;
} }
setEditor((current) => addSensorPoint(current, candidate)); setEditor((current) => addSensorPoint(current, candidate));
@@ -316,7 +318,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
point.node_id !== replaceSourceId, point.node_id !== replaceSourceId,
); );
if (duplicate) { if (duplicate) {
open?.({ type: "progress", message: "目标节点已在当前方案中" }); open?.({ type: "error", message: "目标节点已在当前方案中" });
return; return;
} }
setEditor((current) => setEditor((current) =>
@@ -379,7 +381,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
setSaving(true); setSaving(true);
try { try {
const updated = await overwriteSensorPlacementScheme( const updated = await overwriteSensorPlacementScheme(
network,
scheme.id, scheme.id,
editor.baseline.map((point) => point.node_id), editor.baseline.map((point) => point.node_id),
editor.points.map((point) => point.node_id), editor.points.map((point) => point.node_id),
@@ -410,7 +411,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
setExporting(true); setExporting(true);
try { try {
const blob = await exportSensorPlacementExcel( const blob = await exportSensorPlacementExcel(
network,
scheme.id, scheme.id,
editor.points.map((point) => point.node_id), editor.points.map((point) => point.node_id),
editor.statuses, editor.statuses,
@@ -693,6 +693,19 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
</IconButton> </IconButton>
</span> </span>
</Tooltip> </Tooltip>
<Tooltip title="重做下一步">
<span>
<IconButton
aria-label="重做下一步"
size="small"
disabled={!editor.future.length}
onClick={() => setEditor((current) => redoSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<RedoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="重置为服务器版本"> <Tooltip title="重置为服务器版本">
<span> <span>
<IconButton <IconButton
@@ -177,7 +177,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true); setLoading(true);
try { try {
const response = await api.get( const response = await api.get(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`, `${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
); );
let filteredResults = response.data; let filteredResults = response.data;
@@ -6,7 +6,6 @@ import type {
} from "./types"; } from "./types";
export interface OptimizeSchemeInput { export interface OptimizeSchemeInput {
network: string;
scheme_name: string; scheme_name: string;
sensor_type: "pressure"; sensor_type: "pressure";
method: "sensitivity" | "kmeans"; method: "sensitivity" | "kmeans";
@@ -18,25 +17,22 @@ export const optimizeSensorPlacement = async (
input: OptimizeSchemeInput, input: OptimizeSchemeInput,
): Promise<SensorPlacementScheme> => { ): Promise<SensorPlacementScheme> => {
const response = await api.post<SensorPlacementScheme>( const response = await api.post<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/optimize`, `${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`,
input, input,
); );
return response.data; return response.data;
}; };
export const getSensorPlacementScheme = async ( export const getSensorPlacementScheme = async (
network: string,
schemeId: number, schemeId: number,
): Promise<SensorPlacementScheme> => { ): Promise<SensorPlacementScheme> => {
const response = await api.get<SensorPlacementScheme>( const response = await api.get<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`, `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
{ params: { network } },
); );
return response.data; return response.data;
}; };
export const overwriteSensorPlacementScheme = async ( export const overwriteSensorPlacementScheme = async (
network: string,
schemeId: number, schemeId: number,
expectedSensorLocation: string[], expectedSensorLocation: string[],
sensorLocation: string[], sensorLocation: string[],
@@ -47,13 +43,11 @@ export const overwriteSensorPlacementScheme = async (
expected_sensor_location: expectedSensorLocation, expected_sensor_location: expectedSensorLocation,
sensor_location: sensorLocation, sensor_location: sensorLocation,
}, },
{ params: { network } },
); );
return response.data; return response.data;
}; };
export const exportSensorPlacementExcel = async ( export const exportSensorPlacementExcel = async (
network: string,
schemeId: number, schemeId: number,
sensorLocation: string[], sensorLocation: string[],
adjustmentStatus: Record<string, AdjustmentStatus>, adjustmentStatus: Record<string, AdjustmentStatus>,
@@ -65,7 +59,6 @@ export const exportSensorPlacementExcel = async (
adjustment_status: adjustmentStatus, adjustment_status: adjustmentStatus,
}, },
{ {
params: { network },
responseType: "blob", responseType: "blob",
}, },
); );
@@ -3,6 +3,7 @@ import {
createSchemeEditorState, createSchemeEditorState,
deleteSensorPoint, deleteSensorPoint,
isSchemeDirty, isSchemeDirty,
redoSchemeEdit,
replaceSensorPoint, replaceSensorPoint,
resetSchemeEdit, resetSchemeEdit,
summarizeChanges, summarizeChanges,
@@ -44,6 +45,24 @@ describe("scheme editor", () => {
expect(undoSchemeEdit(added).points).toEqual(initial.points); expect(undoSchemeEdit(added).points).toEqual(initial.points);
}); });
it("can redo an undone edit and clears redo history after a new edit", () => {
const initial = createSchemeEditorState(scheme);
const added = addSensorPoint(initial, point("J3"));
const undone = undoSchemeEdit(added);
const redone = redoSchemeEdit(undone);
expect(redone.points.map((item) => item.node_id)).toEqual([
"J1",
"J2",
"J3",
]);
expect(redone.history).toHaveLength(1);
expect(redone.future).toEqual([]);
const editedAfterUndo = replaceSensorPoint(undone, "J1", point("J4"));
expect(editedAfterUndo.future).toEqual([]);
});
it("replaces a node without changing row order", () => { it("replaces a node without changing row order", () => {
const initial = createSchemeEditorState(scheme); const initial = createSchemeEditorState(scheme);
const replaced = replaceSensorPoint(initial, "J1", point("J3")); const replaced = replaceSensorPoint(initial, "J1", point("J3"));
@@ -80,5 +99,6 @@ describe("scheme editor", () => {
expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]); expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]);
expect(isSchemeDirty(reset)).toBe(false); expect(isSchemeDirty(reset)).toBe(false);
expect(reset.history).toEqual([]); expect(reset.history).toEqual([]);
expect(reset.future).toEqual([]);
}); });
}); });
@@ -13,6 +13,7 @@ export interface SchemeEditorSnapshot {
export interface SchemeEditorState extends SchemeEditorSnapshot { export interface SchemeEditorState extends SchemeEditorSnapshot {
baseline: SensorPoint[]; baseline: SensorPoint[];
history: SchemeEditorSnapshot[]; history: SchemeEditorSnapshot[];
future: SchemeEditorSnapshot[];
} }
const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point })); const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point }));
@@ -30,6 +31,7 @@ export const createSchemeEditorState = (
points: clonePoints(scheme.sensor_points), points: clonePoints(scheme.sensor_points),
statuses: currentStatuses(scheme.sensor_points), statuses: currentStatuses(scheme.sensor_points),
history: [], history: [],
future: [],
}); });
const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({ const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({
@@ -46,6 +48,7 @@ const withHistory = (
points, points,
statuses, statuses,
history: [...state.history, snapshot(state)], history: [...state.history, snapshot(state)],
future: [],
}); });
export const addSensorPoint = ( export const addSensorPoint = (
@@ -114,6 +117,19 @@ export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
points: clonePoints(previous.points), points: clonePoints(previous.points),
statuses: { ...previous.statuses }, statuses: { ...previous.statuses },
history: state.history.slice(0, -1), history: state.history.slice(0, -1),
future: [...state.future, snapshot(state)],
};
};
export const redoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
const next = state.future[state.future.length - 1];
if (!next) return state;
return {
...state,
points: clonePoints(next.points),
statuses: { ...next.statuses },
history: [...state.history, snapshot(state)],
future: state.future.slice(0, -1),
}; };
}; };
@@ -122,6 +138,7 @@ export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState =>
points: clonePoints(state.baseline), points: clonePoints(state.baseline),
statuses: currentStatuses(state.baseline), statuses: currentStatuses(state.baseline),
history: [], history: [],
future: [],
}); });
export const isSchemeDirty = (state: SchemeEditorState) => { export const isSchemeDirty = (state: SchemeEditorState) => {
@@ -105,11 +105,11 @@ const fetchFromBackend = async (
const start_time = dayjs(range.from).toISOString(); const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString(); const end_time = dayjs(range.to).toISOString();
// 清洗数据接口 // 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`; const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据 // 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`; const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口 // 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`; const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try { try {
// 优先查询清洗数据和模拟数据 // 优先查询清洗数据和模拟数据
const [cleaningRes, simulationRes] = await Promise.all([ const [cleaningRes, simulationRes] = await Promise.all([
@@ -351,11 +351,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const end_time = dayjs(range.to).toISOString(); const end_time = dayjs(range.to).toISOString();
// 清洗数据接口 // 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`; const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据 // 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`; const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口 // 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`; const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try { try {
const [cleanRes, rawRes, simRes] = await Promise.all([ const [cleanRes, rawRes, simRes] = await Promise.all([
apiFetch(cleaningDataUrl) apiFetch(cleaningDataUrl)
@@ -530,7 +530,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const response = await api.post( const response = await api.post(
`${ `${
config.BACKEND_URL config.BACKEND_URL
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join( }/api/v1/timeseries/scada-cleaning-runs?device_ids=${deviceIds.join(
",", ",",
)}&start_time=${startTime}&end_time=${endTime}`, )}&start_time=${startTime}&end_time=${endTime}`,
); );
@@ -624,7 +624,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
// 调用后端清洗接口 // 调用后端清洗接口
const response = await api.post( const response = await api.post(
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`, `${config.BACKEND_URL}/api/v1/timeseries/scada-cleaning-runs?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
); );
// 处理成功响应 // 处理成功响应
@@ -108,13 +108,13 @@ const fetchFromBackend = async (
.join(","); .join(",");
// 监测值数据接口(use_cleaned=false // 监测值数据接口(use_cleaned=false
const rawDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`; const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`;
// 清洗数据接口(use_cleaned=true // 清洗数据接口(use_cleaned=true
const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`; const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`;
// 模拟数据接口 // 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`; const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
// 策略模拟数据接口 // 策略模拟数据接口
const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`; const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`;
try { try {
if (type === "none") { if (type === "none") {
@@ -241,11 +241,11 @@ const Timeline: React.FC<TimelineProps> = ({
nodePromise = nodePromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeName
? apiFetch( ? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`, `${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
{ signal }, { signal },
) )
: apiFetch( : apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`, `${config.BACKEND_URL}/api/v1/timeseries/realtime/records?query_time=${query_time}&type=node&property=${junctionProperties}`,
{ signal }, { signal },
); );
requests.push(nodePromise); requests.push(nodePromise);
@@ -270,11 +270,11 @@ const Timeline: React.FC<TimelineProps> = ({
linkPromise = linkPromise =
sourceType === "scheme" && schemeName sourceType === "scheme" && schemeName
? apiFetch( ? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`, `${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
{ signal }, { signal },
) )
: apiFetch( : apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`, `${config.BACKEND_URL}/api/v1/timeseries/realtime/records?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
{ signal }, { signal },
); );
requests.push(linkPromise); requests.push(linkPromise);
@@ -717,13 +717,12 @@ const Timeline: React.FC<TimelineProps> = ({
}); });
try { try {
const body = { const body = {
name: NETWORK_NAME,
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"), start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
duration: calculatedInterval, duration: calculatedInterval,
}; };
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/simulations/run-by-date`, `${config.BACKEND_URL}/api/v1/simulation-runs`,
{ {
method: "POST", method: "POST",
headers: { headers: {
+9 -14
View File
@@ -431,11 +431,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
network: networkName,
link: selectedValveId, link: selectedValveId,
}); });
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/getstatus/?${params.toString()}`, `${config.BACKEND_URL}/api/v1/status?${params.toString()}`,
); );
if (!response.ok) { if (!response.ok) {
@@ -484,11 +483,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
network: networkName,
valve: selectedValveId, valve: selectedValveId,
}); });
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/getvalveproperties/?${params.toString()}`, `${config.BACKEND_URL}/api/v1/valves/properties?${params.toString()}`,
); );
if (!response.ok) { if (!response.ok) {
@@ -534,13 +532,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
setIsValveStatusSaving(true); setIsValveStatusSaving(true);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
network: networkName,
link: selectedValveId, link: selectedValveId,
}); });
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/setstatus/?${params.toString()}`, `${config.BACKEND_URL}/api/v1/status-properties?${params.toString()}`,
{ {
method: "POST", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@@ -567,7 +564,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
setIsValveStatusSaving(false); setIsValveStatusSaving(false);
} }
}, },
[networkName, open, selectedValveId], [open, selectedValveId],
); );
const handleValveSettingSave = useCallback( const handleValveSettingSave = useCallback(
@@ -600,13 +597,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
setIsValveSettingSaving(true); setIsValveSettingSaving(true);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
network: networkName,
valve: selectedValveId, valve: selectedValveId,
}); });
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/setvalveproperties/?${params.toString()}`, `${config.BACKEND_URL}/api/v1/valves/properties?${params.toString()}`,
{ {
method: "POST", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@@ -637,7 +633,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
} }
}, },
[ [
networkName,
open, open,
selectedValveId, selectedValveId,
selectedValveType, selectedValveType,
@@ -679,12 +674,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
if (queryType === "scheme") { if (queryType === "scheme") {
response = await apiFetch( response = await apiFetch(
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}` // `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`, `${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
); );
} else { } else {
response = await apiFetch( response = await apiFetch(
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}` // `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`, `${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?id=${id}&type=${type}&query_time=${querytime}`,
); );
} }
if (!response.ok) { if (!response.ok) {
@@ -32,7 +32,7 @@ describe("useTimelineTimeConfig", () => {
}); });
expect(result.current.durationMinutes).toBe(1440); expect(result.current.durationMinutes).toBe(1440);
expect(String(apiFetch.mock.calls[0][0])).toContain( expect(String(apiFetch.mock.calls[0][0])).toContain(
"/api/v1/gettimeproperties/?network=test-network", "/api/v1/network-options/time",
); );
}); });
@@ -68,9 +68,7 @@ export const useTimelineTimeConfig = (): TimelineTimeConfig => {
const fetchTimeProperties = async () => { const fetchTimeProperties = async () => {
try { try {
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/gettimeproperties/?network=${encodeURIComponent( `${config.BACKEND_URL}/api/v1/network-options/time`,
networkName,
)}`,
); );
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch time properties: ${response.status}`); throw new Error(`Failed to fetch time properties: ${response.status}`);
@@ -164,7 +164,7 @@ export const useToolbarChatActions = ({
? `?session_id=${encodeURIComponent(action.sessionId)}` ? `?session_id=${encodeURIComponent(action.sessionId)}`
: ""; : "";
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/render-ref/${encodeURIComponent(action.renderRef)}${query}`, `${config.AGENT_URL}/api/v1/agent/render-references/${encodeURIComponent(action.renderRef)}${query}`,
{ {
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
+1 -1
View File
@@ -62,7 +62,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
setLoadError(null); setLoadError(null);
try { try {
const response = await apiFetch( const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/meta/projects`, `${config.BACKEND_URL}/api/v1/projects`,
{ projectHeaderMode: "omit" }, { projectHeaderMode: "omit" },
); );
if (!response.ok) { if (!response.ok) {
+3 -2
View File
@@ -77,10 +77,11 @@ export const setMapExtent = (extent: number[]) => {
export const MAPBOX_TOKEN = runtimeConfig.MAPBOX_TOKEN || ""; export const MAPBOX_TOKEN = runtimeConfig.MAPBOX_TOKEN || "";
export const TIANDITU_TOKEN = runtimeConfig.TIANDITU_TOKEN || ""; export const TIANDITU_TOKEN = runtimeConfig.TIANDITU_TOKEN || "";
export const PROJECT_TITLE = process.env.PROJECT_TITLE || "TJWater Project"; export const PROJECT_TITLE =
process.env.PROJECT_TITLE || "TJWater 智慧水务平台";
export const META_DATA = { export const META_DATA = {
title: PROJECT_TITLE, title: PROJECT_TITLE,
description: "Developed by TJWATER", description: PROJECT_TITLE,
icons: { icons: {
icon: "/favicon.ico", icon: "/favicon.ico",
}, },
+103
View File
@@ -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",
);
});
});
+7 -3
View File
@@ -74,7 +74,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
try { try {
const openResponse = await apiFetch( const openResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/projects/open?network=${networkName}`, `${config.BACKEND_URL}/api/v1/projects/current`,
{ method: "POST" }, { method: "POST" },
); );
if (!openResponse.ok) { if (!openResponse.ok) {
@@ -82,7 +82,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
} }
const infoResponse = await apiFetch( const infoResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/project-info?network=${networkName}`, `${config.BACKEND_URL}/api/v1/projects/current`,
); );
if (!infoResponse.ok) { if (!infoResponse.ok) {
console.warn( console.warn(
@@ -118,6 +118,10 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
); );
useEffect(() => { useEffect(() => {
if (status !== "authenticated") {
return;
}
// Check localStorage // Check localStorage
const savedWorkspace = localStorage.getItem(MAP_WORKSPACE_STORAGE_KEY); const savedWorkspace = localStorage.getItem(MAP_WORKSPACE_STORAGE_KEY);
const savedNetwork = localStorage.getItem(NETWORK_NAME_STORAGE_KEY); const savedNetwork = localStorage.getItem(NETWORK_NAME_STORAGE_KEY);
@@ -133,7 +137,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT, savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
); );
} }
}, [applyConfig]); }, [applyConfig, status]);
const isGlobalManagementRoute = const isGlobalManagementRoute =
pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs"); pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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
View File
@@ -15,10 +15,22 @@ export const api = axios.create({
let isSigningOut = false; let isSigningOut = false;
const resolveRequestUrl = (request: { export const resolveRequestUrl = (request: {
baseURL?: string; baseURL?: string;
url?: string; url?: string;
}) => `${request.baseURL ?? ""}${request.url ?? ""}`; }) => {
const requestUrl = request.url ?? "";
if (/^([a-z][a-z\d+\-.]*:)?\/\//i.test(requestUrl)) {
return requestUrl;
}
const baseURL = request.baseURL ?? "";
if (!baseURL || !requestUrl) {
return `${baseURL}${requestUrl}`;
}
return `${baseURL.replace(/\/+$/, "")}/${requestUrl.replace(/^\/+/, "")}`;
};
export interface ApiRequestConfig export interface ApiRequestConfig
extends InternalAxiosRequestConfig, extends InternalAxiosRequestConfig,
@@ -38,7 +50,17 @@ api.interceptors.request.use(async (request: ApiRequestConfig) => {
}); });
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => {
if (
response.data &&
typeof response.data === "object" &&
Array.isArray(response.data.items) &&
typeof response.data.total === "number"
) {
response.data = response.data.items;
}
return response;
},
async (error) => { async (error) => {
if (error?.response?.status === 401 && typeof window !== "undefined") { if (error?.response?.status === 401 && typeof window !== "undefined") {
useAuthStore.getState().setAccessToken(null); useAuthStore.getState().setAccessToken(null);
+33 -3
View File
@@ -7,6 +7,30 @@ import {
let isSigningOut = false; let isSigningOut = false;
const unwrapPage = async (response: Response) => {
if (
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const payload = await response.clone().json().catch(() => null);
if (
!payload ||
typeof payload !== "object" ||
!Array.isArray(payload.items) ||
typeof payload.total !== "number"
) {
return response;
}
const headers = new Headers(response.headers);
headers.delete("content-length");
return new Response(JSON.stringify(payload.items), {
headers,
status: response.status,
statusText: response.statusText,
});
};
const resolveUrl = (input: RequestInfo | URL) => { const resolveUrl = (input: RequestInfo | URL) => {
if (typeof input === "string") return input; if (typeof input === "string") return input;
if (input instanceof URL) return input.toString(); if (input instanceof URL) return input.toString();
@@ -22,10 +46,16 @@ export const apiFetch = async (
input: RequestInfo | URL, input: RequestInfo | URL,
init: ApiFetchInit = {}, init: ApiFetchInit = {},
) => { ) => {
const headers = new Headers(init.headers ?? {}); const headers = new Headers(
init.headers ?? (input instanceof Request ? input.headers : undefined),
);
await applyAuthContextHeaders(resolveUrl(input), headers, init); await applyAuthContextHeaders(resolveUrl(input), headers, init);
const response = await fetch(input, { ...init, headers }); const requestInit: RequestInit = {
...init,
headers,
};
const response = await fetch(input, requestInit);
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) { if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
useAuthStore.getState().setAccessToken(null); useAuthStore.getState().setAccessToken(null);
@@ -35,5 +65,5 @@ export const apiFetch = async (
} }
} }
return response; return unwrapPage(response);
}; };
+1 -1
View File
@@ -39,7 +39,7 @@ describe("fetchAgentModels", () => {
], ],
}); });
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/models"), expect.stringContaining("/api/v1/agent/models"),
expect.objectContaining({ expect.objectContaining({
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
+1 -1
View File
@@ -47,7 +47,7 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => {
}; };
export const fetchAgentModels = async (): Promise<AgentModelConfig> => { export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
+41 -30
View File
@@ -41,20 +41,34 @@ const makeStream = (chunks: string[]) =>
}, },
}); });
const mockNewSessionStream = (response: unknown) => {
apiFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ session_id: "s1" }),
})
.mockResolvedValueOnce(response);
};
describe("streamAgentChat", () => { describe("streamAgentChat", () => {
beforeEach(() => { beforeEach(() => {
apiFetch.mockReset(); apiFetch.mockReset();
}); });
it("parses token and done events from chunked SSE", async () => { it("parses token and done events from chunked SSE", async () => {
apiFetch.mockResolvedValue({ apiFetch
ok: true, .mockResolvedValueOnce({
body: makeStream([ ok: true,
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n', json: async () => ({ session_id: "s1" }),
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n', })
'event: done\ndata: {"session_id":"s1"}\n\n', .mockResolvedValueOnce({
]), ok: true,
}); body: makeStream([
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{ type: string; content?: string; sessionId?: string }> = []; const events: Array<{ type: string; content?: string; sessionId?: string }> = [];
@@ -65,14 +79,13 @@ describe("streamAgentChat", () => {
}); });
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/stream"), expect.stringContaining("/api/v1/agent/sessions/s1/runs"),
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
body: JSON.stringify({ body: JSON.stringify({
message: "hi", message: "hi",
session_id: undefined,
model: "provider/model", model: "provider/model",
approval_mode: undefined, approval_mode: undefined,
}), }),
@@ -111,7 +124,7 @@ describe("streamAgentChat", () => {
}); });
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/session/s1/stream"), expect.stringContaining("/api/v1/agent/sessions/s1/runs/current/events"),
expect.objectContaining({ expect.objectContaining({
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -132,7 +145,7 @@ describe("streamAgentChat", () => {
}); });
it("parses progress events", async () => { it("parses progress events", async () => {
apiFetch.mockResolvedValue({ mockNewSessionStream({
ok: true, ok: true,
body: makeStream([ body: makeStream([
'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n', 'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n',
@@ -159,7 +172,7 @@ describe("streamAgentChat", () => {
}); });
it("parses tool_call arguments when params is empty", async () => { it("parses tool_call arguments when params is empty", async () => {
apiFetch.mockResolvedValue({ mockNewSessionStream({
ok: true, ok: true,
body: makeStream([ body: makeStream([
'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n', 'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
@@ -183,7 +196,7 @@ describe("streamAgentChat", () => {
}); });
it("parses permission request and response events", async () => { it("parses permission request and response events", async () => {
apiFetch.mockResolvedValue({ mockNewSessionStream({
ok: true, ok: true,
body: makeStream([ body: makeStream([
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n', 'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n',
@@ -220,7 +233,7 @@ describe("streamAgentChat", () => {
}); });
it("parses question request, response, and todo update events", async () => { it("parses question request, response, and todo update events", async () => {
apiFetch.mockResolvedValue({ mockNewSessionStream({
ok: true, ok: true,
body: makeStream([ body: makeStream([
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n', 'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
@@ -283,7 +296,7 @@ describe("streamAgentChat", () => {
}); });
it("emits error when response is not ok", async () => { it("emits error when response is not ok", async () => {
apiFetch.mockResolvedValue({ mockNewSessionStream({
ok: false, ok: false,
body: null, body: null,
text: async () => "bad request", text: async () => "bad request",
@@ -301,7 +314,7 @@ describe("streamAgentChat", () => {
}); });
it("emits re-login message on unauthorized response", async () => { it("emits re-login message on unauthorized response", async () => {
apiFetch.mockResolvedValue({ mockNewSessionStream({
ok: false, ok: false,
status: 401, status: 401,
body: null, body: null,
@@ -343,14 +356,11 @@ describe("streamAgentChat", () => {
await abortAgentChat("s1"); await abortAgentChat("s1");
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/abort"), expect.stringContaining("/api/v1/agent/sessions/s1/runs/current"),
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "DELETE",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
}),
}), }),
); );
}); });
@@ -365,13 +375,13 @@ describe("streamAgentChat", () => {
await replyAgentPermission("s1", "perm-1", "once"); await replyAgentPermission("s1", "perm-1", "once");
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"), expect.stringContaining("/api/v1/agent/sessions/s1/permission-responses"),
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
body: JSON.stringify({ body: JSON.stringify({
session_id: "s1", request_id: "perm-1",
reply: "once", reply: "once",
}), }),
}), }),
@@ -389,25 +399,27 @@ describe("streamAgentChat", () => {
await rejectAgentQuestion("s1", "q-2"); await rejectAgentQuestion("s1", "q-2");
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"), expect.stringContaining("/api/v1/agent/sessions/s1/question-responses"),
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
body: JSON.stringify({ body: JSON.stringify({
session_id: "s1", request_id: "q-1",
action: "reply",
answers: [["城区"]], answers: [["城区"]],
}), }),
}), }),
); );
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"), expect.stringContaining("/api/v1/agent/sessions/s1/question-responses"),
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
body: JSON.stringify({ body: JSON.stringify({
session_id: "s1", request_id: "q-2",
action: "reject",
}), }),
}), }),
); );
@@ -425,11 +437,10 @@ describe("streamAgentChat", () => {
expect(sessionId).toBe("forked-s1"); expect(sessionId).toBe("forked-s1");
expect(apiFetch).toHaveBeenCalledWith( expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/fork"), expect.stringContaining("/api/v1/agent/sessions/s1/forks"),
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
session_id: "s1",
keep_message_count: 3, keep_message_count: 3,
}), }),
}), }),
+36 -19
View File
@@ -466,6 +466,28 @@ const readStreamEvents = async (
} }
}; };
const ensureAgentSession = async (sessionId?: string) => {
if (sessionId) return sessionId;
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
projectHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
throw new Error((await response.text()) || `session creation failed: ${response.status}`);
}
const payload = (await response.json()) as { session_id?: string };
if (!payload.session_id) {
throw new Error("session creation returned no session_id");
}
return payload.session_id;
};
export const streamAgentChat = async ({ export const streamAgentChat = async ({
message, message,
sessionId, sessionId,
@@ -476,8 +498,9 @@ export const streamAgentChat = async ({
}: StreamOptions) => { }: StreamOptions) => {
let response: Response; let response: Response;
try { try {
const effectiveSessionId = await ensureAgentSession(sessionId);
response = await apiFetch( response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/stream`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(effectiveSessionId)}/runs`,
{ {
method: "POST", method: "POST",
signal, signal,
@@ -487,7 +510,6 @@ export const streamAgentChat = async ({
}, },
body: JSON.stringify({ body: JSON.stringify({
message, message,
session_id: sessionId,
model, model,
approval_mode: approvalMode, approval_mode: approvalMode,
}), }),
@@ -535,7 +557,7 @@ export const resumeAgentChatStream = async ({
let response: Response; let response: Response;
try { try {
response = await apiFetch( response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/stream`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current/events`,
{ {
method: "GET", method: "GET",
signal, signal,
@@ -576,14 +598,8 @@ export const abortAgentChat = async (sessionId?: string) => {
return; return;
} }
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/abort`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current`, {
method: "POST", method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
}),
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
}); });
@@ -600,14 +616,14 @@ export const replyAgentPermission = async (
reply: PermissionReply, reply: PermissionReply,
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/permission-responses`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: sessionId, request_id: requestId,
reply, reply,
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -627,14 +643,15 @@ export const replyAgentQuestion = async (
answers: string[][], answers: string[][],
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: sessionId, request_id: requestId,
action: "reply",
answers, answers,
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
@@ -653,14 +670,15 @@ export const rejectAgentQuestion = async (
requestId: string, requestId: string,
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: sessionId, request_id: requestId,
action: "reject",
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
@@ -674,13 +692,12 @@ export const rejectAgentQuestion = async (
}; };
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => { export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId ?? "")}/forks`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: sessionId,
keep_message_count: keepMessageCount, keep_message_count: keepMessageCount,
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
+24
View File
@@ -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);
+2 -1
View File
@@ -21,7 +21,8 @@ const shouldIncludeProjectHeader = (
return false; return false;
} }
return !url.includes("/api/v1/meta/projects"); const pathname = new URL(url, "http://tjwater.local").pathname.replace(/\/+$/, "");
return pathname !== "/api/v1/projects";
}; };
export const applyAuthContextHeaders = async ( export const applyAuthContextHeaders = async (