fix(web): 未读到目标观测不再误报非空并标注旧观测
build / build (push) Successful in 1m46s

根因:ReleaseTargetsView 在 overview 缺失时自己补 {state:'unknown'},而
productionTargetClass 把"unknown 且没有 empty_baseline"一律当成非空。后端对
"已探测且非空"发的正是这个形状(Go struct 上 omitempty),所以页面本地默认值
与真实观测在 JSON 上无法区分:首次加载、整库状态读取失败、当前范围没有数据
三条路径都会显示"目标非空,尚无权威版本",管理员还会拿到会返回 409 的
"建立目标权威基线"入口。

修法:没有 overview 时 production 保持 null,分类走新的 no-observation 分支;
四类有效响应(known / 已读且为空 / 非空无版本 / 不可读)的判定与入口不变。
刷新失败保留上一份 overview 时标注"上次观测 · 刷新失败,可能已过期",不把旧
结果当成刚验证的状态。后端 gate、权限、预备份与 API 拒绝一律未改。

测试:把旧"三次 SSR 当刷新"的用例撤掉,改为同一挂载 + 可控 fetch 的真实行为
测试(首次读取未返回 / 空→不可读→恢复 / 刷新失败保留旧观测 / 内外切换 /
overview 属于别的范围);缺观测的三条路径另以 SSR 负例复用审核者的反例。
新增 jsdom 仅作为 vitest 的 DOM 环境(商品代码零改动,npm ci 与锁文件已核)。

验证:make verify 16 项 success(tests-web 504 passed、tsc 无错、npm audit 0);
新用例先在原实现上红(断言收到"目标非空"),修后绿。未部署、未合并、未改
候选运行栈。
This commit is contained in:
姚章浩
2026-11-02 12:29:48 +08:00
parent 6827fa922f
commit 47be4ae502
6 changed files with 892 additions and 24 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 开发快照(发布管理:空目标观测与基线入口)
### Fixed
- 发布管理:还没有读到生产目标状态时(首次加载、整库状态读取失败、当前范围没有数据)
不再把本地默认值当成"目标非空",也不再提供会返回 409/503 的"建立目标权威基线"入口;
有效四类响应(有权威版本 / 已读且为空 / 非空无版本 / 不可读)的呈现与入口保持不变。
- 发布管理:刷新失败但仍保留上一次目标观测时,页面标明"上次观测 · 刷新失败,可能已过期",
不把旧结果当成刚刚验证过的状态。
## 开发快照(发布 HTTP 成功闭环:真实监听 POST /library/syncs → handoff → executor → 幂等重放)
### Added
+527
View File
@@ -17,12 +17,66 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.1",
"jsdom": "^30.1.3",
"prettier": "3.9.10",
"typescript": "5.9.2",
"vite": "7.3.7",
"vitest": "5.0.3"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz",
"integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.3",
"@csstools/css-color-parser": "^4.2.6",
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
"integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -305,6 +359,159 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
"integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.2.1"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
"integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
"integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.3"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
"integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
"integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -747,6 +954,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1405,6 +1630,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -1537,12 +1772,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
"integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.2"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1560,6 +1823,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -1602,6 +1872,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
"integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -1792,6 +2075,19 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -1864,6 +2160,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1871,6 +2174,56 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
"integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.1.3",
"@asamuzakjp/dom-selector": "^10.0.3",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.15",
"@exodus/bytes": "^1.16.0",
"css-tree": "^3.2.1",
"data-urls": "^8.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.12.1",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.2.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2209,6 +2562,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/micromark": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
@@ -2867,6 +3227,19 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz",
"integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^9.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2942,6 +3315,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
@@ -3066,6 +3449,16 @@
"url": "https://opencollective.com/unified"
}
},
"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/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@@ -3112,6 +3505,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -3224,6 +3630,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
"integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.19"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.20",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
"integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -3258,6 +3710,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.12.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
"integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -3562,6 +4024,54 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
"integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.16.0",
"tr46": "^7.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
@@ -3575,6 +4085,23 @@
"node": ">=20.11"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.1",
"jsdom": "^30.1.3",
"prettier": "3.9.10",
"typescript": "5.9.2",
"vite": "7.3.7",
+266
View File
@@ -0,0 +1,266 @@
// @vitest-environment jsdom
//
// 同一挂载上的真实刷新/切换行为:ReleaseTargets 自己走 load() + fetch。
// 旧测试拿三次 renderToStaticMarkup 当成"刷新",那是三个互不相关的静态输入,
// 既不执行 effect 也不发请求,证明不了刷新后不残留旧状态(G1)。
// 这里只用一个挂载 + 可控 fetch,覆盖:
// - 首次读取未返回 / 请求失败 / 当前范围没有 overview:都不许说"目标非空";
// - 空 → 不可读 → 恢复(同一挂载上的真实重新读取);
// - 刷新失败保留旧观测时必须标成上次观测,恢复后清掉;
// - 对内/对外切换不串用,overview 属于别的范围时也不借用。
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import type { Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AuthContext } from './auth'
import type { LibraryOverview } from './librarySnapshots'
import { ReleaseTargets } from './ReleaseTargets'
const adminValue = {
identity: {
id: 'u-admin',
username: 'admin',
display_name: '管理员',
role: 'admin' as const,
must_change_password: false,
},
ready: true,
login: async () => null,
logout: async () => undefined,
changePassword: async () => null,
signedOut: 0,
notifySignedOut: () => undefined,
}
type Scope = 'internal' | 'external'
type Reply = { status: number; body: unknown; hold?: Promise<void> }
const emptyTarget: LibraryOverview['production'] = {
state: 'unknown',
empty_baseline: true,
reason: '生产目标可读且为空:本次是首次发布,写入前仍会生成完整预备份',
}
const unreadableTarget: LibraryOverview['production'] = {
state: 'unreachable',
reason: '无法读取目标整库状态:请检查目标连接后重试,不能据此判定空库或建立基线',
}
const snapshot = {
snapshot_id: 'f992d64ea45bf737a0721a465b5aef2b150ef7757157eb84f3aaf8b6972d98f9',
scope: 'internal',
knowledge_base_id: '0c84a098-d3ed-4b82-a165-e6de9691d6a8',
tenant_id: 10000,
created_at: '2026-11-02T02:00:00Z',
validation_policy: 'artifact-acceptance-v1',
format: 'weknora-library-snapshot/v1',
compatibility: {
weknora_version: 'v0.7.1',
schema_version: 74,
retriever_engine: 'elasticsearch_v8',
embedding_model: 'text-embedding-v4',
embedding_dimension: 1024,
index_mapping_sha256: 'a'.repeat(64),
},
parts: [],
logical_bytes: 0,
unique_bytes: 0,
shared_bytes: 0,
totals: { documents: 0, chunks: 0, index_documents: 0, resources: 0, wiki_pages: 0 },
}
const overviewBody = (scope: string, production: LibraryOverview['production']) => ({
scope,
test_sealed: { ...snapshot, scope },
production,
prebackup: { state: 'unknown', reason: '本次目标快照还没有生产预备份' },
diff: { state: 'unknown', reason: '生产与测试的文档级差异读取未接线', added: [], updated: [], removed: [] },
jobs: [],
gate: { sync_allowed: false, reasons: ['没有与目标快照匹配的有效发布授权'] },
authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' },
capture_available: false,
now: '2026-11-02T02:05:00Z',
})
const snapshotsBody = {
available: true,
reason: '',
archive_root: '/tmp/archive',
snapshots: [],
versions: 0,
content_bytes: 0,
blob_file_bytes: 0,
blob_allocated_bytes: 0,
unreferenced_blobs: 0,
unreferenced_bytes: 0,
manifest_file_bytes: 0,
manifest_allocated_bytes: 0,
capture_note: '',
capture_available: false,
capture_reason: '',
}
const reply = (body: unknown, status = 200): Reply => ({ status, body })
const json = (body: unknown, status: number) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
let overviews: Record<Scope, Reply>
let container: HTMLDivElement
let root: Root
let originalFetch: typeof fetch
const mount = async () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root.render(
<AuthContext.Provider value={adminValue}>
<ReleaseTargets />
</AuthContext.Provider>
)
})
await settle()
}
// load() 是几层 await(fetch → json → setState);按微任务轮次推进到稳定。
const settle = async () => {
for (let round = 0; round < 6; round += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
}
const text = () => container.textContent ?? ''
const buttonLabels = () => Array.from(container.querySelectorAll('button')).map((node) => node.textContent ?? '')
const click = async (label: string) => {
const target = Array.from(container.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').includes(label)
)
if (!target) throw new Error(`没有找到按钮:${label}(现有:${buttonLabels().join(' / ')})`)
await act(async () => {
target.dispatchEvent(new window.MouseEvent('click', { bubbles: true }))
})
await settle()
}
beforeEach(() => {
originalFetch = globalThis.fetch
overviews = {
internal: reply(overviewBody('internal', emptyTarget)),
external: reply(overviewBody('external', unreadableTarget)),
}
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input)
if (url.startsWith('/api/v1/library/snapshots')) return json(snapshotsBody, 200)
if (url.startsWith('/api/v1/library/captures')) return json({ jobs: [] }, 200)
if (url.startsWith('/api/v1/library/maintenance')) return json({}, 200)
if (url.startsWith('/api/v1/library/overview')) {
const scope = new URL(url, 'http://candidate.invalid').searchParams.get('scope')
const answer = overviews[scope === 'external' ? 'external' : 'internal']
if (answer.hold) await answer.hold
return json(answer.body, answer.status)
}
return json({ error: '测试未声明该请求' }, 404)
}) as typeof fetch
})
afterEach(async () => {
if (root) {
await act(async () => {
root.unmount()
})
}
container?.remove()
globalThis.fetch = originalFetch
})
describe('ReleaseTargets 同一挂载上的目标观测(F1/G1)', () => {
it('首次读取未返回时只说正在读取,不说目标非空', async () => {
let release: () => void = () => undefined
const hold = new Promise<void>((resolve) => {
release = resolve
})
overviews.internal = { ...reply(overviewBody('internal', emptyTarget)), hold }
await mount()
expect(text()).toContain('正在读取目标状态')
expect(text()).not.toContain('目标非空')
expect(text()).not.toContain('目标已读且为空')
release()
await settle()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('正在读取目标状态')
})
it('空 → 不可读 → 恢复:同一挂载按每次真实读取重新分类', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
// 管理员 + 已封存时基线入口本来会出现,但空目标不该给(后端会 409)。
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.internal = reply(overviewBody('internal', unreadableTarget))
await click('刷新')
expect(text()).toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.internal = reply(overviewBody('internal', emptyTarget))
await click('刷新')
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标非空')
})
it('刷新失败保留旧观测时标成上次观测,恢复读取后清除', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('上次观测')
overviews.internal = reply({ error: '上游不可用' }, 502)
await click('刷新')
expect(text()).toContain('读取失败')
// 旧观测仍在,但不能冒充刚验证。
expect(text()).toContain('目标已读且为空')
expect(text()).toContain('上次观测 · 刷新失败,可能已过期')
expect(text()).not.toContain('目标非空')
overviews.internal = reply(overviewBody('internal', emptyTarget))
await click('刷新')
expect(text()).not.toContain('上次观测')
expect(text()).not.toContain('读取失败')
expect(text()).toContain('目标已读且为空')
})
it('内外切换不串用:对外的不可读不会被内对的空顶替', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
await click('对外知识库')
expect(text()).toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.external = reply(overviewBody('external', emptyTarget))
await click('刷新')
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('无法读取目标整库状态')
await click('对内知识库')
expect(text()).toContain('目标已读且为空')
})
it('当前范围没有自己的 overview 时不借用别的范围分类', async () => {
// 服务端把对内的 overview 回给了对外请求:scope 不匹配,等于没有观测。
overviews.external = reply(overviewBody('internal', emptyTarget))
await mount()
await click('对外知识库')
expect(text()).toContain('还没有读到')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
})
})
+35 -9
View File
@@ -801,15 +801,41 @@ describe('生产目标四类状态:known / 空 / 非空 / 不可读', () => {
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('刷新后分类只由当前 overview 决定:空 → 不可读 → 空 不残留旧状态', () => {
const seenEmpty = render({ baseline, overview: { ...overview, production: emptyTarget } })
const seenUnreadable = render({ baseline, overview: { ...overview, production: unreadableTarget } })
const recovered = render({ baseline, overview: { ...overview, production: emptyTarget } })
expect(seenEmpty).toContain('目标已读且为空')
expect(seenUnreadable).not.toContain('目标已读且为空')
expect(seenUnreadable).toContain('无法读取目标整库状态')
expect(recovered).toContain('目标已读且为空')
expect(recovered).not.toContain('无法读取目标整库状态')
// 真正的一次挂载刷新由 ReleaseTargets.mount.test.tsx 覆盖:三次 renderToStaticMarkup
// 只是三个互不相关的静态输入,不执行 load/effect/fetch,不能算刷新证据。
// F1:没有读到 overview 时,页面自己造的 {state:'unknown'} 与后端"已探测且非空"
// 在 JSON 上长得一模一样。缺观测的三条真实路径都不能落进 nonempty 分支,
// 也不能提供会返回 409 的建基线入口。
it('没有目标观测时不冒充非空:初始加载 / 整体读取失败 / 范围不匹配', () => {
const cases: Array<[string, Partial<ViewProps>, string]> = [
['初始加载中', { loading: true, overview: null }, '正在读取目标状态'],
['初始 overview 请求失败', { error: '发布状态读取失败(HTTP 503)', overview: null }, '不能据此判定空或非空'],
[
'选中对外但只有对内 overview',
{ scope: 'external', overview: { ...overview, scope: 'internal' } },
'还没有读到',
],
]
for (const [name, overrides, expected] of cases) {
const html = render({ baseline, ...overrides })
expect(html, name).toContain(expected)
expect(html, name).not.toContain('目标非空')
expect(html, name).not.toContain('目标已读且为空')
expect(html, name).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
}
})
it('刷新失败但保留旧观测时标成上次观测,不冒充刚验证', () => {
const html = render({
baseline,
error: '发布状态读取失败(HTTP 502)',
overview: { ...overview, production: emptyTarget },
})
expect(html).toContain('目标已读且为空')
expect(html).toContain('上次观测 · 刷新失败,可能已过期')
expect(html).not.toContain('正在读取目标状态')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('空目标但没有测试封存:启动同步保持禁用并说明缺候选', () => {
+53 -15
View File
@@ -162,15 +162,29 @@ const scopeName = (scope: Scope) => (scope === 'internal' ? '对内知识库' :
// 生产目标必须按接口的四类状态原样呈现:把"已读且为空"和"读不到"都写成"未知",
// 会让人去点一个必然被拒绝的"建立基线"(后端对空目标返回 409 library_baseline_target_empty,
// 对读不到的目标返回 503 library_baseline_unprobeable)。
type ProductionTargetClass = 'known' | 'empty' | 'nonempty' | 'unreachable'
type ProductionTargetClass = 'known' | 'empty' | 'nonempty' | 'unreachable' | 'no-observation'
function productionTargetClass(production: LibraryOverview['production']): ProductionTargetClass {
// 只有真正读到的 overview 才能分类。页面自己补的 {state:'unknown'} 与后端
// "已探测且非空"在 JSON 上无法区分,所以缺观测必须单列一类(no-observation),
// 否则首次加载、整体读取失败、当前范围没数据都会被误报成"目标非空"(F1)。
function productionTargetClass(production: LibraryOverview['production'] | null | undefined): ProductionTargetClass {
if (!production) return 'no-observation'
if (production.state === 'known' && production.version) return 'known'
if (production.state === 'unreachable') return 'unreachable'
// unknown 有两种:可读且空(空基线,首次发布合法起点)与可读但非空(缺权威版本)。
return production.empty_baseline ? 'empty' : 'nonempty'
}
// 刷新失败时页面会保留上一次的 overview:那份观测仍是当时的真实结果,
// 但已经不是刚刚验证过的状态,必须标出来,不能混作当前结论。
const STALE_NOTE = '上次观测 · 刷新失败,可能已过期'
function noObservationText(loading: boolean, error: string | null): string {
if (loading) return '正在读取目标状态(尚未确认空或非空)'
if (error) return '读取失败,还没有读到目标状态(不能据此判定空或非空)'
return '还没有读到该范围的目标状态'
}
// 不可发起时必须说清是哪一条不满足:读的是接口给出的真实原因(执行器不可达、
// 指向别的实例、降级、观测过期),不是一句写死的"通道未接线"。
function captureHint(view: ScopeView, canWrite: boolean): string {
@@ -696,6 +710,8 @@ function ChangeTable({ diff }: { diff: LibraryOverview['diff'] }) {
function ProtectionCard({
production,
productionNote,
stale,
prebackup,
gate,
authorization,
@@ -704,7 +720,10 @@ function ProtectionCard({
baseline,
onOpen,
}: {
production: LibraryOverview['production']
production: LibraryOverview['production'] | null
// 没有目标观测时显示的原因(加载中 / 整体读取失败 / 当前范围没数据)。
productionNote: string
stale: boolean
prebackup: LibraryOverview['prebackup']
gate: LibraryOverview['gate']
authorization: LibraryOverview['authorization']
@@ -715,9 +734,10 @@ function ProtectionCard({
}) {
const backupKnown = prebackup.state === 'known' && prebackup.version
// 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记),
// 读不到时必须先修连接,两者都不能被引导到会 409/503 的基线入口。
// 读不到或还没读到观测时必须先修连接/等读取,两者都不能被引导到会 409/503 的基线入口。
const productionTarget = productionTargetClass(production)
const baselineNeeded = productionTarget === 'nonempty' && targetSealed
const staleNote = stale ? `(${STALE_NOTE})` : ''
return (
<aside className="rel-panel rel-protection">
<header className="rel-panel-head">
@@ -749,7 +769,7 @@ function ProtectionCard({
</dd>
<dt>生产当前版本</dt>
<dd>
{productionTarget === 'known' && production.version ? (
{productionTarget === 'known' && production?.version ? (
<>
{production.version.version_label || production.version.snapshot_id.slice(0, 12)}
<br />
@@ -758,11 +778,13 @@ function ProtectionCard({
</small>
</>
) : productionTarget === 'empty' ? (
'目标已读且为空:首次发布还没有生效版本(写前仍会生成完整预备份)'
`目标已读且为空:首次发布还没有生效版本(写前仍会生成完整预备份)${staleNote}`
) : productionTarget === 'unreachable' ? (
production.reason || '读取失败:无法确认目标是否为空'
`${production?.reason || '读取失败:无法确认目标是否为空'}${staleNote}`
) : productionTarget === 'nonempty' ? (
`未知(目标非空且还没有权威版本)${staleNote}`
) : (
'未知(目标非空且还没有权威版本)'
productionNote
)}
</dd>
<dt>备份范围</dt>
@@ -810,7 +832,10 @@ function ProtectionCard({
interface ReadyProps {
view: ScopeView
diff: LibraryOverview['diff']
production: LibraryOverview['production']
production: LibraryOverview['production'] | null
// 缺观测时的说明与"旧观测"标记都由上层按 loading/error 判定,子组件只负责显示。
productionNote: string
stale: boolean
prebackup: LibraryOverview['prebackup']
gate: LibraryOverview['gate']
authorization: LibraryOverview['authorization']
@@ -827,6 +852,8 @@ function ReadyBody({
view,
diff,
production,
productionNote,
stale,
prebackup,
gate,
authorization,
@@ -845,6 +872,8 @@ function ReadyBody({
<ChangeTable diff={diff} />
<ProtectionCard
production={production}
productionNote={productionNote}
stale={stale}
prebackup={prebackup}
gate={gate}
authorization={authorization}
@@ -1394,8 +1423,13 @@ export function ReleaseTargetsView(props: ViewProps) {
const active = Boolean(view.activeJob) || view.job?.state === 'running' || view.job?.state === 'pending'
// 三类版本严格分开:target 是测试封存,production 是生产权威版本,prebackup 是本次预备份。
const target = view.overview?.test_sealed
const production = view.overview?.production ?? { state: 'unknown' as const }
// 没有 overview 时不能补一个 {state:'unknown'} 再当成"已探测且非空":
// production 保持 null,分类走 no-observation。
const production = view.overview?.production ?? null
const productionClass = productionTargetClass(production)
// 刷新失败但仍保留上一份 overview:显示可以继续,但必须标成上次观测。
const productionStale = Boolean(error) && production !== null
const productionNote = noObservationText(loading, error)
const prebackup = view.overview?.prebackup ?? { state: 'unknown' as const }
const diff = view.overview?.diff ?? {
state: 'unknown' as const,
@@ -1489,7 +1523,7 @@ export function ReleaseTargetsView(props: ViewProps) {
</div>
<p className="rel-versionline">
生产当前{' '}
{productionClass === 'known' && production.version ? (
{productionClass === 'known' && production?.version ? (
<b>
{production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`}
{' · '}
@@ -1498,11 +1532,13 @@ export function ReleaseTargetsView(props: ViewProps) {
) : productionClass === 'empty' ? (
<b>目标已读且为空(首次发布)</b>
) : productionClass === 'unreachable' ? (
<b className="rel-unknown">读取失败:{production.reason || '无法读取目标整库状态'}</b>
) : (
<b className="rel-unknown">读取失败:{production?.reason || '无法读取目标整库状态'}</b>
) : productionClass === 'nonempty' ? (
<b className="rel-unknown">目标非空,尚无权威版本</b>
)}{' '}
→ 拟发布{' '}
) : (
<b className="rel-unknown">{productionNote}</b>
)}
{productionStale ? <em className="rel-scope">({STALE_NOTE})</em> : null} → 拟发布{' '}
<b>{target ? `封存 ${target.snapshot_id.slice(0, 12)} · ${stamp(target.created_at)}` : '还没有测试封存'}</b> ·{' '}
{publishStateText(view.job)}
</p>
@@ -1584,6 +1620,8 @@ export function ReleaseTargetsView(props: ViewProps) {
view={view}
diff={diff}
production={production}
productionNote={productionNote}
stale={productionStale}
prebackup={prebackup}
gate={gate}
authorization={authorization}