From 7c44e05ad6fe13f9f8af74a3702140607641e944 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 7 Aug 2026 11:01:10 +0800 Subject: [PATCH] =?UTF-8?q?docs(08-05-business-detail-routing):=20?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=AA=8C=E8=AF=81=E7=BB=93=E6=9E=9C=E4=B8=8E?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=BF=81=E7=A7=BB=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 112 ++++++++++++++++++ .../08-05-business-detail-routing/design.md | 100 ++++++++++------ .../implement.md | 96 ++++++++++----- .../08-05-business-detail-routing/prd.md | 78 ++++++------ .../08-05-business-detail-routing/task.json | 30 ++++- 5 files changed, 312 insertions(+), 104 deletions(-) diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index bf8d02cc..ef9a2d13 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -1234,6 +1234,118 @@ const blocker = useBlocker(isDirty); // reset to keep editing; proceed only after explicit discard. ``` +## Scenario: Business Ledger List-To-Detail Route Migration + +### 1. Scope / Trigger + +- Trigger: an existing business ledger keeps a selected record inside the list + route through `?id=`, local selected-ID state or a list/detail split, and the + current Trellis task migrates that record to an independent detail route. +- Current examples are procurement and assets; the same contract applies to a + later contract, payment or other ledger migration only when that task has an + already-defined detail information structure. + +### 2. Signatures + +- Canonical detail path: `//:entityId`, built through one shared path + helper that trims the ID and encodes the path segment. +- Optional creation path: `//new`; register and test it as a static + route, never as a value of `:entityId`. +- Safe return state: `location.state.from = pathname + search`, accepted only + through a module-specific path allowlist. +- Legacy entry: `/?id=` performs a `replace` redirect to the + canonical detail and carries a normalized list source without `id`. + +### 3. Contracts + +- The list route owns URL-backed filters, pagination, list queries, creation + entry and row navigation. It must not request or render a selected entity's + detail merely because legacy `id` is present. +- The detail route owns the entity query, edit/actions/evidence/history and + dirty-form registration. It must not request the complete ledger list. +- Parse legacy `?id=` before mounting list queries. Empty or whitespace IDs stay + on the list; a non-empty ID redirects with `replace` so the old URL is not a + durable history entry. +- List filters use the URL as their single state source. Invalid enums, pages + and unauthorized scopes fall back to safe defaults before API request + construction; frontend scope controls never widen backend authorization. +- Row navigation uses `push` and carries the current normalized list source. + An explicit detail return uses the validated source with `replace`; browser + back remains native and must not reopen the detail after an explicit return. +- A create route that has no entity ID before submit uses its own page. On + successful creation, `replace` it with the new detail while preserving the + original list source so browser back cannot reopen a submitted form. +- New detail routes copy the original ledger read guard exactly. A create route + may use a narrower write permission, but must never reuse broad list-read + permission as create authority. Server object scope remains authoritative. +- Object-level `403` and `404` share one non-enumerating Chinese error state; + do not reveal whether the object exists or expose a partial cached summary. +- Every new application route must be classified in + `scripts/qa/route-manifest.json` with executable behavior/visual evidence as + required by the route's classification. + +### 4. Validation & Error Matrix + +- Missing/blank legacy ID -> render the list and issue no detail request. +- Valid legacy ID -> one `replace` to canonical detail; issue no list request + before the redirect. +- Invalid filter or unauthorized `scope=all` -> safe default / own-scope API + query; never an all-scope request. +- Direct detail refresh -> load the entity only and use the module list as the + fallback return target. +- Unsafe `state.from` (external, protocol-relative, hash, current page or + cross-module path) -> reject it and use the module list fallback. +- Dirty form + route/back/explicit-return navigation -> one shared unsaved + dialog; cancel preserves URL and values, discard performs one navigation. +- Static `/new` matched as `:entityId` or a route missing from the manifest -> + acceptance failure. + +### 5. Good/Base/Bad Cases + +- Good: a filtered procurement list opens a canonical detail, explicit return + restores the filters, and a legacy bookmark reaches the same detail without + first requesting the list. +- Base: a direct asset detail has no trusted source and returns safely to the + asset list; a mobile-preferred runtime may map it to an existing mobile + fallback without adding mobile detail capability. +- Bad: keep list and detail state synchronized through two effects, let + `/assets/new` call `/api/assets/new`, or grant create access because the user + can read the asset ledger. + +### 6. Tests Required + +- Source/unit contract for path encoding, blank ID, filter normalization, + unauthorized scope, legacy source normalization and return-source allowlist. +- Focused Playwright for list/detail request isolation, direct deep link, + legacy replace, filter return, static `/new`, create permission, `403/404`, + dirty cancel/discard and create-success history replacement. +- Consumer coverage for project relations, global search, notifications, page + metadata and mobile-runtime mapping when those entry points exist. +- `cd client && npm run typecheck && npm run build`. +- `node scripts/qa/check-flow-coverage.mjs --json --fail-on-gaps` and + `bash scripts/qa/run-local-candidate.sh list` after adding routes. + +### 7. Wrong vs Correct + +#### Wrong + +```tsx +const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? ""); +useEffect(() => setSearchParams({ id: selectedId }, { replace: true }), [selectedId]); +} /> +} /> +``` + +#### Correct + +```tsx +const legacy = resolveAssetLegacyRedirect(searchParams); +if (legacy) return ; + +...} /> +...} /> +``` + ## Convention: Detail Evidence Layout **What**: Detail pages use one evidence-first structure so users can understand diff --git a/.trellis/tasks/08-05-business-detail-routing/design.md b/.trellis/tasks/08-05-business-detail-routing/design.md index ea88c738..065ac8be 100644 --- a/.trellis/tasks/08-05-business-detail-routing/design.md +++ b/.trellis/tasks/08-05-business-detail-routing/design.md @@ -1,56 +1,84 @@ # 设计说明 -## 1. 架构边界 +## 1. 架构边界与目标路由 -本任务只迁移采购、资产已有详情态。列表数据、详情 API、mutation、权限和表单字段保持原合同,不为路由化新增后端接口或数据库字段。 +本任务只迁移采购、资产已有详情/编辑能力,并为“提交前没有实体 ID”的资产创建表单提供独立路由。列表数据、详情 API、mutation payload、权限数据源、审计和状态机保持原合同,不新增后端 endpoint 或数据库字段。 -目标路由: +| 模块 | 列表 | 创建 | 详情 | +| --- | --- | --- | --- | +| 采购 | `/purchase-requests` | 不新增;列表先创建草稿 | `/purchase-requests/:purchaseRequestId` | +| 资产 | `/assets` | `/assets/new` | `/assets/:assetId` | -| 模块 | 列表 | 详情 | +路由注册与页面元数据必须把 `/assets/new` 作为静态路径处理,不能落入 `:assetId`。`PAGE_TITLES` 增加采购详情、资产新建、资产详情层级,导航仍按父模块高亮。 + +## 2. 页面与数据职责拆分 + +- `PurchaseRequestsPage` 收敛为采购列表页:持有 URL 筛选、分页、列表 query、创建草稿入口和行级详情入口。 +- `PurchaseRequestDetailPage` 承接现有采购 detail query、表单 hydration、附件、采购单归档、提交/撤回、审计/状态动作和 dirty 状态;不得为渲染详情请求完整采购列表。 +- `AssetRegistryPage` 收敛为资产列表页:持有 URL 筛选、资产列表 query、资产分类管理、移动扫码入口、创建入口和行级详情入口。 +- `AssetDetailPage` 与 `AssetCreatePage` 使用同一资产编辑工作区/section 集合:详情模式加载实体并按现有权限编辑与流转;创建模式使用初始表单并只执行 POST,避免复制两份长表单。 +- 列表页不再加载详情专用 options;详情/创建页只加载自身表单所需的分类、公司、成本中心、候选采购等数据。React Query 可复用缓存,但职责不能靠隐藏 JSX 维持。 +- 优先提取页面内类型、纯函数和有明确领域含义的 section;不建立跨采购/资产的“万能详情页”。 + +## 3. 列表筛选 URL 合同 + +筛选参数由 URL 直接派生,避免“URL 一份、本地 state 一份”的双向同步 effect。筛选输入使用 `replace` 更新当前列表 history entry;采购翻页同样保留当前筛选。资产文本搜索维持现有即时查询体验,但用 deferred/debounce 值触发请求,避免每次键入造成不必要的渲染/请求抖动。 + +| 模块 | 查询参数 | 默认与约束 | | --- | --- | --- | -| 采购 | `/purchase-requests` | `/purchase-requests/:purchaseRequestId` | -| 资产 | `/assets` | `/assets/:assetId` | +| 采购 | `status`、`scope`、`supplierId`、`legalEntityId`、`costCenterId`、`page` | 无 `status` 时为 `draft`;`status=all` 表示全部;无 `scope` 时为 `own`;`scope=all` 仅 `canViewAll` 生效;`page` 只接受正整数 | +| 资产 | `q`、`status`、`assetType`、`sensitivity`、`categoryId`、`legalEntityId`、`costCenterId`、`assigneeId`、`scope` | 无 `scope` 时保留现有按权限决定的默认范围;显式 `own/all` 仅在有全量权限时可切换,否则强制 own | -## 2. 页面拆分 +联动筛选一次性更新:公司变化且原成本中心不再属于该公司时,同一次 replace 删除 `costCenterId`;采购非页码筛选变化同时把 `page` 归一到 1。未知枚举、无效页码或越权范围只在读取层回落,不进入 API 查询条件。 -- `PurchaseRequestsPage` 与 `AssetRegistryPage` 收敛为纯列表页,只持有筛选、分页、列表 query、创建入口和行级“查看详情”。 -- 新建 `PurchaseRequestDetailPage` 与 `AssetDetailPage`,承接现有详情 query、表单 hydration、附件/动作、右侧状态动作和 dirty 状态。 -- 先提取页面内纯函数、类型和可复用 section,避免复制大段表单逻辑;不为了抽象而建立通用“万能详情页”。 -- 创建行为保持现有 API 合同:创建成功后导航到新详情路由;本任务不新增独立新建 URL。 +## 4. 导航、返回与创建完成 -## 3. 导航与返回 +- 列表进入详情使用 history push,并把 `pathname + search` 作为 `location.state.from`;浏览器后退自然回到原列表 entry,显式返回使用经校验的来源。 +- 扩展 `pcDetailNavigation` 为显式 allowlist 合同,不把任意字符串当 return URL。采购详情允许采购列表来源以及受控的项目详情来源;资产详情/创建只允许资产列表来源。绝对 URL、协议相对 URL、hash、当前页和其他模块来源全部拒绝。 +- 无安全来源时,采购详情回到 `/purchase-requests`,资产详情/创建回到 `/assets`。从全局搜索或通知进入时,浏览器后退仍按 history 返回原页面,显式返回使用安全列表兜底。 +- 采购创建保留现有“先 POST 空草稿”合同;成功后 push 到采购详情并携带当前列表来源。 +- 资产列表的“新增资产”push 到 `/assets/new` 并携带列表来源。创建成功后使用 replace 进入 `/assets/:assetId`,继续携带原列表来源,避免返回到已提交的创建表单。 +- 项目详情中的采购链接携带项目详情作为安全来源;详情返回文案按来源显示“返回项目详情”或“返回采购列表”。 -- 列表进入详情时通过 React Router history push,并在 location state 保存安全的当前列表 URL;浏览器后退自然恢复原筛选和滚动历史。 -- 详情显式“返回”优先使用经过校验的同模块 `state.from`,无来源或刷新后回退到模块列表。 -- 项目关联、全局搜索、审批/通知等入口直接生成新详情 URL;不允许把任意外部字符串作为 return URL。 -- `features/navigation/schema.ts` 添加采购详情、资产详情元数据,并确保长路径优先匹配。 +## 5. 路径构造与现有入口 -## 4. 旧链接兼容 +- 新增单一的采购/资产详情路径构造函数,对实体 ID 使用路径段编码;列表、创建成功、项目关联、通知和搜索解析都消费该函数,避免分散拼接。 +- `GlobalSearchDialog` 对 `purchase_request` 与 `asset` 使用 `entityType + entityId` 构造 canonical 路径,不继续信任旧服务端 `href` 的 `?id=` 或列表级值;其他实体仍沿用原合同。本任务因此无需修改全局搜索 API shape。 +- PC `notificationLinks` 将 `purchase_request`、`asset`/`asset_card` 直接解析为新详情路径,停止生成未消费的 `highlight`;移动通知解析保持现有移动路由边界。 +- `ProjectDetailPage` 的采购关联改为新详情路径;没有现有入口的模块不为满足“路由统一”额外新增按钮。 +- `client/src` 中旧 `?id=` 只允许出现在明确命名的 legacy redirect/parser 与兼容测试中。服务端历史集成审计字段或未被前端消费的 routePath 不在本任务内,不能借机改 API 或集成逻辑。 -- `/purchase-requests?id=` 与 `/assets?id=` 在列表路由入口执行 replace 重定向到新详情路径。 -- 重定向移除 `id`,保留其余无关查询参数;无效或空 ID 不重定向,列表正常展示。 -- 仓库内消费者全部改为新 URL,旧格式只作为外部收藏、历史通知和人工 witness 的兼容层。 +## 6. 旧链接兼容 -## 5. Dirty guard 与错误态 +- `/purchase-requests?id=` 与 `/assets?id=` 在对应列表入口、列表 query 启动前解析;有非空 ID 时 replace 到 canonical 详情路径,避免先闪现列表或同时请求列表/详情。 +- redirect 从查询参数中删除 `id`,将剩余且仍属于对应列表的参数归一化后写入安全 `state.from`;canonical 详情 URL 不携带列表筛选噪音。 +- 空 ID、只有空白字符或缺少 ID 时不重定向,列表按其余有效筛选正常展示。未知实体 ID 进入详情后的统一错误态。 +- redirect 使用 replace,历史栈不保留旧 URL;仓库内新入口全部使用 canonical 路径,旧格式仅作为外部收藏、旧 witness 和历史链接兼容层。 -- 详情页面继续注册 `useUnsavedChangesGuard`;返回按钮、浏览器后退、跨模块链接和直接路由离开都由 provider 阻断。 -- 用户取消离开时保留当前 ID 和所有输入;确认后才执行导航。 -- 服务端 403/404 使用普通中文错误态。为避免资源枚举,详情页不区分“无权限”与“记录不存在”的敏感摘要,只提供安全返回列表入口。 -- 列表路由不发详情 query;详情路由不为构造页面偷偷加载完整列表。 +## 7. Dirty guard、错误态与权限 -## 6. 权限与兼容 - -- 新动态路由复制对应列表路由的 `RequirePermission` guard;页面内现有细粒度动作权限继续保留。 +- 采购详情、资产详情和资产创建页面继续注册 `useUnsavedChangesGuard`;显式返回、浏览器后退、跨模块链接和直接路由离开均由现有 provider 阻断。 +- 用户取消离开时不执行 navigate,保留当前 URL、实体和全部输入;确认后才执行原导航意图。资产创建成功后的 replace 只在 mutation 成功后发生。 +- 403/404 统一映射为普通中文安全错误态:“无法查看该记录,记录可能不存在或你无权访问”;不渲染已取得的局部摘要,不区分可用于资源枚举的原因。 +- 采购列表与详情沿用原 `RequirePermission` anyOf;资产列表与详情沿用原资产 anyOf。`/assets/new` 单独要求 `asset.manage`,页面内编辑、流转、敏感字段和附件权限继续使用现有细粒度判断。 - API endpoint、请求 payload、审计、高风险确认、附件下载原因和状态矩阵均不改变。 -- 移动采购现有 `/mobile/purchase-requests/:id` 不受影响;本任务不新增移动资产详情路由。 -## 7. 测试策略 +## 8. 移动兼容 -- 为两个模块建立同构的路由合同测试:列表纯净、进入详情、直接深链、刷新、返回恢复、旧链 replace、无效/无权限、dirty 取消/确认。 -- 更新现有 `procurement-flow.spec.ts`、`assets-flow.spec.ts`、`r103-cross-page-ui-consistency.spec.ts`、`pc-detail-mobile-visual-wave3.spec.ts` 和人工 witness 中的旧链接。 -- 测试应使用显式 route tag/URL 断言,不再用父列表路径字符串误认动态详情证据。 +- 现有 `/mobile/purchase-requests/:id` 不变;`runtimeContext` 继续把 PC 采购详情映射到对应移动详情。 +- 本任务不新增移动资产详情或创建能力。移动工作台偏好环境访问 `/assets`、`/assets/new` 或 `/assets/:assetId` 时统一回落到既有 `/mobile/assets/scan`,保持旧 `/assets?id=` 在移动端的能力边界。 +- 新 PC 详情路由不得改变移动底部导航、企业微信入口或资产扫码/盘点流程。 -## 8. 回滚 +## 9. 测试策略 -- 新路由、页面拆分、链接消费者和兼容 redirect 同一任务提交;回滚时整体恢复 `?id=`,避免一半消费者指向不存在的路由。 -- 无 schema、API 或数据迁移。应用回滚不需要处理数据库。 +- 为两个模块建立同构路由合同:列表不发详情 query、详情不发完整列表 query、列表进入、直接深链、刷新、返回恢复、旧链 replace、无效/无权限、dirty 取消/确认。 +- 单独覆盖资产 `/assets/new`:静态路由优先、`asset.manage` guard、直接访问、取消返回、创建成功 replace、失败保留输入、成功后后退不回创建页。 +- 覆盖 URL 筛选事实源:直接打开筛选 URL、筛选 replace、采购 page 重置/恢复、范围权限收紧、公司/成本中心联动归一。 +- 覆盖项目关联、全局搜索、PC 通知的 canonical URL;全局搜索 purchase/asset 不受服务端旧 `href` 影响。 +- 更新 `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` 及通知/移动路由用例,必要时增加专门的 `business-detail-routing.spec.ts`。 +- 测试使用完整 URL 与页面 marker 断言,不用父列表路径前缀误认动态详情;403 与 404 都断言同一安全文案。 + +## 10. 回滚 + +- 新路由、页面拆分、筛选 URL、入口消费者和 legacy redirect 作为同一任务提交;回滚时整体恢复 `?id=` 页面形态,避免消费者指向不存在的页面。 +- 无 schema、API 或数据迁移。应用回滚不处理数据库;若需部分回滚,必须至少同时回滚 route registry、lazy exports、canonical path builder 和所有前端消费者。 diff --git a/.trellis/tasks/08-05-business-detail-routing/implement.md b/.trellis/tasks/08-05-business-detail-routing/implement.md index 6cfd7b8f..037c8448 100644 --- a/.trellis/tasks/08-05-business-detail-routing/implement.md +++ b/.trellis/tasks/08-05-business-detail-routing/implement.md @@ -1,43 +1,79 @@ # 实施计划 -## 1. 公共准备 +## 1. 公共路由合同 -- [ ] 盘点采购/资产页面中列表状态、详情状态、纯函数和共享 section,确定最小拆分边界。 -- [ ] 增加安全的列表来源/返回 helper 与旧 `?id=` redirect helper,先写单元或 Playwright 合同。 -- [ ] 在 router 和 page meta 中注册两个详情路径,复制原权限 guard。 +- [x] 盘点采购/资产页面中的列表 state、详情 state、表单 section、纯函数和数据 query,按 `design.md` 固定最小拆分边界。 +- [x] 新增统一的采购/资产 canonical path builder,并为路径段编码、空 ID 和 `/assets/new` 静态优先级写聚焦测试。 +- [x] 扩展 `pcDetailNavigation` 的显式来源 allowlist:保留报销/审批现状,增加采购列表、资产列表和受控项目详情来源;拒绝外部、hash、当前页和跨模块来源。 +- [x] 增加采购/资产 legacy `?id=` 入口解析,在列表 query 前完成 replace,并把归一后的剩余查询写入安全 `state.from`。 +- [x] 在 router、lazy exports 和 page meta 中注册采购详情、资产创建、资产详情;资产创建仅 `asset.manage`,其余动态路由复制原模块 guard。 -## 2. 采购迁移 +## 2. 列表筛选 URL 化 -- [ ] 将采购列表收敛为筛选、列表、创建入口和查看详情。 -- [ ] 新建采购详情页,搬迁 detail query、表单、附件、归档、提交/撤回和 dirty guard。 -- [ ] 创建成功、项目关联、通知/审批/搜索等入口改为 `/purchase-requests/:id`。 -- [ ] 旧 `/purchase-requests?id=` replace 到新路由并保留其他查询条件。 +- [x] 采购列表改为从 URL 读取 `status/scope/supplierId/legalEntityId/costCenterId/page`,筛选变化 replace、非页码筛选把 page 重置为 1、越权 scope 回落 own。 +- [x] 资产列表改为从 URL 读取 `q/status/assetType/sensitivity/categoryId/legalEntityId/costCenterId/assigneeId/scope`,保留现有按权限决定的默认范围。 +- [x] 公司变化与成本中心失配时原子清理 `costCenterId`;无效枚举、页码或范围不进入 API query。 +- [x] 资产文本搜索使用 deferred/debounce 请求值,URL 仍反映用户当前输入;高级筛选面板由有效参数自动展开,手工开合可保持本地 UI state。 -## 3. 资产迁移 +## 3. 采购迁移 -- [ ] 将资产列表收敛为筛选、列表、创建入口和查看详情。 -- [ ] 新建资产详情页,搬迁 detail query、编辑、调拨/盘点相关入口、敏感字段和 dirty guard。 -- [ ] 创建成功、项目关联、搜索和其他入口改为 `/assets/:id`。 -- [ ] 旧 `/assets?id=` replace 到新路由并保留其他查询条件。 +- [x] 将 `PurchaseRequestsPage` 收敛为筛选、分页、列表、创建草稿入口和详情链接;列表路由不得请求详情。 +- [x] 新建 `PurchaseRequestDetailPage`,搬迁 detail query、表单 hydration、附件、采购单归档、提交/撤回、状态动作和 dirty guard;详情路由不得请求完整列表。 +- [x] 创建草稿成功后 push 到 `/purchase-requests/:purchaseRequestId` 并保留当前筛选来源。 +- [x] 详情显式返回按安全来源显示“返回项目详情”或“返回采购列表”;取消 dirty 导航时保留 URL 与表单。 -## 4. 回归与兼容 +## 4. 资产迁移与独立创建 -- [ ] 搜索并消除仓库内新产生的采购/资产 `?id=` 详情链接;只保留兼容测试。 -- [ ] 验证直接深链、刷新、返回列表、浏览器前进/后退和筛选来源。 -- [ ] 验证无权限/不存在不泄露摘要,列表页不请求详情。 -- [ ] 验证 dirty-form 取消保留输入、确认后离开。 -- [ ] 验证移动采购路由、合同、差旅、付款和请假未被改动。 +- [x] 将 `AssetRegistryPage` 收敛为筛选、资产列表、资产分类管理、移动扫码、创建入口和详情链接;列表路由不得请求单条详情。 +- [x] 提取资产表单与动作 section 为创建/详情共用工作区,新建 `AssetDetailPage` 与 `AssetCreatePage`,不得复制两份长表单。 +- [x] `/assets/new` 使用初始表单与 POST mutation;取消/离开沿用 dirty guard,失败保留输入。 +- [ ] 创建成功后 replace 到 `/assets/:assetId` 并沿用原列表来源;验证浏览器后退不会回到已提交创建页。— PAUSED:实现与现有真实流 spec 断言已更新,当前本地数据库缺口阻塞实际提交回归。 +- [x] 资产详情搬迁编辑、敏感字段、领用/归还/调拨和现有动作权限;详情 403/404 不泄露摘要。 -## 5. 测试命令 +## 5. 入口与移动兼容 -- [ ] `cd client && npm run typecheck` -- [ ] `cd client && npm run build` -- [ ] `cd client && npx playwright test tests/e2e/procurement-flow.spec.ts tests/e2e/assets-flow.spec.ts tests/e2e/r103-cross-page-ui-consistency.spec.ts tests/e2e/pc-detail-mobile-visual-wave3.spec.ts --workers=1 --reporter=list` -- [ ] 按修改范围补跑 project/notification/search 关联入口用例。 -- [ ] 运行 task validate、process contract、docs registry、Trellis governance 和 `git diff --check`。 +- [x] 采购/资产列表行、项目采购关联、全局搜索 purchase/asset 解析、PC 通知解析统一使用 canonical path builder。 +- [x] 全局搜索对采购/资产用 `entityType + entityId` 构造目标,不依赖服务端旧 `href`;不改搜索 API shape 或后端数据逻辑。 +- [x] 搜索 `client/src` 中采购/资产 `?id=` 与 `highlight` 详情链接,只保留明确命名的 legacy 解析逻辑;兼容测试可保留旧 URL 输入。 +- [x] 保持移动采购详情映射;补 `/assets/new`、`/assets/:assetId` 到 `/mobile/assets/scan` 的移动工作台回落,不新增移动资产详情。 +- [x] 不新增不存在的审批/关联入口,不改合同、差旅、付款、请假功能。 -## 6. 提交与回滚 +## 6. 错误、权限与回归矩阵 -- [ ] 使用中文 commit message 并引用 `08-05-business-detail-routing`。 -- [ ] 提交前确认无后端 API、schema、权限、差旅/付款/请假或真实数据变化。 -- [ ] 回滚必须同时恢复动态路由、页面消费者、page meta 和旧 `?id=` 页面形态。 +- [x] 403/404 共用“记录可能不存在或无权查看”安全错误态,并提供模块列表返回入口。 +- [x] 验证列表与详情的 route guard、服务端数据范围和页面内细粒度动作权限与迁移前一致;直接访问 `/assets/new` 必须验证 `asset.manage`。 +- [x] 验证直接深链、刷新、列表显式返回、项目来源返回、浏览器前进/后退、筛选来源和 legacy replace。 +- [x] 验证 dirty-form 在采购详情、资产详情和资产创建的取消/确认路径;取消不得改变 URL 或输入。 +- [x] 验证 `/assets/new` 不请求 `/api/assets/new`,采购/资产列表不请求详情,详情不请求完整列表。 +- [x] 更新人工 witness/视觉证据中的 canonical URL,但不得把本地 Chromium 通过扩张为企业微信真机、部署或公网通过。 + +## 7. 测试命令 + +- [x] `cd client && npm run typecheck` +- [x] `cd client && npm run build` +- [ ] `cd client && npx playwright test tests/e2e/procurement-flow.spec.ts tests/e2e/assets-flow.spec.ts tests/e2e/r103-cross-page-ui-consistency.spec.ts tests/e2e/pc-detail-mobile-visual-wave3.spec.ts tests/e2e/global-search.spec.ts --workers=1 --reporter=list` — PAUSED:真实 `procurement-flow`、`assets-flow` 与权限直连依赖当前本地数据库,既有缺列导致 `/api/me` 500;未为本前端任务迁移或重置数据库。 +- [x] 运行新增的聚焦路由合同 spec,以及 notificationLinks、runtimeContext、项目关联和 page meta 的受影响用例。 +- [x] `rg -n '/purchase-requests\\?id=|/assets\\?id=|/purchase-requests\\?highlight=|/assets\\?highlight=' client/src`,逐项确认只剩 legacy parser 或明确非详情用途。 +- [x] `python3 ./.trellis/scripts/task.py validate 08-05-business-detail-routing` +- [x] `node scripts/qa/check-process-contract.mjs --baseline --json` +- [x] `node scripts/qa/audit-docs.mjs --check-registry` +- [x] `node scripts/qa/check-trellis-governance.mjs` +- [x] `git diff --check` + +## 8. 检查、提交与回滚 + +- [x] 使用 `trellis-check` 做实现后质量检查;因新增/复制路由权限 guard,按 `AGENTS.md` 启动只读 reviewer,专门确认未扩大采购/资产访问范围,reviewer 不改代码。 +- [x] 仅在形成可复用路由/返回契约时更新现有 `.trellis/spec/` 或事实源,不新建无必要文档。 +- [ ] 使用中文 commit message 并引用 `08-05-business-detail-routing`;提交前确认无后端 API/schema、权限模型、差旅/付款/请假或真实数据变化。 +- [x] 回滚必须同时恢复 route registry、lazy exports、page meta、canonical path builder、前端消费者、筛选 state 和旧 `?id=` 页面形态。 + +## 9. 验证记录(2026-08-07) + +- PASS:`npm run typecheck`;`npm run build`(Vite 2404 modules,构建完成)。 +- PASS:Chromium(mock API)聚焦回归 29/29,包括 canonical/legacy、筛选与返回、请求职责隔离、403/404、`/assets/new` 权限、dirty guard、全局搜索、移动回落和 1280px 资产详情。 +- PASS:路由覆盖反例单测 29/29;`run-local-candidate.sh list` 内置检查 52/52,76 条应用路由 0 个未分类或阻断缺口。 +- PASS:任务校验、process contract、Trellis governance、敏感内容扫描与 `git diff --check`;docs audit 退出 0,保留仓库既有 22 个未登记文档 warning,本任务未新增文档。 +- PASS:只读 reviewer 未修改文件,确认采购/资产 route guard 与迁移前等价、`/assets/new` 更窄,URL scope 与后端对象范围均未扩大。 +- PASS:R103 截图改写为自身 `output/playwright/r103`,不再覆盖已归档任务证据。 +- PAUSED:真实 `procurement-flow`、`assets-flow` 与权限直连依赖的本地数据库缺少既有 `employees.bank_institution_id` 列,`/api/me` 返回 500;本任务不授权迁移、重置或真实数据写入。 +- NOT RUN:部署、公网、生产配置与企业微信真机;不得据此宣称线上可测试。 diff --git a/.trellis/tasks/08-05-business-detail-routing/prd.md b/.trellis/tasks/08-05-business-detail-routing/prd.md index 57cc4dd5..085485be 100644 --- a/.trellis/tasks/08-05-business-detail-routing/prd.md +++ b/.trellis/tasks/08-05-business-detail-routing/prd.md @@ -2,60 +2,72 @@ ## Goal -把采购、资产两个结构同构的业务台账从“列表页用 `?id=` 填充已有详情态”迁移为“纯列表查找、独立详情处理”,让详情可深链、浏览器返回可预测,并在切换对象时保留未保存保护。 +把采购、资产两个已有业务台账从“列表页用 `?id=` 填充详情态”迁移为“列表查找、独立详情处理”,让记录可深链、筛选可恢复、返回可预测;资产新建使用独立创建路由,避免把完整长表单重新塞回台账列表。 ## Confirmed Facts -- 报销、项目和审批任务已有独立详情路由,可作为仓库内成熟模式。 -- 采购、资产都已经具有完整详情/编辑状态,并以列表页 `?id=`、本地 selected ID 和 searchParams 双向同步驱动。 -- 采购、资产未发现首屏自动选中第一条记录;本任务是搬迁既有详情态,不新增业务信息结构。 -- 两页都已使用 `useUnsavedChangesGuard`,路由迁移必须保留 dirty-form 取消与确认语义。 +- 报销、项目和审批任务已有独立详情路由与安全 `state.from` 返回约定,可作为仓库内成熟模式。 +- 采购、资产都已有完整详情/编辑能力,并以列表页 `?id=`、本地 selected ID 和 searchParams 双向同步驱动;本任务是搬迁既有信息结构,不重做业务详情。 +- 采购“新建采购”会先创建草稿并取得实体 ID,因此创建成功后可直接进入 `/purchase-requests/:purchaseRequestId`,不需要采购新建路由。 +- 资产“新增资产”在提交前只有本地长表单、没有实体 ID;历史任务已确认“新建资产卡片进入独立创建视图”,本轮采用 `/assets/new`。 +- 两个列表的筛选目前主要存在组件本地 state;拆出详情后若不把可恢复筛选写入 URL,返回列表会丢失用户上下文。 +- 两页都已使用 `useUnsavedChangesGuard`,路由迁移必须保留取消离开与确认离开的既有语义。 +- 项目详情、全局搜索和 PC 通知仍存在旧采购/资产入口:项目详情生成采购 `?id=`,全局搜索依赖服务端 `href`,PC 通知生成未被页面消费的 `highlight`。 - 现有采购/资产 E2E 和人工 witness 多处直接访问 `?id=`,需要兼容重定向并同步结构化路由证据。 - 合同没有 selected ID 或详情态,属于从零建设详情页;已拆到 `08-05-contract-detail-workspace`。 -- 完整差旅已冻结,`/travel-requests` 与差旅报销指南无关;其自动选中缺陷归入低风险子任务,不做路由化。 -- 付款批次和请假继续保持既有页面边界,不纳入本任务。 +- 完整差旅已冻结;付款批次和请假继续保持既有页面边界,均不纳入本任务。 ## Scope -- 仅迁移采购、资产两个已有 `?id=` 详情态台账到独立实体详情路由。 -- 包含列表纯化、详情页拆分、旧链接兼容、关联入口更新、权限 guard、返回/深链和 dirty-form 保护。 +- 仅迁移采购、资产两个已有 `?id=` 详情态到独立实体详情路由,并新增资产独立创建路由 `/assets/new`。 +- 包含列表纯化、可恢复筛选 URL 化、详情/创建页拆分、旧链接兼容、现有前端关联入口更新、权限 guard、返回/深链和未保存保护。 +- 资产列表保留现有资产分类管理等列表级工具;“纯列表”指不再承载某条资产或采购记录的详情/编辑工作区。 - 不新建合同详情信息结构,不恢复差旅,不改付款批次或请假页面。 ## Requirements -- R1:`/purchase-requests` 与 `/assets` 只负责标题、创建入口、常用筛选、列表/卡片和进入详情,不渲染记录详情工作区。 -- R2:采购与资产分别使用稳定的实体 ID 详情路由,支持直接打开、刷新、复制链接和浏览器返回;页面元数据识别详情层级。 -- R3:只搬迁现有详情、编辑、附件、审计、状态动作和权限显隐,不新增合同式信息结构或改变业务能力。 -- R4:从列表进入详情、从项目/通知/搜索等关联入口进入详情和返回列表时保留必要来源;浏览器后退不得直接跳出业务模块。 -- R5:路由离开、返回或切换对象存在未保存修改时,必须沿用现有 dirty-form 保护;取消后保留原记录和输入。 -- R6:旧 `/purchase-requests?id=`、`/assets?id=` 深链在过渡期重定向到新详情路由,并保留无关筛选参数;无效/无权限 ID 使用自然中文错误与安全返回入口。 -- R7:权限 guard 与服务端数据范围保持不变;不得因拆分路由扩大可访问范围。 -- R8:更新列表行、项目关联、全局搜索、审批/通知跳转和现有测试中的采购/资产详情链接,不继续产生新的 `?id=` 链接。 -- R9:合同、差旅、付款、请假明确排除,不做顺手重构。 +- R1:`/purchase-requests` 与 `/assets` 只承载标题、创建入口、可恢复筛选、列表/卡片、分页或现有列表级工具,不渲染单条记录详情工作区。 +- R2:采购详情固定为 `/purchase-requests/:purchaseRequestId`,资产详情固定为 `/assets/:assetId`;均支持直接打开、刷新、复制链接和正确页面元数据。 +- R3:资产创建固定为 `/assets/new`,仅 `asset.manage` 可访问;创建成功后替换为新资产详情路由,浏览器返回不得重新进入已提交的创建表单。 +- R4:采购状态、范围、供应商、公司、成本中心、页码,以及资产搜索、状态、类型、敏感级别、分类、公司、成本中心、归属人和范围等可恢复筛选以 URL 为事实源;无效值回落到安全默认,不扩大数据范围。 +- R5:只搬迁现有详情、编辑、附件、审计、状态动作、资产流转和权限显隐,不新增合同式信息结构或改变业务能力。 +- R6:从列表或资产创建页进入详情时保留经过校验的返回来源;从项目、全局搜索和通知等现有入口进入详情时使用新 URL。浏览器后退沿历史返回,显式返回在无安全来源时回到对应模块列表。 +- R7:路由离开、显式返回、浏览器后退或跳转其他记录时存在未保存修改,必须沿用现有 dirty-form 保护;取消后保留当前 URL、记录与输入。 +- R8:旧 `/purchase-requests?id=`、`/assets?id=` 使用 replace 进入新详情路由,并把移除 `id` 后的安全列表查询保留为返回来源;空 ID 不触发详情,无效/无权限 ID 不泄露记录摘要。 +- R9:详情 403/404 使用统一自然中文安全错误态,仅说明“记录可能不存在或无权查看”并提供返回列表入口,不区分可用于资源枚举的敏感细节。 +- R10:新列表、详情和创建路由沿用现有权限与服务端数据范围;资产 `/assets/new` 使用比资产列表更窄的 `asset.manage` guard,不得把列表可见权限误当创建权限。 +- R11:采购创建成功、采购/资产列表行、项目关联、全局搜索结果解析、PC 通知解析和受影响测试改用统一的采购/资产详情路径构造;`client/src` 不再新增业务性的采购/资产 `?id=` 或 `highlight` 详情链接,兼容入口除外。 +- R12:合同、差旅、付款、请假明确排除,不做顺手重构;移动采购详情保持现状,资产 PC 新路由在移动工作台偏好环境中仍安全回落到既有移动资产扫码页。 ## Acceptance Criteria -- [ ] 采购和资产台账首屏均为纯列表,未选择记录时不渲染详情工作区。 -- [ ] 两个模块都有可刷新、可复制、可直接访问的详情 URL,并具有正确标题、权限 guard 和安全错误态。 -- [ ] 列表筛选来源可在详情返回后合理恢复,浏览器前进/后退行为可预测。 -- [ ] 旧 `?id=` 链接兼容重定向到对应新路由,不出现静默空白、错误对象或丢失无关筛选条件。 -- [ ] 编辑、附件、状态动作、审计和确认能力与迁移前等价,未保存保护覆盖返回和路由离开。 -- [ ] 项目关联、搜索、通知/审批等仓库内入口不再生成新的采购/资产 `?id=` 详情链接。 -- [ ] 合同、差旅、付款和请假代码不因本任务发生功能性修改。 -- [ ] 聚焦 Playwright 覆盖两个模块的列表进入、深链刷新、返回恢复、旧链兼容、无权限/不存在、dirty-form 取消与确认。 -- [ ] `cd client && npm run typecheck && npm run build`、相关现有 E2E 与 Trellis 门禁通过。 +- [x] `/purchase-requests` 与 `/assets` 首屏均为列表态,且不请求单条记录详情;资产分类管理等现有列表级工具仍可用。 +- [x] `/purchase-requests/:purchaseRequestId` 与 `/assets/:assetId` 可直接访问、刷新和复制,页面标题、导航激活、权限 guard 与安全错误态正确。 +- [ ] `/assets/new` 展示现有资产创建表单,非 `asset.manage` 用户不能直接访问;创建成功后 URL 变为 `/assets/:assetId`,返回不会回到已提交表单。— PAUSED:路由、权限与静态优先级已在 Chromium 通过;真实创建成功与历史栈因既有本地数据库列缺失未执行。 +- [x] 采购和资产的全部可恢复列表筛选可由 URL 还原;列表进入详情再返回时筛选、范围和采购页码保持一致,筛选变更不会污染浏览器返回历史。 +- [x] 旧 `?id=` 链接 replace 到对应新详情路由,返回时不丢失移除 `id` 后的安全筛选;空 ID 留在列表,无效或无权限 ID 不出现空白、错误对象或敏感摘要。 +- [ ] 编辑、附件、状态动作、审计、资产流转和确认能力与迁移前等价,未保存保护覆盖详情返回、创建取消、浏览器后退和跨路由离开。— PAUSED:mock 路由与未保存保护已通过;真实采购附件/状态及资产流转等价回归受本地数据库缺口阻塞。 +- [ ] 采购创建成功直接进入新详情;项目关联、全局搜索和 PC 通知中的采购/资产记录均进入新详情 URL,不再依赖未消费的 `highlight`。— PAUSED:项目、搜索与通知入口已验证;真实采购创建成功链路受本地数据库缺口阻塞。 +- [x] `/assets/new` 不被动态资产详情路由当成 `assetId`,详情路由不加载完整列表,列表路由不加载详情。 +- [x] 移动采购详情路由不回退;移动工作台偏好环境访问资产列表、详情或创建 URL 时继续进入既有 `/mobile/assets/scan`,不新增移动资产详情能力。 +- [x] 合同、差旅、付款和请假代码不因本任务发生功能性修改;后端 endpoint、payload、权限/数据逻辑和数据库均不改变。 +- [ ] 聚焦 Playwright 覆盖列表进入、筛选深链、详情刷新、返回恢复、旧链兼容、资产创建、无权限/不存在、dirty-form 取消与确认,以及项目/搜索/通知入口。— PAUSED:29 项 mock Chromium 回归已通过;真实资产创建和完整业务链仍受本地数据库缺口阻塞。 +- [ ] `cd client && npm run typecheck && npm run build`、相关现有 E2E、任务校验与 Trellis 门禁通过。— PAUSED:类型、构建、mock E2E 与全部治理门禁已通过;`procurement-flow`、`assets-flow` 和真实权限直连用例未能在当前本地数据库执行。 ## Out of Scope - 合同详情页或合同文件能力。 - 差旅、付款批次、请假详情路由。 -- 后端状态机、权限模型、数据库 schema 或真实数据迁移。 -- 全站所有配置页和移动端路由重写。 -- 模块冻结、一级导航调整、功能下线或视觉换肤。 +- 采购独立新建路由;采购继续先创建草稿再进入详情。 +- 新移动资产详情页、移动资产创建页或移动导航重写。 +- 后端 API endpoint/payload、状态机、权限模型、数据库 schema 或真实数据迁移。 +- 模块冻结、一级导航调整、功能下线、详情信息架构重做或视觉换肤。 ## Test Plan - `cd client && npm run typecheck && npm run build` -- 聚焦 Playwright 覆盖采购/资产列表进入、详情深链刷新、旧 `?id=` 重定向、筛选返回、无权限/不存在和 dirty-form 取消/确认。 -- 更新并运行 `procurement-flow.spec.ts`、`assets-flow.spec.ts`、`r103-cross-page-ui-consistency.spec.ts` 及受影响的关联入口用例。 -- 运行 `python3 ./.trellis/scripts/task.py validate 08-05-business-detail-routing`、process contract、docs registry、Trellis governance 和 `git diff --check`。 +- 聚焦 Playwright 覆盖采购/资产筛选 URL、列表进入详情、深链刷新、显式返回与浏览器后退、旧 `?id=` replace、无权限/不存在和 dirty-form 取消/确认。 +- 覆盖 `/assets/new` 的权限、未保存取消、创建成功 replace、`new` 静态路由优先级,以及采购创建成功后的详情导航。 +- 更新并运行 `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`。 diff --git a/.trellis/tasks/08-05-business-detail-routing/task.json b/.trellis/tasks/08-05-business-detail-routing/task.json index 645ebb10..66d5c195 100644 --- a/.trellis/tasks/08-05-business-detail-routing/task.json +++ b/.trellis/tasks/08-05-business-detail-routing/task.json @@ -2,8 +2,8 @@ "id": "business-detail-routing", "name": "business-detail-routing", "title": "业务详情独立路由化", - "description": "将采购与资产已有列表内详情态迁移为独立实体路由,兼容旧链接并保留权限、返回和未保存保护。", - "status": "planning", + "description": "将采购与资产已有列表内详情态迁移为独立实体路由,为资产提供独立创建路由,并兼容旧链接、筛选返回、权限与未保存保护。", + "status": "in_progress", "dev_type": null, "scope": "client-procurement-and-asset-routes", "package": null, @@ -20,9 +20,29 @@ "subtasks": [], "children": [], "parent": "08-05-product-interaction-consistency", - "relatedFiles": [], - "notes": "", + "relatedFiles": [ + "client/src/pages/PurchaseRequestsPage.tsx", + "client/src/pages/AssetRegistryPage.tsx", + "client/src/pages/ProjectDetailPage.tsx", + "client/src/components/GlobalSearchDialog.tsx", + "client/src/router/index.tsx", + "client/src/router/lazy/index.ts", + "client/src/features/navigation/schema.ts", + "client/src/lib/pcDetailNavigation.ts", + "client/src/lib/businessDetailRoutes.ts", + "client/src/lib/notificationLinks.ts", + "client/src/lib/runtimeContext.ts", + "client/src/router/lazy/PurchaseRequestDetailPage.ts", + "client/src/router/lazy/AssetCreatePage.ts", + "client/src/router/lazy/AssetDetailPage.ts", + "scripts/qa/route-manifest.json", + "client/tests/e2e/business-detail-routing.spec.ts", + "client/tests/e2e/procurement-flow.spec.ts", + "client/tests/e2e/assets-flow.spec.ts", + "client/tests/e2e/r103-cross-page-ui-consistency.spec.ts" + ], + "notes": "本地实现、类型构建、29 项 Chromium 聚焦回归、路由覆盖与治理门禁已完成;只读 reviewer 确认未扩大采购/资产权限范围。真实 procurement-flow、assets-flow 与权限直连因既有本地数据库缺列导致 /api/me 500 而 PAUSED,未迁移或重置数据库;部署、公网和企业微信真机未执行。", "meta": { - "description": "将采购与资产已有列表内详情态迁移为独立实体路由,兼容旧链接并保留权限、返回和未保存保护。" + "description": "将采购与资产已有列表内详情态迁移为独立实体路由,为资产提供独立创建路由,并兼容旧链接、筛选返回、权限与未保存保护。" } }