修复: 08-07-business-routing-regression-repair 关闭资产创建跳转回归
这个提交包含在:
@@ -1185,6 +1185,9 @@ validation prevent repeated re-entry and reduce support/debugging cost.
|
||||
- Reload/close guard: `beforeunload` while `isDirty === true`.
|
||||
- Shared UI contract: one unsaved-change dialog with continue-editing and
|
||||
discard actions.
|
||||
- Successful-save navigation: `usePostSaveNavigation()` opens one synchronous
|
||||
allow-next window around the canonical navigation action; its signature is
|
||||
`(action: () => void) => void`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -1194,6 +1197,12 @@ validation prevent repeated re-entry and reduce support/debugging cost.
|
||||
is called only after explicit discard confirmation.
|
||||
- Record switching and panel closing use the same pending-action contract as
|
||||
route navigation. Successful save or explicit discard clears dirty state.
|
||||
- When a successful server save immediately replaces a create route with its
|
||||
canonical detail, use the dedicated post-save navigation entry after a valid
|
||||
entity ID is available. Do not depend on React state cleanup completing
|
||||
before `navigate(...)`, and do not expose a persistent guard-disable switch.
|
||||
- Failed, timed-out or invalid save responses never use the post-save bypass;
|
||||
they retain the form values and normal dirty protection.
|
||||
- Register `beforeunload` only while dirty and remove it during cleanup.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
@@ -1202,21 +1211,31 @@ validation prevent repeated re-entry and reduce support/debugging cost.
|
||||
- Dirty in-app navigation or browser back -> block and show one dialog.
|
||||
- Continue editing -> URL, record selection and values remain unchanged.
|
||||
- Discard -> execute exactly one pending navigation/selection and clear dirty.
|
||||
- Successful create -> replace to canonical detail without an unsaved dialog,
|
||||
even when the clean-state update has not committed yet.
|
||||
- Save failure or missing entity ID -> remain on the form with dirty protection;
|
||||
never bypass navigation protection to hide the failure.
|
||||
- Reload/close while dirty -> browser-level unload warning remains active.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: reimbursement, procurement, contract and asset forms reuse one guard
|
||||
while keeping their own save and dirty-field logic.
|
||||
- Good: an asset POST succeeds, then the page wraps one `replace` navigation in
|
||||
`usePostSaveNavigation()` before refreshing non-critical list caches.
|
||||
- Base: a short non-editable filter form does not register the guard.
|
||||
- Bad: listen to history after navigation, then push the previous URL back; the
|
||||
user can see state flicker or lose form data before confirmation.
|
||||
- Bad: call `setSavedSignature(...)` and immediately navigate while relying on
|
||||
React to commit the clean state before `useBlocker` evaluates.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Playwright covers link navigation, browser back, reload and record switch.
|
||||
- Assert cancel preserves URL and values; discard changes target once; save
|
||||
removes the warning.
|
||||
- A create-route regression covers successful canonical `replace`, absence of
|
||||
the unsaved dialog and browser history that does not reopen the submitted form.
|
||||
- Frontend typecheck and build after router or shared guard changes.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
@@ -1227,6 +1246,11 @@ validation prevent repeated re-entry and reduce support/debugging cost.
|
||||
window.addEventListener("popstate", () => navigate(previousUrl));
|
||||
```
|
||||
|
||||
```ts
|
||||
setSavedSignature(nextSignature);
|
||||
navigate(canonicalDetailPath, { replace: true }); // dirty ref may still be true
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```ts
|
||||
@@ -1234,6 +1258,13 @@ const blocker = useBlocker(isDirty);
|
||||
// reset to keep editing; proceed only after explicit discard.
|
||||
```
|
||||
|
||||
```ts
|
||||
const runAfterSuccessfulSave = usePostSaveNavigation();
|
||||
// Call only after the API returned a valid entity ID.
|
||||
runAfterSuccessfulSave(() => navigate(canonicalDetailPath, { replace: true }));
|
||||
void refreshNonCriticalCaches();
|
||||
```
|
||||
|
||||
## Scenario: Business Ledger List-To-Detail Route Migration
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"08-05-business-detail-routing",
|
||||
"08-05-contract-detail-workspace",
|
||||
"08-07-interaction-pattern-browser-regression",
|
||||
"08-07-e2e-fixture-repair"
|
||||
"08-07-e2e-fixture-repair",
|
||||
"08-07-business-routing-regression-repair"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,84 @@
|
||||
# 设计说明
|
||||
|
||||
## 1. 问题与根因
|
||||
|
||||
真实创建链路的状态序列为:
|
||||
|
||||
```text
|
||||
资产表单 dirty=true
|
||||
-> POST /api/assets 成功并返回 asset.id
|
||||
-> setSavedAssetFormSignature(...)
|
||||
-> 同一回调内 navigate(/assets/:id)
|
||||
-> React 尚未提交 saved signature,UnsavedChangesProvider 仍认为 dirty=true
|
||||
-> useBlocker 拦截导航并打开“放弃未保存的修改?”
|
||||
-> URL 留在 /assets/new
|
||||
```
|
||||
|
||||
Playwright 失败 DOM 已出现该确认对话框,因此 `await invalidateQueries(...)` 可能放大跳转延迟,但不是直接拦截原因。修复必须处理共享 guard 的同步状态边界,不能只交换两个 await 的顺序后碰运气。
|
||||
|
||||
## 2. 设计选择
|
||||
|
||||
### 2.1 共享 post-save 导航入口
|
||||
|
||||
在 `UnsavedChangesProvider` 内复用现有 `allowNextNavigationRef`,增加语义收窄的同步入口,例如:
|
||||
|
||||
```ts
|
||||
runAfterSuccessfulSave(action: () => void): void
|
||||
```
|
||||
|
||||
并通过专用 hook(例如 `usePostSaveNavigation`)暴露。入口执行时:
|
||||
|
||||
1. 同步设置 `allowNextNavigationRef.current = true`;
|
||||
2. 执行一次传入的导航动作;
|
||||
3. 在 microtask 中恢复为 false;
|
||||
4. 不修改、清空或伪造其他表单的 dirty registration。
|
||||
|
||||
这个入口只用于服务端已经确认持久化成功后的程序化导航。普通离开继续使用 `runGuarded`;失败路径不调用该入口。
|
||||
|
||||
### 2.2 资产创建成功顺序
|
||||
|
||||
创建分支按以下顺序处理:
|
||||
|
||||
```text
|
||||
清空表单错误 / 记录已保存签名 / 成功反馈
|
||||
-> runAfterSuccessfulSave(() => navigate(canonicalDetail, replace + from))
|
||||
-> 后台 invalidate asset list/detail caches
|
||||
```
|
||||
|
||||
编辑既有资产不发生路由切换,可继续等待其详情/列表缓存刷新。本任务不重构其他 mutation。
|
||||
|
||||
关键不变量:
|
||||
|
||||
- canonical 导航是成功创建后的用户可见主结果,不能依赖缓存刷新成功。
|
||||
- `replace` 保证历史栈不保留已提交创建页。
|
||||
- post-save bypass 只开放一个 navigation window,不能永久关闭 guard。
|
||||
- API 失败仍由 mutation `onError` 处理,表单与 dirty state 保持。
|
||||
|
||||
### 2.3 测试断言
|
||||
|
||||
- `assets-flow.spec.ts` 继续使用真实 API,验证 POST 成功后 URL、无 unsaved dialog、返回/前进和后续流转。
|
||||
- 聚焦运行既有“资产长表单切回记录列表”用例,证明取消/放弃保护未退化。
|
||||
- `procurement-flow.spec.ts` 同时断言“申请人”字段和 `EMPLOYEE.name`,不再依赖英文角色词。
|
||||
- `business-detail-routing.spec.ts` 保持 canonical path、`replace` 来源与移动回落的纯契约覆盖。
|
||||
|
||||
## 3. 证据更新
|
||||
|
||||
归档材料按 append-only delta 更新:
|
||||
|
||||
- `08-05-business-detail-routing`:保留原 AC 的 `PAUSED` 历史,在文末追加 2026-08-07 真实回归 delta,标明该 AC 当前为失败并由本任务修复。
|
||||
- `08-07-local-database-migration-readiness`:保留原 16/10/0 记录,追加分类修正:资产跳转是产品回归;采购英文断言及 8 条登录态/mock 失败属于测试侧。
|
||||
|
||||
不回写“当时已通过”,不删除原始发现与时间线。
|
||||
|
||||
## 4. 兼容与风险
|
||||
|
||||
- 共享 guard 入口若命名过宽,可能被误用于绕过真实未保存保护;因此 API 必须带 post-save 语义,并在 spec 中限定调用条件。
|
||||
- 导航先于缓存失效可能让详情页短暂进入正常加载态,但不会丢失创建结果;当前详情路由已有独立实体查询。
|
||||
- 真实资产/采购 E2E 会写入带 Playwright 标记的本地合成记录;不清理,避免误删开发数据。
|
||||
- 本任务不碰权限、状态机、API 或数据库,因此不需要 migration/permission reviewer;仍需 `trellis-check` 做共享组件与跨页面一致性复核。
|
||||
|
||||
## 5. 回滚
|
||||
|
||||
- 代码回滚:撤回共享 post-save 入口及资产页调用,恢复原 mutation 时序。
|
||||
- 测试回滚:恢复对应断言,但不得把已证伪的资产 AC 改写为通过。
|
||||
- 证据 delta 为历史事实,不随代码回滚删除;若修复回滚,应追加新的状态说明。
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,72 @@
|
||||
# 实施计划
|
||||
|
||||
> 当前状态:in_progress。用户已批准最终规划摘要,任务已由 `task.py start` 激活。
|
||||
|
||||
## 1. 实施前固定现场
|
||||
|
||||
- [x] 记录 HEAD、当前 task 与 `git status --porcelain`,确认只有本任务规划文件和父任务 child link 变化。
|
||||
- [x] 复读 PRD、design、`.trellis/spec/hyb-oa/frontend-react-vite-design.md` 的未保存保护与 list-to-detail route migration 场景。
|
||||
- [x] 只读确认本地 Prisma migration status 仍 current;若数据库再次漂移,真实 API 回归标 `PAUSED`,本任务不得执行任何 migration/reset/seed。
|
||||
|
||||
## 2. 修复共享 guard 的成功保存导航
|
||||
|
||||
- [x] 在 `UnsavedChangesProvider` 内抽取单次 allow-next 执行函数,复用现有 ref + microtask 恢复语义。
|
||||
- [x] 暴露语义收窄的 post-save hook/方法,不改变现有 `useUnsavedChangesGuard` 调用者契约。
|
||||
- [x] 普通 dirty navigation、确认、取消、browser back 与 beforeunload 逻辑保持原样。
|
||||
|
||||
## 3. 修复资产创建成功链路
|
||||
|
||||
- [x] 创建成功后先完成成功态更新并通过 post-save 入口 `replace` 到 canonical detail,保留 `backSource`。
|
||||
- [x] 将资产 list/detail invalidation 移出关键导航等待路径;后台刷新失败不得触发创建失败 toast 或阻止跳转。
|
||||
- [x] 编辑既有资产的保存刷新行为不做无关重构。
|
||||
- [x] API 失败路径继续保留表单与 dirty state。
|
||||
|
||||
## 4. 测试与证据维护
|
||||
|
||||
- [x] `assets-flow.spec.ts` 增加/保留“无 unsaved dialog + canonical URL + history”断言。
|
||||
- [x] `procurement-flow.spec.ts` 将英文 `Employee` 改为 `EMPLOYEE.name`,保留“申请人”语义断言;并同步现行筛选/状态控件。
|
||||
- [x] 追加两个归档任务的 post-archive delta,分别记录产品回归与测试侧失败。
|
||||
- [x] 更新 frontend spec 的 post-save bypass 合同、错误矩阵、Good/Bad 和测试要求。
|
||||
- [x] 不修改其余 8 条会话/mock 失败相关实现或断言。
|
||||
|
||||
## 5. 验证顺序
|
||||
|
||||
- [x] `cd client && npm run typecheck`
|
||||
- [x] `cd client && npm run build`
|
||||
- [x] `cd client && npx playwright test tests/e2e/business-detail-routing.spec.ts --workers=1 --reporter=list`
|
||||
- [x] `cd client && npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts --grep "资产长表单切回记录列表" --workers=1 --reporter=list`(1 passed / 0 failed / 0 skipped)
|
||||
- [x] 在回环本地、migration current、后台任务关闭且服务配置完整的当前 checkout 上运行:
|
||||
`cd client && E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test tests/e2e/assets-flow.spec.ts tests/e2e/procurement-flow.spec.ts --workers=1 --reporter=list`
|
||||
- [x] 记录真实 `2 passed / 0 failed / 0 skipped`;第一次运行暴露的视口/筛选控件漂移已在目标 spec 内最小同步,未修改范围外失败。
|
||||
|
||||
## 6. 质量与收口
|
||||
|
||||
- [x] 使用 `trellis-check` 复核共享 guard API 是否只在成功保存后使用,并搜索所有调用点。
|
||||
- [x] `python3 ./.trellis/scripts/task.py validate 08-07-business-routing-regression-repair`
|
||||
- [x] `node scripts/qa/check-trellis-governance.mjs --json`
|
||||
- [x] `git diff --check`
|
||||
- [x] 确认无 server、Prisma、权限、合同 task、其余会话 fixture 或生产配置改动。
|
||||
- [x] 提交前给出中文 commit 计划并获得用户明确批准;不 push、不部署。
|
||||
|
||||
## 7. 停止条件
|
||||
|
||||
- migration status 非 current、目标非回环或本地服务缺必要安全配置 → `PAUSED`,不执行数据库修复。
|
||||
- 资产 POST 未成功或未返回 ID → 先归因 API/数据,不启用 bypass 掩盖失败。
|
||||
- post-save 入口需要全局关闭 guard、修改路由权限或影响其他表单保存语义 → 返回规划复核。
|
||||
- 真实 E2E 出现本任务范围外新产品失败 → 记录并回来确认,不扩大范围。
|
||||
|
||||
## 8. 执行记录
|
||||
|
||||
- 起始 HEAD:`892b313`;任务启动前只有本任务规划目录与父任务 child link 变化。
|
||||
- 迁移基线:只读 `prisma migrate status` 为 current;未执行 migration、reset、seed、数据清理或手工 DDL。
|
||||
- 共享 guard:新增 `usePostSaveNavigation()`,内部复用原同步 allow-next ref 与 microtask 恢复;`client/src` 只有资产创建成功回调一个 post-save 调用点,显式放弃仍走 Provider 私有执行函数。
|
||||
- 资产链路:POST 成功且 ID 有效后先 `replace` 到 canonical 详情,再后台失效资产 list/detail cache;无 ID 与请求失败不启用 bypass。
|
||||
- 聚焦回归:
|
||||
- `business-detail-routing.spec.ts`:`5 passed / 0 failed / 0 skipped`。
|
||||
- `r103-cross-page-ui-consistency.spec.ts --grep "资产长表单切回记录列表"`:`1 passed / 0 failed / 0 skipped`,覆盖未保存取消/放弃、成功保存无弹窗/history,以及合成 400 后保留输入与 dirty guard。
|
||||
- 真实主链路:`assets-flow.spec.ts` 与 `procurement-flow.spec.ts` 合计 `2 passed / 0 failed / 0 skipped`;真实资产链路继续完成领用、归还、调拨与盘点,采购链路完成提单、审批与归档。
|
||||
- 初次真实补跑暴露两个测试侧漂移:资产用例在 1024px 继续查找桌面侧栏入口;采购用例仍操作已迁移的常驻范围按钮/状态下拉。仅在两份目标 spec 内做最小同步,未修改产品代码来迎合旧断言。
|
||||
- 静态与构建:`npm run typecheck` PASS;`npm run build` PASS(仅有既有 Browserslist 数据提示)。
|
||||
- 流程门禁:task validate PASS;flow coverage `blockingGapCount=0`、`10/10` core bundles、`9` acceptance specs;process contract PASS `0/0`;Trellis governance PASS `0/0`;`git diff --check` PASS。
|
||||
- 文档审计:`0 errors / 1 warning`;警告为仓库既有 `22` 个未注册 docs 文件,本任务未新增 docs 文件。
|
||||
- 边界:无 `server/`、Prisma、权限、合同 task、其余 8 条会话失败对应测试、生产配置、部署或公网改动;真实 E2E 合成记录按批准保留,未清理。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 资产创建跳转与路由回归收尾
|
||||
|
||||
## Goal
|
||||
|
||||
在合同详情迁移开始前,关闭 `08-05-business-detail-routing` 中已被真实 API 回归证伪的资产创建验收项:资产创建成功后必须直接进入 canonical 详情路由,且不能被“未保存修改”保护误拦截。同时修正采购主链路中已过时的英文姓名断言,并以追加 delta 的方式纠正两个归档任务对失败性质的记录。
|
||||
|
||||
本任务只处理 1 条确认的产品回归和 1 条确认的测试断言漂移;其余 8 条 mock 会话/登录态失败保持独立候选,不在本轮扩张。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- 归档任务 `.trellis/tasks/archive/2026-08/08-05-business-detail-routing/prd.md:46` 将“资产创建成功后 URL 变为 `/assets/:assetId`,返回不会回到已提交表单”保留为 `PAUSED`,原因仅是当时本地数据库缺列,未完成真实创建验证。
|
||||
- 迁移基线恢复后,`assets-flow.spec.ts` 的真实 POST `/api/assets` 已成功返回实体 ID,但页面 30 秒后仍停留 `/assets/new`;归档迁移任务已在 `implement.md:57` 记录该结果。
|
||||
- 当前本地 Playwright 失败 DOM 快照明确出现“放弃未保存的修改?”对话框;这证明直接阻塞跳转的是共享未保存保护,而不是 API 响应形状。
|
||||
- 后端 `server/src/routes/assets.js:1329` 返回 `{ asset }`,前端 `client/src/api/assets.ts:307-311` 同样声明 `{ asset: AssetItem }`;`data.asset.id` 可用,响应形状不是根因。
|
||||
- `client/src/pages/AssetRegistryPage.tsx:577-590` 在成功回调中先更新 saved signature,再于同一回调内 `navigate(...)`。React 状态尚未完成提交时,共享 guard 仍持有 dirty=true,因此程序化成功跳转被当作离开未保存表单而拦截。
|
||||
- `client/src/components/common/UnsavedChangesGuard.tsx:31-50` 已有同步 `allowNextNavigationRef`,但目前只在用户确认“放弃并离开”时使用,没有“服务端已保存成功后安全跳转”的受控入口。
|
||||
- `client/tests/e2e/procurement-flow.spec.ts:91-93` 已先验证“申请人”,随后仍断言英文 `Employee`;真实页面展示中文申请人姓名,符合 `docs/产品设计规则.md:23,118` 的自然中文规则。
|
||||
- `r103-cross-page-ui-consistency.spec.ts` 的 7 条失败与 `global-search.spec.ts` 的 1 条失败均伴随登录态回落,属于另一个会话/mock 夹具问题集合。
|
||||
|
||||
## Scope
|
||||
|
||||
- 修复资产创建成功后的程序化跳转,使其绕过仅针对未保存数据的离开确认,并继续使用 `replace` 与安全 `state.from`。
|
||||
- 让创建成功后的 canonical 跳转成为用户可见关键路径;缓存失效不得先于或阻塞该跳转。
|
||||
- 保持普通脏表单的链接、返回、刷新、记录切换和显式返回保护行为不变。
|
||||
- 将采购主链路的英文 `Employee` 断言改为 fixture 中的自然中文申请人姓名。
|
||||
- 为 `08-05-business-detail-routing` 与 `08-07-local-database-migration-readiness` 的归档材料追加 post-archive delta:资产失败是确认的产品回归,其余 9 条为测试断言或 mock 会话候选;不覆盖原始历史记录。
|
||||
- 在现有前端 spec 中补充“成功持久化后的程序化导航不得依赖异步 dirty state 清理”的共享约束。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1:资产创建 mutation 只有在 POST 成功并取得实体 ID 后,才可使用共享的 post-save 导航入口绕过未保存拦截;失败、超时或返回无 ID 时不得绕过。
|
||||
- R2:创建成功必须 `replace` 到 `buildAssetDetailPath(data.asset.id)`,保留经校验的 `backSource`;浏览器返回不得重新打开已提交的 `/assets/new`。
|
||||
- R3:创建成功后的 URL 跳转先于等待列表/详情缓存失效。缓存刷新可在后台继续,但失败不能撤销已成功创建的业务结果,也不能把用户留在创建表单。
|
||||
- R4:共享 guard 的新入口必须语义收窄为“成功保存后导航”,复用现有同步 allow-next 机制;不得提供无条件全局关闭 guard 的开关。
|
||||
- R5:正常脏表单行为保持不变:未保存时离开仍显示一次确认;继续编辑保留 URL 与输入;放弃后只执行一次目标动作;刷新仍触发浏览器保护。
|
||||
- R6:创建请求失败时仍停留 `/assets/new`,保留输入并展示自然中文错误;不得误清 dirty state 或跳到详情。
|
||||
- R7:采购主链路断言使用 `EMPLOYEE.name` 等 fixture 事实,不硬编码英文角色词,也不降低对“申请人”字段存在和正确对象姓名的验证强度。
|
||||
- R8:真实 `assets-flow.spec.ts` 与 `procurement-flow.spec.ts` 必须补跑并形成明确结果;真实流产生的 Playwright 合成数据不在本任务清理、seed 或 reset。
|
||||
- R9:归档证据只追加复核 delta,保留原 `PAUSED` 与发现时间线;不得把当时未验证的 AC 倒写成历史已通过。
|
||||
- R10:其余 8 条登录态/mock 会话失败对应的用例不做修复,不改登录实现、不顺手增加 service-worker 或会话 fixture 修复;本轮仅扩充资产未保存保护的独立聚焦回归,另行评估那 8 条失败。
|
||||
- R11:不修改后端 API、Prisma schema/migration、权限模型、路由 guard、资产数据范围、合同 task 或生产配置。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 真实 API 创建资产成功后,页面直接进入 `/assets/:assetId`,不出现“放弃未保存的修改?”对话框(真实 `assets-flow` 通过)。
|
||||
- [x] 成功跳转使用 `replace` 并保留安全列表来源;浏览器返回不会回到已提交的 `/assets/new`(真实资产链路后退/前进通过)。
|
||||
- [x] 创建成功的导航不等待列表/详情缓存失效完成;缓存刷新异常不会阻止 canonical 跳转(代码顺序与后台 catch 复核通过)。
|
||||
- [x] 创建失败仍保留表单、dirty protection 与自然中文错误,不发生 post-save bypass(聚焦 mock 400 回归通过)。
|
||||
- [x] 资产未保存取消/放弃回归继续通过,证明共享 guard 的普通保护未被削弱(聚焦 mock 回归通过)。
|
||||
- [x] `assets-flow.spec.ts` 真实主链路通过,覆盖创建跳转、返回/前进与后续资产流转(1 passed / 0 failed / 0 skipped)。
|
||||
- [x] `procurement-flow.spec.ts` 不再断言 `Employee`,而是验证“申请人”与 fixture 中文姓名;完整主链路通过(1 passed / 0 failed / 0 skipped)。
|
||||
- [x] 两个归档任务均有 append-only post-archive delta,明确 1 条产品回归与 9 条测试侧问题的不同归属及本任务引用。
|
||||
- [x] 现有前端 spec 明确成功持久化导航的 bypass 契约、失败边界和回归要求。
|
||||
- [x] `cd client && npm run typecheck && npm run build`、聚焦 Playwright、任务校验、Trellis governance 与 `git diff --check` 通过。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 修复 `r103-cross-page-ui-consistency.spec.ts` 的其余 7 条登录态失败或 `global-search.spec.ts` 的 1 条登录态失败。
|
||||
- 重构全部 React Query mutation、统一全站缓存策略或调整所有成功 toast 时序。
|
||||
- 修改采购或资产 API payload、后端业务逻辑、数据库、权限、导航 schema、移动端路由或生产配置。
|
||||
- 启动 `08-05-contract-detail-workspace` 或执行任何合同 migration。
|
||||
- seed、reset、批量删除或手工清理本地 Playwright 合成数据。
|
||||
- 部署、公网检查、企业微信真机或线上可测试声明。
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. 静态与构建:
|
||||
- `cd client && npm run typecheck`
|
||||
- `cd client && npm run build`
|
||||
2. 共享 guard 与路由聚焦回归:
|
||||
- `cd client && npx playwright test tests/e2e/business-detail-routing.spec.ts --workers=1 --reporter=list`
|
||||
- `cd client && npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts --grep "资产长表单切回记录列表" --workers=1 --reporter=list`
|
||||
3. 当前回环本地服务上补跑真实主链路:
|
||||
- `cd client && E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test tests/e2e/assets-flow.spec.ts tests/e2e/procurement-flow.spec.ts --workers=1 --reporter=list`
|
||||
4. 记录真实 passed/failed/skipped 数量;若出现范围外新失败,只分类和留证,不顺手扩大修复。
|
||||
5. 流程门禁:
|
||||
- `python3 ./.trellis/scripts/task.py validate 08-07-business-routing-regression-repair`
|
||||
- `node scripts/qa/check-trellis-governance.mjs --json`
|
||||
- `git diff --check`
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "business-routing-regression-repair",
|
||||
"name": "business-routing-regression-repair",
|
||||
"title": "资产创建跳转与路由回归收尾",
|
||||
"description": "修复资产真实创建成功后被共享未保存保护误拦截、无法进入 canonical 详情路由的问题,修正采购主链路英文姓名断言,并以 append-only delta 纠正归档证据分类;不处理其余八条会话夹具失败。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "asset-post-save-navigation-and-routing-regression-closeout",
|
||||
"package": "client",
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-07",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-05-product-interaction-consistency",
|
||||
"relatedFiles": [
|
||||
"client/src/components/common/UnsavedChangesGuard.tsx",
|
||||
"client/src/pages/AssetRegistryPage.tsx",
|
||||
"client/tests/e2e/assets-flow.spec.ts",
|
||||
"client/tests/e2e/procurement-flow.spec.ts",
|
||||
"client/tests/e2e/business-detail-routing.spec.ts",
|
||||
"client/tests/e2e/r103-cross-page-ui-consistency.spec.ts",
|
||||
".trellis/spec/hyb-oa/frontend-react-vite-design.md",
|
||||
".trellis/tasks/archive/2026-08/08-05-business-detail-routing/prd.md",
|
||||
".trellis/tasks/archive/2026-08/08-07-local-database-migration-readiness/implement.md",
|
||||
"docs/产品设计规则.md"
|
||||
],
|
||||
"notes": "实施与本地验证完成:共享 post-save 单次导航放行修复资产创建竞态;聚焦路由 5/5、未保存保护 1/1、真实资产与采购主链路 2/2 通过。其余八条登录态/mock 失败未改;用户已批准工作提交与归档,不 push、不部署。",
|
||||
"meta": {
|
||||
"description": "关闭已归档路由任务被真实 API 证伪的资产创建 AC,并恢复两条真实主链路回归。"
|
||||
}
|
||||
}
|
||||
@@ -71,3 +71,11 @@
|
||||
- 更新并运行 `procurement-flow.spec.ts`、`assets-flow.spec.ts`、`r103-cross-page-ui-consistency.spec.ts`、`pc-detail-mobile-visual-wave3.spec.ts`、`global-search.spec.ts`、通知链接与移动路由受影响用例;必要时增加聚焦路由合同 spec。
|
||||
- 用代码搜索确认 `client/src` 中业务入口不再产生采购/资产 `?id=` 或 `highlight` 详情链接,只保留明确命名的兼容解析逻辑。
|
||||
- 运行 `python3 ./.trellis/scripts/task.py validate 08-05-business-detail-routing`、`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check`。
|
||||
|
||||
## Post-Archive Delta · 2026-08-07
|
||||
|
||||
- 原资产创建验收项继续保留当时的 `PAUSED` 标记与发现时间线;迁移基线恢复后,真实 API 回归证明该项曾被证伪:POST 已成功返回实体 ID,但 `/assets/new` 的同步 `navigate` 被共享未保存保护拦截。
|
||||
- 后续任务 `08-07-business-routing-regression-repair` 已修复该产品回归:新增仅限服务端保存成功后的单次 post-save 导航放行,资产创建先 `replace` 到 `/assets/:assetId`,再后台失效缓存;普通 dirty navigation、失败路径和权限边界未改变。
|
||||
- 2026-08-07 有效验证:聚焦未保存保护 mock 用例 `1 passed / 0 failed / 0 skipped`;真实 `assets-flow.spec.ts` 与 `procurement-flow.spec.ts` 合计 `2 passed / 0 failed / 0 skipped`。资产链路覆盖创建详情跳转、无未保存弹窗、后退/前进及后续流转。
|
||||
- 采购主链路中的英文 `Employee` 断言已改为 fixture 中文姓名;同次真实回归发现的视口/筛选控件测试漂移也仅在目标 E2E 文件内做了最小同步,不代表产品范围扩张。
|
||||
- 本 delta 不把历史 `PAUSED` 记录倒写为当时已通过;企业微信真机、生产/公网和其余独立会话夹具问题仍按原边界处理。
|
||||
|
||||
@@ -116,3 +116,11 @@
|
||||
- `client/test-results/global-search-E2E-全局搜索:采购与资产忽略旧-href-并使用实体-canonical-路径/error-context.md`:第二次回到 portal 后 `global-search-trigger` 等待超时,页面为登录态。
|
||||
- `client/test-results/r103-cross-page-ui-consist-2485f-务台账标题区保持一主操作加至多一个更多,列表工具不常驻/error-context.md`:合同列表标题未出现。
|
||||
- `client/test-results/r103-cross-page-ui-consist-bed95-cy-采购页使用安全格式上传并通过认证-Blob-下载/error-context.md`、`06cda-资产列表、详情各自只请求职责内数据并恢复-URL-筛选/error-context.md`、`aa8b7-与资产长表单详情对-403-404-使用同一安全错误态/error-context.md`、`c9ebc-ncy-采购长表单切回记录列表时支持取消保持与确认放弃/error-context.md`、`b8364-cy-共享未保存保护覆盖浏览器返回取消、确认与刷新拦截/error-context.md`、`b7ec9-ursements-在中等宽度下操作列可达且页面不横溢/error-context.md`:均在页面契约断言前回到登录页,归为 mock 会话/页面环境问题,未归因于 migration。
|
||||
|
||||
## Post-Archive Delta · 2026-08-07
|
||||
|
||||
- 原 `16 passed / 10 failed / 0 skipped` 仍是迁移任务当时的历史证据,不回写为全绿。
|
||||
- 失败分类已由后续任务 `08-07-business-routing-regression-repair` 纠正:资产创建成功后停留 `/assets/new` 是产品回归(共享未保存保护竞态);采购摘要中的英文 `Employee` 是测试断言漂移;其余 8 条仍是独立登录态/mock 会话候选。
|
||||
- 修复后在同一回环本地数据库、当前 checkout 和完整服务配置下,真实 `assets-flow.spec.ts` 与 `procurement-flow.spec.ts` 合计 `2 passed / 0 failed / 0 skipped`;聚焦共享未保存保护用例 `1 passed / 0 failed / 0 skipped`。
|
||||
- 真实回归过程中发现的两个额外测试契约漂移(资产用例在 1024px 继续查找桌面侧栏入口、采购用例仍操作已迁移的常驻范围控件)已在目标测试文件内最小修正;未修改数据库、登录实现、其余 8 条会话夹具或产品范围外代码。
|
||||
- 数据库迁移层的结论仍是 `PASS`;本 delta 只更新失败归属和后续验证,不改变原迁移执行证据,也不宣称企业微信真机或生产/公网已验证。
|
||||
|
||||
@@ -21,6 +21,7 @@ type GuardRegistration = {
|
||||
type UnsavedChangesContextValue = {
|
||||
register: (id: string, registration: GuardRegistration | null) => void;
|
||||
runGuarded: (action: () => void) => void;
|
||||
runAfterSuccessfulSave: (action: () => void) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
@@ -76,6 +77,17 @@ export function UnsavedChangesProvider({ children }: PropsWithChildren) {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const runWithNextNavigationAllowed = useCallback((action: () => void) => {
|
||||
allowNextNavigationRef.current = true;
|
||||
try {
|
||||
action();
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
allowNextNavigationRef.current = false;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === "blocked") {
|
||||
setDialogOpen(true);
|
||||
@@ -95,7 +107,10 @@ export function UnsavedChangesProvider({ children }: PropsWithChildren) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ register, runGuarded }), [register, runGuarded]);
|
||||
const value = useMemo(
|
||||
() => ({ register, runGuarded, runAfterSuccessfulSave: runWithNextNavigationAllowed }),
|
||||
[register, runGuarded, runWithNextNavigationAllowed],
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
@@ -129,14 +144,7 @@ export function UnsavedChangesProvider({ children }: PropsWithChildren) {
|
||||
return;
|
||||
}
|
||||
if (!action) return;
|
||||
allowNextNavigationRef.current = true;
|
||||
try {
|
||||
action();
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
allowNextNavigationRef.current = false;
|
||||
});
|
||||
}
|
||||
runWithNextNavigationAllowed(action);
|
||||
}}
|
||||
/>
|
||||
</UnsavedChangesContext.Provider>
|
||||
@@ -157,3 +165,12 @@ export function useUnsavedChangesGuard(dirty: boolean, label: string) {
|
||||
|
||||
return context.runGuarded;
|
||||
}
|
||||
|
||||
export function usePostSaveNavigation() {
|
||||
const context = useContext(UnsavedChangesContext);
|
||||
if (!context) {
|
||||
throw new Error("usePostSaveNavigation 必须在 UnsavedChangesProvider 内使用");
|
||||
}
|
||||
|
||||
return context.runAfterSuccessfulSave;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
import {
|
||||
usePostSaveNavigation,
|
||||
useUnsavedChangesGuard,
|
||||
} from "../components/common/UnsavedChangesGuard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
@@ -405,6 +408,7 @@ function AssetRegistryWorkspace({
|
||||
Boolean(isCreatingAsset || selectedAssetId) && Boolean(canEditCurrent) && JSON.stringify(assetForm) !== savedAssetFormSignature,
|
||||
"资产表单",
|
||||
);
|
||||
const runAfterSuccessfulSave = usePostSaveNavigation();
|
||||
const hasAdvancedAssetFilters = Boolean(
|
||||
sensitivityFilter ||
|
||||
categoryFilter ||
|
||||
@@ -575,19 +579,38 @@ function AssetRegistryWorkspace({
|
||||
return patchAsset(selectedAssetId, buildPatchAssetPayload());
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
if (isCreatingAsset) {
|
||||
const detailPath = buildAssetDetailPath(data.asset.id);
|
||||
if (detailPath === "/assets") {
|
||||
const message = "资产已创建,但未返回可用的资产标识,请返回资产列表确认。";
|
||||
setAssetFormError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetFormError("");
|
||||
setSavedAssetFormSignature(JSON.stringify(assetForm));
|
||||
toast.success("资产卡片已创建");
|
||||
runAfterSuccessfulSave(() => {
|
||||
navigate(detailPath, {
|
||||
replace: true,
|
||||
state: { from: backSource },
|
||||
});
|
||||
});
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
setAssetFormError("");
|
||||
setSavedAssetFormSignature(JSON.stringify(assetForm));
|
||||
toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新");
|
||||
if (isCreatingAsset) {
|
||||
navigate(buildAssetDetailPath(data.asset.id), {
|
||||
replace: true,
|
||||
state: { from: backSource },
|
||||
});
|
||||
}
|
||||
toast.success("资产卡片已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
|
||||
@@ -118,6 +118,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
};
|
||||
const assetId = createAssetPayload.data.asset.id;
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.goForward();
|
||||
@@ -161,6 +162,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
|
||||
await logout(page, ADMINISTRATION.name);
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await login(page, FINANCE_MGR);
|
||||
await (await openNavLink(page, "资产盘点")).click();
|
||||
await page.getByTestId("inventory-create").click();
|
||||
|
||||
@@ -90,7 +90,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await page.goto(`/workflow/tasks/${currentTaskId}`);
|
||||
await expect(page.getByTestId("workflow-task-overview")).toContainText("采购申请");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("申请人");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("Employee");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(EMPLOYEE.name);
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
@@ -101,8 +101,9 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
|
||||
await login(page, FINANCE_MGR);
|
||||
await (await openNavLink(page, "采购")).click();
|
||||
await page.getByRole("button", { name: "全范围" }).click();
|
||||
await page.getByRole("combobox").first().selectOption("approved");
|
||||
await page.getByTestId("purchase-advanced-filters").click();
|
||||
await page.getByTestId("purchase-scope-all").click();
|
||||
await page.getByTestId("purchase-tab-approved").click();
|
||||
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
|
||||
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
|
||||
await purchaseRow.getByTestId("purchase-row-open").click();
|
||||
|
||||
@@ -492,6 +492,29 @@ async function mockApis(page: Page, options: { roleCodes?: string[] } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/assets" && request.method() === "POST") {
|
||||
const payload = request.postDataJSON() as { name?: string };
|
||||
if (payload.name === "合成失败资产") {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r103-asset-create-failure",
|
||||
error: { code: "VALID_001", message: "资产保存失败,请检查填写内容。" },
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
asset: {
|
||||
...assetListItem(),
|
||||
...payload,
|
||||
id: assetId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/assets/${assetId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, { asset: assetListItem() });
|
||||
return;
|
||||
@@ -894,6 +917,45 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await page.getByTestId("unsaved-changes-discard").click();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await expect(page.getByTestId("asset-row")).toBeVisible();
|
||||
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await page.getByTestId("asset-name").fill("合成已保存资产");
|
||||
await page.getByTestId("asset-category").selectOption("asset-category-r103");
|
||||
await page.getByTestId("asset-legal-entity").selectOption("legal-r103");
|
||||
await page.getByTestId("asset-cost-center").selectOption("cost-r103");
|
||||
await page.getByTestId("asset-save").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toHaveCount(0);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-unsaved-changes", "false");
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/assets$/);
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(new RegExp(`/assets/${assetId}$`));
|
||||
|
||||
await page.goBack();
|
||||
await page.getByTestId("asset-create").click();
|
||||
await page.getByTestId("asset-name").fill("合成失败资产");
|
||||
await page.getByTestId("asset-category").selectOption("asset-category-r103");
|
||||
await page.getByTestId("asset-legal-entity").selectOption("legal-r103");
|
||||
await page.getByTestId("asset-cost-center").selectOption("cost-r103");
|
||||
const failedCreateResponse = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/assets",
|
||||
);
|
||||
await page.getByTestId("asset-save").click();
|
||||
const failedResponse = await failedCreateResponse;
|
||||
expect(failedResponse.ok()).toBe(false);
|
||||
expect(failedResponse.status()).toBe(400);
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产");
|
||||
await expect(page.getByRole("main").getByText("资产保存失败,请检查填写内容。", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "返回资产列表", exact: true }).click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("资产表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(/\/assets\/new$/);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("合成失败资产");
|
||||
});
|
||||
|
||||
test("共享未保存保护覆盖浏览器返回取消、确认与刷新拦截", async ({ page }) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户