fix(pc-detail-responsive-approval-polish): 协调详情断点并收敛审批展示
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-03 21:31:44 +08:00
父节点 a5b7e18507
当前提交 c018ea4eb8
共修改 14 个文件,包含 642 行新增和 71 行删除
@@ -12,6 +12,75 @@ This spec keeps hyb-oa-specific React/Vite, shared-component, business-boundary
and verification contracts. Do not copy broad aesthetic guidance from
`frontend-design` into this file; use the skill directly for design judgment.
## Scenario: PC Shell And Detail Breakpoint Coordination
### 1. Scope / Trigger
- Trigger: changing the desktop sidebar, app-shell content offset, topbar drawer entry, detail two-column grid or sticky detail action panel.
- Purpose: prevent the shell and detail workspace from expanding at the same narrow breakpoint and leaving the business main column unusably small.
### 2. Signatures
- Fixed sidebar: `.app-shell-sidebar` uses `xl:flex`.
- Main content offset: `.app-shell-main` uses `xl:ml-72` outside mobile routes.
- Topbar drawer trigger: `aria-label="打开菜单"` uses `xl:hidden`.
- Detail workspace and action panel remain `lg:grid-cols-*` and `lg:sticky` unless a later task changes the complete contract.
### 3. Contracts
- Sidebar visibility, main-content offset and drawer-trigger visibility switch at one shared breakpoint; never change only one of the three.
- Evaluate detail widths after subtracting the fixed sidebar and page padding, not as a fraction of the full viewport.
- At the narrowest viewport where the fixed sidebar appears, the main detail column is at least `560px`, the action column is at least `280px`, and the document has no horizontal overflow beyond a `2px` rendering tolerance.
- Business identifiers and normal company-time date strings use DOM line-count or geometry assertions; `position: sticky` alone is not proof that the layout is usable.
- A Playwright spec writes screenshots only to its own current output directory. Never reuse an archived Trellis task's evidence directory as a live test target.
### 4. Validation & Error Matrix
- Fixed sidebar visible while drawer trigger is also visible -> breakpoint contract failure.
- Main content has the sidebar offset while the sidebar is hidden -> breakpoint contract failure.
- Two columns exist but either column is below its minimum width -> layout failure, even if sticky CSS passes.
- `documentElement.scrollWidth > clientWidth + 2` -> horizontal-overflow failure.
- A routine test run modifies screenshots under another task's evidence directory -> output-contract failure; move the writer before regenerating any evidence.
### 5. Good/Base/Bad Cases
- Good: `1100px` uses the topbar drawer and keeps the detail grid; `1280px` shows the fixed sidebar and still meets both column-width thresholds.
- Base: mobile routes continue to use their existing mobile shell and sticky bottom actions.
- Bad: move the detail grid from `xl` to `lg` while leaving the 288px app sidebar and content offset at `lg`, then validate only `position: sticky`.
### 6. Tests Required
- A source-structure contract asserts the three shell classes use `xl`, while the shared detail grid and action panel retain `lg`.
- A focused Playwright regression at `1280px` measures both detail columns, horizontal overflow and representative identifier/date line counts using realistic-length fixtures.
- At least one synthetic workflow detail and one asset detail cover the shared layout; existing mobile regression remains green.
- After screenshot-producing tests, assert archived task evidence remains clean in `git status`; current-suite screenshots may be retained only under that suite's own output directory.
### 7. Wrong vs Correct
#### Wrong
```tsx
<aside className="lg:flex lg:flex-col" />
<main className="lg:ml-72" />
<button className="lg:hidden" aria-label="打开菜单" />
```
```css
.detail-workspace-grid { @apply lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)]; }
```
#### Correct
```tsx
<aside className="xl:flex xl:flex-col" />
<main className="xl:ml-72" />
<button className="xl:hidden" aria-label="打开菜单" />
```
```css
.detail-workspace-grid { @apply lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)]; }
```
## Rules
- Use existing React/Vite/Tailwind patterns and shared components before adding
@@ -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,94 @@
# PC 详情响应式断点与审批展示回归设计
## 1. 设计边界
本任务只协调现有 PC 壳层与详情工作区的断点,并修复审批详情已经暴露的只读展示问题。不改变导航条目、权限、移动路由、审批状态机、动作 API、服务端 DTO 或数据库。
视觉与交互继续使用仓库现有企业详情页模式。当前项目声明的 `frontend-design` skill 不可用;规划阶段使用可用的 `ui-ux-pro-max` 核对了响应式无横向滚动、真实长度 fixture 和长内容适配原则,最终以生产实图、现有共享组件和项目规约为依据。
## 2. 响应式断点合同
### 2.1 壳层三处同源切换
固定侧栏、主内容左边距和顶部抽屉入口必须在同一个 `xl=1280px` 断点切换:
- `Sidebar`:`hidden xl:flex xl:flex-col`;
- `AppShellLayout` 主区:`xl:ml-72`;
- `Topbar` 菜单按钮:`xl:hidden`。
三者仍分属各自组件,无法通过运行时常量安全生成 Tailwind class;用结构测试锁定它们统一为 `xl`,并断言不再残留对应的 `lg` class。这样避免只改左边距而留下固定侧栏或菜单重复。
详情工作区与动作面板继续在 `lg=1024px` 进入双栏和 sticky:
- 小于 1024:单栏详情,现有移动/窄屏表达不变;
- 1024–1279:全局侧栏收起,顶部菜单可用,详情双栏和 sticky 生效;
- 1280 及以上:固定侧栏展开,详情双栏和 sticky 生效。
### 2.2 1280px 最坏内容预算
1280px 是固定侧栏首次出现后的最窄点。按当前结构估算:
```text
1280 视口
- 288 固定侧栏
- 约 64 页面左右 padding
- 约 16 layout-container padding
- 20 详情列 gap
= 约 892px 可分配给两列
```
现有 `1.45fr : 0.72fr` 在该宽度下约得到 596px 主栏和 296px 右栏,满足本任务阈值:
- 主栏计算宽度不少于 560px;
- 右栏计算宽度不少于 280px;
- 页面文档宽度不超过视口 2px;
- 常规业务编号和公司时区日期时间保持单行。
因此首选保留现有列比例,只修复壳层断点冲突。若浏览器实测未达到阈值,再在同一共享 `.detail-workspace-grid` 中调整比例;不得用页面私有列宽覆盖制造第二事实源。
## 3. 可测量布局回归
1280px 的审批详情和资产详情使用真实长度 fixture。测试读取 DOM 几何而不是只看截图:
- `BusinessDetailMain` 与 `BusinessDetailAside` 的 `getBoundingClientRect().width`;
- `.detail-workspace-grid` 的实际列数;
- `.detail-action-panel` 的 `position`、`top` 和宽度;
- `documentElement.scrollWidth <= window.innerWidth + 2`;
- 对报销编号、日期时间和代表性资产字段创建 `Range`,断言 `getClientRects()` 只有一行,避免 `break-words` 让普通 `scrollWidth` 断言产生假阴性。
截图只保留为人工辅助证据。结构测试单独锁定三处壳层 class,移动端使用已有回归,不新增五档视口矩阵。
## 4. 共享操作面板与审批动作
### 4.1 共享空面板
`BusinessDetailActionPanel` 先归一化 children,再判断 `status / primaryAction / secondaryActions / moreActions / summary / children` 是否存在。全部为空时直接返回 `null`;只含 summary 或 status 时仍正常渲染只读面板。
调用方传入的空 Fragment 在 React 层仍可能是真值,因此 Workflow 页面必须先派生动作布尔值,只在至少一个动作允许时构造对应 slot。
### 4.2 Workflow 动作模型
从 `allowedActions` 派生:
- `canApprove`:决定 `primaryAction` 是否渲染“通过”;
- `canReturn / canReject`:决定次动作是否渲染;
- `canTransfer`:决定“更多 → 转审”是否渲染;
- mutation 期间只禁用已经可见的动作,不用禁用按钮表示“没有权限/任务已完成”。
已完成任务不生成任何动作 slot,但保留处理 summary,右栏成为只读状态面板。待处理任务继续保持现有确认、理由、mutation 和刷新合同。
审批概况中的“可执行动作” `MetricCard` 仅在动作集合非空时渲染;无动作时不显示 `-` 空卡片。
## 5. 审批显示收敛
- 在现有 `client/src/lib/approvalDisplay.ts` 增加审批语境的 `approvalRoleLabel`,至少覆盖 `manager / ceo / finance / finance_mgr / hr`,未知值回退为“审批人”而不是回显技术码。
- Workflow 处理摘要使用该 helper,不新增页面内角色映射。
- 处理历史时间改用现有 `formatDisplayDateTime`,保持 `Asia/Shanghai` 合同;不修改 API ISO 值。
- 返回按钮统一为“返回审批列表”,继续回到现有 `/workflow/tasks`,不新增 tab 状态持久化。
## 6. 兼容、发布与回滚
- 这是纯前端变化,无迁移、服务端 DTO 或权限变化。
- 壳层断点影响所有 PC 路由,必须回归普通列表、管理页和核心详情;1024–1279px 导航从固定侧栏变为顶部菜单属于有意行为。
- 回滚可恢复三处 `lg` class 和 Workflow 展示改动;不涉及数据回滚。
- 自动化、本地构建、标准部署、公网 `/ready` 和发布后体检全部通过后,才可通知用户线上复核。
@@ -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,52 @@
# PC 详情响应式断点与审批展示回归实施计划
## 1. 开发前
- [x] 用户审查并明确批准最新 PRD、设计和实施清单后,运行 `task.py start`;本轮规划批准不等于实施批准。
- [x] 使用 `trellis-before-dev` 重新读取 frontend、hyb-oa、审批与部署相关规约。
- [x] 记录 1280px 基线几何:固定侧栏、详情主/侧栏宽度、编号/时间行数和页面横向溢出。
## 2. 壳层与详情断点
- [x] 将 `Sidebar` 固定侧栏、`AppShellLayout` 主区左边距和 `Topbar` 菜单入口同步从 `lg` 调整为 `xl`,不改变移动路由。
- [x] 保持详情工作区和动作面板 `lg` 双栏/sticky;在 1280px 实测主栏 ≥560px、右栏 ≥280px。只有未达阈值时才调整共享列比例,不添加页面私有覆盖。
- [x] 补结构测试,断言三处壳层使用同一个 `xl` 断点且无对应 `lg` 残留。
## 3. 共享操作面板与审批只读态
- [x] `BusinessDetailActionPanel` 无 status/action/summary/children 时返回 null,有只读 summary 时继续渲染。
- [x] Workflow 按 `allowedActions` 派生 primary/secondary/more slots:已完成任务不渲染动作条,待处理任务保持通过、退回、驳回、转审及 loading/确认合同。
- [x] 无允许动作时移除审批概况中的“可执行动作”空事实卡。
## 4. 审批展示
- [x] 在共享 `approvalDisplay` 增加审批角色中文 helper,Workflow 摘要不再回显 `finance` 等技术码。
- [x] 处理历史时间使用 `formatDisplayDateTime`;返回入口改为“返回审批列表”。
- [x] 使用合成 completed 与 pending 任务分别回归只读态和动作态。
## 5. 自动化验证
- [x] 1280×代表高度的审批详情 fixture 使用真实长度编号、日期时间、动作和转审候选;断言主/侧栏宽度、两列、sticky、无横向溢出及关键文本单行。
- [x] 1280px 资产详情使用真实长度字段并执行同样几何断言,打开长选项/操作区确认 z-index 和内容不被正文覆盖。
- [x] 用结构合同证明 1024–1279px 顶部菜单可用、固定侧栏不出现;浏览器布局硬门禁只测 1280px,并复用现有移动回归证明移动端不变。
- [x] 审批 pending 动作分别覆盖通过、退回、驳回、转审可见性和既有确认;completed 断言动作按钮为 0、summary 存在、空动作事实卡为 0。
- [x] 角色显示中文、历史时间为上海时区格式、返回文案正确;共享空面板 guard 有结构或可观察消费者回归。
## 6. 质量门禁与发布
- [x] `cd client && npm run typecheck && npm run build`
- [x] 运行聚焦 Playwright:审批详情、资产详情、壳层导航、普通列表/管理页与移动基础回归。
- [x] `bash scripts/check-minimal.sh`
- [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] `node scripts/qa/check-sensitive-content.mjs`
- [x] `git diff --check`
- [x] 按 `trellis-check` 复核范围、共享断点、动作权限与测试强度;本任务不改变审批状态机,因此无需服务端状态机 reviewer。
- [ ] 使用中文 commit message 并引用 `pc-detail-responsive-approval-polish`,完成后按 Trellis 流程归档。
- [ ] 用户已批准实施后,代码验证完成仍需单独确认部署权限;部署通过标准 13 项体检、自动合同刷新、公网 `/ready` 与发布后体检后再通知。
## 回滚点
- 壳层三处断点必须同批回滚,禁止只恢复其中一处。
- Workflow 动作呈现和共享面板 guard 可与壳层同提交回滚;无数据或服务端回滚步骤。
@@ -0,0 +1,83 @@
# PC 详情响应式断点与审批展示回归
## Goal
修复 PC 详情页在窄笔记本宽度下因全局侧栏与详情双栏同时展开而导致的正文挤压,并把真实内容宽度、空操作区和已完成审批展示纳入可测量的自动化合同,避免再次以 `sticky` 存在代替视觉可用性验收。
## Background
- 已归档任务 `detail-page-structure-progress-actions` 将 `.detail-workspace-grid` 和 `BusinessDetailActionPanel` 从 `xl` 前移到 `lg`,目标是在笔记本宽度保持右侧操作持续可见。
- `client/src/layouts/AppShellLayout.tsx:69`、`client/src/components/navigation/Sidebar.tsx:6` 与 `client/src/components/navigation/Topbar.tsx:146` 也在 `lg=1024px` 同时展开 288px 全局侧栏、增加主区左边距并隐藏抽屉菜单。
- 生产 1024px 实图证明两个断点叠加后,审批详情正文被压窄,报销单号拆成多行、时间强制换行。右栏 `position: sticky`、`top` 和 `z-index` 虽然符合现有测试,但整体布局不可接受,说明原验收是假阳性。
- 若全局侧栏改为 `xl=1280px` 展开,新的最坏点是 1280px:视口扣除 288px 侧栏后再分配详情主/侧栏。验收必须覆盖断点前后和实际最窄内容区,而不是只测 1024px。
- `BusinessDetailActionPanel` 已计算 `hasActionContent`,但当前只用于分隔线;没有任何 action、summary 或 children 时仍渲染空面板。
- 已完成审批仍渲染蓝色主按钮外观的禁用“通过”以及禁用“退回/驳回”,同时摘要显示原始角色码 `finance`,处理历史显示原始 ISO 时间。
## Scope
- 协调现有 PC 全局侧栏、主内容左边距、顶部菜单与详情双栏的响应式断点。
- 补强共享详情操作面板的空内容行为,以及 Workflow 审批详情的已完成只读态、角色、时间和返回文案。
- 用 1280px 常规 PC 最坏点建立数值布局回归,复用现有移动回归确认移动端不变。
- 不修改服务端、数据库、权限、审批动作合同或生产业务数据。
## Requirements
### R1 常规移动与 PC 的断点协调
- 全局固定侧栏、主内容左边距和顶部抽屉入口必须由同一个响应式断点控制,不能在同一视口出现固定侧栏与抽屉入口重复或主内容偏移漂移。
- 常规移动端继续使用现有移动路由、移动壳层和固定底部动作合同;本任务不新增设备分类或逐设备支持声明。
- 1024–1279px 作为 PC 窄窗口自然兼容区间:固定侧栏收起、顶部菜单可用、详情双栏保持足够正文空间;不为该区间建立独立视觉承诺或完整视口矩阵。
- 不得通过隐藏操作、恢复页面底部孤立操作卡或制造第二套动作区解决正文宽度问题。
- 1280px 及以上展开固定侧栏后,详情工作区列宽比例必须按扣除侧栏后的内容区重新评估;主栏不得因右栏最小宽度而出现业务编号、常规日期时间的非必要破碎换行。
- 修改全局壳层断点时,必须回归普通列表、管理页、审批详情、报销详情、付款详情和资产详情,确保导航、抽屉、内容偏移与横向滚动无回退。
### R2 可测量的响应式验收
- 自动化以 1280px 作为常规 PC 最窄硬门禁,验证固定侧栏、详情双栏、sticky、计算列宽和内容适配;现有移动回归继续证明常规移动端不受影响。
- 通过结构断言锁定固定侧栏、主内容左边距和顶部抽屉入口使用同一个 `xl` 断点,防止其中一处以后单独漂移;不为 1024–1279px 增加重复的全页面视觉矩阵。
- 使用真实长度的中文动作、业务编号、日期时间和下拉选项,不以短合成文案代替生产内容。
- 断言必须包含计算宽度或溢出指标,例如主栏/右栏最小宽度、关键业务编号 `scrollWidth <= clientWidth`、页面无横向滚动;截图仅作辅助证据,不作为唯一门禁。
- 审批详情与资产详情必须分别覆盖最坏内容宽度;已有只检查 `position/top/z-index` 的断言应补强,不能继续单独作为布局通过证据。
### R3 空操作面板与已完成审批
- `BusinessDetailActionPanel` 在 action、summary、children 和 status 均无可见内容时不渲染整个面板;调用方不需要各自重复空值判断。
- 审批概况中的“可执行动作”事实卡在没有任何允许动作时不渲染,不再显示一个值为 `-` 的空卡片。
- 已完成或无可执行动作的审批详情不渲染看似可点击的“通过/退回/驳回/更多”动作条;保留必要的只读处理摘要,并明确当前任务已完成或仅供查看。
- 待处理审批继续保留一个主动作“通过”、次动作“退回/驳回”和“更多 → 转审”,权限、确认、原因与 mutation 合同不改变。
### R4 审批展示缺陷
- 审批详情不得显示 `finance`、`manager` 等原始角色码;使用审批语境下的自然中文标签,并从共享审批显示 helper 取得,避免页面内新增第二份映射。
- 处理历史时间复用 `formatDisplayDateTime`,按 `Asia/Shanghai` 展示;API 与数据库 ISO 时间保持不变。
- 返回入口统一使用中性“返回审批列表”,不为区分待处理/已处理新增 tab 状态持久化。
## Acceptance Criteria
- [x] 1024–1279px 不展开 288px 固定侧栏,顶部菜单仍可打开导航,详情操作持续可达且无横向滚动。
- [x] 1280px 最坏点下主栏和右栏均达到设计阈值,常规报销编号、日期时间不发生截图中所示的破碎换行。
- [x] 结构测试锁定侧栏、主区左边距与抽屉入口统一使用 `xl`;1280px 浏览器回归锁定详情列数、sticky、计算宽度和无横向溢出。
- [x] 审批详情和资产详情均使用真实长度 fixture 在 1280px 通过数值断言;截图不再是唯一视觉证据。
- [x] 完全无内容的共享操作面板不渲染;有 summary 但无 action 的面板仍保留只读摘要。
- [x] 已完成审批不显示蓝色“通过”或其他禁用动作条,待处理审批动作和确认行为不回退。
- [x] 审批角色显示自然中文,处理历史时间显示公司时区格式,不再出现技术码或原始 ISO;返回入口显示“返回审批列表”。
- [x] 前端 typecheck/build、相关 Playwright、流程/Trellis/敏感内容和 `git diff --check` 门禁通过。
- [ ] 部署与公网检查完成后才能通知用户线上复核;生产验收不再要求用户替自动化寻找布局缺陷。
## Out of Scope
- 重做全局导航信息架构、改变导航条目或权限。
- 重设计全部详情页视觉体系、移动端详情或业务状态机。
- 修改审批权限、动作 API、确认规则、工作流历史数据或服务端时间格式。
- 为视觉验证制造生产审批、资产或付款数据。
## Test Plan
- 结构测试锁定 Sidebar、AppShell 主区与 Topbar 菜单统一使用 `xl` 断点,详情双栏/sticky 继续使用 `lg`。
- 1280px Playwright 使用真实长度合成数据覆盖审批详情和资产详情,断言主栏 ≥560px、右栏 ≥280px、两列、sticky、关键文本单行且页面无横向滚动。
- 审批 completed fixture 断言无动作按钮、无“可执行动作”空事实卡、中文角色、上海时间和中性返回文案;pending fixture 回归通过、退回、驳回、转审及确认行为。
- 运行现有移动基础回归、前端 typecheck/build、全量最小门禁、Trellis/流程/敏感内容与 `git diff --check`。
## Notes
- 本任务是复杂前端响应式修复,需要 `design.md` 与 `implement.md`;当前仅进入规划,未批准实施。
@@ -0,0 +1,26 @@
{
"id": "pc-detail-responsive-approval-polish",
"name": "pc-detail-responsive-approval-polish",
"title": "PC详情响应式断点与审批展示回归",
"description": "协调 PC 壳层与详情双栏断点,并补强审批只读态和可测量的 1280px 布局回归。",
"status": "in_progress",
"dev_type": null,
"scope": "client",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-03",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "实现与本地验证完成:壳层三处断点统一为 xl,详情双栏与 sticky 保持 lg;审批只读态、中文角色、公司时区和 1280px 数值回归通过;截图输出已从历史任务证据目录收敛到当前 spec 自有目录。等待提交确认与单独部署批准。",
"meta": {}
}
+1 -1
查看文件
@@ -3,7 +3,7 @@ import { NavLinks } from "./NavLinks";
export function Sidebar() {
return (
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 lg:flex lg:flex-col">
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 xl:flex xl:flex-col">
<div className="border-b border-slate-200 px-6 py-5">
<AppBrand />
</div>
+1 -1
查看文件
@@ -143,7 +143,7 @@ export function Topbar() {
<div className="flex min-w-0 items-center gap-3">
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
<DialogTrigger asChild>
<Button className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单" type="button">
<Button className="xl:hidden" size="icon" variant="secondary" aria-label="打开菜单" type="button">
<MenuIcon />
</Button>
</DialogTrigger>
@@ -1,4 +1,4 @@
import { Children, type ReactNode } from "react";
import { Children, Fragment, isValidElement, type ReactNode } from "react";
import { cn } from "../../lib/utils";
import { SectionPanel } from "./SectionPanel";
@@ -71,6 +71,15 @@ export function BusinessDetailFact({
);
}
function hasVisibleNode(node: ReactNode): boolean {
return Children.toArray(node).some((child) => {
if (isValidElement(child) && child.type === Fragment) {
return hasVisibleNode(child.props.children);
}
return true;
});
}
export function BusinessDetailActionPanel({
title = "操作",
status,
@@ -92,21 +101,29 @@ export function BusinessDetailActionPanel({
className?: string;
testId?: string;
}) {
const visibleChildren = Children.toArray(children);
const hasActionContent = Boolean(primaryAction || secondaryActions || moreActions);
const hasChildren = visibleChildren.length > 0;
const hasContentBeforeSummary = Boolean(primaryAction || secondaryActions || moreActions || hasChildren);
const hasStatus = hasVisibleNode(status);
const hasPrimaryAction = hasVisibleNode(primaryAction);
const hasSecondaryActions = hasVisibleNode(secondaryActions);
const hasMoreActions = hasVisibleNode(moreActions);
const hasSummary = hasVisibleNode(summary);
const hasChildren = hasVisibleNode(children);
const hasActionContent = hasPrimaryAction || hasSecondaryActions || hasMoreActions;
const hasContentBeforeSummary = hasActionContent || hasChildren;
if (!hasStatus && !hasActionContent && !hasSummary && !hasChildren) {
return null;
}
return (
<SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg:top-24", className)}>
<div className="detail-action-stack" data-testid={testId}>
{primaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{secondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
{moreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
{hasPrimaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{hasSecondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
{hasMoreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
{hasChildren ? (
<div className={cn(hasActionContent && "border-t border-zinc-200 pt-3")}>{children}</div>
) : null}
{summary ? (
{hasSummary ? (
<div className={cn(hasContentBeforeSummary && "border-t border-zinc-200 pt-3")}>
<div className="space-y-2 text-sm text-zinc-600">{summary}</div>
</div>
+1 -1
查看文件
@@ -66,7 +66,7 @@ export function AppShellLayout() {
{isMobileRoute ? null : <Sidebar />}
<main
className={`app-shell-main flex min-w-0 flex-1 flex-col ${isMobileRoute ? "hyb-mobile-shell overflow-hidden" : "lg:ml-72"}`}
className={`app-shell-main flex min-w-0 flex-1 flex-col ${isMobileRoute ? "hyb-mobile-shell overflow-hidden" : "xl:ml-72"}`}
style={isMobileRoute ? { height: "var(--mobile-viewport-height, 100vh)", maxHeight: "var(--mobile-viewport-height, 100vh)", minHeight: "var(--mobile-viewport-height, 100vh)" } : undefined}
>
{isMobileRoute ? null : <Topbar />}
+16
查看文件
@@ -52,6 +52,22 @@ export function workflowActionLabel(action: string | null | undefined) {
return action ? labels[action] ?? action : "-";
}
export function approvalRoleLabel(role: string | null | undefined) {
if (!role?.trim()) {
return "-";
}
const labels: Record<string, string> = {
manager: "主管",
ceo: "负责人",
finance: "财务",
finance_mgr: "财务经理",
hr: "人事",
};
return labels[role.trim().toLowerCase()] ?? "审批人";
}
export function formatApprovalHistoryTime(value: string | null | undefined) {
return value ? value.slice(0, 19).replace("T", " ") : "-";
}
+56 -45
查看文件
@@ -32,7 +32,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { approvalRoleLabel, resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
import {
buildPcDetailSource,
@@ -254,13 +254,16 @@ export function WorkflowTaskDetailPage() {
onClick={() => navigate(backSource, { replace: true })}
type="button"
>
{backSource.startsWith("/reimbursements") ? "返回报销列表" : "返回待办列表"}
返回审批列表
</Button>
</div>
);
}
const detail = detailQuery.data;
const canApprove = allowedActions.has("approve");
const canReturn = allowedActions.has("return");
const canReject = allowedActions.has("reject");
const canTransfer = allowedActions.has("transfer");
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
@@ -281,7 +284,7 @@ export function WorkflowTaskDetailPage() {
actions={(
<Button asChild variant="secondary" data-testid="workflow-task-back">
<Link replace to={backSource}>
{backSource.startsWith("/reimbursements") ? "返回报销列表" : "返回待办列表"}
返回审批列表
</Link>
</Button>
)}
@@ -294,7 +297,9 @@ export function WorkflowTaskDetailPage() {
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels || "-"}</span>} />
{allowedActionLabels ? (
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels}</span>} />
) : null}
<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} />
</StatStrip>
@@ -431,7 +436,7 @@ export function WorkflowTaskDetailPage() {
{workflowActionLabel(item.action)} · {item.actorName || item.actorId}
</div>
<div className="text-zinc-600">{item.comment || "-"}</div>
<div className="text-xs text-zinc-500">{item.createdAt || "-"}</div>
<div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
))
) : (
@@ -445,53 +450,59 @@ export function WorkflowTaskDetailPage() {
<BusinessDetailActionPanel
title="处理"
className="workflow-action-panel"
secondaryActions={(
primaryAction={canApprove ? (
<Button
data-testid="workflow-task-approve"
disabled={mutation.isPending}
onClick={() => requestWorkflowAction("approve")}
type="button"
>
通过
</Button>
) : null}
secondaryActions={canReturn || canReject ? (
<>
<Button
data-testid="workflow-task-approve"
disabled={!allowedActions.has("approve") || mutation.isPending}
onClick={() => requestWorkflowAction("approve")}
type="button"
>
通过
</Button>
<Button
data-testid="workflow-task-return-toggle"
variant="secondary"
disabled={!allowedActions.has("return") || mutation.isPending}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
type="button"
>
退回
</Button>
<Button
data-testid="workflow-task-reject-toggle"
variant="secondary"
disabled={!allowedActions.has("reject") || mutation.isPending}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
type="button"
>
驳回
</Button>
{canTransfer ? (
<details className="workflow-action-more">
<summary>更多</summary>
<button
data-testid="workflow-task-transfer-menu-item"
type="button"
onClick={() => setActiveSecondaryAction("transfer")}
>
转审
</button>
</details>
{canReturn ? (
<Button
data-testid="workflow-task-return-toggle"
variant="secondary"
disabled={mutation.isPending}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
type="button"
>
退回
</Button>
) : null}
{canReject ? (
<Button
data-testid="workflow-task-reject-toggle"
variant="secondary"
disabled={mutation.isPending}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
type="button"
>
驳回
</Button>
) : null}
</>
)}
) : null}
moreActions={canTransfer ? (
<details className="workflow-action-more">
<summary>更多</summary>
<button
data-testid="workflow-task-transfer-menu-item"
type="button"
onClick={() => setActiveSecondaryAction("transfer")}
>
转审
</button>
</details>
) : null}
summary={(
<>
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
<div>当前审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div>
<div>创建时间:{formatDisplayDateTime(detail.task.createdAt)}</div>
<div>完成时间:{formatDisplayDateTime(detail.task.completedAt)}</div>
<div>发起时间:{formatDisplayDateTime(detail.workflowInstance?.startedAt)}</div>
@@ -1,4 +1,4 @@
import { mkdirSync } from "node:fs";
import { mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import type { Page, Route } from "@playwright/test";
@@ -7,10 +7,6 @@ import { expect, test } from "./helpers/fixtures";
test.use({ serviceWorkers: "block", timezoneId: "America/Los_Angeles" });
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-detail-mobile-visual-wave3");
const taskOutputDir = path.resolve(
process.cwd(),
"../output/playwright/07-18-small-team-internal-readiness",
);
const visualUser = {
identifier: "[email protected]",
@@ -24,7 +20,7 @@ const visualMe = {
name: visualUser.name,
email: visualUser.identifier,
phone: null,
roleCodes: ["employee", "manager", "finance"],
roleCodes: ["employee", "manager", "finance_mgr"],
permissions: [
"expense.create",
"expense.submit",
@@ -39,6 +35,10 @@ const visualMe = {
"invoice.manage",
"payment.batch.manage",
"risk.review.manage",
"asset.view_own",
"asset.view_all",
"asset.manage",
"asset.transfer",
"leave.view_own",
"attendance.view_own",
],
@@ -255,6 +255,77 @@ const workflowDetail = {
],
};
const completedWorkflowDetail = {
...workflowDetail,
task: {
...workflowDetail.task,
id: "task-wave3-completed",
assigneeRole: "finance",
status: "completed",
outcome: "approved",
completedAt: "2026-05-27T10:10:00.000Z",
},
workflowInstance: {
...workflowDetail.workflowInstance,
currentStatus: "approved",
completedAt: "2026-05-27T10:10:00.000Z",
},
allowedActions: [],
history: [
...workflowDetail.history,
{
id: "workflow-history-wave3-completed",
taskId: "task-wave3-completed",
actorId: visualMe.id,
actorName: visualMe.name,
actorEmployeeNo: visualMe.employeeNo,
action: "approve",
comment: "资料已核对,同意通过",
payload: null,
createdAt: "2026-05-27T10:10:00.000Z",
},
],
};
const assetDetail = {
id: "asset-wave3",
assetNo: "ZC-2026-研发中心-000018",
name: "研发中心高性能移动工作站与现场采集设备",
assetType: "fixed_asset",
sensitivityLevel: "internal",
categoryId: "asset-category-wave3",
legalEntityId: "legal-wave3",
costCenterId: "cost-wave3",
purchaseRequestId: null,
serialNo: "SN-WAVE3-20260527-000018",
location: "研发中心设备区 A-018",
status: "in_use",
assigneeEmployeeId: visualMe.id,
ownerEmployeeId: visualMe.id,
keeperEmployeeId: visualMe.id,
archiveLocation: null,
externalRef: null,
sourceHash: null,
verificationNo: "VER-2026-WAVE3-0018",
confidentialityNote: null,
metadata: null,
sensitiveFieldsRedacted: false,
lastVerifiedAt: "2026-05-27T09:20:00.000Z",
expiresAt: null,
activatedAt: "2026-05-20",
retiredAt: null,
createdAt: "2026-05-20T08:00:00.000Z",
updatedAt: "2026-05-27T09:20:00.000Z",
category: { id: "asset-category-wave3", code: "DEV", name: "研发设备", documentCategoryId: null, documentCategory: null },
legalEntity: { id: "legal-wave3", code: "HYB", name: "本公司" },
costCenter: { id: "cost-wave3", code: "RD", name: "研发中心", legalEntityId: "legal-wave3" },
purchaseRequest: null,
assignee: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name, department: { id: "dept-wave3", name: "研发部" } },
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name, department: { id: "dept-wave3", name: "研发部" } },
keeper: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name, department: { id: "dept-wave3", name: "研发部" } },
permissions: { canEdit: true },
};
const notifications = [
{
id: "notification-wave3-1",
@@ -536,11 +607,6 @@ function screenshotPath(fileName: string) {
return path.join(outputDir, fileName);
}
function taskScreenshotPath(fileName: string) {
mkdirSync(taskOutputDir, { recursive: true });
return path.join(taskOutputDir, fileName);
}
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
@@ -960,6 +1026,11 @@ async function mockWave3Apis(page: Page) {
return;
}
if (pathName === "/api/workflow/tasks/task-wave3-completed") {
await fulfillJson(route, completedWorkflowDetail);
return;
}
if (pathName === "/api/workflow/tasks") {
await fulfillJson(route, {
page: 1,
@@ -988,6 +1059,44 @@ async function mockWave3Apis(page: Page) {
return;
}
if (pathName === "/api/asset-categories") {
await fulfillJson(route, {
includeArchived: false,
items: [assetDetail.category],
});
return;
}
if (pathName === "/api/assets/assignees") {
await fulfillJson(route, { items: [assetDetail.assignee] });
return;
}
if (pathName === "/api/assets/asset-wave3") {
await fulfillJson(route, { asset: assetDetail });
return;
}
if (pathName === "/api/assets") {
await fulfillJson(route, { mine: false, items: [assetDetail] });
return;
}
if (pathName === "/api/master-data/legal-entities") {
await fulfillJson(route, { includeArchived: false, items: [assetDetail.legalEntity] });
return;
}
if (pathName === "/api/master-data/cost-centers") {
await fulfillJson(route, { includeArchived: false, items: [assetDetail.costCenter] });
return;
}
if (pathName === "/api/master-data/document-categories") {
await fulfillJson(route, { includeArchived: false, items: [] });
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: "workspace-wave3", persisted: true, profile: "small_team_internal" },
@@ -1085,6 +1194,71 @@ test.beforeEach(async ({ page }) => {
await login(page);
});
test("PC 壳层与详情断点保持结构一致", async () => {
const appShell = readFileSync(path.resolve(process.cwd(), "src/layouts/AppShellLayout.tsx"), "utf8");
const sidebar = readFileSync(path.resolve(process.cwd(), "src/components/navigation/Sidebar.tsx"), "utf8");
const topbar = readFileSync(path.resolve(process.cwd(), "src/components/navigation/Topbar.tsx"), "utf8");
const detailTemplate = readFileSync(path.resolve(process.cwd(), "src/components/patterns/BusinessDetailTemplate.tsx"), "utf8");
const styles = readFileSync(path.resolve(process.cwd(), "src/index.css"), "utf8");
expect(appShell).toContain('"xl:ml-72"');
expect(appShell).not.toContain('"lg:ml-72"');
expect(sidebar).toContain("xl:flex xl:flex-col");
expect(sidebar).not.toContain("lg:flex lg:flex-col");
expect(topbar).toContain('className="xl:hidden"');
expect(topbar).not.toContain('className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单"');
expect(detailTemplate).toContain("detail-action-panel lg:sticky lg:top-24");
expect(detailTemplate).toMatch(/if \(!hasStatus && !hasActionContent && !hasSummary && !hasChildren\) \{\s*return null;/u);
expect(styles).toMatch(/\.detail-workspace-grid\s*\{[\s\S]*?lg:grid-cols-/u);
expect(styles).toMatch(/\.workflow-action-panel\s*\{[\s\S]*?relative z-30/u);
});
test("已完成审批只保留中文只读摘要", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/workflow/tasks/task-wave3-completed");
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("当前审批角色:财务");
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("完成时间:2026年5月27日 18:10");
await expect(page.getByTestId("workflow-task-overview").getByText("可执行动作", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("workflow-task-approve")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-return-toggle")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-reject-toggle")).toHaveCount(0);
await expect(page.getByText("更多", { exact: true })).toHaveCount(0);
await expect(page.getByText("通过 · 王顺理", { exact: true })).toBeVisible();
await expect(page.getByText("2026年5月27日 18:10", { exact: true })).toBeVisible();
await expect(page.getByTestId("workflow-task-back")).toContainText("返回审批列表");
});
test("1280px 资产详情保持可用双栏和可点击操作区", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/assets?id=asset-wave3");
await expect(page.getByTestId("asset-name")).toHaveValue(assetDetail.name);
await expect(page.getByTestId("asset-detail-action-panel")).toBeVisible();
await expect(page.locator(".detail-action-panel")).toHaveCSS("position", "sticky");
const assetLayout = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const [main, aside] = Array.from(workspace.children) as HTMLElement[];
return {
mainWidth: main.getBoundingClientRect().width,
asideWidth: aside.getBoundingClientRect().width,
columns: getComputedStyle(workspace).gridTemplateColumns.split(" ").filter(Boolean).length,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(assetLayout.columns).toBe(2);
expect(assetLayout.mainWidth).toBeGreaterThanOrEqual(560);
expect(assetLayout.asideWidth).toBeGreaterThanOrEqual(280);
expect(assetLayout.overflow).toBeLessThanOrEqual(2);
const saveButtonHitTest = await page.getByTestId("asset-save").evaluate((button) => {
const rect = button.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return hit === button || button.contains(hit);
});
expect(saveButtonHitTest).toBe(true);
});
test("报销详情固定使用上海时区并收敛只读信息", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements/reim-wave3");
@@ -1151,8 +1325,16 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
await page.goto("/workflow/tasks/task-wave3");
await page.setViewportSize({ width: 1024, height: 768 });
await page.setViewportSize({ width: 1100, height: 768 });
await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible();
await expect(page.locator(".app-shell-sidebar")).toBeHidden();
await expect(page.getByRole("button", { name: "打开菜单" })).toBeVisible();
await expect(page.locator(".detail-workspace-grid")).toHaveCSS("grid-template-columns", /.+ .+/);
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.locator(".app-shell-sidebar")).toBeVisible();
await expect(page.getByRole("button", { name: "打开菜单" })).toBeHidden();
await expect(page.getByTestId("workflow-task-next-action")).toHaveCount(0);
await expect(page.getByText("审批动作")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
@@ -1161,6 +1343,25 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.locator(".workflow-action-panel")).toHaveCSS("position", "sticky");
await expect(page.locator(".workflow-action-panel")).toHaveCSS("top", "96px");
await expect(page.locator(".workflow-action-panel")).toHaveCSS("z-index", "30");
const workflowLayout = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const [main, aside] = Array.from(workspace.children) as HTMLElement[];
return {
mainWidth: main.getBoundingClientRect().width,
asideWidth: aside.getBoundingClientRect().width,
columns: getComputedStyle(workspace).gridTemplateColumns.split(" ").filter(Boolean).length,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(workflowLayout.columns).toBe(2);
expect(workflowLayout.mainWidth).toBeGreaterThanOrEqual(560);
expect(workflowLayout.asideWidth).toBeGreaterThanOrEqual(280);
expect(workflowLayout.overflow).toBeLessThanOrEqual(2);
const formNumberLineCount = await page.getByText(reimbursementDetail.formNumber, { exact: true }).evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
return range.getClientRects().length;
});
expect(formNumberLineCount).toBe(1);
await page.setViewportSize({ width: 1440, height: 960 });
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
@@ -1217,7 +1418,7 @@ test("PC 报销详情按来源返回并保留列表筛选", async ({ page }) =>
await page.goto("/reimbursements?status=reviewing&page=1&scope=all");
await page.getByTestId("reimbursement-detail-link").first().click();
await expect(page).toHaveURL(/\/workflow\/tasks\/task-wave3$/);
await expect(page.getByTestId("workflow-task-back")).toContainText("返回报销列表");
await expect(page.getByTestId("workflow-task-back")).toContainText("返回审批列表");
await page.getByTestId("workflow-task-back").click();
await expect(page).toHaveURL(/\/reimbursements\?status=reviewing&page=1&scope=all$/);
@@ -1255,7 +1456,7 @@ test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async (
await expect(operation).toBeVisible();
await operation.click({ trial: true });
await page.screenshot({
path: taskScreenshotPath(`contracts-registry-${viewport.width}x${viewport.height}.png`),
path: screenshotPath(`contracts-registry-${viewport.width}x${viewport.height}.png`),
fullPage: true,
});
}