diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index ef9a2d13..462c8a1b 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -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 diff --git a/.trellis/tasks/08-05-product-interaction-consistency/task.json b/.trellis/tasks/08-05-product-interaction-consistency/task.json index 84be6565..820f69b7 100644 --- a/.trellis/tasks/08-05-product-interaction-consistency/task.json +++ b/.trellis/tasks/08-05-product-interaction-consistency/task.json @@ -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": [], diff --git a/.trellis/tasks/08-07-business-routing-regression-repair/check.jsonl b/.trellis/tasks/08-07-business-routing-regression-repair/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-07-business-routing-regression-repair/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-07-business-routing-regression-repair/design.md b/.trellis/tasks/08-07-business-routing-regression-repair/design.md new file mode 100644 index 00000000..9474cf87 --- /dev/null +++ b/.trellis/tasks/08-07-business-routing-regression-repair/design.md @@ -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 为历史事实,不随代码回滚删除;若修复回滚,应追加新的状态说明。 diff --git a/.trellis/tasks/08-07-business-routing-regression-repair/implement.jsonl b/.trellis/tasks/08-07-business-routing-regression-repair/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-07-business-routing-regression-repair/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-07-business-routing-regression-repair/implement.md b/.trellis/tasks/08-07-business-routing-regression-repair/implement.md new file mode 100644 index 00000000..b15acd7e --- /dev/null +++ b/.trellis/tasks/08-07-business-routing-regression-repair/implement.md @@ -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 合成记录按批准保留,未清理。 diff --git a/.trellis/tasks/08-07-business-routing-regression-repair/prd.md b/.trellis/tasks/08-07-business-routing-regression-repair/prd.md new file mode 100644 index 00000000..c6b50821 --- /dev/null +++ b/.trellis/tasks/08-07-business-routing-regression-repair/prd.md @@ -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` diff --git a/.trellis/tasks/08-07-business-routing-regression-repair/task.json b/.trellis/tasks/08-07-business-routing-regression-repair/task.json new file mode 100644 index 00000000..a1464a16 --- /dev/null +++ b/.trellis/tasks/08-07-business-routing-regression-repair/task.json @@ -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,并恢复两条真实主链路回归。" + } +} diff --git a/.trellis/tasks/archive/2026-08/08-05-business-detail-routing/prd.md b/.trellis/tasks/archive/2026-08/08-05-business-detail-routing/prd.md index 085485be..16c92eeb 100644 --- a/.trellis/tasks/archive/2026-08/08-05-business-detail-routing/prd.md +++ b/.trellis/tasks/archive/2026-08/08-05-business-detail-routing/prd.md @@ -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` 记录倒写为当时已通过;企业微信真机、生产/公网和其余独立会话夹具问题仍按原边界处理。 diff --git a/.trellis/tasks/archive/2026-08/08-07-local-database-migration-readiness/implement.md b/.trellis/tasks/archive/2026-08/08-07-local-database-migration-readiness/implement.md index 7d8b4254..9e690bef 100644 --- a/.trellis/tasks/archive/2026-08/08-07-local-database-migration-readiness/implement.md +++ b/.trellis/tasks/archive/2026-08/08-07-local-database-migration-readiness/implement.md @@ -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 只更新失败归属和后续验证,不改变原迁移执行证据,也不宣称企业微信真机或生产/公网已验证。 diff --git a/client/src/components/common/UnsavedChangesGuard.tsx b/client/src/components/common/UnsavedChangesGuard.tsx index 904b6f0c..4091cb97 100644 --- a/client/src/components/common/UnsavedChangesGuard.tsx +++ b/client/src/components/common/UnsavedChangesGuard.tsx @@ -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(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 ( @@ -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); }} /> @@ -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; +} diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 8e6901cc..d4027e38 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -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); diff --git a/client/tests/e2e/assets-flow.spec.ts b/client/tests/e2e/assets-flow.spec.ts index 3665d338..bf617910 100644 --- a/client/tests/e2e/assets-flow.spec.ts +++ b/client/tests/e2e/assets-flow.spec.ts @@ -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(); diff --git a/client/tests/e2e/procurement-flow.spec.ts b/client/tests/e2e/procurement-flow.spec.ts index 517c03f3..7ee80b39 100644 --- a/client/tests/e2e/procurement-flow.spec.ts +++ b/client/tests/e2e/procurement-flow.spec.ts @@ -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(); diff --git a/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts b/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts index 1869ff4b..55b543ad 100644 --- a/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts +++ b/client/tests/e2e/r103-cross-page-ui-consistency.spec.ts @@ -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 }) => {