实现 B-2b 统一报销工作台迁移(reimbursement-form-shared-component-migration)

这个提交包含在:
2026-09-02 07:35:01 +08:00
父节点 6bca91a360
当前提交 abdd250325
共修改 38 个文件,包含 1850 行新增和 2723 行删除
@@ -489,70 +489,82 @@ reference, not a copy of Feishu or Ant Design branding.
| `请假记录` / `考勤` | 业务台账列表 or record view | Enterprise WeChat-owned records, mirror status, exceptions and audit | Ordinary users do not see OA daily leave/punch approval creation; OA emergency entry only for authorized emergency/admin scope. |
| Mobile routes | Mobile expression of the same template | Top status/focus, card list or sectioned detail | One bottom safe-area primary action; secondary actions in `更多` or confirmation. |
## Scenario: Reimbursement Approval Content Workbench
## Scenario: Unified Reimbursement Object Workbench
### 1. Scope / Trigger
- Trigger: a reimbursement approval task must expose the evidence needed for a decision without forcing the approver into a second page.
- Trigger: changing PC/mobile reimbursement edit, read-only or approval detail, or changing workflow task routing for a reimbursement entity.
- Purpose: keep one interactive business-object structure while allowing different desktop and mobile layout skeletons and different injected capabilities.
### 2. Signatures
- Mobile reimbursement target: `/mobile/reimbursements/:reimbursementId`.
- PC reimbursement target: `/reimbursements/:reimbursementId`.
- Mobile source state: `<Link state={{ from: currentMobileSource }}>`.
- PC source state: `<Link state={{ from: currentPcSource }}>`; `currentPcSource` is the trusted internal `pathname + search` of the current reimbursement list or approval task.
- Business routes: `/reimbursements/:reimbursementId` and `/mobile/reimbursements/:reimbursementId`.
- Approval routes: `/workflow/tasks/:taskId` and `/mobile/approvals/:taskId`.
- Dispatchers: `WorkflowTaskRoutePage` and `MobileWorkflowTaskRoutePage` fetch one authorized `WorkflowTaskDetail`, then select the reimbursement adapter or the generic non-reimbursement detail.
- Pure layout shells: `ReimbursementDesktopWorkspaceShell` and `ReimbursementMobileWorkspaceShell`.
- Shared model/capability types: `ReimbursementWorkspaceModel`, `ReimbursementWorkspaceSlots` and `ReimbursementEvidenceCapabilities`.
- Structural preflight: `node scripts/qa/check-ui-design-rules.mjs --strict`.
### 3. Contracts
- The pending-task list card continues to open `/mobile/approvals/:taskId`; do not confuse task selection with the separate business-detail exit inside the task detail.
- When `WorkflowTaskDetail.reimbursement` exists, PC and mobile task details load the current reimbursement detail and render the shared `ReimbursementItemEvidenceList`; the complete original form remains a secondary exit rather than the only evidence path.
- The shared component receives a minimal projection. Review/open/reference and future edit/delete/link behavior are explicit callbacks; it never infers capabilities from roles.
- Approval links keep the task URL. A dispatcher recognizes `entityType=reimbursement` and renders the reimbursement approval adapter directly; it never mounts the generic detail first and never requires a `查看报销单` detour for core evidence.
- A task dispatcher owns the single task-detail query and passes the same result plus controlled refetch to the selected renderer. The reimbursement adapter may fetch current reimbursement detail but must not fetch the same task detail again.
- Desktop and mobile shells share the same model, section order, `ReimbursementItemEvidenceList` and capability contract. They may use different DOM/layout skeletons: desktop main evidence plus one sticky command panel; mobile focus cards plus a bottom safe-area action surface.
- Shells compose ready data and slots only. They contain no `useQuery`, `useMutation`, API/fetch call, role/permission inference, workflow/reimbursement state-machine decision, `mode === ...` comparison or `isMobile` business branch. `check-ui-design-rules.mjs --strict` enforces this boundary and rejects a dormant reimbursement branch in the generic approval pages.
- The shared evidence component receives a minimal projection. Open/reference/edit/delete/link/unlink behavior exists only through callbacks; the component never infers capabilities from roles. Approval injects view/reference/decision only, read-only injects view only, and edit injects maintenance/submission capabilities.
- Submission-time fee-standard and budget facts come from the workflow context snapshot. Current item/invoice grouping, amount evidence and attachments come from the reimbursement detail. Approval pages do not call reimbursement precheck or present current budget data as a submission snapshot.
- Reimbursement approval actions fail closed until workflow snapshot parsing and current reimbursement evidence are ready. Historical missing budget snapshots are explained; malformed snapshots disable all decision paths.
- Mobile workflow caching uses an explicit persisted projection. Reimbursement items, invoices, attachments, amount evidence, material access data, workflow snapshot checks and shared component projections do not enter IndexedDB. Offline reimbursement approve/reject/return/draft is forbidden, while other workflow entity offline behavior remains unchanged.
- Mobile navigation passes the current approval task URL through the existing `state.from` convention so the business detail back button returns to that task. This is a one-level source contract, not a new nested return stack.
- PC reimbursement list and task-detail exits use the same one-level `state.from` contract. The detail page accepts only `/reimbursements` list URLs and `/workflow/tasks/:id`; absolute/protocol-relative URLs, hashes, other modules and the current page are rejected. A direct open or refresh safely falls back to `/reimbursements`.
- PC detail return is a visible text action immediately before the detail hero, uses `replace`, and is not duplicated inside the business primary-action group or `更多`.
- Approval actions remain on the approval task page. Opening the business detail does not approve, reject, return or mutate the task.
- The information order is compact header and reason, item with owned invoices, unlinked invoices when present, supporting attachments, real issues/differences when present, then history/progress. Normal state creates no `0 条` check card. Issue copy identifies the object, reason, impact and next step rather than exposing internal counters alone.
- `MobileStickyActionBar` and `MOBILE_STICKY_ACTION_PAGE_CLASS` are one contract. Add the page class only when `mobileActions` exists; omitting it lets `page-scaffold` transform capture the fixed bar, while adding it without a bar leaves false bottom padding.
- `/reimbursements/:id/print` remains a purpose-built print/PDF view. It is not a fourth interactive workbench and receives no workbench mutation.
### 4. Validation & Error Matrix
- Task has a reimbursement -> show the reimbursement-detail link.
- Task is another entity type -> do not show a reimbursement link; add that entity's route only in a scoped task.
- Reimbursement summary is absent -> render no broken or guessed target.
- Current or historical assigned workflow reviewers may read the reimbursement evidence projection through server-side authorization; unrelated employees remain forbidden. Preview/download endpoints continue their own permission, governance, audit and short-lived URL checks.
- Authorized task resolves to reimbursement -> dispatcher renders the approval adapter from the one task-detail result.
- Task resolves to leave/attendance/purchase/travel -> generic detail remains active, including its existing mobile offline approve/reject draft behavior.
- Current reimbursement detail, critical check or snapshot parsing is absent/invalid -> all reimbursement decision entry points are disabled and the action handler rejects a direct call with natural Chinese recovery copy.
- Mobile reimbursement approval is offline -> no evidence is loaded from IndexedDB and no approval draft is allowed; an explained disabled state remains visible.
- Mobile edit/approval has a bottom action -> fixed bar is visible at the viewport bottom and final evidence remains reachable above it. No bottom action -> no fixed-action page padding.
- Current or historical assigned reviewers may read the authorized current evidence; unrelated employees remain forbidden. Material endpoints continue their own permission, governance, audit and short-lived WeCom grant checks.
### 5. Good/Base/Bad Cases
- Good: approver opens the task, reviews each item with its invoices and inline submission warnings, references an object into the return comment, then acts from the single action panel.
- Base: a completed reimbursement task shows the current read-only evidence with a clear warning that it may contain post-return changes; historical comments and submission snapshots retain historical meaning.
- Bad: the task shows four summary fields plus a mandatory `查看报销单` detour, caches invoice evidence offline, or enables a decision when current evidence failed to load.
- Bad: make a static-looking summary card secretly clickable, change the pending-list click handler, or copy a partial invoice/attachment viewer into the task page.
- Good: edit, read-only and approval display the same item/invoice grouping; only the adapter-provided actions differ. The approver reads evidence and acts from one task URL.
- Base: a completed task shows the latest authorized read-only evidence with a clear time-semantics note while history and submission snapshots remain historical.
- Bad: keep a dormant reimbursement branch in `WorkflowTaskDetailPage`/`MobileApprovalsPage`, query task detail in both dispatcher and adapter, or put `if (mode === "approval")` around API calls in a shell.
- Bad: cache evidence/context data offline, show normal `0 条` summaries, render two copies of the reimbursement header, or leave a mobile fixed action captured by an animated ancestor.
### 6. Tests Required
- Mobile E2E: task detail -> reimbursement detail -> items/evidence visible -> back to the same task -> approval actions still visible.
- PC E2E: filtered reimbursement list -> task/detail -> visible source-aware return; the list path retains its query string, task detail returns to the same task, and direct-open detail falls back to the reimbursement list.
- Assert non-reimbursement task details do not receive a reimbursement target.
- Run `cd client && npm run typecheck && npm run build`.
- UI strict/self-test proves shell and legacy-branch rules are active and reports zero findings.
- Source/structure tests prove both task dispatchers contain one `fetchWorkflowTaskDetail` call, the approval adapter contains none, and both shells share the model/slots/capability contract and section order.
- PC/mobile E2E covers edit/read-only/approval, no-issue suppression, fail-closed, item-local invoice recovery, submit-confirm failure recovery, task deep links/back behavior and mobile fixed-action geometry.
- IndexedDB tests inspect the stored record and reject reimbursement item/invoice/attachment/context/material fields plus synthetic sentinel values. Non-reimbursement mobile offline draft create/sync/conflict remains green.
- Run `cd client && npm run typecheck && npm run build`, focused workflow/reimbursement Playwright, route/flow gates, Trellis governance and `git diff --check`.
### 7. Wrong vs Correct
#### Wrong
```tsx
<div onClick={() => handleTaskSelection(task.id)}>报销摘要</div>
export function ReimbursementWorkspaceShell({ mode, taskId }) {
const task = useQuery({ queryFn: () => fetchWorkflowTaskDetail(taskId) });
return mode === "approval" ? <ApprovalContent task={task.data} /> : <EditContent />;
}
```
#### Correct
```tsx
<Link
to={`/mobile/reimbursements/${encodeURIComponent(reimbursement.id)}`}
state={{ from: currentMobileSource }}
>
查看完整报销单
</Link>
const taskQuery = useQuery({
queryKey: ["workflow", "task", taskId],
queryFn: () => fetchWorkflowTaskDetail(taskId),
});
return taskQuery.data.workflowInstance?.entityType === "reimbursement"
? <ReimbursementApprovalAdapter detail={taskQuery.data} refetchTask={taskQuery.refetch} />
: <WorkflowTaskDetailPage detail={taskQuery.data} refetchTask={taskQuery.refetch} />;
```
**Implementation order for design refactors**:
@@ -1,21 +1,164 @@
# Design:B-2b 报销填写态共享组件迁移
# Design:B-2b 统一报销工作台迁移
## Current Decision Boundary
## 1. Architecture Decision
B-2b 不重新定义业务内容组件,而是采用 B-2a 已验证的展示模型、字段顺序和快照/实时标签;本阶段增加 edit capability、票据抽屉和提交前检查,并删除旧重复布局。
采用“任务路由分派 + 唯一报销工作台内容合同 + PC/移动布局壳 + 三个模式适配器”。不把审批模式继续塞进现有大型详情页,也不让通用 workflow 页面继续拥有报销业务布局。
详细交互在 B-2a 真人反馈后收敛,但以下架构边界已经冻结:
```text
/workflow/tasks/:taskId /mobile/approvals/:taskId
│ │
└──── authorized task route dispatcher ┘
│
entityType=reimbursement
│
ReimbursementApprovalAdapter
│
ReimbursementDesktopWorkspaceShell
(共享内容合同)
- 共享组件不直接拥有 API 查询或 mutation;填写页通过显式能力和回调注入新增、编辑、删除、票据关联与恢复动作。
- review/readonly 模式不接收 edit capability,不能靠 CSS 隐藏维护动作。
- PC/移动复用同一 projection 与语义 DOM;响应式容器决定抽屉/底部 sheet 形态。
- 服务端 B2 门禁仍是提交权威,前端检查只做同合同的提前反馈。
- 旧布局在迁移验证通过后删除,不保留永久 feature flag 双实现。
/reimbursements/:reimbursementId
│
reimbursement route selector
├─ participant + draft/rejected -> ReimbursementEditAdapter
└─ otherwise authorized -> ReimbursementReadonlyAdapter
│
Desktop / Mobile WorkspaceShell
(共享内容合同)
```
## Deferred Until Entry Conditions Are Met
任务分派器通过唯一一次已授权 task detail 查询选择实体渲染器,并把同一份 query result(含受控 refetch 能力)移交给所选渲染器。它不重复查询、不渲染报销摘要、不计算报销检查、不持有审批表单状态。非报销实体继续进入现有通用详情。
- 票据抽屉在 PC 使用侧栏还是对话框、移动使用 bottom sheet 还是全屏层。
- 编辑入口的最终密度、明细卡片折叠规则和提交摘要的视觉权重。
- B-2a 真人反馈要求的字段顺序微调。
## 2. Shell Contract
这些是 B-2b 启动前要补齐的设计细节,不改变本 PRD 的迁移承诺和验收结果。
PC/移动布局壳都是纯组合组件,共享模型和 capability 合同,但不强求同一份 DOM,也不接收 `mode` 用于业务判断:
```ts
type ReimbursementWorkspaceModel = {
header: ReimbursementWorkspaceHeaderModel;
evidence: ReimbursementWorkspaceEvidenceProjection;
issueSummary: ReimbursementWorkspaceIssueSummary | null;
timeline: ReimbursementWorkspaceTimelineModel;
};
type ReimbursementWorkspaceCapabilities = {
item?: ReimbursementEvidenceItemCapabilities;
invoice?: ReimbursementEvidenceInvoiceCapabilities;
attachment?: ReimbursementEvidenceAttachmentCapabilities;
decision?: ReimbursementDecisionCapabilities;
};
type ReimbursementWorkspaceShellProps = {
model: ReimbursementWorkspaceModel;
capabilities: ReimbursementWorkspaceCapabilities;
primaryActions?: ReactNode;
secondaryActions?: ReactNode;
statusSummary?: ReactNode;
transientFeedback?: ReactNode;
};
```
`ReimbursementDesktopWorkspaceShell` 与 `ReimbursementMobileWorkspaceShell` 可以根据数据是否存在组合区块,也可以渲染注入的 slot;两者必须保持同一业务内容顺序和语义子组件,但可分别实现 PC 右栏与移动卡片/底部安全区布局。它们不能根据角色、permission、workflow 状态、报销状态或模式推导能力,不能用 `isMobile` 业务分支互相模拟。壳内禁止 `useQuery`、`useMutation`、API client 和业务 mutation state。纯视觉展开优先使用语义 HTML;确需本地视觉状态时不得承载业务事实。
## 3. Adapter Responsibilities
### Edit Adapter
- 获取/刷新报销详情、费用类型、项目、票据候选和 participant precheck。
- 拥有表头、明细、票据、附件、提交、删除、OCR 状态与错误恢复 mutation/state。
- 将可用动作投影为 capabilities;状态或权限不允许时不注入,不让壳二次判断。
- 服务端提交/B2 结果覆盖客户端乐观判断。
### Approval Adapter
- 消费路由边界移交的 workflow task detail/query result,不为同一 task 再发一份分派查询;另行获取实时报销详情。
- 拥有 task detail 的业务解释和刷新策略、allowedActions、提交快照、处理历史、审批 mutation、退回引用、材料查看、联网状态和 fail-closed。
- 只注入证据查看、引用和审批决策能力;编辑/关联/解绑 capability 必须不存在。
- 任务、快照、实时报销内容任一关键部分未就绪时,输出不可决策模型和原因。
### Readonly Adapter
- 只获取既有授权允许的报销详情、处理进度和材料查看能力。
- 不请求参与人专用 precheck,不持有任何业务 mutation。
- 不注入编辑、提交或 workflow 决策能力。
共享查询键、材料打开 helper、展示投影和格式化函数可以复用;适配器不能通过一个巨大 hook 返回所有模式的 mutation 再由页面隐藏。
## 4. Information Architecture
### PC
```text
报销单号 + 状态
报销人 · 日期 · 合计
事由
┌────────────────────────────────────┬────────────────────┐
│ 明细及所属票据 │ 当前节点/状态 │
│ 未归属票据(仅有时) │ 可决策状态 │
│ 补充附件 │ 异常锚点(仅有时) │
│ 异常/快照差异(仅有时) │ 审批/提交动作 │
│ 处理历史/进度 │ sticky │
└────────────────────────────────────┴────────────────────┘
```
### Mobile
标题/状态 → 报销人/日期/合计 → 事由 → 明细/票据 → 未归属(仅有时)→ 附件 → 异常/差异(仅有时)→ 历史/进度 → 底部动作。
正常检查不生成卡片。异常模型必须带对象引用和锚点:
```ts
type ReimbursementWorkspaceIssue = {
key: string;
target: { kind: "item" | "invoice" | "submission"; displayLabel: string };
severity: "blocking" | "review" | "info";
reason: string;
impact: string;
nextStep?: string;
anchorId: string;
};
```
右栏只投影非零、可定位的问题。快照缺失是 `info`,视觉低于 blocking/review;快照与实时事实一致时不生成独立差异区。
## 5. Route and Navigation
- PC `/workflow/tasks/:taskId` 与移动 `/mobile/approvals/:taskId` 保持不变,避免破坏通知、待办、刷新和深链。
- 分派期间显示统一加载骨架;不得先挂载 `WorkflowTaskDetailPage`/通用移动详情再切换报销工作台。
- `/reimbursements/:id` 继续是报销记录入口。返回目标由合法、受限的 navigation state/source helper 解析;审批工作台不再提供“返回审批详情”的自循环外链。
- 新增或重分类路由时同步 `scripts/qa/route-manifest.json`;如果只是同一路由内分派,也要为 reimbursement renderer 增加行为证据。
## 6. Legacy Removal
最终候选删除:
- `WorkflowTaskDetailPage` 的报销 DetailField、报销证据/检查区、报销右栏摘要与报销专用动作分支。
- `MobileApprovalsPage` 的报销内容、报销检查、报销离线解释和报销动作业务分支;通用列表/非报销详情可保留。
- `ReimbursementDetailPage`/`MobileReimbursementDetailPage` 中被工作台替代的旧明细表格和旧材料分组。
- 旧报销专用文案、测试 fixture 与只为休眠路径服务的 helper/import。
允许在实现过程的未提交工作区短暂双接线;业务候选提交前必须用结构测试/`rg` allowlist 证明旧分支为 0。不得保留 feature flag 双轨。
上述壳层禁用项与通用审批旧分支清零必须实现为 `scripts/qa/check-ui-design-rules.mjs` 的 error 规则,并通过既有 `--strict` 部署 preflight;单元测试只补行为和类型证据,不能替代该门禁。
## 7. Data and Permission Contracts
- 不改后端 schema。现有 reimbursement detail、workflow detail、材料预览和动作 API 保持权威。
- 提交时费用标准/预算来自 workflow context 快照;当前票据/附件来自 reimbursement detail。
- approval adapter 复用 B-2a IndexedDB 白名单投影;工作台 model/evidence 不进入 cache。
- edit adapter 继续要求参与人 + `draft/rejected` + 现有 permission;readonly/approval 不因 UI 复用获得写能力。
- 材料 URL、访问参数和原始 payload 不进入 task、日志、浏览器持久缓存或错误文案。
## 8. Print Boundary
`/reimbursements/:id/print` 继续作为专用打印/PDF 视图,允许独立的纸面布局。它不参与三模式交互工作台,不持有工作台 mutation,也不作为审批取得核心内容的入口。本轮只做回归,避免字段删除误伤打印;不迁移或重新设计打印结构。
## 9. Rollout and Rollback
- 单一独立候选上线,不新增 migration、seed、flag 或生产数据写入。
- 切换前必须完成 PC/移动三模式和非报销回归;上线后使用公网真实 bundle + 合成 API 验收,不操作真人单据。
- 回滚是完整回到 B-2a 已部署版本;不保留运行时双壳开关。
- 真实三张 rejected 单据可与开发并行,任何反馈只通过新的规划修订进入本任务,不直接现场改产品代码。
## 10. UI/UX Basis
本任务不引入新视觉系统。采用 `ui-ux-pro-max` 对本场景适用的通用原则:一致标题层级、组合优于模式继承、异常不只靠颜色、反馈明确、触控目标至少 44×44、渐进披露避免把所有解释预先铺开。其数据库没有命中特定“审批工作台”React 模板,因此不采用不存在的模板结论,继续以项目现有组件和 `docs/产品设计规则.md` 的信息架构为基线。
@@ -1,22 +1,68 @@
# Implementation Plan:B-2b 报销填写态共享组件迁移
# Implementation Plan:B-2b 统一报销工作台迁移
## Entry Gate
## Before Development
- [ ] B-2a 已部署并取得至少一次真人审批反馈。
- [ ] 回填本任务最终 drawer/sheet 交互与字段顺序。
- [ ] 用户重新批准更新后的 B-2b planning summary。
- [x] 用户批准最新 `prd.md`、`design.md`、`implement.md`。
- [x] 切换当前 Trellis task 到 B-2b 并运行 `task.py start`;实施前加载 `trellis-before-dev`。
- [x] 读取 frontend、approval workflow、reimbursement/OCR、security/privacy、code reuse 与 route manifest 规约。
- [x] 冻结现有 PC/移动三模式、权限/状态、路由/返回和打印页回归矩阵。
- [x] 记录 B-2a 当前 DOM/test-id、公共 projection、IndexedDB 投影与可回滚提交基线。
## Planned Sequence
## Implementation Order
1. [ ] 为 B-2a 共享组件接入 edit capability,保持 review/readonly 零维护动作。
2. [ ] PC 填写态迁移明细、票据和就近检查。
3. [ ] 移动填写态迁移相同语义结构与安全区操作。
4. [ ] 接入票据抽屉和未归属票据恢复。
5. [ ] 接入提交前摘要与服务端错误就近定位。
6. [ ] 删除被替代的旧明细/材料/只读展示分支。
7. [ ] 完成状态/权限矩阵、全量前端门禁和独立部署验证。
1. [x] 先在 `scripts/qa/check-ui-design-rules.mjs` 建立 error 级结构门禁:PC/移动布局壳禁止 query/mutation/API/role/status/mode 请求分支;通用审批页最终禁止报销业务渲染;旧分支允许清单仅覆盖分派器,并补 self-test。
2. [x] 抽取统一报销头、异常模型、快照/实时差异和三模式共享展示 projection 纯函数;补正常/异常/缺失边界测试。
3. [x] 建立无业务状态的 PC/移动布局壳,共享模型、区块顺序、`ReimbursementItemEvidenceList` 与 capability 合同;PC 实现右栏 sticky,移动实现卡片 + 底部安全区,不复用同一 DOM 或引入 `isMobile` 业务分支。
4. [x] 在 PC/移动 task route 建立唯一 task detail 查询和授权后实体分派;把同一 query result/refetch 入口移交渲染器,报销进入 approval adapter,非报销继续通用详情;避免重复 task 查询和通用页闪现。
5. [x] 建立 approval adapter:消费已移交的 task detail,另取实时报销 detail,迁移 allowedActions、快照、task 刷新策略、fail-closed、退回引用、材料查看与 IndexedDB/离线合同。
6. [x] 删除 `WorkflowTaskDetailPage` 与 `MobileApprovalsPage` 的报销业务内容、检查、右栏摘要和休眠兜底;立即跑非报销审批回归。
7. [x] 建立 readonly adapter,把获准只读报销详情、材料查看和处理进度接入同一工作台,不请求参与人 precheck,不注入 mutation。
8. [x] 建立 edit adapter,迁移表头/明细/票据/附件维护、票据抽屉、未归属恢复、提交前检查和 B2 阻断后再次提交;所有能力通过 capability 注入。
9. [x] 删除 PC/移动旧明细表格、旧报销材料分组和被替代 helper/test fixture;确保最终候选无双实现或 feature flag 兜底。
10. [x] 落实七条信息收敛:紧凑头、唯一事由、正常项隐藏、异常就近、差异条件显示、右栏去重、动作解释按需;补可访问性和锚点/focus 回归。
11. [x] 回归 `/reimbursements/:id/print`,证明打印页未被工作台拆分误伤且没有获得交互 mutation。
12. [x] 更新与新合同冲突的父任务、frontend/approval/reimbursement spec 或产品事实源;不顺带处理无关治理债。
## Rollback
## Verification Matrix
- 独立于 B-2a 形成候选与回滚点。
- 不改变 B2 服务端门禁或既有票据关联数据;代码回滚恢复旧填写 UI,不删除业务数据。
### Structural
- [x] `check-ui-design-rules.mjs --strict` 对 PC/移动壳中的 `useQuery|useMutation|api/|roleCodes|hasPermission|workflow status|reimbursement status|mode ===` 命中报 error(仅显式视觉 allowlist 可例外),并由 self-test 证明规则有效。
- [x] task route 每次详情加载只发一份授权 task detail 查询;approval adapter 复用该结果和 refetch 入口,不产生同 key 重复请求。
- [x] 三适配器 capability 快照准确:edit 有维护/提交、approval 有查看/引用/决策、readonly 只有查看。
- [x] 通用 PC/移动审批详情不再导入报销证据组件或包含报销摘要/检查文案;唯一 `reimbursement` 判断位于授权分派层允许清单。
- [x] 旧明细表/材料布局与休眠测试 fixture 删除,没有双实现 flag。
### Behavior
- [x] PC/移动 edit:draft/rejected 新增/编辑/删除明细、PDF 发票上传/选择、关联/解绑、未归属恢复、附件维护、提交预检、B2 阻断、修复后再次提交。
- [x] PC/移动 readonly:参与人 reviewing 与非参与人获准查看;无维护、无审批能力;材料权限/审计不变。
- [x] PC/移动 approval:pending/已处理、快照 ready/missing/invalid、detail failure、材料单项 failure、退回引用、approve/return/reject/transfer 和联网 fail-closed。
- [x] 非报销 workflow:请假、补卡、采购、差旅通用详情和移动离线草稿保持基线。
- [x] 路由:列表进入、通知深链、刷新、后退、移动分流、无权 403/不泄露、无返回循环。
- [x] 信息:正常 fixture 无“0 条”检查/常驻动作解释;异常 fixture 每项都有对象、原因、影响、下一步和可聚焦锚点;只有快照/实时不一致时出现差异。
### Commands
- [x] 相关前端单元/组件/Playwright;`cd client && npm run typecheck && npm run build`。
- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 与 `--self-test`。
- [x] route manifest/candidate gate、相关后端 B2/workflow/detail/material tests、`cd server && npm run prisma:validate`。
- [x] `python3 ./.trellis/scripts/task.py validate 09-01-reimbursement-form-shared-component-migration`。
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/check-trellis-governance.mjs`、敏感内容检查、docs registry(如需)、`git diff --check`。
- [x] 因涉及 workflow 分派、权限/状态和报销动作,实施后执行只读 review;inline 模式由主会话完成,不派发 implement/check 子代理。
验证说明:移动离线专项的默认 iPhone 设备配置会选择本机未安装的 WebKit;本轮按既有口径改用 Chromium + iPhone 视口执行,3/3 通过。该结果验证 IndexedDB、离线分流和 fail-closed 合同,不替代 Safari/WebView 渲染验收。
## Commit and Deployment Boundary
- [ ] 代码检查通过后按中文 task 引用提交;task 元数据回填独立提交,不 amend。
- [ ] 未经明确授权不 push、不部署、不写生产数据。
- [ ] 部署候选无 migration/schema/flag/seed;使用既有生产入口与独立回滚基线。
- [ ] 公网用合成三模式任务验证,不碰 `0016/0017/0018` 或其他真人单据。
- [ ] 部署与真人反馈完成前,B-2b 和父任务都不得归档。
## Rollback Points
- 适配器接线或非报销回归失败:停止在本地,不删除旧分支,修正后重新验证。
- 最终旧分支删除后检查失败:回到本任务候选前提交,不引入运行时双轨 flag。
- 公网验收失败:回滚到 B-2a 生产基线,保留数据库与业务数据不变。
@@ -1,71 +1,139 @@
# B-2b 报销填写态共享组件迁移
# B-2b 统一报销工作台迁移
## Goal
在 B-2a 共享内容组件经真实审批使用验证后,把 PC/移动报销填写态迁入同一业务结构,消除旧填写态、新审批态和旧只读态长期并存,并完成此前延期的票据抽屉与提交前检查体验。
把报销从“填写详情页、审批详情页、旧只读详情页分别组织同一业务对象”收敛为唯一的交互式报销工作台。工作台以报销内容为主体,通过 `edit`、`readonly`、`approval` 三个模式适配器注入数据、状态和能力;审批只是叠加在报销对象上的流程与决策能力,不再复制一套报销页面壳。
本任务优先服务当前唯一有真实流转的“报销 → 审批 → 付款”链路。请假/补卡继续以企业微信为主,采购/差旅保持冻结的低频能力,不因追求形式一致而同步重构。
## Background and Confirmed Facts
- B-2a 已部署到生产并通过公网真实 bundle 的 PC/移动合成验收,解决了审批人必须跳转才能取得明细、票据和附件的问题。
- B-2a 是经批准的过渡交付:先恢复核心内容可达性并产出 `ReimbursementItemEvidenceList`、快照投影、离线白名单与 fail-closed 合同;B-2b 负责根据真人目视反馈收敛页面结构。
- 真人目视反馈确认:通用审批壳的流程摘要、报销六字段摘要、新证据区和右侧检查计数存在重复;解释性文字按系统内部状态平铺,审批人需要的“哪条明细、什么问题、是否阻断、如何处理”反而不突出。
- `ReimbursementDetailPage.tsx` 与 `MobileReimbursementDetailPage.tsx` 已是高状态、高 mutation 的大型页面。继续在其中加入 `if (approvalMode)` 会把查询、权限、编辑和审批职责耦合成更难维护的巨型组件。
- 项目定位决定采用方案 A:只有报销任务进入业务对象工作台的审批模式;其他 workflow 实体继续使用通用审批详情。
- `/workflow/tasks/:taskId` 与 `/mobile/approvals/:taskId` 仍是审批深链和任务身份入口;报销任务在任务元数据解析后交给报销审批适配器,不要求用户再跳到另一业务路由。
- B-2a 的提交时费用标准/预算快照与当前票据事实仍是两类不同时间语义,不因页面合并而混用。
## Entry Conditions
- B-2a 已部署并通过 PC/移动公网核验。
- 至少取得一次真实报销审批反馈,确认审批人能理解明细—票据对应、快照/实时标签和退回引用。
- B-2a 共享组件 API 已冻结;若真实反馈要求改字段顺序,先回写 B-2a 组件合同再启动迁移。
- B-2a 已部署并通过生产 `/ready`、公网 bundle 与合成业务验收。
- 已取得一次真人目视反馈,并据此冻结方案 A、信息收敛规则和壳/适配器边界。
- 本 PRD、`design.md` 与 `implement.md` 经用户复核批准后,B-2b 才可进入实施。
- 三张真实 `rejected` 单据的员工修改与反馈可以并行推进,不是本任务启动或完成的阻塞条件;反馈如改变已批准产品行为,必须回到规划阶段修订。
## Scope
- PC `ReimbursementDetailPage` 与移动 `MobileReimbursementDetailPage` 的 draft/rejected 填写态及非参与人只读内容投影。
- B-2a 共享组件的 edit 能力、票据抽屉、提交前检查和错误恢复。
- 删除被共享组件替代的旧明细/材料展示分支,并保持现有 API、权限和 B2 服务端门禁权威。
- 建立 PC/移动共享内容合同的唯一交互式报销工作台;PC 与移动允许使用各自布局壳,但必须消费同一展示模型、区块顺序、语义组件和 capability 契约。
- 建立 `edit`、`readonly`、`approval` 三类模式适配器;查询、业务状态、权限判断、mutation、错误恢复和 fail-closed 均归适配器。
- `/reimbursements/:reimbursementId` 的参与人填写态和非参与人只读态迁入工作台。
- `/workflow/tasks/:taskId` 与 `/mobile/approvals/:taskId` 的报销任务进入同一工作台的审批适配器;其他实体继续走通用审批详情。
- 复用并扩展 B-2a 的明细—票据证据组件、票据抽屉、提交前检查和未归属恢复。
- 删除 PC/移动通用审批页中的报销业务分支,以及被工作台替代的旧报销明细/材料布局。
- 收敛顶部摘要、检查提示、右侧动作摘要和退回/驳回解释。
## Requirements
## Product Requirements
### R1 填写态采用同一业务内容组件
### R1 一个业务对象只保留一个交互式工作台
- PC/移动填写态、审批态和非参与人只读态共享同一“明细 + 所属票据”组件及字段顺序。
- 填写态的新增、编辑、删除、关联和恢复动作通过显式能力与回调注入;审批/只读模式不渲染这些能力。
- 删除被共享组件替代的旧明细表格、旧材料对应展示或重复只读布局,不保留永久双实现兜底。
- 报销详情的主内容顺序在 `edit`、`readonly`、`approval` 三种模式中一致:紧凑报销头 → 明细及所属票据 → 未归属票据 → 补充附件 → 必要检查/差异 → 处理历史或处理进度。
- PC 宽屏使用主列证据 + 右侧单一 sticky 动作区;窄屏和移动采用同一内容顺序的卡片/分区详情布局,动作区转到底部且不遮挡内容。PC 与移动不强求同一份 DOM,不得用密集的 `isMobile` 业务分支把两种布局重新耦合成巨型壳。
- `/workflow/tasks/:taskId` 保留为审批入口和浏览器地址。任务解析为 `reimbursement` 后由报销审批适配器渲染工作台;不得先渲染通用报销摘要再嵌入工作台,也不得要求用户点击“查看报销单”才能取得核心内容。
- `/reimbursements/:reimbursementId` 使用同一工作台。参与人处于 `draft/rejected` 时获得编辑能力;其他获准查看者只得到只读能力。
- “打开完整原始表单”不再作为独立核心内容页面入口;打印/PDF 仍通过专用打印路由提供。
### R2 票据抽屉围绕当前明细工作
### R2 壳与模式适配器必须严格分层
- 员工可从明细就近添加/选择 PDF 发票,查看已关联票据和解除自己可维护状态下的关联。
- 整单补充附件仍独立,不伪装成明细票据。
- 未归属票据有明确恢复入口;不得要求员工依赖操作顺序或跳到另一大区寻找。
- PC/移动布局壳只接收同一份已经计算完成的展示模型、区块内容和 capability/action slots,分别负责各端布局、响应式结构和语义标题;内容顺序和业务组件合同不得分叉。
- 工作台壳不得调用 `useQuery`、`useMutation` 或 API;不得持有编辑、审批、权限、状态机、票据上传或错误恢复等业务状态;不得根据 `mode` 决定是否发请求。
- 壳内不以 `roleCodes`、permission code、workflow 状态、报销状态或 `if (mode === ...)` 推导业务能力。模式差异由适配器注入了什么数据、capability 和 action slot 体现。
- `edit` 适配器拥有参与人判断、`draft/rejected` 状态、表头/明细/票据/附件维护状态和 mutation、提交前检查及 B2 错误恢复。
- 任务路由边界唯一获取用于授权和实体分派的 workflow task detail,并把同一份查询结果及受控刷新入口移交给 `approval` 适配器;不得为分派和审批内容各发一次相同 task detail 请求。
- `approval` 适配器拥有 workflow task detail 的业务解释、allowed actions、提交快照、审批 mutation、刷新策略、fail-closed、退回引用、处理历史与联网要求;不得获得报销维护 mutation。
- `readonly` 适配器只执行获准的只读查询和材料查看,不注入新增、编辑、删除、关联、解绑、提交或审批能力。
- 共享投影和展示组件接收显式最小字段或纯展示模型,不接收完整 Prisma/API 记录,也不在组件内部判断角色。
### R3 提交前检查进入填写路径
### R3 通用审批页的报销旧分支必须删除
- 在填写过程中持续显示明细完整性、票据关联、票面金额证据和需人工核对摘要。
- 提交动作前展示与服务端 B2 一致的检查结果;服务端仍为权威,前端不得自行放宽。
- 错误就近落到明细/票据,汇总区只告诉员工还有几项及如何到达,不只在页面顶部报错。
- PC `WorkflowTaskDetailPage` 中报销专用的六字段 `DetailField`、报销证据区、提交/当前检查区、报销右栏计数和报销审批动作接线,在新工作台路由生效后全部删除。
- 移动 `MobileApprovalsPage` 中对应的报销详情、证据、检查和报销 fail-closed UI 分支迁入审批适配器后删除。
- 通用审批页只保留请假、补卡、采购、差旅等现有实体的通用投影与动作,不保留休眠报销路径、隐藏兜底或复制文案“以防万一”。
- 最终结构检查必须证明通用审批页不再导入报销证据组件、不再包含报销检查文案,也不再根据 `entityType === "reimbursement"` 渲染业务内容。该检查与壳层禁用 API/权限/模式判断的检查必须实现于 `scripts/qa/check-ui-design-rules.mjs`,由部署 preflight 的 `--strict` 门禁执行,不得仅存在于单测。
- 任务类型分派层可以识别 `reimbursement` 并选择适配器;它只负责路由,不得重新实现报销摘要或决策逻辑。
### R4 填写与审批语义一致
### R4 信息必须按审批人决策需要收敛
- 发生日期不默认等于发票日期;跨天/跨期同一业务可作为一条明细并写明起止日期。
- 系统不自动判断明细是否应拆分,页面把它表达为填报要求和财务复核项。
- 需票明细、无票补助、manual review 和金额不可用继续遵守现有业务事实源及 B2 合同。
1. 顶部只显示报销单号、状态、报销人、日期、合计;事由作为紧接标题的正文展示。不得再用六张字段卡重复下面已经出现的信息,也不显示“业务类型=报销”等自明字段。
2. 主列直接进入明细和所属票据;附件随后展示。相同报销头、事由、票据或检查事实不得在不同区块重复完整表达。
3. 正常项不解释、不计数。只有存在异常或人工决策需要时才显示提示,并就近说明“对象、原因、影响、下一步”;不得默认平铺“0 条”状态或仅显示无法定位对象的总数。
4. 提交时快照与当前票据事实一致时,不并排展示两个“检查摘要”;只有出现异常、快照缺失或时间语义差异时才显示必要内容。
5. 历史提交缺少预算快照时,以低强调度说明“该次提交未留存预算快照”;它不得使用告警视觉冒充当前业务异常,也不得用实时预算结果补写历史。
6. PC 右栏只保留当前节点/状态、是否可决策、异常锚点和审批动作。报销合计如已在顶部清晰可见,不在右栏重复;创建/发起/完成等时间进入处理历史或按需展开,不平铺为决策摘要。
7. “退回”和“驳回”的后果说明只在用户展开相应动作或进入确认时出现;默认右栏不常驻解释卡。错误和提交结果继续提供明确反馈,不能为了降噪删除必要恢复信息。
- 警告不得只依赖颜色;标题层级、DOM 顺序和读屏顺序必须与视觉顺序一致。
### R5 编辑态围绕明细完成票据维护与提交恢复
- `edit` 适配器向同一明细—票据组件注入新增、编辑、删除、上传/选择 PDF 发票、关联、解绑和未归属恢复能力。
- 员工可在当前明细上下文内查看和维护所属票据;未归属票据直接提供选择明细的恢复入口,不依赖口头操作顺序或跳到独立材料区。
- 整单补充附件继续作为独立证据,不伪造与某条明细的结构化关系。
- 填写过程中持续计算可解释的提交前检查;汇总区只提示仍有多少可操作问题并提供锚点,具体错误就近落到明细或票据。
- 服务端 B2 门禁继续为权威。前端不得因工作台迁移放宽 `ocr_failed`、manual review、金额超票面、缺票或未归属合同。
- 发生日期不取发票日期;系统不自动决定业务是否应拆分,页面继续按既有业务事实源提示员工和财务人工复核。
### R6 审批态保留快照、实时事实与 fail-closed
- 费用标准/预算使用 workflow 提交快照;票据归属、金额证据和附件使用当前实时报销详情。两者不得互相替代。
- 完整报销内容、关键当前检查或提交快照解析未就绪时,通过、退回、驳回、转审和报销离线草稿均不可执行;动作入口除禁用外仍有中文原因。
- 审批适配器只注入查看原件、查看附件、引用到退回意见及 workflow 决策能力,不注入报销维护能力。
- 报销证据继续排除在移动 IndexedDB workflow cache 之外;其他审批类型的离线草稿能力保持不变。
- 已处理任务查看当前报销版本时保留时间语义提示;历史意见和提交快照不被当前内容覆盖。
### R7 路由、权限与回退必须保持稳定
- 通用任务路由以一次已授权 task detail 查询选择报销或非报销渲染器,并把该查询结果移交所选渲染器继续使用。非参与人不能从分派错误、报销加载状态或材料错误推断无权对象信息。
- 报销详情、票据、附件、短时材料 URL 和审批动作继续使用既有后端授权、用途审计和状态机;本任务不以纯前端隐藏替代服务端校验。
- PC/移动从待办列表进入、刷新、浏览器返回、深链打开和已处理任务查看均保持可预测;工作台返回目标由入口上下文决定,不形成“返回审批详情 → 又回到同一工作台”的循环。
- 页面级加载失败显示重试和安全返回;单个材料加载失败只影响该材料,不清空已加载证据。
## Acceptance Criteria
- [ ] PC/移动填写态使用 B-2a 同一业务内容组件,审批/只读/编辑模式只在能力和动作上不同。
- [ ] 员工可在明细上下文内完成票据添加、归属查看和错误恢复,不再依赖“先明细、后整单材料区”的隐含顺序。
- [ ] 提交前检查与服务端 B2 结果一致,错误就近显示且再次提交链路可完成。
- [ ] 旧重复布局和不再使用的展示分支被删除;父任务不存在三套并行内容结构。
- [ ] PC/移动、参与人/非参与人、draft/rejected/reviewing 状态矩阵通过,既有票据/附件权限无回归。
- [ ] 公网部署和真实员工重提反馈通过后,父任务才可归档。
- [ ] AC1:PC 与移动的 `edit`、`readonly`、`approval` 使用同一展示模型、区块顺序、明细—票据语义组件和 capability 契约;PC 可使用右栏 sticky 布局壳,移动可使用卡片 + 底部动作布局壳,不要求同一份 DOM,模式差异只来自适配器注入的数据与能力。
- [ ] AC2:PC/移动布局壳中 `useQuery`、`useMutation`、API 调用、角色/权限判断、workflow/报销状态机判断和 `if (mode === ...)` 业务分支均为 0;`scripts/qa/check-ui-design-rules.mjs --strict` 静态扫描并报 error,且该命令作为部署 preflight 执行。
- [ ] AC3:`WorkflowTaskDetailPage` 与 `MobileApprovalsPage` 的报销业务渲染分支、六字段摘要、重复检查区和休眠兜底全部删除;通用审批页仍通过非报销回归。
- [ ] AC4:报销审批首屏只有一套紧凑报销头;事由、明细、所属票据、附件、异常和历史各有唯一主展示位置,不再出现两套摘要或四处检查计数。
- [ ] AC5:无异常时不显示“0 条”检查卡或常驻退回/驳回解释;异常时每条提示可定位到具体明细/票据,包含原因、影响和下一步。快照/实时不一致时才额外显示差异。
- [ ] AC6:PC 右栏只含决策所需状态、异常锚点和单一动作区;窄屏/移动动作区不遮挡内容,触控区域不小于 44×44,键盘和读屏顺序正确。
- [ ] AC7:员工可在明细上下文完成新增/编辑/删除、PDF 发票添加、关联/解绑、未归属恢复、提交阻断修复和再次提交;服务端 B2 结果仍为最终权威。
- [ ] AC8:审批内容或快照失败时所有报销决策 fail-closed;审批适配器没有任何报销维护能力;readonly 适配器没有编辑或审批能力。
- [ ] AC9:报销证据不进入 IndexedDB;非报销审批离线草稿基线、PC/移动非报销详情和 workflow 状态机均无回归。
- [ ] AC10:参与人/非参与人、`draft/rejected/reviewing`、pending/已处理任务和材料权限矩阵通过;没有新增 schema、migration、flag、seed 或生产数据改写。
- [ ] AC11:生产部署后使用公网真实 bundle + 合成 API 完成三模式、失败关闭和离线验收;真实员工重提反馈记录后,父任务才允许归档。
## Out of Scope
- 自动从票据推断发生日期或业务拆分正确性。
- 结构化退回原因和统计模型。
- 财务代员工编辑、批量改票或新审批状态。
- 与 B-2a 同批部署;本任务必须是独立候选、独立回滚点。
- 请假、补卡、采购、差旅改造成“业务对象页 + 审批模式”;它们继续使用通用审批详情。
- `/reimbursements/:id/print` 打印/PDF 页面重构。该页面是格式化交付视图,不是交互式工作台;本任务有意保留其独立布局,但不得把它当作编辑/只读/审批的第四套交互实现,也不得向其中复制工作台 mutation。
- 自动从发票推断发生日期、自动判断业务拆分正确性或替财务作真实性结论。
- 结构化退回原因、统计模型、财务代员工编辑、批量改票或新审批状态。
- 修改 workflow 状态机、B2 flag、付款流程、数据库 schema、生产配置或真人单据。
- 为当前冗余页面单独发布临时删减版;信息收敛随 B-2b 独立候选一次交付。
## Test Plan
- 组件/纯函数:review/readonly/edit 三模式字段顺序一致,能力注入不向只读视角泄漏维护动作。
- Playwright:PC/移动 participant 与 nonparticipant 的 draft/rejected/reviewing 矩阵,覆盖票据抽屉、未归属恢复、提交阻断和再次提交。
- 后端:复跑 B2 票据门禁、报销参与人授权、票据 bind/unbind/link 与材料预览聚焦套件;本任务原则上不改后端合同。
- 前端:typecheck/build、UI strict/self-test、代表性视口、键盘/读屏焦点和固定操作区遮挡检查。
- 治理:task validate、process contract、Trellis governance、敏感内容检查、`git diff --check`;部署和公网验证另行批准。
- 结构边界:`check-ui-design-rules.mjs --strict` 扫描 PC/移动布局壳,禁止 query/mutation/API/角色/状态机/mode 请求分支,并扫描通用审批页报销分支和旧文案为 0;三适配器能力集合另做组件/类型验证。
- 纯函数/组件:统一报销头、明细—票据投影、异常过滤、快照/实时差异、异常锚点和 capability 注入覆盖正常/异常/缺失边界。
- PC/移动 Playwright:三模式内容顺序;无异常降噪;异常就近文案;右栏/底部动作;票据抽屉;未归属恢复;B2 阻断后修复并再次提交;退回引用;深链/返回。
- 权限/状态:participant/nonparticipant × draft/rejected/reviewing;pending/已处理审批;无权 task、跨单材料、无维护能力和并发关联回归。
- 离线:直接读取 IndexedDB 禁存字段/哨兵值;报销动作和草稿 fail-closed;非报销审批离线草稿仍可形成和同步。
- 后端:原则上不改合同;复跑 B2 门禁、报销详情只读投影、workflow task detail、材料查看审计、票据 bind/unbind/link 和审批动作聚焦测试。
- 前端/治理:typecheck、build、UI strict/self-test、route manifest、相关 Playwright、process contract、Trellis governance、敏感内容检查、docs registry(如改事实源)和 `git diff --check`。
- 部署:另行批准独立候选;切换前 UI strict;部署后核 `/ready`、bundle marker、公网文案、三模式合成路径、B2 状态和真人单据状态不变。
## Planning Note
## Risks and Deferred Items
本子任务当前只冻结迁移承诺和验收边界。详细 `design.md` / `implement.md` 在 B-2a 真人反馈回填后编写,避免在共享组件尚未验证前固化票据抽屉交互。
- 大型旧页面拆分容易在中间阶段形成双实现;允许在开发分支短暂并存,但最终候选必须通过旧分支为 0 的结构门禁。
- task 路由先取元数据再分派可能产生加载闪烁;设计必须用稳定骨架而不是先渲染通用页再切换。
- 打印页继续独立,未来如字段语义漂移可另建“打印投影复用”任务;本轮不扩大交付面。
- 全局错误色 token、部署脚本 `--no-deps`、`.claude` gitignore 和其他治理债不并入本任务。
@@ -1,11 +1,11 @@
{
"id": "reimbursement-form-shared-component-migration",
"name": "reimbursement-form-shared-component-migration",
"title": "B-2b 报销填写态共享组件迁移",
"description": "在 B-2a 真人反馈后,把 PC/移动填写态迁入同一明细票据组件并删除重复旧布局。",
"status": "planning",
"title": "B-2b 统一报销工作台迁移",
"description": "把 PC/移动报销编辑、只读、审批收敛为一个工作台壳和三个能力适配器,并删除通用审批页的报销旧分支。",
"status": "in_progress",
"dev_type": null,
"scope": "client,spec",
"scope": "client,workflow,spec",
"package": null,
"priority": "P2",
"creator": "shunli",
@@ -21,6 +21,6 @@
"children": [],
"parent": "09-01-reimbursement-one-page-approval-workbench",
"relatedFiles": [],
"notes": "父任务第二阶段;B-2a 部署和真人反馈是启动条件,当前只冻结迁移承诺与验收边界。",
"notes": "父任务第二阶段;B-2a 已部署并取得真人目视反馈。规划采用方案 A:仅报销进入业务对象工作台审批模式,其他 workflow 实体保持通用详情。实现与本地全量门禁已完成,待用户最终 diff 复核后提交;尚未 push、部署或触碰真人单据。",
"meta": {}
}
@@ -13,14 +13,16 @@ Workflow task shell
+ approval decision panel
B-2b
Existing reimbursement form shell
+ same shared item/evidence component (edit mode)
+ invoice drawer
+ submit preflight summary
Authorized task/reimbursement route dispatch
-> edit / readonly / approval adapters
-> one stateless reimbursement workspace shell
+ same shared item/evidence component
+ invoice drawer + submit preflight
- generic workflow reimbursement branches
- retired legacy item/material duplication
```
共享边界选在“明细 + 挂在明细下的票据”,而不是复用整个旧 `ReimbursementDetailPage`。旧页面混有填写 mutation、权限判断、材料上传、处理进度和操作面板,直接嵌入审批页会把报销人维护能力与审批职责再次耦合。
共享边界在 B-2a 先选为“明细 + 挂在明细下的票据”;真人目视反馈后,B-2b 把它上升为唯一交互式报销工作台。旧 `ReimbursementDetailPage` 的查询、mutation、权限判断和编辑状态进入 edit adapter,workflow task、审批 mutation 与 fail-closed 进入 approval adapter,只读查询进入 readonly adapter;工作台壳只负责编排已经计算好的展示模型和 capability/action slots。
## 2. Data Authority Matrix
@@ -50,14 +52,13 @@ type ReimbursementItemEvidenceModel = {
type ReimbursementItemEvidenceListProps = {
models: ReimbursementItemEvidenceModel[];
unlinkedInvoices: ReimbursementInvoiceEvidence[];
mode: "review" | "edit" | "readonly";
onOpenInvoice: (invoiceId: string) => void;
onReferenceReturn?: (reference: ReturnReference) => void;
editCapabilities?: ReimbursementEvidenceEditCapabilities;
};
```
分组、文案和检查映射由纯函数完成,页面容器只负责查询、mutation、导航和动作面板。`editCapabilities` 在 B-2a 不启用,B-2b 再接入,避免审批页出现维护分支。
分组、文案和检查映射由纯函数完成。B-2a 页面容器暂时负责查询、mutation、导航和动作面板;B-2b 将这些职责迁入三种适配器,唯一工作台壳不得拥有 query/mutation/角色/状态机判断。`editCapabilities` 在 B-2a 不启用,B-2b 只由 edit adapter 接入,避免审批/只读视角出现维护分支。
## 4. Responsive Page Structure
@@ -118,7 +119,8 @@ B-2a 客户端在 workflow task 已授权后读取现有报销详情 API,复
- B-2a 保留完整原始表单入口作为降级路径;核心核验不得依赖它。
- B-2a 不删除旧填写组件,只新增共享组件的 review/readonly 能力。
- B-2b 接入 edit 能力后删除被替代布局;不长期维护双实现。
- B-2b 接入三种适配器后删除被替代布局,并删除 PC/移动通用审批页的报销业务分支;不长期维护双实现或运行时 feature flag。
- `/reimbursements/:id/print` 保持专用打印布局,不纳入三模式交互壳,只做不误伤回归。
- 旧 workflow 无预算快照兼容为 null。
- 当前已处理任务展示实时内容时加版本提示,避免历史误读。
@@ -20,15 +20,15 @@
采用“先审批、后填写迁移”的 B 路线,并拆成两次可独立验收的交付:
1. **B-2a 报销审批内容工作台**:建立共享的“明细 + 所属票据”只读/核验组件,先接入 PC 和移动审批页;审批人无需跳转即可取得决策所需内容。
2. **B-2b 报销填写态共享组件迁移**:在 B-2a 真人使用反馈稳定后,把 PC/移动填写态迁移到同一组件,并完成票据抽屉、提交前检查和旧只读布局收口。
2. **B-2b 统一报销工作台迁移**:根据 B-2a 真人目视反馈,把 PC/移动编辑、只读、审批收敛为一个纯布局工作台壳和三个能力适配器,并完成票据抽屉、提交前检查、信息降噪及旧页面分支删除。
B-2a 完成时允许旧填写态暂时存在;B-2b 是本父任务的必交子任务,不得以“以后优化”关闭父任务。父任务只有在填写态迁移完成、旧重复内容布局被删除或明确退役后才能归档为完成。
B-2a 完成时允许两套交互式页面壳暂时存在;B-2b 是本父任务的必交子任务,不得以“以后优化”关闭父任务。父任务只有在三种模式使用唯一报销工作台、通用审批页报销分支和旧重复布局均被删除后才能归档为完成。专用打印/PDF 页面是有意排除的格式化交付视图,不计作第四种交互模式。
## Scope
- 父任务负责两阶段交付合同、共享组件边界、快照/实时数据口径、跨子任务验收和最终旧布局收口。
- 父任务负责两阶段交付合同、唯一工作台边界、快照/实时数据口径、跨子任务验收和最终旧布局收口。
- B-2a 负责 PC/移动审批一页式内容工作台与共享组件 review/readonly 能力。
- B-2b 负责 PC/移动填写态迁移、票据抽屉、提交前检查和重复布局删除。
- B-2b 负责唯一工作台壳、edit/readonly/approval 适配器、票据抽屉、提交前检查、通用审批页报销分支删除和信息收敛。
- 两个子任务分别形成候选、部署和回滚点;父任务不直接承载业务代码实施。
## Product Requirements
@@ -39,12 +39,13 @@ B-2a 完成时允许旧填写态暂时存在;B-2b 是本父任务的必交子
- “查看报销单”降级为“打开完整原始表单/打印视图”的次要入口,用于少见的完整字段追溯,不再承担核心内容可达性。
- 非报销审批类型保持当前各自投影,本父任务不借机重构请假、采购、差旅或考勤审批。
### R2 共享内容组件必须有明确迁移终点
### R2 共享内容组件必须收敛为唯一工作台
- B-2a 建立的共享件以业务数据和交互能力为边界,不依赖 `WorkflowTaskDetailPage` 或审批状态;至少覆盖“明细 + 关联票据 + 行内核验提示”。
- PC 与移动审批使用同一语义组件和字段顺序,只允许响应式布局和操作容器不同。
- B-2b 必须让 PC/移动填写态采用同一组件;编辑、删除、关联等维护动作通过显式能力/回调注入,不复制一套填写专用展示结构。
- B-2b 完成后,不得继续保留“旧填写态、新审批态、旧只读态”三套并行布局。
- B-2b 必须让 PC/移动编辑、只读、审批采用同一纯布局工作台壳和同一业务内容顺序;查询、状态、mutation、权限和 fail-closed 全部归各模式适配器。
- 工作台壳不得根据 mode、角色、权限或业务状态发请求或推导能力;编辑、删除、关联、审批等动作只通过显式 capability/action slot 注入。
- B-2b 完成后,不得继续保留“旧填写态、新审批态、旧只读态”三套并行布局,也不得在 PC/移动通用审批页保留休眠报销业务分支。
### R3 快照事实与实时事实分层展示
@@ -93,13 +94,14 @@ B-2a 完成时允许旧填写态暂时存在;B-2b 是本父任务的必交子
- [ ] 退回可以引用具体明细/票据到现有自由文本意见;本父任务不新增结构化退回原因数据模型。
- [ ] PC 只有右侧单一审批动作面板,完整流程/历史位于主列靠后;窄屏和移动才把同一动作面板转到底部,多个动作不竖向铺卡片。
- [ ] 报销审批离线时不能形成任何审批动作或草稿,且离线缓存中不存在明细、票据、附件或材料访问证据;其他审批类型离线合同不变。
- [ ] B-2b 完成后,填写态、审批态和只读态共享同一业务内容组件,不再保留三套漂移布局。
- [ ] B-2b 完成后,编辑、审批和只读共享同一无业务状态的工作台壳;通用 PC/移动审批页报销分支和旧重复布局为 0。
- [ ] 两次交付分别通过各自的前后端、PC/移动、无障碍、文案、权限和部署验证;生产部署与真人数据写入仍需单独批准。
## Out of Scope
- 一次性同时重写审批态和填写态。
- 把已上线的 B-2a 与 B-2b 合并成一次候选或一次回滚点;B-2b 仍必须独立规划、验证和部署。
- 请假、采购、差旅申请、考勤等其他业务的审批内容重构。
- `/reimbursements/:id/print` 打印/PDF 视图重构;它保持独立纸面布局,但不得承担交互式工作台职责。
- 结构化退回原因 schema、统计报表、数据库 migration。
- 修改审批角色、审批规则、状态机、B2 flag 或付款流程。
- 自动判断明细日期粒度、业务是否应拆分或费用是否真实合理。
@@ -0,0 +1,49 @@
import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList";
import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList";
import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import { BusinessDetailAside, BusinessDetailMain, BusinessDetailWorkspace } from "../patterns/BusinessDetailTemplate";
import { EntityOverviewHero } from "../patterns/EntityOverviewHero";
import { PageScaffold } from "../patterns/PageScaffold";
import { SectionPanel } from "../patterns/SectionPanel";
type Props = {
model: ReimbursementWorkspaceModel;
capabilities?: ReimbursementEvidenceCapabilities;
slots: ReimbursementWorkspaceSlots;
};
export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots }: Props) {
return (
<PageScaffold data-testid="reimbursement-desktop-workspace">
{slots.backAction}
<EntityOverviewHero
title={`报销单 ${model.header.displayCode}`}
subtitle={model.header.description || "未填写事由"}
meta={(
<>
{slots.status}
<span className="info-chip">报销人:{model.header.ownerName}</span>
<span className="info-chip">日期:{formatDisplayDate(model.header.reimbursementDate)}</span>
<span className="info-chip">合计:{formatMoney(model.header.totalAmount)}</span>
</>
)}
/>
{slots.transientFeedback}
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailMain>
{slots.headerDetails}
{slots.beforeEvidence}
<SectionPanel title="明细与票据" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
</div>
</SectionPanel>
{slots.afterEvidence}
{slots.timeline}
</BusinessDetailMain>
<BusinessDetailAside>{slots.actionPanel}</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
);
}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Button } from "../ui/button";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import type {
@@ -11,35 +12,48 @@ export type ReimbursementEvidenceCapabilities = {
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onOpenMaterialBundle?: () => void;
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onReferenceItem?: (item: ReimbursementItemEvidence) => void;
onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onEditItem?: (item: ReimbursementItemEvidence) => void;
onDeleteItem?: (item: ReimbursementItemEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
linkInvoicePending?: boolean;
linkInvoiceErrors?: Record<string, string>;
onUnlinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
};
type Props = {
evidence: ReimbursementApprovalEvidenceProjection;
capabilities?: ReimbursementEvidenceCapabilities;
mobile?: boolean;
};
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
if (status === "verified") return "票面金额已读取";
if (status === "ocr_failed") return "票面金额未能读取,暂时不能完成核验";
return "票面金额需查看原件人工核对";
if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。";
if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。";
return null;
}
function InvoiceCard({
invoice,
capabilities,
linkTargets,
}: {
invoice: ReimbursementInvoiceEvidence;
capabilities: ReimbursementEvidenceCapabilities;
linkTargets: ReimbursementItemEvidence[];
}) {
const [selectedItemId, setSelectedItemId] = useState("");
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
const hasBlockingIssue = !invoice.linkedItemId || invoice.amountEvidenceStatus === "ocr_failed";
return (
<article className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="approval-invoice-evidence">
<article
className="rounded-lg border border-zinc-200 bg-white p-3"
data-testid="approval-invoice-evidence"
data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined}
tabIndex={hasBlockingIssue ? -1 : undefined}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="truncate text-sm font-medium text-zinc-900">{title}</h4>
@@ -48,13 +62,15 @@ function InvoiceCard({
{" · "}{formatDisplayDate(invoice.invoiceDate)}
{" · "}{formatMoney(invoice.totalAmount)}
</p>
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? (
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{capabilities.onOpenInvoice ? (
<Button className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
<Button aria-label={`查看发票原件:${title}`} className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
查看原件
</Button>
) : null}
@@ -64,7 +80,34 @@ function InvoiceCard({
</Button>
) : null}
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onLinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">选择明细</Button>
<div className="grid min-w-[220px] gap-2">
<label className="text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细</label>
<select
className="select-std min-h-11"
disabled={capabilities.linkInvoicePending}
id={`reimbursement-link-item-${invoice.id}`}
onChange={(event) => setSelectedItemId(event.target.value)}
value={selectedItemId}
>
<option value="">请选择报销明细</option>
{linkTargets.map((item, index) => (
<option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>
))}
</select>
<Button
className="min-h-11"
disabled={!selectedItemId || capabilities.linkInvoicePending}
onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)}
size="sm"
type="button"
variant="secondary"
>
{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}
</Button>
{capabilities.linkInvoiceErrors?.[invoice.id] ? (
<p className="text-xs text-red-700" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p>
) : null}
</div>
) : null}
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button>
@@ -75,12 +118,14 @@ function InvoiceCard({
);
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: Props) {
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
return (
<div className="space-y-5" data-testid="reimbursement-approval-evidence">
<div className="space-y-3">
{evidence.items.length ? evidence.items.map((item, index) => (
<article className="rounded-xl border border-zinc-200 bg-zinc-50/60 p-4" data-testid="approval-item-evidence" key={item.id}>
{evidence.items.length ? evidence.items.map((item, index) => {
const hasBlockingIssue = item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
return (
<article className={mobile ? "mobile-list-card" : "rounded-xl border border-zinc-200 bg-zinc-50/60 p-4"} data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} tabIndex={hasBlockingIssue ? -1 : undefined} key={item.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-zinc-500">明细 {index + 1}</p>
@@ -127,30 +172,30 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P
当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。
</p>
) : null}
<div className="mt-4 space-y-2">
{item.invoices.length || item.expenseType?.requiresInvoice !== false ? <div className="mt-4 space-y-2">
<p className="text-sm font-medium text-zinc-800">所属票据({item.invoices.length})</p>
{item.invoices.length ? item.invoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} />
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
)) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
这条明细尚无对应票据,请在作出审批决定前核对是否需要补充。
这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。
</p>
)}
</div>
</div> : null}
</article>
)) : (
); }) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
)}
</div>
{evidence.unlinkedInvoices.length ? (
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices">
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked">
<div>
<h3 className="font-medium text-red-900">尚未对应明细的票据({evidence.unlinkedInvoices.length})</h3>
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,审批前需要申请人处理或在意见中说明。</p>
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>
</div>
{evidence.unlinkedInvoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} />
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
))}
</section>
) : null}
@@ -171,10 +216,15 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
</div>
{capabilities.onOpenAttachment ? (
<Button className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
<Button aria-label={`查看补充附件:${attachment.originalName}`} className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
查看附件
</Button>
) : null}
{capabilities.onDeleteAttachment ? (
<Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">
删除附件
</Button>
) : null}
</div>
)) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
</section>
@@ -0,0 +1,58 @@
import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList";
import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList";
import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import { MobileDetailHeader } from "../mobile/MobileDetailHeader";
import { MOBILE_STICKY_ACTION_PAGE_CLASS } from "../patterns/MobileStickyActionBar";
import { PageScaffold } from "../patterns/PageScaffold";
type Props = {
model: ReimbursementWorkspaceModel;
capabilities?: ReimbursementEvidenceCapabilities;
slots: ReimbursementWorkspaceSlots;
fallbackTo: string;
};
export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots, fallbackTo }: Props) {
return (
<PageScaffold className={slots.mobileActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : undefined} data-testid="reimbursement-mobile-workspace">
<MobileDetailHeader
fallbackTo={fallbackTo}
status={slots.status}
subtitle="报销详情"
testId="mobile-reimbursement-detail-header"
title={model.header.displayCode}
/>
{slots.transientFeedback}
<section className="mobile-card mobile-focus-card mobile-page-stack">
<div>
<p className="text-xs text-zinc-500">报销合计</p>
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.header.totalAmount)}</p>
</div>
<div className="mobile-info-grid">
<div className="mobile-list-card">
<p className="text-xs text-zinc-500">报销人</p>
<p className="mt-1 font-medium text-zinc-900">{model.header.ownerName}</p>
</div>
<div className="mobile-list-card">
<p className="text-xs text-zinc-500">报销日期</p>
<p className="mt-1 font-medium text-zinc-900">{formatDisplayDate(model.header.reimbursementDate)}</p>
</div>
</div>
<div>
<p className="text-xs text-zinc-500">事由</p>
<p className="mt-1 text-sm leading-6 text-zinc-700">{model.header.description || "未填写事由"}</p>
</div>
{slots.headerDetails}
</section>
{slots.beforeEvidence}
<section className="mobile-card scroll-mt-20" data-testid="reimbursement-workspace-evidence" id="reimbursement-workspace-evidence">
<h2 className="mb-3 text-base font-semibold text-zinc-900">明细与票据</h2>
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
</section>
{slots.afterEvidence}
{slots.timeline}
{slots.mobileActions}
</PageScaffold>
);
}
+96
查看文件
@@ -0,0 +1,96 @@
import type { ReactNode } from "react";
import type { ReimbursementDetail } from "../api/reimbursements";
import type { ReimbursementApprovalEvidenceProjection } from "./reimbursementApprovalProjection";
import { resolveReimbursementDisplayCode } from "./reimbursementDisplay";
export type ReimbursementWorkspaceHeaderModel = {
displayCode: string;
ownerName: string;
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
};
export type ReimbursementWorkspaceIssueAnchor = {
id: string;
label: string;
severity: "blocking" | "review" | "info";
};
export type ReimbursementWorkspaceModel = {
header: ReimbursementWorkspaceHeaderModel;
evidence: ReimbursementApprovalEvidenceProjection;
issueAnchors: ReimbursementWorkspaceIssueAnchor[];
};
export type ReimbursementWorkspaceSlots = {
status: ReactNode;
backAction?: ReactNode;
headerDetails?: ReactNode;
beforeEvidence?: ReactNode;
afterEvidence?: ReactNode;
timeline?: ReactNode;
actionPanel?: ReactNode;
mobileActions?: ReactNode;
transientFeedback?: ReactNode;
};
export function buildReimbursementWorkspaceHeader(
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id">,
): ReimbursementWorkspaceHeaderModel {
return {
displayCode: resolveReimbursementDisplayCode(detail),
ownerName: detail.owner?.name ?? "-",
reimbursementDate: detail.reimbursementDate,
totalAmount: detail.totalAmount,
description: detail.description,
};
}
export function buildReimbursementWorkspaceIssueAnchors(
evidence: ReimbursementApprovalEvidenceProjection,
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
): ReimbursementWorkspaceIssueAnchor[] {
const anchors: ReimbursementWorkspaceIssueAnchor[] = [];
if (options.snapshotInvalid) {
anchors.push({ id: "reimbursement-workspace-snapshot", label: "提交记录无法读取", severity: "blocking" });
} else if (options.snapshotMissing) {
anchors.push({ id: "reimbursement-workspace-snapshot", label: "未留存预算快照", severity: "info" });
}
if (evidence.currentCheck.unlinkedInvoiceCount) {
anchors.push({
id: "reimbursement-workspace-unlinked",
label: `${evidence.currentCheck.unlinkedInvoiceCount} 张票据尚未选择明细`,
severity: "blocking",
});
}
if (evidence.currentCheck.missingInvoiceItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.missingInvoiceItemCount} 条明细缺少票据`,
severity: "review",
});
}
if (evidence.currentCheck.amountUnavailableItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.amountUnavailableItemCount} 条明细的票面金额未能读取`,
severity: "blocking",
});
}
if (evidence.currentCheck.manualReviewItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.manualReviewItemCount} 条明细需查看原件核对金额`,
severity: "review",
});
}
if (evidence.currentCheck.amountExceededItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.amountExceededItemCount} 条明细申报额高于票面金额`,
severity: "blocking",
});
}
return anchors;
}
+18 -193
查看文件
@@ -1,19 +1,14 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
fetchReimbursements,
type ReimbursementListItem,
} from "../api/reimbursements";
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
import { isApiError } from "../api/types";
import {
approveWorkflowTask,
@@ -29,8 +24,6 @@ import {
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
@@ -46,13 +39,6 @@ import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../lib/reimbursementApprovalProjection";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { cn } from "../lib/utils";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
import {
@@ -362,14 +348,15 @@ function toEmployeeProgressCards(input: {
return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8);
}
export function MobileApprovalsPage() {
export function MobileApprovalsPage({ preloadedTaskDetail = null }: { preloadedTaskDetail?: WorkflowTaskDetail | null } = {}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const routeParams = useParams();
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
const legacyQueryTaskId = routeParams.taskId ? "" : searchParams.get("taskId")?.trim() || "";
const deepLinkTaskId = routeParams.taskId?.trim() || legacyQueryTaskId;
const isPushDetail = Boolean(deepLinkTaskId);
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
@@ -469,7 +456,7 @@ export function MobileApprovalsPage() {
});
const detailQuery = useQuery({
enabled: Boolean(selectedTaskId) && canUseRemote,
enabled: Boolean(selectedTaskId) && !legacyQueryTaskId && canUseRemote && !preloadedTaskDetail,
queryKey: ["mobile", "workflow", "task-detail", selectedTaskId],
queryFn: () => fetchWorkflowTaskDetail(selectedTaskId),
});
@@ -560,56 +547,10 @@ export function MobileApprovalsPage() {
[filteredTaskCards, selectedTaskId],
);
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const mobileReimbursementId = selectedTask?.reimbursement?.id ?? "";
const reimbursementDetailQuery = useQuery({
enabled: Boolean(mobileReimbursementId) && canUseRemote,
queryKey: ["reimbursements", mobileReimbursementId],
queryFn: () => fetchReimbursementDetail(mobileReimbursementId),
});
const reimbursementEvidence = useMemo(() => {
const snapshot = selectedTask?.workflowInstance?.contextData;
if (!reimbursementDetailQuery.data || !snapshot) return null;
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot);
}, [reimbursementDetailQuery.data, selectedTask?.workflowInstance?.contextData]);
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openInvoiceMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const result = await fetchInvoiceSignedUrl(invoiceId);
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
if (!openedInWecom) openFileInBrowser(result.signedUrl);
},
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开发票失败,请重试")),
});
const openAttachmentMutation = useMutation({
mutationFn: async ({ attachment, reason }: {
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
reason?: string;
}) => {
if (isWecomWebView()) {
const result = await fetchReimbursementAttachmentPreviewUrl(mobileReimbursementId, attachment.id, reason);
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
return null;
}
return downloadReimbursementAttachment(mobileReimbursementId, attachment.id, reason);
},
onSuccess: (result) => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开附件失败,请重试")),
});
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(mobileReimbursementId, reason),
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开完整材料失败,可逐份查看原件")),
});
const selectedTask = preloadedTaskDetail ?? detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
const selectedDetailReference = selectedDisplayReference ??
publicReference(selectedTask?.reimbursement?.formNumber) ??
publicReference(selectedTask?.purchaseRequest?.requestNo) ??
publicReference(selectedTask?.travelRequest?.requestNo);
const selectedDetailTitle = selectedTask
@@ -620,24 +561,18 @@ export function MobileApprovalsPage() {
})
: "";
const selectedApplicantName = selectedTaskCard?.applicantName ??
selectedTask?.reimbursement?.submitter?.name ??
selectedTask?.leaveRequest?.owner?.name ??
selectedTask?.attendanceCorrection?.owner?.name ??
selectedTask?.purchaseRequest?.applicant?.name ??
selectedTask?.travelRequest?.applicant?.name ??
null;
const selectedMetric = selectedTaskCard?.metric ??
selectedTask?.reimbursement?.totalAmount ??
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
(selectedTask?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ??
selectedTask?.purchaseRequest?.totalAmount ??
selectedTask?.travelRequest?.estimatedAmount ??
null;
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
const isSelectedReimbursement = selectedTask?.workflowInstance?.entityType === "reimbursement";
const reimbursementDecisionReady = !isSelectedReimbursement || Boolean(
canUseRemote && reimbursementEvidence && selectedTask?.workflowInstance?.contextData.parseStatus === "ready",
);
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
const employeeProgressCards = useMemo(
() =>
@@ -666,10 +601,6 @@ export function MobileApprovalsPage() {
});
function requestMobileWorkflowAction(action: WorkflowConfirmAction) {
if (isSelectedReimbursement && !reimbursementDecisionReady) {
setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。");
return;
}
if (!canUseRemote && action === "return") {
setError("当前离线不支持退回,请联网后再处理。");
return;
@@ -689,31 +620,6 @@ export function MobileApprovalsPage() {
setConfirmAction(action);
}
function appendReturnReference(reference: string) {
if (!reference) return;
setComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
setShowSecondaryActions(true);
setError("");
}
async function handleOpenAttachment(attachmentId: string) {
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
toast.error("附件信息已变化,请刷新后重试");
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) return;
openAttachmentMutation.mutate({ attachment, reason });
}
async function handleOpenAllMaterials() {
const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
async function refreshDraftQueueCache() {
if (!ownerEmployeeId) {
return [];
@@ -737,9 +643,6 @@ export function MobileApprovalsPage() {
if (!selectedTask) {
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (selectedTask.workflowInstance?.entityType === "reimbursement") {
throw new Error("报销审批不能保存离线草稿,请联网核对完整内容后处理");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前审批任务不允许该动作");
}
@@ -782,9 +685,6 @@ export function MobileApprovalsPage() {
if (!selectedTask) {
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (selectedTask.workflowInstance?.entityType === "reimbursement" && !reimbursementDecisionReady) {
throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前审批任务不允许该动作");
}
@@ -944,14 +844,16 @@ export function MobileApprovalsPage() {
: "最近 30 天已处理审批用于复核,不支持再次审批。";
const selectedActionText = selectedTask
? selectedTaskAllowsActions
? isSelectedReimbursement && !reimbursementDecisionReady
? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。"
: canUseRemote
? canUseRemote
? "核对摘要和历史后,可在底部通过、驳回或退回。"
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
: ""
: "当前任务已处理,本页只展示业务摘要和处理历史。"
: "";
if (legacyQueryTaskId) {
return <Navigate replace state={{ from: currentMobileSource }} to={`/mobile/approvals/${encodeURIComponent(legacyQueryTaskId)}`} />;
}
return (
<>
<PageScaffold
@@ -982,9 +884,7 @@ export function MobileApprovalsPage() {
{!isOnline && canAccessRemoteApprovals ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}
{isSelectedReimbursement
? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。"
: "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"}
仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
) : null}
@@ -1216,7 +1116,7 @@ export function MobileApprovalsPage() {
<>
<div className="mobile-subcard bg-white text-sm" data-testid="mobile-approvals-detail-focus">
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 leading-6 text-zinc-700">{selectedActionText}</div>
{selectedActionText ? <div className="mt-1 leading-6 text-zinc-700">{selectedActionText}</div> : null}
</div>
<div className="mobile-list-card text-sm">
@@ -1261,77 +1161,6 @@ export function MobileApprovalsPage() {
</div>
) : null}
</div>
{selectedTask.reimbursement ? (
<div className="mt-3 space-y-3">
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
</div>
<Button asChild className="w-full" variant="secondary">
<Link
data-testid="mobile-approval-reimbursement-detail-link"
state={{ from: currentMobileSource }}
to={`/mobile/reimbursements/${encodeURIComponent(selectedTask.reimbursement.id)}`}
>
查看完整报销单
</Link>
</Button>
{!canUseRemote ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-800">
当前只显示允许离线保存的摘要。请联网加载完整明细和票据后再处理该报销审批。
</p>
) : reimbursementDetailQuery.isLoading ? (
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
正在加载完整报销内容;加载完成前所有审批动作均不可用。
</p>
) : reimbursementDetailQuery.isError ? (
<div className="space-y-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
<p>{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}</p>
<Button className="min-h-11" onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button>
</div>
) : null}
{selectedTask.workflowInstance?.contextData.parseStatus === "invalid" ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。
</p>
) : null}
{reimbursementEvidence ? (
<>
{selectedTask.task.status !== "pending" ? (
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
当前版本可能包含申请人在退回后所做的修改;历史审批意见和提交时检查仍对应当时版本。
</p>
) : null}
<ReimbursementItemEvidenceList
evidence={reimbursementEvidence}
capabilities={{
onOpenInvoice: (invoice) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id),
onOpenMaterialBundle: () => void handleOpenAllMaterials(),
onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
}}
/>
<div className="grid gap-2" data-testid="mobile-reimbursement-approval-checks">
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
<p className="font-medium text-zinc-900">提交时检查</p>
{selectedTask.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (
<p className="mt-1 text-amber-800">该次提交未记录预算快照。</p>
) : selectedTask.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (
<p className="mt-1 text-zinc-600">提交时未匹配适用预算。</p>
) : selectedTask.workflowInstance?.contextData.budgetCheck ? (
<p className="mt-1">预算 {selectedTask.workflowInstance.contextData.budgetCheck.budgetCode},提交后使用率 {selectedTask.workflowInstance.contextData.budgetCheck.usagePercent}%</p>
) : null}
<p className="mt-1">费用标准提示 {selectedTask.workflowInstance?.contextData.standardViolations.length ?? 0} 条</p>
</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
<p className="font-medium text-zinc-900">当前票据检查</p>
<p className="mt-1">票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张;需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条。</p>
</div>
</div>
</>
) : null}
</div>
) : null}
{selectedTask.leaveRequest ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
@@ -1393,7 +1222,6 @@ export function MobileApprovalsPage() {
<Textarea
id="mobile-approvals-comment"
data-testid="mobile-approvals-comment"
disabled={isSelectedReimbursement && !reimbursementDecisionReady}
onChange={(event) => setComment(event.target.value)}
placeholder="意见(驳回/退回必填)"
rows={3}
@@ -1402,9 +1230,7 @@ export function MobileApprovalsPage() {
{!canUseRemote ? (
<p className="text-xs text-zinc-500">
{isSelectedReimbursement
? "报销审批离线时不能保存审批草稿,请联网核对完整内容。"
: "离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。"}
离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。
</p>
) : null}
@@ -1415,7 +1241,7 @@ export function MobileApprovalsPage() {
<Button
className="min-h-11 w-full"
data-testid="mobile-approvals-return"
disabled={isActionSubmitting || !reimbursementDecisionReady || !canUseRemote || !selectedTask.allowedActions.includes("return")}
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
onClick={() => requestMobileWorkflowAction("return")}
type="button"
variant="secondary"
@@ -1425,7 +1251,7 @@ export function MobileApprovalsPage() {
<Button
className="min-h-11 w-full"
data-testid="mobile-approvals-reject"
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("reject")}
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
onClick={() => requestMobileWorkflowAction("reject")}
type="button"
variant="secondary"
@@ -1443,7 +1269,7 @@ export function MobileApprovalsPage() {
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve"
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("approve")}
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
onClick={() => requestMobileWorkflowAction("approve")}
type="button"
>
@@ -1648,7 +1474,6 @@ export function MobileApprovalsPage() {
}
}}
/>
{downloadReasonDialog}
</>
);
}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
@@ -33,24 +32,21 @@ import {
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { hasPermission } from "../auth/permissions";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { isTravelReimbursement } from "../lib/reimbursementTravel";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ReimbursementMobileWorkspaceShell } from "../components/reimbursements/ReimbursementMobileWorkspaceShell";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -63,17 +59,17 @@ import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
import { formatMoney } from "../lib/displayFormat";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
@@ -84,9 +80,6 @@ import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardIssueCount,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
@@ -160,12 +153,6 @@ function isEditableStatus(status: ReimbursementStatus | undefined) {
return status === "draft" || status === "rejected";
}
function itemLabel(item: ReimbursementItem) {
const typeName = item.expenseType?.name ?? "费用";
const desc = item.description?.trim();
return desc ? `${typeName} · ${desc}` : typeName;
}
export function MobileReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
@@ -178,7 +165,6 @@ export function MobileReimbursementDetailPage() {
const [itemError, setItemError] = useState("");
const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
@@ -194,7 +180,6 @@ export function MobileReimbursementDetailPage() {
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
@@ -216,21 +201,12 @@ export function MobileReimbursementDetailPage() {
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
const canEdit = isEditableStatus(detail?.status);
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean(
detail?.status === "reviewing" &&
pendingTaskId &&
isParticipant &&
detail?.pendingWorkflow?.task?.assignee_id !== me?.id,
);
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
const canManageAttachments = canMaintainReimbursement;
const selectedInvoiceEntries = Object.entries(selectedInvoices);
const reimbursementPrecheckQuery = useQuery({
@@ -444,21 +420,6 @@ export function MobileReimbursementDetailPage() {
},
});
const remindMutation = useMutation({
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
invalidateDetail(),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("已催办当前审批人");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
const results: Array<{ invoiceId: string; success: boolean; message?: string }> = [];
@@ -575,11 +536,6 @@ export function MobileReimbursementDetailPage() {
variables.invoiceId,
variables.linkedItemId,
));
setInvoiceItemSelections((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
setInvoiceItemLinkErrors((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
@@ -808,7 +764,6 @@ export function MobileReimbursementDetailPage() {
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
@@ -821,8 +776,7 @@ export function MobileReimbursementDetailPage() {
},
onError: (error) => {
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
toast.error(message);
},
});
@@ -913,14 +867,6 @@ export function MobileReimbursementDetailPage() {
});
};
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "未关联";
}
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
return item ? itemLabel(item) : "无法识别";
};
const expenseTypes = expenseTypesQuery.data?.items ?? [];
if (!reimbursementId) {
@@ -954,617 +900,87 @@ export function MobileReimbursementDetailPage() {
);
}
const displayCode = resolveReimbursementDisplayCode(detail);
const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
const showStickyActions = showSubmitAction;
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
return (
<PageScaffold
className={cn(
"space-y-4",
showStickyActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "pb-6",
)}
data-testid="mobile-reimbursement-detail"
>
{downloadReasonDialog}
<MobileDetailHeader
title={displayCode}
subtitle="报销详情"
fallbackTo={reimbursementFallback(detail.status)}
status={
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
{STATUS_LABEL[detail.status]}
</span>
}
testId="mobile-reimbursement-detail-header"
/>
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-reimbursement-detail-focus">
<div className="mobile-section-header mobile-section-header--top">
<div>
<h2 className="mobile-section-title">报销概况</h2>
{!isParticipant ? (
<p className="mt-1 text-xs text-zinc-500">
只读查看
{detail.status === "draft"
? " · 等待报销人完善"
: detail.status === "rejected"
? " · 等待报销人修改后重提"
: ""}
</p>
) : null}
</div>
</div>
<div data-testid="mobile-reimbursement-summary">
<div className="text-xs text-zinc-500">合计金额</div>
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatMoney(detail.totalAmount)}</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">报销日期</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{formatDate(detail.reimbursementDate)}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">报销人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">部门</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.department?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
</div>
</div>
<ReimbursementHeaderEditor
reimbursementId={reimbursementId}
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEdit && isParticipant)}
mobile
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
embedded
/>
{detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm text-zinc-600">
{detail.project ? <p>项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
{detail.linkedTravelRequest ? (
<Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}>
差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
) : null}
{detail.riskFlags.length ? (
<div className="mt-4 border-t border-zinc-200 pt-4">
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
</div>
) : null}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ProcessingProgress
progress={detail.processingProgress ?? { stages: [] }}
ariaLabel="报销处理进度"
testId="mobile-reimbursement-processing-progress"
targetLinkLabel="查看所在付款批次"
mobile
/>
</div>
</section>
{invoiceGuardIssues ? (
<section
className="mobile-card border-red-300 bg-red-50"
data-testid="mobile-reimbursement-invoice-guard-summary"
role="alert"
aria-live="assertive"
>
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
<p className="mt-2 text-sm leading-6 text-red-700">
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
</p>
</section>
) : null}
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
standardViolations: (reimbursementPrecheckQuery.data?.standardChecks ?? []).filter((item) => item.exceeded),
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
if (sourceAttachment) void handleOpenAttachment(sourceAttachment);
},
onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined,
onEditItem: canMaintainReimbursement ? (item: { id: string }) => {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined,
onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName }) : undefined,
};
const workspaceHeaderDetails = (
<>
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} mobile embedded />
{detail.project ? <p className="text-sm text-zinc-600">项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
</>
);
const workspaceBeforeEvidence = (
<>
{invoiceGuardIssues ? <section className="mobile-card border-red-300 bg-red-50" role="alert" aria-live="assertive"><h2 className="text-base font-semibold text-red-800">票据检查未通过</h2><p className="mt-2 text-sm leading-6 text-red-700">请按明细和票据中的提示处理后再次提交。</p></section> : null}
{actionError ? <p className="mobile-card border-red-200 bg-red-50 text-sm text-red-700" data-testid="reimbursement-action-error" role="alert" aria-live="assertive">{actionError}</p> : null}
{itemError ? <p className="mobile-card border-red-200 bg-red-50 text-sm text-red-700" role="alert">{itemError}</p> : null}
{canMaintainReimbursement ? (
<Button size="sm" variant="secondary" type="button" onClick={startCreateItem}>
新增
</Button>
) : null}
<section className="mobile-card space-y-3">
<div className="mobile-section-header"><h2 className="mobile-section-title">维护报销内容</h2></div>
<div className="grid grid-cols-2 gap-2">
<Button className="min-h-11" onClick={startCreateItem} type="button" variant="secondary">新增明细</Button>
<Button className="min-h-11" data-testid={TEST_IDS.reimbursement.invoiceAdd} onClick={() => closeInvoiceDialog(true)} type="button" variant="secondary">添加发票</Button>
<Button className="min-h-11" onClick={() => attachmentInputRef.current?.click()} type="button" variant="secondary">选择附件</Button>
<Button className="min-h-11" disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button>
</div>
{itemError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{itemError}</div>
) : null}
<input ref={attachmentInputRef} className="sr-only" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files=Array.from(event.target.files ?? []); if(files.length>MAX_REIMBURSEMENT_ATTACHMENT_FILES){ setSelectedAttachmentFiles([]); event.target.value=""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return;} setSelectedAttachmentFiles(files); }} />
{itemForm ? (
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
<div className="mobile-info-grid">
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>费用类型</span>
<select
className="select-std"
disabled={!expenseTypes.length || itemSaveMutation.isPending}
value={itemForm.expenseTypeId}
onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}
>
{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}
{expenseTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? (
<span className="text-xs text-zinc-500">
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description}
</span>
) : null}
</label>
<label className="block space-y-1 text-sm text-zinc-700">
<span>日期</span>
<Input
disabled={itemSaveMutation.isPending}
type="date"
value={itemForm.expenseDate}
onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)}
/>
</label>
<label className="block space-y-1 text-sm text-zinc-700">
<span>金额</span>
<Input
disabled={itemSaveMutation.isPending}
inputMode="decimal"
placeholder="0.00"
value={itemForm.amount}
onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)}
/>
</label>
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>说明</span>
<Textarea
disabled={itemSaveMutation.isPending}
rows={2}
value={itemForm.description}
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
/>
</label>
{shouldShowOfficeElectronicsHint(
expenseTypes.find((type) => type.id === itemForm.expenseTypeId),
itemForm.amount,
) ? (
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
) : null}
<div className="space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
<label className="block space-y-1 text-sm text-zinc-700"><span>费用类型</span><select className="select-std w-full" disabled={!expenseTypes.length || itemSaveMutation.isPending} value={itemForm.expenseTypeId} onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}>{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}{expenseTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>日期</span><Input disabled={itemSaveMutation.isPending} type="date" value={itemForm.expenseDate} onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button></div>
</div>
<div className="mobile-two-action-grid">
<Button
type="button"
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
onClick={() => itemSaveMutation.mutate(itemForm)}
>
{itemSaveMutation.isPending ? "保存中..." : "保存"}
</Button>
<Button type="button" variant="secondary" disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)}>
取消
</Button>
</div>
</div>
) : null}
<div className="mt-3 space-y-2">
{detail.items.length ? (
detail.items.map((item) => (
<article
key={item.id}
className={cn(
"mobile-list-card",
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.expenseDate)}</div>
</div>
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
</div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{shouldShowOfficeElectronicsHint(item.expenseType, item.amount) ? (
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
) : null}
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
<p
key={message}
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{canMaintainReimbursement ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
编辑
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={itemDeleteMutation.isPending}
onClick={() => setConfirmDeleteItemId(item.id)}
>
删除
</Button>
</div>
) : null}
</article>
))
) : (
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无明细
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-3 text-sm font-medium text-zinc-900">
<span>共 {detail.items.length} 条明细</span>
<span>报销合计 {formatMoney(detail.totalAmount)}</span>
</div>
<ReimbursementOperationalHints
precheck={reimbursementPrecheckQuery.data}
items={detail.items}
canAccessAssets={canAccessAssets}
mobile
/>
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-materials">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">报销材料</h2>
<p className="mt-1 text-xs text-zinc-500">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
</p>
</div>
</div>
{detail.invoiceAmountManualReviewRequired ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
本单包含票面金额未自动读取的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
</p>
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
<Button
className="min-h-12 w-full"
data-testid="mobile-reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
</div>
) : null}
{materialPreviewError ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{canOpenInvoiceDialog || canManageAttachments ? (
<div className="mobile-two-action-grid mt-3">
{canOpenInvoiceDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
size="sm"
variant="secondary"
type="button"
onClick={() => closeInvoiceDialog(true)}
>
添加发票
</Button>
) : null}
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => attachmentInputRef.current?.click()}
>
添加附件
</Button>
) : null}
</div>
) : null}
{invoiceActionError ? (
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{invoiceActionError}
</p>
) : null}
{canManageAttachments ? (
<div className="mt-3 space-y-3">
<input
ref={attachmentInputRef}
className="sr-only"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
setSelectedAttachmentFiles([]);
event.target.value = "";
toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
return;
}
setSelectedAttachmentFiles(files);
}}
/>
{selectedAttachmentFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{selectedAttachmentFiles.slice(0, 4).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedAttachmentFiles.length > 4 ? <div>另有 {selectedAttachmentFiles.length - 4} 个文件</div> : null}
</div>
) : null}
<Button
className="w-full"
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
</>
);
const workspaceAfterEvidence = (
<>
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
{canDeleteDraft ? <section className="mobile-card"><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button></section> : null}
</>
);
const mobileActions = showSubmitAction ? <MobileStickyActionBar testId="mobile-reimbursement-sticky-actions"><Button className="min-h-12 w-full" data-testid={TEST_IDS.reimbursement.submit} disabled={submitMutation.isPending} onClick={() => void handleSubmit()} type="button">{submitMutation.isPending ? "提交中..." : "提交审批"}</Button></MobileStickyActionBar> : null;
return (
<article
key={invoice.id}
className={cn(
"mobile-list-card",
guardMessages.length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
tabIndex={guardMessages.length ? -1 : undefined}
>
<button
type="button"
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看发票原件:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<span className="min-w-0 flex-1">
<span className="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500">
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
</span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
</span>
<span className="shrink-0 text-right">
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
</span>
</button>
{guardMessages.map((message) => (
<p
key={message}
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{invoice.amountEvidenceStatus === "manual_review" ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
票面金额未自动识别,需由财务查看原件核对。
</p>
) : null}
{invoice.amountEvidenceStatus === "ocr_failed" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。
</p>
) : null}
{canMaintainReimbursement && !invoice.linkedItemId ? (
<div className="mt-3 space-y-2">
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
选择报销明细
</label>
<select
id={`mobile-invoice-item-link-${invoice.id}`}
className="select-std min-h-11 w-full"
value={invoiceItemSelections[invoice.id] ?? ""}
onChange={(event) => setInvoiceItemSelections((previous) => ({
...previous,
[invoice.id]: event.target.value,
}))}
>
<option value="">请选择明细</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
</option>
))}
</select>
<Button
className="min-h-11 w-full"
type="button"
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
onClick={() => linkInvoiceItemMutation.mutate({
invoiceId: invoice.id,
linkedItemId: invoiceItemSelections[invoice.id],
})}
>
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
</Button>
{invoiceItemLinkErrors[invoice.id] ? (
<p className="text-sm leading-5 text-red-700" role="alert">
{invoiceItemLinkErrors[invoice.id]}
</p>
) : null}
</div>
) : null}
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</article>
);
})}
</div>
<div data-testid="mobile-reimbursement-attachments" className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<article key={attachment.id} className="mobile-list-card">
<button
type="button"
className="flex min-h-11 w-full items-center gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openAttachmentMutation.isPending}
aria-label={`查看补充附件:${attachment.originalName}`}
onClick={() => void handleOpenAttachment(attachment)}
>
<span className="min-w-0 flex-1">
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</span>
<span className="mt-1 block text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</span>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canManageAttachments ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
>
删除
</Button>
) : null}
</article>
))}
</div>
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无报销材料
</div>
)}
</section>
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
{actionError ? (
<div
className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
role="alert"
aria-live="assertive"
>
{actionError}
</div>
) : null}
<div className="mt-3 grid gap-2">
{canDeleteDraft ? (
<Button
className="w-full"
disabled={deleteReimbursementMutation.isPending}
type="button"
variant="destructive"
onClick={() => setConfirmDeleteReimbursement(true)}
>
删除草稿
</Button>
) : null}
</div>
</section>
) : null}
{showStickyActions ? (
<MobileStickyActionBar testId="mobile-reimbursement-sticky-actions">
{showSubmitAction ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
data-testid={TEST_IDS.reimbursement.submit}
disabled={submitMutation.isPending}
type="button"
onClick={() => void handleSubmit()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
</MobileStickyActionBar>
) : null}
<>
{downloadReasonDialog}
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
<DialogContent
className="overflow-y-auto p-4"
@@ -1898,7 +1314,7 @@ export function MobileReimbursementDetailPage() {
}
}}
/>
</PageScaffold>
</>
);
}
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchWorkflowTaskDetail } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { Button } from "../components/ui/button";
import { getCachedWorkflowTaskDetail, cacheWorkflowTaskDetail } from "../mobile/workflowActionQueue";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { MobileApprovalsPage } from "./MobileApprovalsPage";
import { ReimbursementApprovalAdapter } from "./reimbursement-workspace/ReimbursementApprovalAdapter";
export function MobileWorkflowTaskRoutePage() {
const { taskId = "" } = useParams();
const { accessToken, me } = useAuth();
const queryClient = useQueryClient();
const [online, setOnline] = useState(() => typeof navigator === "undefined" || navigator.onLine);
const ownerEmployeeId = me?.id ?? null;
const canUseRemote = online && Boolean(accessToken);
useEffect(() => {
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
const detailQuery = useQuery({
enabled: Boolean(taskId) && canUseRemote,
queryKey: ["mobile", "workflow", "task-detail", taskId],
queryFn: () => fetchWorkflowTaskDetail(taskId),
});
const cachedDetailQuery = useQuery({
enabled: Boolean(taskId) && Boolean(ownerEmployeeId),
queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId],
queryFn: () => getCachedWorkflowTaskDetail(taskId, ownerEmployeeId),
});
useEffect(() => {
if (!detailQuery.data || !ownerEmployeeId) return;
void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(() =>
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId] }),
);
}, [detailQuery.data, ownerEmployeeId, queryClient, taskId]);
const detail = detailQuery.data ?? cachedDetailQuery.data?.detail ?? null;
if (!taskId) return <p className="mobile-card text-sm text-red-700">缺少审批任务参数。</p>;
if (!detail && (detailQuery.isLoading || cachedDetailQuery.isLoading)) return <div className="mobile-card h-40 animate-pulse bg-zinc-100" />;
if (!detail) {
return (
<div className="mobile-card space-y-3">
<p className="text-sm text-red-700">{canUseRemote ? resolveUserErrorMessage(detailQuery.error, "审批任务加载失败,请返回列表重试。") : "当前离线且没有该任务的安全缓存,请联网后重试。"}</p>
<Button asChild variant="secondary"><a href="/mobile/approvals">返回审批列表</a></Button>
</div>
);
}
if (detail.workflowInstance?.entityType === "reimbursement") {
return <ReimbursementApprovalAdapter detail={detail} layout="mobile" online={canUseRemote} refetchTask={detailQuery.refetch} taskId={taskId} />;
}
return <MobileApprovalsPage preloadedTaskDetail={detail} />;
}
+69 -702
查看文件
@@ -2,10 +2,9 @@ import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveEr
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ChevronRight } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import { fetchProjects } from "../api/projects";
import {
createReimbursementItem,
deleteReimbursementAttachment,
@@ -21,7 +20,6 @@ import {
updateReimbursementItem,
uploadReimbursementAttachments,
withdrawReimbursement,
linkReimbursementProject,
linkReimbursementInvoiceItem,
type ReimbursementItem,
} from "../api/reimbursements";
@@ -38,17 +36,16 @@ import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ReimbursementDesktopWorkspaceShell } from "../components/reimbursements/ReimbursementDesktopWorkspaceShell";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -60,21 +57,12 @@ import {
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { BusinessDetailActionPanel } from "../components/patterns/BusinessDetailTemplate";
import { SectionPanel } from "../components/patterns/SectionPanel";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
@@ -94,12 +82,11 @@ import {
type ItemEditorInitialValues,
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
@@ -111,7 +98,6 @@ type SelectedInvoiceMap = Record<string, string>;
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
return resolveUserErrorMessage(error, fallbackMessage);
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const location = useLocation();
@@ -121,7 +107,6 @@ export function ReimbursementDetailPage() {
const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
@@ -139,7 +124,6 @@ export function ReimbursementDetailPage() {
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
@@ -172,22 +156,10 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const projectsQuery = useQuery({
enabled: Boolean(
detailQuery.data &&
me?.id &&
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
["draft", "rejected"].includes(detailQuery.data.status),
),
queryKey: ["projects", "reimbursement-link"],
queryFn: () => fetchProjects({ mine: true }),
});
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(
@@ -385,23 +357,6 @@ export function ReimbursementDetailPage() {
},
});
const projectLinkMutation = useMutation({
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["projects"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("已更新关联项目");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "关联项目失败");
toast.error(message);
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
if (!reimbursementId) {
@@ -544,11 +499,6 @@ export function ReimbursementDetailPage() {
variables.invoiceId,
variables.linkedItemId,
));
setInvoiceItemSelections((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
setInvoiceItemLinkErrors((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
@@ -762,7 +712,6 @@ export function ReimbursementDetailPage() {
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
@@ -775,8 +724,7 @@ export function ReimbursementDetailPage() {
},
onError: (error) => {
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
toast.error(message);
},
});
@@ -837,10 +785,8 @@ export function ReimbursementDetailPage() {
isReimbursementParticipant;
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
const submitInvoiceNotice = canMaintainReimbursement && requiresAnyInvoice && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) {
@@ -981,19 +927,6 @@ export function ReimbursementDetailPage() {
);
};
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "未关联";
}
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
if (!linkedItem) {
return "无法识别";
}
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
};
const itemCount = detail.items.length;
const invoiceCount = detail.invoices.length;
const hasBlockingPrecheck = blockStandardChecks.length > 0;
@@ -1001,9 +934,6 @@ export function ReimbursementDetailPage() {
const currentProcessingStage = processingProgress.stages.find((stage) =>
stage.state === "current" || stage.state === "exception",
);
const currentHandlerText = currentProcessingStage
? `${currentProcessingStage.label}${currentProcessingStage.actorName ? ` · ${currentProcessingStage.actorName}` : ""}`
: detail.status === "paid" ? "已完成" : "待处理";
const nextStepText = (() => {
if (["draft", "rejected"].includes(detail.status)) {
if (!isReimbursementParticipant) {
@@ -1031,7 +961,6 @@ export function ReimbursementDetailPage() {
}
return "查看记录";
})();
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
const primaryAction = (() => {
if (canMaintainReimbursement) {
if (!itemCount) {
@@ -1105,636 +1034,74 @@ export function ReimbursementDetailPage() {
) : null,
];
return (
<PageScaffold>
{downloadReasonDialog}
<div>
<Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost">
<Link replace to={backSource}>
<ArrowLeft aria-hidden="true" className="size-4" />
{backLabel}
</Link>
</Button>
</div>
<EntityOverviewHero
title={`报销详情 ${displayCode}`}
meta={(
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
standardViolations: precheckStandardChecks.filter((item) => item.exceeded),
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
if (sourceAttachment) void handleOpenAttachment(sourceAttachment);
},
onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined,
onEditItem: canMaintainReimbursement ? (item: { id: string }) => {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined,
onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) =>
setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName }) : undefined,
};
const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? (
<SectionPanel title="补充信息">
{canMaintainReimbursement ? (
<ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable showDescription={!isTravelReimbursement(detail)} />
) : null}
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} embedded />
{detail.project ? <p className="mt-3 text-sm text-zinc-600">关联项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
{detail.linkedTravelRequest ? <Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={"/travel-requests?tab=requests&id=" + detail.linkedTravelRequest.id}>差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}</Link> : null}
</SectionPanel>
) : null;
const workspaceBeforeEvidence = (
<>
<StatusBadge status={detail.status} />
{!isReimbursementParticipant ? (
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">只读查看</span>
{invoiceGuardIssues ? <div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3" role="alert" aria-live="assertive"><p className="font-medium text-red-900">票据检查未通过</p><p className="mt-1 text-sm text-red-700">请按明细和票据中的提示处理后再次提交。</p></div> : null}
{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="reimbursement-action-error" role="alert">{actionError}</p> : null}
{itemError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{itemError}</p> : null}
{canMaintainReimbursement ? (
<SectionPanel title="维护报销内容" actions={<div className="flex flex-wrap gap-2"><Button data-testid="reimbursement-item-add" disabled={itemSaveMutation.isPending} onClick={startCreateItem} type="button" variant="secondary">新增明细</Button><Button data-testid={TEST_IDS.reimbursement.invoiceAdd} disabled={!canOpenInvoiceBindDialog} onClick={() => handleInvoiceDialogOpenChange(true)} type="button" variant="secondary">添加发票</Button><label className="inline-flex min-h-11 cursor-pointer items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-700" htmlFor="reimbursement-attachment-upload">选择附件</label><input className="hidden" id="reimbursement-attachment-upload" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} /><Button disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button></div>}>
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</SectionPanel>
) : null}
</>
)}
/>
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailMain>
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div>
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div data-testid="reimbursement-business-summary">
<div className="detail-fact-grid xl:grid-cols-3" data-testid="reimbursement-summary">
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销总额</div>
<div className="detail-fact-card__value">{formatMoney(detail.totalAmount)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前处理</div>
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">部门</div>
<div className="detail-fact-card__value">
{detail.department?.name ?? "-"}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销日期</div>
<div className="detail-fact-card__value">{detail.reimbursementDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">提交人 / 报销人</div>
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">关联项目</div>
<div className="detail-fact-card__value">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</div>
</div>
</div>
<ReimbursementHeaderEditor
reimbursementId={reimbursementId}
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEditItems && isReimbursementParticipant)}
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
embedded
/>
{canMaintainReimbursement || detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-medium text-zinc-900">关联事项</span>
{detail.linkedTravelRequest ? (
<Link to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`} className="text-primary hover:underline">
差旅申请 {detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
{canMaintainReimbursement ? (
<select
className="select-std mt-3 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
aria-label="关联项目"
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>
))}
</select>
) : detail.project ? (
<Link className="mt-2 inline-block text-primary hover:underline" to={`/projects/${detail.project.id}`}>
项目 {detail.project.projectNo} · {detail.project.name}
</Link>
) : null}
</div>
) : null}
</div>
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid="reimbursement-action-error"
role="alert"
aria-live="assertive"
>
{actionError}
</p>
) : null}
{submitInvoiceNotice ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
{submitInvoiceNotice}
</p>
) : null}
{hasBlockingPrecheck ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
请修改超标明细后提交。
</p>
) : null}
{showReviewHints ? (
<div className="space-y-3" data-testid="reimbursement-review-hints">
{budgetHint ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%
{budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"}
</p>
) : null}
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
) : null}
</div>
</SectionPanel>
<SectionPanel title="处理进度">
<ProcessingProgress
progress={processingProgress}
ariaLabel="报销处理进度"
testId="reimbursement-workflow-progress"
targetLinkLabel="查看所在付款批次"
/>
</SectionPanel>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
{canMaintainReimbursement ? (
<div className="flex justify-end">
<Button
data-testid="reimbursement-item-add"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
variant="secondary"
>
新增明细
</Button>
</div>
) : null}
{itemError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid="reimbursement-item-error"
>
{itemError}
</p>
) : null}
{reimbursementPrecheckQuery.isLoading && canMaintainReimbursement ? (
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
) : null}
<div className="ops-table-focus overflow-x-auto border border-zinc-200">
<Table data-testid="reimbursement-items-table">
<TableHeader>
<tr>
<TableHead>费用类型</TableHead>
<TableHead>发生日期</TableHead>
<TableHead>金额</TableHead>
<TableHead>说明</TableHead>
<TableHead className="w-44">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{detail.items.length ? (
detail.items.map((item) => (
<TableRow
data-testid="reimbursement-item-row"
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
key={item.id}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
className={
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length
? "bg-red-50/70"
: blockStandardChecks.some((check) => check.itemId === item.id)
? "bg-red-50/70"
: warnStandardChecks.some((check) => check.itemId === item.id)
? "bg-amber-50/70"
: ""
}
>
<TableCell>
<div className="font-medium text-zinc-900">
{item.expenseType?.name ?? "未识别费用类型"}
</div>
</TableCell>
<TableCell>{item.expenseDate ?? "-"}</TableCell>
<TableCell>
<div>{formatMoney(item.amount)}</div>
{precheckStandardChecks
.filter((check) => check.itemId === item.id)
.map((check) => (
<div
key={check.standardId}
className={`mt-1 text-xs ${check.exceeded ? (check.controlMode === "block" ? "text-red-600" : "text-amber-600") : "text-zinc-500"}`}
>
标准 {formatMoney(check.maxAmount)}/{check.perUnit}
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
</div>
))}
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
<div
key={message}
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2 py-1.5 text-xs leading-5 text-red-700"
role="alert"
>
{message}
</div>
))}
</TableCell>
<TableCell>
<div>{item.description ?? "-"}</div>
{shouldShowOfficeElectronicsHint(item.expenseType, item.amount) ? (
<div className="mt-1 text-xs text-amber-600">
该金额通常应选择办公电子设备。
</div>
) : null}
{precheckStandardChecks
.filter((check) => check.itemId === item.id && check.exceeded)
.map((check) => (
<div
key={`${check.standardId}-warning`}
className={`mt-1 text-xs ${check.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}
>
{check.standardName}:当前金额超出费用标准。
</div>
))}
</TableCell>
<TableCell>
{canMaintainReimbursement ? (
<div className="flex min-w-36 items-center gap-2">
<Button
data-testid="reimbursement-item-edit"
className="min-w-16"
size="sm"
type="button"
variant="secondary"
onClick={() => startEditItem(item)}
>
编辑
</Button>
<Button
data-testid="reimbursement-item-delete"
className="min-w-16"
size="sm"
type="button"
variant="secondary"
disabled={itemDeleteMutation.isPending}
onClick={() => setConfirmDeleteItemId(item.id)}
>
删除
</Button>
</div>
) : null}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={5}>
暂无明细
</TableCell>
</TableRow>
)}
</TableBody>
<tfoot className="border-t border-zinc-200 bg-zinc-50 font-medium text-zinc-900">
<tr>
<TableCell colSpan={2}>共 {detail.items.length} 条明细</TableCell>
<TableCell>{formatMoney(detail.totalAmount)}</TableCell>
<TableCell colSpan={2}>报销合计</TableCell>
</tr>
</tfoot>
</Table>
</div>
<ReimbursementOperationalHints
precheck={reimbursementPrecheckQuery.data}
items={detail.items}
canAccessAssets={canAccessAssets}
/>
{itemEditor ? (
<ReimbursementItemEditor
mode={itemEditor.mode}
expenseTypes={expenseTypes}
expenseTypesLoading={expenseTypesQuery.isLoading}
expenseTypesError={expenseTypesQuery.isError}
departmentId={detail.departmentId}
initialValues={editorInitialValues}
onSave={handleItemSave}
onCancel={handleItemCancel}
isPending={itemSaveMutation.isPending}
/>
) : null}
</div>
</div>
</SectionPanel>
<SectionPanel title="报销材料">
<div className="space-y-4 text-sm" data-testid="reimbursement-materials">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-zinc-600">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
<span className="mt-1 block text-xs text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</span>
</p>
<div className="flex flex-wrap gap-2">
{detail.invoices.length || (detail.attachments ?? []).length ? (
<Button
data-testid="reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
) : null}
{canOpenInvoiceBindDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
type="button"
variant="secondary"
onClick={() => handleInvoiceDialogOpenChange(true)}
>
添加发票
</Button>
) : null}
</div>
</div>
{materialPreviewError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{invoiceActionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid={TEST_IDS.reimbursement.invoiceActionError}
>
{invoiceActionError}
</p>
) : null}
{detail.invoiceAmountManualReviewRequired ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
本单包含票面金额未自动读取的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
</p>
) : null}
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
return (
<div
className={`detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center ${guardMessages.length ? "border-red-300 bg-red-50/60" : ""}`}
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
key={invoice.id}
tabIndex={guardMessages.length ? -1 : undefined}
>
<button
type="button"
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看发票原件:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<span className="inline-flex shrink-0 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500">
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
</span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
</span>
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{(guardMessages.length
|| invoice.amountEvidenceStatus === "manual_review"
|| invoice.amountEvidenceStatus === "ocr_failed"
|| canUnbindInvoices) ? (
<div className="w-full space-y-2 md:w-72">
{guardMessages.map((message) => (
<p
key={message}
className="rounded-md border border-red-200 bg-white px-3 py-2 text-xs leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{invoice.amountEvidenceStatus === "manual_review" ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
票面金额未自动识别,需由财务查看原件核对。
</p>
) : null}
{invoice.amountEvidenceStatus === "ocr_failed" ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs leading-5 text-red-700">
该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。
</p>
) : null}
{canMaintainReimbursement && !invoice.linkedItemId ? (
<div className="space-y-2">
<label className="block text-xs font-medium text-zinc-700" htmlFor={`invoice-item-link-${invoice.id}`}>
选择报销明细
</label>
<select
id={`invoice-item-link-${invoice.id}`}
className="select-std w-full"
value={invoiceItemSelections[invoice.id] ?? ""}
onChange={(event) => setInvoiceItemSelections((previous) => ({
...previous,
[invoice.id]: event.target.value,
}))}
>
<option value="">请选择明细</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
</option>
))}
</select>
<Button
className="w-full"
type="button"
size="sm"
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
onClick={() => linkInvoiceItemMutation.mutate({
invoiceId: invoice.id,
linkedItemId: invoiceItemSelections[invoice.id],
})}
>
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
</Button>
{invoiceItemLinkErrors[invoice.id] ? (
<p className="text-xs leading-5 text-red-700" role="alert">
{invoiceItemLinkErrors[invoice.id]}
</p>
) : null}
</div>
) : null}
{canUnbindInvoices ? (
<Button
className="w-full"
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</div>
) : null}
</div>
const workspaceAfterEvidence = (
<>
{(budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <SectionPanel title="需要关注">{budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</SectionPanel> : null}
<SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" /></SectionPanel>
</>
);
})}
</div>
<div className="space-y-4" data-testid="reimbursement-attachments">
{canMaintainReimbursement ? (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<label
htmlFor="reimbursement-attachment-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedAttachmentFiles.length
? selectedAttachmentFiles.length === 1
? selectedAttachmentFiles[0].name
: `已选择 ${selectedAttachmentFiles.length} 个文件`
: "选择附件"}
</label>
<input
id="reimbursement-attachment-upload"
className="hidden"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
setSelectedAttachmentFiles([]);
event.target.value = "";
toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
return;
}
setSelectedAttachmentFiles(files);
}}
/>
<Button
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
</div>
) : null}
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
<button
type="button"
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openAttachmentMutation.isPending}
aria-label={`查看补充附件:${attachment.originalName}`}
onClick={() => void handleOpenAttachment(attachment)}
>
<span className="inline-flex shrink-0 rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-zinc-900">{attachment.originalName}</span>
<span className="mt-1 block text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</span>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canMaintainReimbursement ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
) : null}
</div>
))}
</div>
) : detail.invoices.length ? null : (
<p className="text-sm text-zinc-500">暂无报销材料</p>
)}
</div>
</div>
</SectionPanel>
</BusinessDetailMain>
<BusinessDetailAside>
<BusinessDetailActionPanel
status={<StatusBadge status={detail.status} />}
primaryAction={primaryAction}
secondaryActions={secondaryActions}
moreActions={showSecondaryDetail ? (
<div className="grid gap-2" data-testid="reimbursement-secondary-detail">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
) : null}
testId="reimbursement-action-panel"
>
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
showWithdrawAction={isReimbursementParticipant}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
/>
const workspaceActionPanel = (
<BusinessDetailActionPanel status={<StatusBadge status={detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
);
return (
<>
{downloadReasonDialog}
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent
className="max-h-[85vh] max-w-3xl overflow-y-auto"
@@ -2005,6 +1372,6 @@ export function ReimbursementDetailPage() {
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
}}
/>
</PageScaffold>
</>
);
}
+58 -762
查看文件
@@ -1,79 +1,38 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
fetchWorkflowTaskDetail,
rejectWorkflowTask,
returnWorkflowTask,
transferWorkflowTask,
} from "../api/workflow";
import { toast } from "sonner";
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
} from "../api/reimbursements";
import { isApiError } from "../api/types";
import type { WorkflowTaskDetail } from "../api/workflow";
import { approveWorkflowTask, fetchWorkflowApprovers, rejectWorkflowTask, returnWorkflowTask, transferWorkflowTask } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
import { StatusBadge } from "../components/common/StatusBadge";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { BusinessDetailActionPanel, BusinessDetailAside, BusinessDetailMain, BusinessDetailWorkspace } from "../components/patterns/BusinessDetailTemplate";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { StatStrip } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { StatStrip } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { approvalRoleLabel, resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../lib/reimbursementApprovalProjection";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import {
buildPcDetailSource,
resolvePcDetailSource,
type PcDetailNavigationState,
} from "../lib/pcDetailNavigation";
import { buildPcDetailSource, resolvePcDetailSource, type PcDetailNavigationState } from "../lib/pcDetailNavigation";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
type Props = { detail: WorkflowTaskDetail; refetchTask: () => Promise<unknown>; taskId: string };
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
function entityTitle(entityType: string | undefined) {
if (entityType === "leave") {
return "请假申请";
}
if (entityType === "reimbursement") {
return "报销单";
}
if (entityType === "attendance_correction") {
return "考勤修正";
}
if (entityType === "purchase_request") {
return "采购申请";
}
if (entityType === "travel_request") {
return "差旅申请";
}
if (entityType === "leave") return "请假申请";
if (entityType === "attendance_correction") return "考勤修正";
if (entityType === "purchase_request") return "采购申请";
if (entityType === "travel_request") return "差旅申请";
return entityType || "业务单据";
}
@@ -85,48 +44,36 @@ function correctionKindLabel(kind: string | undefined | null) {
}
function workflowInstanceStatusLabel(status: string | undefined | null) {
if (!status) {
return "-";
}
const labels: Record<string, string> = {
pending: "审批中",
active: "审批中",
approved: "已通过",
rejected: "已驳回",
terminated: "已终止",
cancelled: "已取消",
completed: "已完成",
};
return labels[status] ?? status;
const labels: Record<string, string> = { pending: "审批中", active: "审批中", approved: "已通过", rejected: "已驳回", terminated: "已终止", cancelled: "已取消", completed: "已完成" };
return status ? labels[status] ?? status : "-";
}
function DetailField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="detail-fact-card">
<dt className="detail-fact-card__label">{label}</dt>
<dd className="detail-fact-card__value">{value || "-"}</dd>
</div>
);
return <div className="detail-fact-card"><dt className="detail-fact-card__label">{label}</dt><dd className="detail-fact-card__value">{value || "-"}</dd></div>;
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
function BusinessFacts({ detail }: { detail: WorkflowTaskDetail }) {
return (
<dl className="detail-fact-grid" data-testid="workflow-task-summary">
{detail.leaveRequest ? <><DetailField label="假期类型" value={detail.leaveRequest.leaveType?.name ?? "-"} /><DetailField label="申请人" value={detail.leaveRequest.owner?.name ?? "-"} /><DetailField label="起止日期" value={`${detail.leaveRequest.startDate ?? "-"} → ${detail.leaveRequest.endDate ?? "-"}`} /><DetailField label="天数" value={`${detail.leaveRequest.requestedDays} 天`} /><DetailField label="说明" value={detail.leaveRequest.reason ?? "-"} /><DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "未上传"} /></> : null}
{detail.attendanceCorrection ? <><DetailField label="修正类型" value={correctionKindLabel(detail.attendanceCorrection.kind)} /><DetailField label="申请人" value={detail.attendanceCorrection.owner?.name ?? "-"} /><DetailField label="日期" value={detail.attendanceCorrection.workDate ?? "-"} /><DetailField label="时间" value={detail.attendanceCorrection.missedPunchAt ?? `${detail.attendanceCorrection.startAt ?? "-"} → ${detail.attendanceCorrection.endAt ?? "-"}`} /><DetailField label="地点" value={detail.attendanceCorrection.locationSummary ?? "-"} /><DetailField label="补偿口径" value={detail.attendanceCorrection.compensationPolicy ?? "-"} /><DetailField label="说明" value={detail.attendanceCorrection.reason ?? "-"} /></> : null}
{detail.purchaseRequest ? <><DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} /><DetailField label="标题" value={detail.purchaseRequest.title} /><DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} /><DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} /><DetailField label="公司" value={detail.purchaseRequest.legalEntity?.name ?? "-"} /><DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} /><DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} /><DetailField label="预计到货" value={detail.purchaseRequest.expectedDeliveryDate ?? "-"} /><DetailField label="说明" value={detail.purchaseRequest.reason ?? "-"} /></> : null}
{detail.travelRequest ? <><DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} /><DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} /><DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} /><DetailField label="公司" value={detail.travelRequest.legalEntity?.name ?? "-"} /><DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} /><DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} /><DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} /><DetailField label="出差目的" value={detail.travelRequest.purpose || "-"} /></> : null}
</dl>
);
}
export function WorkflowTaskDetailPage() {
const { taskId = "" } = useParams();
export function WorkflowTaskDetailPage({ detail, refetchTask, taskId }: Props) {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [rejectComment, setRejectComment] = useState("请补充必要说明后重提");
const [returnComment, setReturnComment] = useState("请补充必要信息并修正后重新提交");
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
const [rejectComment, setRejectComment] = useState("");
const [returnComment, setReturnComment] = useState("");
const [transferReason, setTransferReason] = useState("");
const [selectedApprover, setSelectedApprover] = useState("");
const [actionError, setActionError] = useState("");
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
@@ -135,705 +82,54 @@ export function WorkflowTaskDetailPage() {
const currentTaskSource = buildPcDetailSource(location.pathname, location.search);
const navigationState = location.state as PcDetailNavigationState | null;
const backSource = resolvePcDetailSource(navigationState?.from, currentTaskSource) ?? "/workflow/tasks";
const detailQuery = useQuery({
enabled: Boolean(taskId),
queryKey: ["workflow", "task", taskId],
queryFn: () => fetchWorkflowTaskDetail(taskId),
});
const reimbursementId = detailQuery.data?.reimbursement?.id ?? "";
const reimbursementDetailQuery = useQuery({
enabled: Boolean(reimbursementId),
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
});
const reimbursementSnapshot = detailQuery.data?.workflowInstance?.contextData ?? null;
const reimbursementEvidence = useMemo(() => {
if (!reimbursementDetailQuery.data || !reimbursementSnapshot) return null;
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, reimbursementSnapshot);
}, [reimbursementDetailQuery.data, reimbursementSnapshot]);
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openInvoiceMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const result = await fetchInvoiceSignedUrl(invoiceId);
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
if (!openedInWecom) openFileInBrowser(result.signedUrl);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开发票失败,请重试")),
});
const openAttachmentMutation = useMutation({
mutationFn: async ({ attachment, reason }: {
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
reason?: string;
}) => {
if (isWecomWebView()) {
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
return null;
}
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
},
onSuccess: (result) => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开附件失败,请重试")),
});
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")),
});
const approverQuery = useQuery({
enabled: hasPermission(permissions, "expense.transfer"),
queryKey: ["workflow", "approvers"],
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }),
});
const allowedActions = useMemo(() => {
return new Set(detailQuery.data?.allowedActions ?? []);
}, [detailQuery.data?.allowedActions]);
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
const approverQuery = useQuery({ enabled: canTransfer, queryKey: ["workflow", "approvers"], queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }) });
const mutation = useMutation({
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
if (!taskId) {
throw new Error("missing taskId");
}
if (
detailQuery.data?.workflowInstance?.entityType === "reimbursement" &&
(!reimbursementEvidence || detailQuery.data.workflowInstance.contextData.parseStatus !== "ready")
) {
throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
}
if (action === "approve") {
return approveWorkflowTask(taskId);
}
if (action === "reject") {
return rejectWorkflowTask(taskId, rejectComment.trim());
}
if (action === "return") {
return returnWorkflowTask(taskId, returnComment.trim());
}
if (!selectedApprover) {
throw new Error("请选择转审目标");
}
if (action === "approve") return approveWorkflowTask(taskId);
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
if (!selectedApprover) throw new Error("请选择转审目标");
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
},
onSuccess: async (_data, action) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
setActionError("");
setActiveSecondaryAction(null);
setTransferConfirmOpen(false);
const messages: Record<string, string> = { approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" };
toast.success(messages[action] ?? "操作成功");
await Promise.all([queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), refetchTask()]);
setActionError(""); setActiveSecondaryAction(null); setConfirmAction(null); setTransferConfirmOpen(false);
toast.success({ approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
}
const msg = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(msg);
toast.error(msg);
if (isAlreadyProcessedTaskError(error)) void refetchTask();
const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试"); setActionError(message); toast.error(message);
},
});
function requestAction(action: WorkflowConfirmAction) {
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
if ((action === "reject" || action === "return") && !comment.trim()) { setActionError(`${action === "reject" ? "驳回" : "退回"}必须填写意见`); return; }
setActionError(""); setConfirmAction(action);
}
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
function requestWorkflowAction(action: WorkflowConfirmAction) {
if (
detailQuery.data?.workflowInstance?.entityType === "reimbursement" &&
(!reimbursementEvidence || detailQuery.data.workflowInstance.contextData.parseStatus !== "ready")
) {
setActionError("完整报销内容或提交时检查尚未就绪,暂时不能处理;请重试加载后再决定。");
return;
}
if (action === "reject" && rejectComment.trim().length < 1) {
setActionError("驳回必须填写意见");
return;
}
if (action === "return" && returnComment.trim().length < 1) {
setActionError("退回必须填写意见");
return;
}
setActionError("");
setConfirmAction(action);
}
function requestWorkflowTransfer() {
if (!selectedApprover) {
setActionError("请选择转审目标");
return;
}
if (transferReason.trim().length < 10) {
setActionError("转审理由至少 10 个字");
return;
}
setActionError("");
setTransferConfirmOpen(true);
}
function appendReturnReference(reference: string) {
if (!reference) return;
setReturnComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
setActiveSecondaryAction("return");
setActionError("");
}
async function handleOpenAttachment(attachmentId: string) {
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
toast.error("附件信息已变化,请刷新后重试");
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) return;
openAttachmentMutation.mutate({ attachment, reason });
}
async function handleOpenAllMaterials() {
const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
if (!taskId) {
return (
<div className="card-std p-5">
<p className="text-sm text-red-600">缺少 taskId 参数。</p>
</div>
);
}
if (shouldPreferMobileApprovalExperience()) {
return <Navigate replace to={buildMobileApprovalsPath(taskId)} />;
}
if (detailQuery.isLoading) {
return <CardSkeleton lines={8} />;
}
if (detailQuery.isError || !detailQuery.data) {
const message = resolveUserErrorMessage(detailQuery.error, "加载失败,请返回列表重试");
return (
<div className="card-std space-y-3 p-5">
<p className="text-sm text-red-600">{message}</p>
<Button
variant="secondary"
onClick={() => navigate(backSource, { replace: true })}
type="button"
>
返回审批列表
</Button>
</div>
);
}
const detail = detailQuery.data;
const isReimbursementTask = detail.workflowInstance?.entityType === "reimbursement";
const reimbursementDecisionReady = !isReimbursementTask || Boolean(
reimbursementEvidence && detail.workflowInstance?.contextData.parseStatus === "ready",
);
const canApprove = allowedActions.has("approve");
const canReturn = allowedActions.has("return");
const canReject = allowedActions.has("reject");
const canTransfer = allowedActions.has("transfer");
const submissionConcernCount = isReimbursementTask
? (detail.workflowInstance?.contextData.standardViolations.length ?? 0) +
(detail.workflowInstance?.contextData.budgetCheck?.exceededThreshold ? 1 : 0)
: 0;
const currentEvidenceConcernCount = reimbursementEvidence
? reimbursementEvidence.currentCheck.unlinkedInvoiceCount +
reimbursementEvidence.currentCheck.missingInvoiceItemCount +
reimbursementEvidence.currentCheck.amountUnavailableItemCount +
reimbursementEvidence.currentCheck.manualReviewItemCount +
reimbursementEvidence.currentCheck.amountExceededItemCount
: 0;
const transferDisabledReason = !selectedApprover
? "请先选择目标审批人。"
: transferReason.trim().length < 10
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
: "转审理由已满足要求。";
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
const actionDisabled = mutation.isPending;
return (
<>
<PageScaffold>
<EntityOverviewHero
title="审批详情"
meta={(
<>
<StatusBadge status={detail.task.status} />
<span className="info-chip">业务状态:{businessStatusLabel}</span>
<span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
<span className="info-chip">节点:{detail.task.stepName}</span>
</>
)}
actions={(
<Button asChild variant="secondary" data-testid="workflow-task-back">
<Link replace to={backSource}>
返回审批列表
</Link>
</Button>
)}
/>
<EntityOverviewHero title="审批详情" meta={<><StatusBadge status={detail.task.status} /><span className="info-chip">业务状态:{businessStatusLabel}</span><span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span><span className="info-chip">节点:{detail.task.stepName}</span></>} actions={<Button asChild variant="secondary" data-testid="workflow-task-back"><Link replace to={backSource}>返回审批列表</Link></Button>} />
<BusinessDetailWorkspace>
<BusinessDetailMain>
<SectionPanel title="当前状态与审批对象">
<div className="space-y-4" data-testid="workflow-task-overview">
<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>} />
{allowedActionLabels ? (
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels}</span>} />
) : null}
<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} />
</StatStrip>
<dl className="detail-fact-grid" data-testid="workflow-task-summary">
{detail.reimbursement ? (
<>
<DetailField label="报销单号" value={detail.reimbursement.formNumber ?? "-"} />
<DetailField label="报销金额" value={detail.reimbursement.totalAmount ?? "-"} />
<DetailField label="提交人" value={detail.reimbursement.submitter?.name ?? "-"} />
<DetailField label="报销人" value={detail.reimbursement.owner?.name ?? "-"} />
<DetailField label="报销日期" value={detail.reimbursement.reimbursementDate ?? "-"} />
<DetailField label="报销事由" value={detail.reimbursement.description ?? "-"} />
</>
) : null}
{detail.leaveRequest ? (
<>
<DetailField label="假期类型" value={detail.leaveRequest.leaveType?.name ?? "-"} />
<DetailField label="申请人" value={detail.leaveRequest.owner?.name ?? "-"} />
<DetailField
label="时间段"
value={
<>
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
{" → "}
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
</>
}
/>
<DetailField label="请假天数" value={detail.leaveRequest.requestedDays} />
<DetailField label="说明" value={detail.leaveRequest.reason ?? "-"} />
<DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "-"} />
</>
) : null}
{detail.attendanceCorrection ? (
<>
<DetailField label="修正类型" value={correctionKindLabel(detail.attendanceCorrection.kind)} />
<DetailField label="申请人" value={detail.attendanceCorrection.owner?.name ?? "-"} />
<DetailField label="考勤日期" value={detail.attendanceCorrection.workDate ?? "-"} />
<DetailField label="状态" value={workflowInstanceStatusLabel(detail.attendanceCorrection.status)} />
<DetailField
label="时间"
value={
detail.attendanceCorrection.kind === "missed_punch"
? formatDisplayDateTime(detail.attendanceCorrection.missedPunchAt)
: `${formatDisplayDateTime(detail.attendanceCorrection.startAt)} → ${formatDisplayDateTime(detail.attendanceCorrection.endAt)}`
}
/>
<DetailField label="外出地点" value={detail.attendanceCorrection.locationSummary ?? "-"} />
<DetailField label="补偿口径" value={detail.attendanceCorrection.compensationPolicy ?? "-"} />
<DetailField label="说明" value={detail.attendanceCorrection.reason ?? "-"} />
</>
) : null}
{detail.purchaseRequest ? (
<>
<DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} />
<DetailField label="标题" value={detail.purchaseRequest.title || "-"} />
<DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} />
<DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} />
<DetailField label="公司" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} />
<DetailField label="单据分类" value={detail.purchaseRequest.documentCategory?.name ?? "-"} />
<DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} />
<DetailField label="预计到货" value={detail.purchaseRequest.expectedDeliveryDate ?? "-"} />
<DetailField label="说明" value={detail.purchaseRequest.reason ?? "-"} />
</>
) : null}
{detail.travelRequest ? (
<>
<DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} />
<DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} />
<DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} />
<DetailField label="公司" value={detail.travelRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} />
<DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} />
<DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} />
<DetailField label="出差目的" value={detail.travelRequest.purpose || "-"} />
<DetailField
label="行程段"
value={
detail.travelRequest.segments.length
? detail.travelRequest.segments
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
.join(";")
: "-"
}
/>
</>
) : null}
</dl>
{detail.reimbursement ? (
<Button asChild variant="secondary">
<Link
data-testid="workflow-task-reimbursement-detail-link"
state={{ from: currentTaskSource }}
to={`/reimbursements/${encodeURIComponent(detail.reimbursement.id)}`}
>
打开完整原始表单
</Link>
</Button>
) : null}
</div>
</SectionPanel>
{isReimbursementTask ? (
<SectionPanel title="报销明细与材料">
{detail.task.status !== "pending" ? (
<p className="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
当前展示报销单的最新版本,可能包含申请人在退回后所做的修改;审批意见和提交时检查仍保留历史语义。
</p>
) : null}
{reimbursementDetailQuery.isLoading ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-4 text-sm text-zinc-600">
正在加载完整报销内容和票据,请稍候;加载完成前不能作出审批决定。
</div>
) : null}
{reimbursementDetailQuery.isError ? (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700" role="alert">
<p>{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}</p>
<Button onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button>
</div>
) : null}
{detail.workflowInstance?.contextData.parseStatus === "invalid" ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700" role="alert">
提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对该次提交记录。
</p>
) : null}
{reimbursementEvidence ? (
<ReimbursementItemEvidenceList
evidence={reimbursementEvidence}
capabilities={{
onOpenInvoice: (invoice) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id),
onOpenMaterialBundle: () => void handleOpenAllMaterials(),
onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
}}
/>
) : null}
</SectionPanel>
) : null}
{isReimbursementTask ? (
<SectionPanel title="提交时检查与当前票据检查">
<div className="grid gap-3 md:grid-cols-2" data-testid="reimbursement-approval-checks">
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-700">
<h3 className="font-medium text-zinc-900">提交时检查</h3>
{detail.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (
<p className="mt-2 text-amber-800">该次提交未记录预算快照。</p>
) : detail.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (
<p className="mt-2 text-zinc-600">提交时未匹配适用预算。</p>
) : detail.workflowInstance?.contextData.budgetCheck ? (
<div className="mt-2 space-y-1">
<p>预算:{detail.workflowInstance.contextData.budgetCheck.budgetCode}</p>
<p>提交后使用率:{detail.workflowInstance.contextData.budgetCheck.usagePercent}%</p>
<p>{detail.workflowInstance.contextData.budgetCheck.exceededBudget ? "提交时已超出预算" : "提交时未超出预算"}</p>
</div>
) : null}
<p className="mt-2">费用标准提示:{detail.workflowInstance?.contextData.standardViolations.length ?? 0} 条</p>
</div>
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-700">
<h3 className="font-medium text-zinc-900">当前票据检查</h3>
{reimbursementEvidence ? (
<div className="mt-2 space-y-1">
<p>票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张</p>
<p>缺少票据的明细 {reimbursementEvidence.currentCheck.missingInvoiceItemCount} 条</p>
<p>金额未能读取 {reimbursementEvidence.currentCheck.amountUnavailableItemCount} 条,需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条</p>
</div>
) : <p className="mt-2 text-red-700">当前票据检查尚未就绪。</p>}
</div>
</div>
</SectionPanel>
) : null}
<SectionPanel title="处理历史">
<div className="detail-timeline">
{detail.history.length ? (
detail.history.map((item) => (
<div className="detail-timeline-item" key={item.id}>
<div className="font-medium text-zinc-900">
{workflowActionLabel(item.action)} · {item.actorName || item.actorId}
</div>
<div className="text-zinc-600">{item.comment || "-"}</div>
<div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
))
) : (
<p className="text-sm text-zinc-500">暂无历史记录</p>
)}
</div>
</SectionPanel>
<SectionPanel title="当前状态与审批对象"><div className="space-y-4" data-testid="workflow-task-overview"><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>} />{allowedActionLabels ? <MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels}</span>} /> : null}<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} /></StatStrip><BusinessFacts detail={detail} /></div></SectionPanel>
<SectionPanel title="处理历史"><div className="detail-timeline">{detail.history.length ? detail.history.map((item) => <div className="detail-timeline-item" key={item.id}><div className="font-medium text-zinc-900">{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo}</div><div className="text-zinc-600">{item.comment || "-"}</div><div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div></div>) : <p className="text-sm text-zinc-500">暂无历史记录</p>}</div></SectionPanel>
</BusinessDetailMain>
<BusinessDetailAside>
<BusinessDetailActionPanel
title="处理"
className="workflow-action-panel"
primaryAction={canApprove ? (
<Button
data-testid="workflow-task-approve"
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => requestWorkflowAction("approve")}
type="button"
>
通过
</Button>
) : null}
secondaryActions={canReturn || canReject ? (
<>
{canReturn ? (
<Button
data-testid="workflow-task-return-toggle"
variant="secondary"
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
type="button"
>
退回
</Button>
) : null}
{canReject ? (
<Button
data-testid="workflow-task-reject-toggle"
variant="secondary"
disabled={mutation.isPending || !reimbursementDecisionReady}
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"
disabled={!reimbursementDecisionReady}
type="button"
onClick={() => setActiveSecondaryAction("transfer")}
>
转审
</button>
</details>
) : null}
summary={(
<>
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</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>
{isReimbursementTask ? (
<>
<div>报销合计:{detail.reimbursement?.totalAmount ?? "-"}</div>
<div>
内容核验:{reimbursementDecisionReady ? "完整内容已就绪" : "尚未就绪,所有审批动作已停用"}
</div>
{reimbursementEvidence ? (
<div>
检查摘要:提交时提示 {submissionConcernCount} 项;当前票据需关注 {currentEvidenceConcernCount} 项
</div>
) : null}
</>
) : null}
</>
)}
testId="workflow-task-action-panel"
>
{canReturn || canReject ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="workflow-action-consequence-help">
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要复制或重新发起。</div>
</div>
) : null}
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid="workflow-task-action-error"
>
{actionError}
</p>
) : null}
{activeSecondaryAction === "reject" ? (
<div className="detail-action-card space-y-2">
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-reject-comment">驳回意见</Label>
<p className="text-xs leading-5 text-zinc-500">驳回会终止本次流程或进入驳回态,申请人通常需要重新发起。</p>
<Textarea
id="workflow-task-reject-comment"
data-testid="workflow-task-reject-comment"
onChange={(event) => setRejectComment(event.target.value)}
rows={3}
value={rejectComment}
/>
<Button
data-testid="workflow-task-reject"
variant="secondary"
disabled={!allowedActions.has("reject") || mutation.isPending || !reimbursementDecisionReady || rejectComment.trim().length < 1}
onClick={() => requestWorkflowAction("reject")}
type="button"
>
确认驳回
</Button>
</div>
) : null}
{activeSecondaryAction === "return" ? (
<div className="detail-action-card space-y-2">
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-return-comment">退回意见</Label>
<p className="text-xs leading-5 text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea
id="workflow-task-return-comment"
data-testid="workflow-task-return-comment"
onChange={(event) => setReturnComment(event.target.value)}
rows={3}
value={returnComment}
/>
<Button
data-testid="workflow-task-return"
variant="secondary"
disabled={!allowedActions.has("return") || mutation.isPending || !reimbursementDecisionReady || returnComment.trim().length < 1}
onClick={() => requestWorkflowAction("return")}
type="button"
>
确认退回
</Button>
</div>
) : null}
{activeSecondaryAction === "transfer" && canTransfer ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">转审</p>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-approver">目标审批人</Label>
<select
id="workflow-task-transfer-approver"
className="select-std"
data-testid="workflow-task-transfer-approver"
onChange={(event) => setSelectedApprover(event.target.value)}
value={selectedApprover}
>
<option value="">请选择目标审批人</option>
{(approverQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-reason">转审理由</Label>
<Textarea
id="workflow-task-transfer-reason"
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
rows={3}
value={transferReason}
/>
<p
id="workflow-task-transfer-help"
className={transferReason.trim().length >= 10 && selectedApprover ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
data-testid="workflow-task-transfer-disabled-reason"
>
{transferDisabledReason}
</p>
<Button
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer"
variant="secondary"
disabled={
!allowedActions.has("transfer") ||
mutation.isPending ||
!reimbursementDecisionReady ||
!selectedApprover ||
transferReason.trim().length < 10
}
onClick={requestWorkflowTransfer}
type="button"
>
确认转审
</Button>
</div>
) : null}
<BusinessDetailActionPanel title="处理" primaryAction={allowedActions.has("approve") ? <Button data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null} secondaryActions={<>{allowedActions.has("return") ? <Button data-testid="workflow-task-return-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}{allowedActions.has("reject") ? <Button data-testid="workflow-task-reject-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}</>} moreActions={canTransfer ? <Button className="w-full" data-testid="workflow-task-transfer-menu-item" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null} summary={<><div>节点:{detail.task.stepName}</div><div>实例状态:{businessStatusLabel}</div><div>当前审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div></>} testId="workflow-task-action-panel">
<div className="space-y-3">{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{actionError}</p> : null}{activeSecondaryAction === "return" ? <div className="space-y-2"><Label htmlFor="workflow-task-return-comment">退回意见</Label><p className="text-xs text-zinc-500" data-testid="workflow-action-consequence-help">退回后原单可修改并重新提交,单号和历史会保留。</p><Textarea data-testid="workflow-task-return-comment" id="workflow-task-return-comment" value={returnComment} onChange={(event) => setReturnComment(event.target.value)} /><Button className="w-full" data-testid="workflow-task-return" disabled={!returnComment.trim() || actionDisabled} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button></div> : null}{activeSecondaryAction === "reject" ? <div className="space-y-2"><Label htmlFor="workflow-task-reject-comment">驳回意见</Label><p className="text-xs text-zinc-500" data-testid="workflow-action-consequence-help">驳回会终止本次流程,申请人通常需要重新发起。</p><Textarea data-testid="workflow-task-reject-comment" id="workflow-task-reject-comment" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /><Button className="w-full" data-testid="workflow-task-reject" disabled={!rejectComment.trim() || actionDisabled} onClick={() => requestAction("reject")} type="button" variant="secondary">确认驳回</Button></div> : null}{activeSecondaryAction === "transfer" ? <div className="space-y-2"><Label htmlFor="workflow-task-transfer-approver">目标审批人</Label><select className="select-std" data-testid="workflow-task-transfer-approver" id="workflow-task-transfer-approver" value={selectedApprover} onChange={(event) => setSelectedApprover(event.target.value)}><option value="">请选择目标审批人</option>{(approverQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name} ({item.employeeNo})</option>)}</select><Label htmlFor="workflow-task-transfer-reason">转审理由</Label><Textarea data-testid="workflow-task-transfer-reason" id="workflow-task-transfer-reason" value={transferReason} onChange={(event) => setTransferReason(event.target.value)} /><p className="text-xs text-zinc-500" data-testid="workflow-task-transfer-disabled-reason">{!selectedApprover ? "请先选择目标审批人。" : transferReason.trim().length < 10 ? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。` : "转审理由已满足要求。"}</p><Button className="w-full" data-testid="workflow-task-transfer" disabled={!selectedApprover || transferReason.trim().length < 10 || actionDisabled} onClick={() => setTransferConfirmOpen(true)} type="button" variant="secondary">确认转审</Button></div> : null}</div>
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
<ConfirmDialog
open={Boolean(confirmCopy)}
onOpenChange={(open) => {
if (!open) {
setConfirmAction(null);
}
}}
title={confirmCopy?.title ?? ""}
description={confirmCopy?.description}
confirmLabel={confirmCopy?.confirmLabel}
variant={confirmCopy?.variant}
testId="workflow-action-confirm-dialog"
confirmTestId="workflow-action-confirm-submit"
cancelTestId="workflow-action-confirm-cancel"
onConfirm={async () => {
if (!confirmAction) {
return;
}
await mutation.mutateAsync(confirmAction);
}}
/>
<ConfirmDialog
open={transferConfirmOpen}
onOpenChange={setTransferConfirmOpen}
title="确认转审审批?"
description={`事项:${entityTitle(detail.workflowInstance?.entityType)} · ${detail.task.stepName}。转审后当前任务会交给目标审批人处理,系统会写入审批历史。`}
confirmLabel="确认转审"
variant="default"
testId="workflow-transfer-confirm-dialog"
confirmTestId="workflow-transfer-confirm-submit"
cancelTestId="workflow-transfer-confirm-cancel"
onConfirm={async () => {
await mutation.mutateAsync("transfer");
}}
/>
{downloadReasonDialog}
<ConfirmDialog open={Boolean(confirmCopy)} onOpenChange={(open) => { if (!open) setConfirmAction(null); }} title={confirmCopy?.title ?? ""} description={confirmCopy?.description} confirmLabel={confirmCopy?.confirmLabel} variant={confirmCopy?.variant} testId="workflow-action-confirm-dialog" confirmTestId="workflow-action-confirm-submit" cancelTestId="workflow-action-confirm-cancel" onConfirm={async () => { if (confirmAction) await mutation.mutateAsync(confirmAction); }} />
<ConfirmDialog open={transferConfirmOpen} onOpenChange={setTransferConfirmOpen} title="确认转审审批?" description={`事项:${entityTitle(detail.workflowInstance?.entityType)} · ${detail.task.stepName}。转审后将交给目标审批人继续处理。`} confirmLabel="确认转审" testId="workflow-transfer-confirm-dialog" onConfirm={async () => { await mutation.mutateAsync("transfer"); }} />
</>
);
}
@@ -0,0 +1,35 @@
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchWorkflowTaskDetail } from "../api/workflow";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { Button } from "../components/ui/button";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
import { ReimbursementApprovalAdapter } from "./reimbursement-workspace/ReimbursementApprovalAdapter";
import { WorkflowTaskDetailPage } from "./WorkflowTaskDetailPage";
export function WorkflowTaskRoutePage() {
const { taskId = "" } = useParams();
const navigate = useNavigate();
const detailQuery = useQuery({
enabled: Boolean(taskId),
queryKey: ["workflow", "task", taskId],
queryFn: () => fetchWorkflowTaskDetail(taskId),
});
if (shouldPreferMobileApprovalExperience()) return <Navigate replace to={buildMobileApprovalsPath(taskId)} />;
if (!taskId) return <p className="card-std p-5 text-sm text-red-700">缺少审批任务参数。</p>;
if (detailQuery.isLoading) return <CardSkeleton lines={8} />;
if (detailQuery.isError || !detailQuery.data) {
return (
<div className="card-std space-y-3 p-5">
<p className="text-sm text-red-700">{resolveUserErrorMessage(detailQuery.error, "加载失败,请返回列表重试")}</p>
<Button type="button" variant="secondary" onClick={() => navigate("/workflow/tasks", { replace: true })}>返回审批列表</Button>
</div>
);
}
if (detailQuery.data.workflowInstance?.entityType === "reimbursement") {
return <ReimbursementApprovalAdapter detail={detailQuery.data} layout="desktop" refetchTask={detailQuery.refetch} taskId={taskId} />;
}
return <WorkflowTaskDetailPage detail={detailQuery.data} refetchTask={detailQuery.refetch} taskId={taskId} />;
}
@@ -0,0 +1,446 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation } from "react-router-dom";
import { toast } from "sonner";
import type { WorkflowTaskDetail } from "../../api/workflow";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
rejectWorkflowTask,
returnWorkflowTask,
transferWorkflowTask,
} from "../../api/workflow";
import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
} from "../../api/reimbursements";
import { fetchInvoiceSignedUrl } from "../../api/invoicePool";
import { isApiError } from "../../api/types";
import { useAuth } from "../../auth/AuthContext";
import { hasPermission } from "../../auth/permissions";
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
import { StatusBadge } from "../../components/common/StatusBadge";
import { useAttachmentDownloadReasonDialog } from "../../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementDesktopWorkspaceShell } from "../../components/reimbursements/ReimbursementDesktopWorkspaceShell";
import { ReimbursementMobileWorkspaceShell } from "../../components/reimbursements/ReimbursementMobileWorkspaceShell";
import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate";
import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar";
import { SectionPanel } from "../../components/patterns/SectionPanel";
import { Button } from "../../components/ui/button";
import { Label } from "../../components/ui/label";
import { Textarea } from "../../components/ui/textarea";
import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../../lib/reimbursementApprovalProjection";
import {
buildReimbursementWorkspaceHeader,
buildReimbursementWorkspaceIssueAnchors,
} from "../../lib/reimbursementWorkspace";
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { isWecomWebView } from "../../lib/runtimeContext";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
import { openFileInBrowser, previewFileInWecom } from "../../lib/wecomFilePreview";
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
type Props = {
detail: WorkflowTaskDetail;
layout: "desktop" | "mobile";
refetchTask: () => Promise<unknown>;
taskId: string;
online?: boolean;
};
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
function workflowInstanceStatusLabel(status: string | undefined | null) {
const labels: Record<string, string> = {
pending: "审批中",
active: "审批中",
reviewing: "审批中",
approved: "已通过",
rejected: "已驳回",
terminated: "已终止",
cancelled: "已取消",
completed: "已完成",
};
return status ? labels[status] ?? status : "-";
}
function workflowTaskStatusLabel(status: string) {
const labels: Record<string, string> = {
pending: "待处理",
completed: "已处理",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? "未知状态";
}
function resolveApprovalBackTarget(value: unknown, layout: "desktop" | "mobile") {
const fallback = layout === "mobile" ? "/mobile/approvals" : "/workflow/tasks";
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("#")) {
return fallback;
}
if (layout === "mobile") {
return value === "/mobile" || value.startsWith("/mobile/approvals?") ? value : fallback;
}
return value === "/workflow/tasks" || value.startsWith("/workflow/tasks?") ? value : fallback;
}
export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, taskId, online = true }: Props) {
const queryClient = useQueryClient();
const location = useLocation();
const { permissions } = useAuth();
const backTarget = resolveApprovalBackTarget((location.state as { from?: unknown } | null)?.from, layout);
const reimbursementId = detail.reimbursement?.id ?? "";
const [rejectComment, setRejectComment] = useState("");
const [returnComment, setReturnComment] = useState("");
const [transferReason, setTransferReason] = useState("");
const [selectedApprover, setSelectedApprover] = useState("");
const [actionError, setActionError] = useState("");
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
const [transferConfirmOpen, setTransferConfirmOpen] = useState(false);
const reimbursementDetailQuery = useQuery({
enabled: Boolean(reimbursementId) && online,
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
});
const snapshot = detail.workflowInstance?.contextData ?? null;
const evidence = useMemo(() => {
if (!reimbursementDetailQuery.data || !snapshot) return null;
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot);
}, [reimbursementDetailQuery.data, snapshot]);
const model = useMemo(() => {
if (!reimbursementDetailQuery.data || !evidence) return null;
return {
header: buildReimbursementWorkspaceHeader(reimbursementDetailQuery.data),
evidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
snapshotInvalid: snapshot?.parseStatus === "invalid",
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
}),
};
}, [evidence, reimbursementDetailQuery.data, snapshot?.budgetCheckStatus, snapshot?.parseStatus]);
const decisionReady = Boolean(online && model && snapshot?.parseStatus === "ready");
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
const approverQuery = useQuery({
enabled: canTransfer && online,
queryKey: ["workflow", "approvers"],
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }),
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openInvoiceMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const result = await fetchInvoiceSignedUrl(invoiceId);
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
if (!openedInWecom) openFileInBrowser(result.signedUrl);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开发票失败,请重试")),
});
const openAttachmentMutation = useMutation({
mutationFn: async ({ attachment, reason }: {
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
reason?: string;
}) => {
if (isWecomWebView()) {
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
return null;
}
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
},
onSuccess: (result) => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开附件失败,请重试")),
});
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")),
});
const actionMutation = useMutation({
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
if (!decisionReady) throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
if (action === "approve") return approveWorkflowTask(taskId);
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
if (!selectedApprover) throw new Error("请选择转审目标");
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
},
onSuccess: async (_data, action) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
refetchTask(),
]);
setActionError("");
setActiveSecondaryAction(null);
setConfirmAction(null);
setTransferConfirmOpen(false);
toast.success({ approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) void refetchTask();
const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(message);
toast.error(message);
},
});
function requestAction(action: WorkflowConfirmAction) {
if (!decisionReady) {
setActionError(online ? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。" : "报销审批需联网核对完整报销内容后才能处理。");
return;
}
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
if ((action === "reject" || action === "return") && !comment.trim()) {
setActionError(`${action === "reject" ? "驳回" : "退回"}必须填写意见`);
return;
}
setActionError("");
setConfirmAction(action);
}
function appendReturnReference(reference: string) {
if (!reference) return;
setReturnComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
setActiveSecondaryAction("return");
}
async function openAttachment(attachmentId: string) {
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
if (!attachment) return toast.error("附件信息已变化,请刷新后重试");
const reason = await requestDownloadReason(attachment);
if (reason !== null) openAttachmentMutation.mutate({ attachment, reason });
}
async function openAllMaterials() {
const governed = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
const reason = governed ? await requestDownloadReason(governed) : undefined;
if (reason !== null) materialPreviewMutation.mutate(reason);
}
if (!model) {
const message = !online
? "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。"
: snapshot?.parseStatus === "invalid"
? "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。"
: reimbursementDetailQuery.isError
? resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")
: "正在加载完整报销内容和票据;加载完成前不能作出审批决定。";
const disabledActions = (
<div className="grid grid-cols-2 gap-2" data-testid="reimbursement-approval-fail-closed-actions">
<Button data-testid={layout === "mobile" ? "mobile-approvals-approve" : "workflow-task-approve"} disabled type="button">通过</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-return" : "workflow-task-return-toggle"} disabled type="button" variant="secondary">退回</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-reject" : "workflow-task-reject-toggle"} disabled type="button" variant="secondary">驳回</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-transfer" : "workflow-task-transfer-menu-item"} disabled type="button" variant="secondary">转审</Button>
</div>
);
return (
<div className={layout === "mobile" ? "mobile-card space-y-3" : "card-std mx-auto max-w-3xl space-y-3 p-5"} role="alert">
<p className="text-sm text-zinc-700">{message}</p>
{disabledActions}
{online && reimbursementDetailQuery.isError ? <Button onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button> : null}
<Button asChild type="button" variant="secondary"><Link to={layout === "mobile" ? "/mobile/approvals" : "/workflow/tasks"}>返回审批列表</Link></Button>
</div>
);
}
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
const actionDisabled = actionMutation.isPending || !decisionReady;
const issueAnchors = model.issueAnchors.length ? (
<div className="space-y-2" data-testid="reimbursement-workspace-issue-anchors">
<p className="text-sm font-medium text-zinc-900">需要关注</p>
{model.issueAnchors.map((issue) => (
<a className="block text-sm text-zinc-700 underline-offset-4 hover:underline" href={`#${issue.id}`} key={`${issue.id}-${issue.label}`}>{issue.label}</a>
))}
</div>
) : null;
const statusSummary = (
<>
<div>节点:{detail.task.stepName}</div>
<div>状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
<div>审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div>
<div>{decisionReady ? "完整内容已就绪" : "内容未就绪,审批动作已停用"}</div>
</>
);
const offlineNotice = !online ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900" role="alert">
报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。
</p>
) : null;
const commentEditor = (
<>
{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{actionError}</p> : null}
{activeSecondaryAction === "return" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-return-comment`}>退回意见</Label>
<p className="text-xs text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-return-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-return-comment`} value={returnComment} onChange={(event) => setReturnComment(event.target.value)} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-return" : "mobile-approvals-return-submit"} disabled={actionDisabled || !returnComment.trim()} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button>
</div>
) : null}
{activeSecondaryAction === "reject" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-reject-comment`}>驳回意见</Label>
<p className="text-xs text-zinc-500">驳回会终止本次流程,申请人通常需要重新发起。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-reject-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-reject-comment`} value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-reject" : "mobile-approvals-reject-submit"} disabled={actionDisabled || !rejectComment.trim()} onClick={() => requestAction("reject")} type="button" variant="secondary">确认驳回</Button>
</div>
) : null}
{activeSecondaryAction === "transfer" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-transfer-approver`}>目标审批人</Label>
<select className="select-std min-h-11" data-testid={layout === "desktop" ? "workflow-task-transfer-approver" : "mobile-approvals-transfer-to"} id={`${layout}-reimbursement-transfer-approver`} value={selectedApprover} onChange={(event) => setSelectedApprover(event.target.value)}>
<option value="">请选择目标审批人</option>
{(approverQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name} ({item.employeeNo})</option>)}
</select>
<Label htmlFor={`${layout}-reimbursement-transfer-reason`}>转审理由</Label>
<Textarea data-testid={layout === "desktop" ? "workflow-task-transfer-reason" : "mobile-approvals-transfer-reason"} id={`${layout}-reimbursement-transfer-reason`} value={transferReason} onChange={(event) => setTransferReason(event.target.value)} />
<p className="text-xs text-zinc-500" data-testid={layout === "desktop" ? "workflow-task-transfer-disabled-reason" : "mobile-approvals-transfer-disabled-reason"}>{!selectedApprover ? "请先选择目标审批人。" : transferReason.trim().length < 10 ? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。` : "转审理由已满足要求。"}</p>
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-transfer" : "mobile-approvals-transfer-submit"} disabled={actionDisabled || !selectedApprover || transferReason.trim().length < 10} onClick={() => setTransferConfirmOpen(true)} type="button" variant="secondary">确认转审</Button>
</div>
) : null}
</>
);
const timeline = (
<SectionPanel title="处理历史">
<div className="detail-timeline">
{detail.history.length ? detail.history.map((item) => (
<div className="detail-timeline-item" key={item.id}>
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo}</div>
<div className="text-zinc-600">{item.comment || "-"}</div>
<div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
)) : <p className="text-sm text-zinc-500">暂无历史记录</p>}
</div>
</SectionPanel>
);
const hasMaterials = model.evidence.items.some((item) => item.invoices.length > 0)
|| model.evidence.unlinkedInvoices.length > 0
|| model.evidence.attachments.length > 0;
const capabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => void openAttachment(attachment.id),
onOpenMaterialBundle: hasMaterials ? () => void openAllMaterials() : undefined,
onReferenceItem: (item: Parameters<typeof buildReimbursementReturnReference>[0]["item"]) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice: Parameters<typeof buildReimbursementReturnReference>[0]["invoice"]) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
};
const dialogs = (
<>
<ConfirmDialog
open={Boolean(confirmCopy)}
onOpenChange={(open) => { if (!open) setConfirmAction(null); }}
title={confirmCopy?.title ?? ""}
description={confirmCopy?.description}
confirmLabel={confirmCopy?.confirmLabel}
variant={confirmCopy?.variant}
testId="workflow-action-confirm-dialog"
confirmTestId="workflow-action-confirm-submit"
cancelTestId="workflow-action-confirm-cancel"
onConfirm={async () => { if (confirmAction) await actionMutation.mutateAsync(confirmAction); }}
/>
<ConfirmDialog
open={transferConfirmOpen}
onOpenChange={setTransferConfirmOpen}
title="确认转审审批?"
description={`报销单 ${model.header.displayCode} 将交给所选审批人继续处理。`}
confirmLabel="确认转审"
testId="workflow-transfer-confirm-dialog"
onConfirm={async () => { await actionMutation.mutateAsync("transfer"); }}
/>
{downloadReasonDialog}
</>
);
if (layout === "mobile") {
const mobileActions = detail.task.status === "pending" ? (
<>
<section className="mobile-card space-y-3">
{offlineNotice}
{issueAnchors}
{commentEditor}
<div className="grid grid-cols-2 gap-2">
{allowedActions.has("return") || !online ? <Button className="min-h-11" data-testid="mobile-approvals-return" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}
{allowedActions.has("reject") || !online ? <Button className="min-h-11" data-testid="mobile-approvals-reject" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}
{canTransfer || !online ? <Button className="min-h-11" data-testid="mobile-approvals-transfer" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="secondary">转审</Button> : null}
</div>
</section>
<MobileStickyActionBar testId="mobile-approvals-sticky-actions">
<Button className="min-h-12 w-full" data-testid="mobile-approvals-approve" disabled={actionDisabled || !allowedActions.has("approve")} onClick={() => requestAction("approve")} type="button">通过</Button>
</MobileStickyActionBar>
</>
) : null;
return (
<>
<ReimbursementMobileWorkspaceShell
capabilities={capabilities}
fallbackTo={backTarget}
model={model}
slots={{
status: <StatusBadge label={workflowTaskStatusLabel(detail.task.status)} status={detail.task.status} />,
beforeEvidence: detail.task.status !== "pending" ? <p className="mobile-card text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="mobile-card text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
timeline,
mobileActions,
}}
/>
{dialogs}
</>
);
}
const actionPanel = (
<BusinessDetailActionPanel
title="审批决定"
primaryAction={allowedActions.has("approve") ? <Button data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null}
secondaryActions={(
<>
{allowedActions.has("return") ? <Button data-testid="workflow-task-return-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}
{allowedActions.has("reject") ? <Button data-testid="workflow-task-reject-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}
</>
)}
moreActions={canTransfer ? <Button className="w-full" data-testid="workflow-task-transfer-menu-item" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
summary={statusSummary}
testId="workflow-task-action-panel"
>
<div className="space-y-3">{issueAnchors}{commentEditor}</div>
</BusinessDetailActionPanel>
);
return (
<>
<ReimbursementDesktopWorkspaceShell
capabilities={capabilities}
model={model}
slots={{
status: <StatusBadge label={workflowTaskStatusLabel(detail.task.status)} status={detail.task.status} />,
backAction: <Button asChild variant="ghost"><Link replace to={backTarget}>返回审批列表</Link></Button>,
beforeEvidence: detail.task.status !== "pending" ? <p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
timeline,
actionPanel,
}}
/>
{dialogs}
</>
);
}
+4 -3
查看文件
@@ -34,6 +34,7 @@ import {
LeaveRequestPage,
MasterDataPage,
MobileApprovalsPage,
MobileWorkflowTaskRoutePage,
MobileAssetScanPage,
MobileCapturePage,
MobileLeavePage,
@@ -77,7 +78,7 @@ import {
WeeklyReportsPage,
WorkspaceSetupPage,
WorkflowDefinitionsPage,
WorkflowTaskDetailPage,
WorkflowTaskRoutePage,
WorkflowTasksPage,
} from "./lazy";
import { GuestOnly, RequireAuth, RequireBusinessApprover, RequirePermission } from "./guards";
@@ -373,7 +374,7 @@ const routeElements = (
path="workflow/tasks/:taskId"
element={
<RequireBusinessApprover>
{withRouteFallback(<WorkflowTaskDetailPage />)}
{withRouteFallback(<WorkflowTaskRoutePage />)}
</RequireBusinessApprover>
}
/>
@@ -535,7 +536,7 @@ const routeElements = (
"travel.approve",
]}
>
{withRouteFallback(<MobileApprovalsPage />)}
{withRouteFallback(<MobileWorkflowTaskRoutePage />)}
</RequirePermission>
}
/>
@@ -0,0 +1 @@
export { MobileWorkflowTaskRoutePage as default } from "../../pages/MobileWorkflowTaskRoutePage";
@@ -1 +0,0 @@
export { WorkflowTaskDetailPage as default } from "../../pages/WorkflowTaskDetailPage";
@@ -0,0 +1 @@
export { WorkflowTaskRoutePage as default } from "../../pages/WorkflowTaskRoutePage";
+2 -1
查看文件
@@ -17,6 +17,7 @@ export const InvoicePoolPage = lazy(() => import("./InvoicePoolPage"));
export const LeaveRequestPage = lazy(() => import("./LeaveRequestPage"));
export const MasterDataPage = lazy(() => import("./MasterDataPage"));
export const MobileApprovalsPage = lazy(() => import("./MobileApprovalsPage"));
export const MobileWorkflowTaskRoutePage = lazy(() => import("./MobileWorkflowTaskRoutePage"));
export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
export const MobileLeavePage = lazy(() => import("./MobileLeavePage"));
@@ -62,5 +63,5 @@ export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemp
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
export const WorkflowDefinitionsPage = lazy(() => import("./WorkflowDefinitionsPage"));
export const WorkflowTaskDetailPage = lazy(() => import("./WorkflowTaskDetailPage"));
export const WorkflowTaskRoutePage = lazy(() => import("./WorkflowTaskRoutePage"));
export const WorkflowTasksPage = lazy(() => import("./WorkflowTasksPage"));
@@ -330,7 +330,12 @@ function workflowTaskDetail(status: "pending" | "completed" = "pending") {
entityType: "reimbursement",
currentStatus: isCompleted ? "approved" : "reviewing",
currentStepId: isCompleted ? null : "step-dg7",
contextData: { standardViolations: [] },
contextData: {
standardViolations: [],
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
},
startedAt: "2026-05-12T08:00:00.000Z",
completedAt: isCompleted ? "2026-05-12T09:00:00.000Z" : null,
},
@@ -842,29 +847,6 @@ async function expectMobileDetailHeaderSpacing(page: Page, testId: string) {
expect(metrics!.gap).toBeGreaterThanOrEqual(12);
}
async function expectApprovalActionsStable(page: Page) {
const actions = await page.evaluate(() =>
Array.from(document.querySelectorAll<HTMLElement>(".mobile-approval-action-grid > button")).map((button) => {
const rect = button.getBoundingClientRect();
return {
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
};
}),
);
expect(actions).toHaveLength(3);
const firstTop = actions[0].top;
for (const action of actions) {
expect(Math.abs(action.top - firstTop)).toBeLessThanOrEqual(2);
expect(action.width).toBeGreaterThanOrEqual(64);
}
expect(actions[0].right).toBeLessThanOrEqual(actions[1].left + 2);
expect(actions[1].right).toBeLessThanOrEqual(actions[2].left + 2);
}
async function expectActionTileGapStable(page: Page) {
const gaps = await page.evaluate(() =>
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] .mobile-icon-action")).map((tile) => {
@@ -999,31 +981,24 @@ test.describe("MIF7 移动信息架构整改", () => {
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approvals-detail")).not.toContainText("业务单号");
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toContainText("报销单 BX20260512001");
await expect(mobile.page.getByTestId("mobile-approvals-history")).toContainText("提交 · 测试员工");
await expect(mobile.page.getByTestId("mobile-approvals-history")).toContainText("转审 · 测试审批人");
await expect(mobile.page.getByTestId("mobile-approvals-history")).not.toContainText("transferToEmployeeId");
await expectMobileApprovalStatusSlots(mobile.page);
await expectMobileDetailHeaderSpacing(mobile.page, "mobile-approvals-detail-header");
await expectApprovalActionsStable(mobile.page);
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).not.toContainText("业务单号");
await expect(mobile.page.getByTestId("mobile-reimbursement-detail-header")).toContainText("BX20260512001");
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toContainText("提交 · 测试员工");
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toContainText("转审 · 测试审批人");
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).not.toContainText("transferToEmployeeId");
await expectMobileDetailHeaderSpacing(mobile.page, "mobile-reimbursement-detail-header");
await expect(mobile.page.getByTestId("mobile-approvals-sticky-actions")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCSS("min-height", "48px");
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
await mobile.page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
await expect(mobile.page).toHaveURL(/\/mobile$/);
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
await mobile.page.getByTestId("mobile-approval-reimbursement-detail-link").click();
await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements\/reimbursement-dg7$/);
await expect(mobile.page.getByTestId("mobile-reimbursement-items")).toBeVisible();
await mobile.page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
await expectApprovalActionsStable(mobile.page);
await expect(mobile.page).toHaveURL(/\/mobile$/);
await mobile.page.goto("/mobile/approvals");
await expect(mobile.page.getByRole("tab", { name: "待我审批" })).toBeVisible();
@@ -1034,9 +1009,10 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-approvals-task-task-dg7-completed")).toBeVisible();
await mobile.page.getByTestId("mobile-approvals-task-task-dg7-completed").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7-completed$/);
await expect(mobile.page.getByTestId("mobile-approvals-detail-header")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await mobile.page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
await mobile.page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\?taskTab=completed$/);
await expect(mobile.page.getByRole("tab", { name: "已处理" })).toHaveAttribute("data-state", "active");
await mobile.page.screenshot({ path: screenshotPath("approver-task-tabs"), fullPage: true });
@@ -1130,8 +1106,8 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
const sectionOrder = await mobile.page.evaluate(() =>
[
"mobile-reimbursement-items",
"mobile-reimbursement-attachments",
"approval-item-evidence",
"approval-attachments",
"mobile-reimbursement-processing-progress",
].map((testId) => document.querySelector(`[data-testid='${testId}']`)?.getBoundingClientRect().top ?? -1),
);
@@ -53,18 +53,52 @@ test("R10 移动审批与移动报销复用同一底部动作容器", () => {
const sharedActionBar = source("src/components/patterns/MobileStickyActionBar.tsx");
const businessEditTemplate = source("src/components/patterns/BusinessEditTemplate.tsx");
const mobileApprovals = source("src/pages/MobileApprovalsPage.tsx");
const reimbursementApproval = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
const mobileReimbursement = source("src/pages/MobileReimbursementDetailPage.tsx");
const reimbursementMobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
expect(sharedActionBar).toContain("mobile-sticky-action-surface fixed inset-x-0 bottom-0");
expect(sharedActionBar).toContain("env(safe-area-inset-bottom,0px)");
expect(businessEditTemplate).toContain("MOBILE_STICKY_ACTION_PAGE_CLASS");
expect(businessEditTemplate).toContain("<MobileStickyActionBar");
expect(mobileApprovals).toContain("<MobileStickyActionBar");
expect(reimbursementApproval).toContain("<MobileStickyActionBar");
expect(mobileReimbursement).toContain("<MobileStickyActionBar");
expect(reimbursementMobileShell).toContain("MOBILE_STICKY_ACTION_PAGE_CLASS");
expect(reimbursementMobileShell).toContain("slots.mobileActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : undefined");
expect(mobileApprovals).not.toContain('className="mobile-sticky-action-surface fixed');
expect(reimbursementApproval).not.toContain('className="mobile-sticky-action-surface fixed');
expect(mobileReimbursement).not.toContain('className="mobile-sticky-action-surface fixed');
});
test("B-2b 报销工作台双端共享内容合同且 task detail 只由路由查询一次", () => {
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
const desktopRoute = source("src/pages/WorkflowTaskRoutePage.tsx");
const mobileRoute = source("src/pages/MobileWorkflowTaskRoutePage.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
const workflowDetail = source("src/pages/WorkflowTaskDetailPage.tsx");
const mobileApprovals = source("src/pages/MobileApprovalsPage.tsx");
for (const shell of [desktopShell, mobileShell]) {
expect(shell).toContain("ReimbursementWorkspaceModel");
expect(shell).toContain("ReimbursementWorkspaceSlots");
expect(shell).toContain("ReimbursementEvidenceCapabilities");
expect(shell).toContain("<ReimbursementItemEvidenceList");
expect(shell.indexOf("slots.beforeEvidence")).toBeLessThan(shell.indexOf("<ReimbursementItemEvidenceList"));
expect(shell.indexOf("<ReimbursementItemEvidenceList")).toBeLessThan(shell.indexOf("slots.afterEvidence"));
expect(shell.indexOf("slots.afterEvidence")).toBeLessThan(shell.indexOf("slots.timeline"));
}
expect(desktopRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(mobileRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(approvalAdapter).not.toContain("fetchWorkflowTaskDetail");
expect(workflowDetail).not.toContain("ReimbursementItemEvidenceList");
expect(workflowDetail).not.toContain("提交时检查");
expect(mobileApprovals).not.toContain("selectedTask?.reimbursement");
expect(mobileApprovals).not.toContain("reimbursementDecisionReady");
});
test("R1/R6 未知状态和个人事项状态不回落到技术枚举", () => {
const statusBadge = source("src/components/common/StatusBadge.tsx");
const todosPage = source("src/pages/TodosPage.tsx");
@@ -351,9 +351,9 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
}
await page.context().setOffline(true);
await expect(page.getByText("请联网加载完整明细和票据后再处理该报销审批")).toBeVisible();
await expect(page.getByText("报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
await page.getByTestId("mobile-approvals-more").click();
await expect(page.getByTestId("mobile-approvals-return")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-reject")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-transfer")).toBeDisabled();
});
@@ -221,7 +221,12 @@ const workflowDetail = {
entityType: "reimbursement",
currentStatus: "reviewing",
currentStepId: "manager-review",
contextData: { standardViolations: [] },
contextData: {
standardViolations: [],
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
},
startedAt: "2026-05-27T09:00:00.000Z",
completedAt: null,
},
@@ -1252,16 +1257,16 @@ test("已完成审批只保留中文只读摘要", async ({ page }) => {
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("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("审批角色:财务");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 18:10");
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("返回审批列表");
await expect(page.getByRole("link", { name: "返回审批列表" })).toBeVisible();
});
test("1280px 资产详情保持可用双栏和可点击操作区", async ({ page }) => {
@@ -1296,49 +1301,55 @@ test("1280px 资产详情保持可用双栏和可点击操作区", async ({ page
test("报销详情固定使用上海时区并收敛只读信息", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements/reim-wave3");
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-back")).toContainText("返回报销列表");
await expect(page.getByTestId("reimbursement-summary")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-items")).toContainText("¥860.00");
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const pcInvoicePreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const pcAttachmentPreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const pcInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
const pcMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
const pcAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
await pcInvoicePreview.focus();
await expect(pcInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcMaterialPreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcAttachmentPreview).toBeFocused();
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
await page.goto("/workflow/tasks/task-wave3");
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 17:00");
await expect(page.getByText(`提交 · ${visualMe.name}`)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/reimbursements/reim-wave3");
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText("¥1,860.00");
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("¥860.00");
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("负责人复核");
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const mobileInvoicePreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const mobileAttachmentPreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const mobileInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
const mobileMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
const mobileAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
await mobileInvoicePreview.focus();
await expect(mobileInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileMaterialPreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileAttachmentPreview).toBeFocused();
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
@@ -1351,15 +1362,13 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements/reim-wave3");
await expect(page.getByRole("heading", { name: /报销详情/ })).toBeVisible();
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: `报销单 ${reimbursementDetail.formNumber}` })).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-first-screen-actions")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-action-panel").getByRole("button", { name: "撤回" })).toBeVisible();
@@ -1372,7 +1381,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/workflow/tasks/task-wave3");
await page.setViewportSize({ width: 1100, height: 768 });
await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).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", /.+ .+/);
@@ -1383,12 +1392,11 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
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();
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
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");
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 17:00");
const workflowActionPanel = page.getByTestId("workflow-task-action-panel").locator("xpath=ancestor::section[1]");
await expect(workflowActionPanel).toHaveCSS("position", "sticky");
await expect(workflowActionPanel).toHaveCSS("top", "96px");
const workflowLayout = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const [main, aside] = Array.from(workspace.children) as HTMLElement[];
return {
@@ -1402,20 +1410,15 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
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 formNumberLineCount = await page.getByRole("heading", { name: `报销单 ${reimbursementDetail.formNumber}` }).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$/);
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-back")).toContainText("返回审批详情");
await page.getByTestId("reimbursement-back").click();
await expect(page).toHaveURL(/\/workflow\/tasks\/task-wave3$/);
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-task-reimbursement-detail-link")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
await page.goto("/notifications");
@@ -1458,20 +1461,18 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
});
test("PC 报销详情按来源返回并保留列表筛选", async ({ page }) => {
test("PC 报销审批只接受安全审批列表返回目标", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
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 page.getByTestId("workflow-task-back").click();
await expect(page).toHaveURL(/\/reimbursements\?status=reviewing&page=1&scope=all$/);
await expect(page.getByRole("link", { name: "返回审批列表" })).toBeVisible();
await page.getByRole("link", { name: "返回审批列表" }).click();
await expect(page).toHaveURL(/\/workflow\/tasks$/);
await page.goto("/workflow/tasks/task-wave3");
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
await expect(page.getByTestId("reimbursement-back")).toContainText("返回审批详情");
await page.getByTestId("reimbursement-back").click();
await expect(page).toHaveURL(/\/workflow\/tasks\/task-wave3$/);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("workflow-task-reimbursement-detail-link")).toHaveCount(0);
});
test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
@@ -1549,14 +1550,14 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("05-mobile-reimbursements.png"), fullPage: true });
await page.goto("/mobile/reimbursements/reim-wave3");
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toBeVisible();
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-items")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
@@ -1570,8 +1571,8 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("07-mobile-approvals.png"), fullPage: true });
await page.goto("/mobile/approvals/task-wave3");
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-detail-focus")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: screenshotPath("08-mobile-approval-detail.png"), fullPage: true });
@@ -105,6 +105,7 @@ function reimbursementDetail(overrides: { reimbursementDate?: string; descriptio
},
],
invoices: [],
attachments: [],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
@@ -376,11 +377,11 @@ test.describe("R102 reimbursement main flow", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
await expect(page.getByRole("heading", { name: `报销单 ${formNumber}` })).toBeVisible();
await expect(page.getByText("报销ID")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-business-summary")).toBeVisible();
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("approval-attachments")).toBeVisible();
await expect(page.getByText("执行支付")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
@@ -413,20 +414,20 @@ test.describe("R102 reimbursement main flow", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-summary")).toContainText("报销总额");
await expect(page.getByTestId("reimbursement-items-table")).toContainText("共 1 条明细");
await expect(page.getByTestId("reimbursement-items-table")).toContainText("报销合计");
await expect(page.getByTestId("reimbursement-operational-hints")).toContainText("待关联发票或补充无票说明");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("approval-item-evidence")).toContainText("明细 1");
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByTestId("approval-item-evidence")).toContainText("这条明细尚无对应票据");
await page.getByTestId("reimbursement-business-summary").getByRole("button", { name: "编辑", exact: true }).click();
await page.getByRole("button", { name: "编辑", exact: true }).click();
const editor = page.getByTestId("reimbursement-header-editor");
await editor.getByLabel("报销日期").fill("2026-05-07");
await editor.getByLabel("报销事由").fill("办公网络费用");
await editor.getByRole("button", { name: "保存", exact: true }).click();
await expect(page.getByTestId("reimbursement-header-editor")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-business-summary")).toContainText("办公网络费用");
await expect(page.getByTestId("reimbursement-summary")).toContainText("2026-05-07");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("办公网络费用");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月7日");
});
test("移动报销卡片显示业务编号", async ({ page }) => {
@@ -448,8 +449,8 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.locator("table:visible")).toHaveCount(0);
await page.getByTestId("mobile-reimbursement-card").getByRole("link").click();
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("共 1 条明细");
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("报销合计 ¥168.30");
await expect(page.getByTestId("reimbursement-approval-evidence").getByTestId("approval-item-evidence")).toHaveCount(1);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥168.30");
});
test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => {
@@ -526,15 +527,13 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page).toHaveURL(/\/portal/);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${otherReimbursementId}`);
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-withdraw")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-withdraw-comment")).toHaveCount(0);
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByText("等待报销人完善")).toBeVisible();
await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0);
await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("R102-OTHER");
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0);
await page.setViewportSize({ width: 375, height: 812 });
@@ -565,9 +564,9 @@ test.describe("R102 reimbursement main flow", () => {
await page.getByText("其他员工").click();
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-items").getByRole("button", { name: "新增" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /新增|编辑|删除/ })).toHaveCount(0);
await expect(page.getByTestId("approval-unlinked-invoices")).toBeVisible();
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0);
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
expect(routeState?.from).toContain("status=reviewing");
@@ -585,7 +584,7 @@ test.describe("R102 reimbursement main flow", () => {
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-item-edit").click();
await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑明细" }).click();
const addItem = page.getByTestId("reimbursement-item-add");
await addItem.click();
@@ -156,7 +156,7 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
await employee.page.screenshot({ path: screenshotPath("mobile-reimbursements") });
await card.getByRole("link").click();
await expect(employee.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}`));
await expect(employee.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(employee.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-reimbursement-detail") });
const detailMetrics = await collectMobileMetrics(employee.page);
@@ -131,7 +131,7 @@ test.describe.serial("R110 手机和 PC 路由分流", () => {
await mobile.page.goto(`/reimbursements/${seeded.id}`);
await expect(mobile.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}$`));
await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expectNoPcShell(mobile.page);
await mobile.page.goto(`/reimbursements/${seeded.id}/print`);
@@ -810,11 +810,8 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
);
await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click();
if (options.mobile) {
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 2 项需要处理");
} else {
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
}
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(confirmDialog).toHaveCount(0);
await expect(page.locator("div.fixed.inset-0[data-state='open']")).toHaveCount(0);
const firstGuardIssue = page.locator('[data-reimbursement-guard-issue="true"]').first();
@@ -827,7 +824,7 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
invoiceId: invoice.id,
linkedItemId: targetItemId,
}]);
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toHaveCount(0);
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toHaveCount(0);
await page.getByTestId("reimbursement-submit").click();
await expect(confirmDialog).toBeVisible();
@@ -853,18 +850,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("报销详情 BX20260508001")).toBeVisible();
await expect(page.getByRole("heading", { name: "报销单 BX20260508001" })).toBeVisible();
await expect(page.getByText("预算与审核提示")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
const pcSectionOrder = await page.locator("[data-testid='reimbursement-overview'], [data-testid='reimbursement-items'], [data-testid='reimbursement-materials'], [data-testid='reimbursement-workflow-progress']")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(pcSectionOrder).toEqual([
"reimbursement-overview",
"reimbursement-workflow-progress",
"reimbursement-items",
"reimbursement-materials",
]);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("approval-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-workflow-progress")).toBeVisible();
await expect(page.locator('a[href="#reimbursement-workflow-progress-panel"]')).toHaveCount(0);
await expect(page.locator('a[href="#reimbursement-processing-progress-panel"]')).toHaveCount(0);
await expect(page.getByText("ERP")).toHaveCount(0);
@@ -882,29 +873,23 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
await expect(page.getByTestId("reimbursement-materials")).toContainText("数电发票 · 12345678");
await expect(page.getByTestId("reimbursement-materials")).toContainText("电子火车票 · 12345679");
await expect(page.getByTestId("reimbursement-materials")).not.toContainText("无代码");
await expect(page.getByTestId("reimbursement-materials")).not.toContainText("未识别销售方");
const itemRow = page.getByTestId("reimbursement-item-row");
await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
await expect(itemRow).not.toContainText("电子火车票");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345678");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345679");
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("无代码");
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("未识别销售方");
await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
await page.locator("#reimbursement-attachment-upload").setInputFiles({
name: "付款截图.png",
mimeType: "image/png",
buffer: Buffer.from("r111 attachment"),
});
await page.getByRole("button", { name: "上传附件" }).click();
await page.getByRole("button", { name: /上传.*附件/ }).click();
await expect(page.getByTestId("approval-attachments")).toContainText("付款截图.png");
await expect(page.getByRole("button", { name: "查看补充附件:付款截图.png" })).toBeVisible();
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("合成差旅概要");
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("测试地点");
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
await page.getByTestId("reimbursement-materials-open-all").click();
await page.getByRole("button", { name: "查看完整材料" }).click();
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0])).toContain("previewToken=merged-pdf");
@@ -943,11 +928,11 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await login(page);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
await page.getByTestId("reimbursement-item-add").click();
await page.locator("#expenseTypeId").selectOption("expense-type-office-supplies-r111");
await page.getByTestId("reimbursement-item-amount").fill("2000.00");
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toHaveCount(0);
await expect(page.getByText(/请在明细说明中写明物品名称、实际使用人或责任人、存放位置/)).toBeVisible();
@@ -980,11 +965,11 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
await page.getByRole("button", { name: "新增", exact: true }).click();
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
await page.getByRole("button", { name: "新增明细", exact: true }).click();
await page.getByLabel("费用类型").selectOption("expense-type-office-supplies-r111");
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
@@ -1013,8 +998,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByRole("button", { name: "提交报销", exact: true })).toBeVisible();
await expect(page.getByText("提交审批", { exact: true })).toBeVisible();
await expect(page.getByTestId("reimbursement-overview").getByText("添加发票", { exact: true })).toHaveCount(0);
await expect(page.getByText("下一步:提交审批", { exact: true })).toBeVisible();
await expect(page.getByText("所属票据(0)")).toHaveCount(0);
await page.getByRole("button", { name: "提交报销", exact: true }).click();
await expect.poll(() => state.submitRequestCount).toBe(1);
});
@@ -1118,13 +1103,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
await expect(page.getByText("关联发票的票面金额未识别,请重试识别或联系财务处理。")).toBeVisible();
await expect(
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
await expect(page.getByText(
"该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。",
)).toBeVisible();
@@ -1136,9 +1118,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByLabel("选择报销明细").selectOption("item-missing-r111");
state.invoiceItemLinkError = { invoice: "invalid_unlinked_item_state" };
await page.getByRole("button", { name: "确认关联" }).click();
await expect(
page.getByTestId("reimbursement-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。"),
).toBeVisible();
await expect(page.getByTestId("approval-unlinked-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。")).toBeVisible();
expect(state.invoices.find((invoice) => invoice.id === unlinkedInvoiceId)?.linkedItemId).toBeNull();
state.invoiceItemLinkError = null;
await page.getByRole("button", { name: "确认关联" }).click();
@@ -1149,10 +1129,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
invoiceId: unlinkedInvoiceId,
linkedItemId: "item-missing-r111",
}]);
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toHaveCount(0);
await expect(
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).toBeVisible();
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toHaveCount(0);
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
const structuredGuardDetails = state.submitGuardDetails;
state.submitGuardDetails = {
@@ -1176,10 +1154,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 4 项需要处理");
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
await expect(page.getByTestId("reimbursement-action-error")).toContainText("票据检查未通过");
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
await expect(page.getByText(
"该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。",
)).toBeVisible();
@@ -1217,8 +1195,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
try {
await login(mobile.page);
await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "报销材料" })).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
@@ -1276,36 +1254,31 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
).toBeVisible({ timeout: 15_000 });
await mobile.page.keyboard.press("Escape");
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("数电发票 · 12345678");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("电子火车票 · 12345679");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("未识别销售方");
const mobileSectionOrder = await mobile.page.locator("[data-testid='mobile-reimbursement-detail-focus'], [data-testid='mobile-reimbursement-items'], [data-testid='mobile-reimbursement-materials'], [data-testid='mobile-reimbursement-processing-section']")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(mobileSectionOrder).toEqual([
"mobile-reimbursement-detail-focus",
"mobile-reimbursement-processing-section",
"mobile-reimbursement-items",
"mobile-reimbursement-materials",
]);
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
await expect(mobileItem).not.toContainText("电子火车票");
const mobileEvidence = mobile.page.getByTestId("reimbursement-approval-evidence");
await expect(mobileEvidence).toContainText("12345678");
await expect(mobileEvidence).toContainText("12345679");
await expect(mobileEvidence).not.toContainText("无代码");
await expect(mobileEvidence).not.toContainText("未识别销售方");
const mobileItem = mobile.page.getByTestId("approval-item-evidence").filter({ hasText: "客户招待" }).first();
await expect(mobileItem).toContainText("12345678");
await expect(mobileItem).not.toContainText("12345679");
await expect(mobile.page.getByTestId("approval-unlinked-invoices")).toContainText("12345679");
await expect(mobileEvidence.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
await mobile.page.getByRole("button", { name: "添加附件" }).click();
await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({
const maintenanceSection = mobile.page.locator("section").filter({ has: mobile.page.getByRole("heading", { name: "维护报销内容" }) });
await expect(maintenanceSection.getByRole("button", { name: "选择附件" })).toBeVisible();
await maintenanceSection.locator("input[type='file']").setInputFiles({
name: "付款截图.png",
mimeType: "image/png",
buffer: Buffer.from("r111 mobile attachment"),
});
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
await maintenanceSection.getByRole("button", { name: "上传 1 个附件" }).click();
await expect(mobile.page.getByRole("button", { name: "查看补充附件:付款截图.png" })).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("合成差旅概要");
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("测试地点");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
await expect(mobile.page.getByText("合成差旅概要")).toBeVisible();
await expect(mobile.page.getByText("测试地点")).toBeVisible();
await expect(mobile.page.getByTestId("approval-attachments")).toContainText("补充附件(1)");
const openAllButton = mobile.page.getByRole("button", { name: "查看完整材料" });
const openAllTarget = await openAllButton.boundingBox();
expect(openAllTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
await openAllButton.click();
@@ -424,7 +424,7 @@ test.describe("R5 真机反馈移动交互", () => {
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0);
await expect(page.getByRole("button", { name: "返回" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
@@ -577,7 +577,7 @@ test.describe("R6 移动响应式体系", () => {
await expectMobilePageStable(page, device.label, `${device.key}-mobile-leave`);
await page.goto("/mobile/reimbursements/r6-responsive-reimbursement");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
const detailMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-reimbursement-detail`);
@@ -30,7 +30,7 @@ const MOBILE_PAGES = [
{ key: "home", path: "/mobile", marker: "mobile-action-grid", deep: false },
{ key: "approvals", path: "/mobile/approvals", marker: "mobile-approvals-list", deep: false },
{ key: "reimbursements", path: "/mobile/reimbursements", marker: "mobile-reimbursements-list", deep: false },
{ key: "reimbursement-detail", path: "/mobile/reimbursements/mud6-reimbursement", marker: "mobile-reimbursement-detail", deep: true },
{ key: "reimbursement-detail", path: "/mobile/reimbursements/mud6-reimbursement", marker: "reimbursement-mobile-workspace", deep: true },
{ key: "purchase-requests", path: "/mobile/purchase-requests", marker: "mobile-purchase-requests-list", deep: false },
{ key: "me", path: "/mobile/me", marker: "mobile-more-links", deep: false },
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
@@ -563,7 +563,7 @@ async function exerciseMobileRendering(page: Page, key: string, label: string) {
expect(meMetrics.mobileMe.hasDecorativeTopLine, `${label} mobile section top line hidden`).toBe(false);
await page.goto("/mobile/reimbursements/wcm6-reimbursement");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
const detailMetrics = await expectRenderingStable(page, label, `${key}-mobile-reimbursement-detail`);
@@ -94,7 +94,12 @@ const pendingTaskDetail = {
entityType: "reimbursement",
currentStatus: "submitted",
currentStepId: pendingTask.stepId,
contextData: { standardViolations: [] },
contextData: {
standardViolations: [],
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
},
startedAt: "2026-05-05T08:50:00.000Z",
completedAt: null,
},
@@ -115,6 +120,57 @@ const pendingTaskDetail = {
history: [],
};
const pendingReimbursementDetail = {
...pendingTaskDetail.reimbursement,
submitterId: "employee-mock-2",
ownerId: "employee-mock-2",
departmentId: "dept-1",
department: { id: "dept-1", name: "研发" },
submittedAt: "2026-05-05T08:50:00.000Z",
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
invoices: [
{
id: "invoice-r89-1",
status: "bound",
invoiceCode: "044001900111",
invoiceNumber: "R8900001",
invoiceDate: "2026-05-05",
sellerName: "测试供应商",
totalAmount: "1683.00",
originalName: "r89-invoice.pdf",
mimeType: "application/pdf",
linkedItemId: "item-r89-1",
amountEvidenceStatus: "verified",
},
],
attachments: [],
budgetHint: null,
workflowProgress: { currentStepIndex: 0, totalSteps: 1, steps: [] },
processingProgress: { stages: [] },
approvalHistory: [],
riskFlags: [],
items: [
{
id: "item-r89-1",
expenseTypeId: "expense-r89-1",
expenseDate: "2026-05-05",
amount: "1683.00",
description: "R89 移动审批验证",
assetPriorApprovalBasis: null,
expenseType: {
id: "expense-r89-1",
code: "TRAVEL",
name: "交通费",
category: "travel",
enabled: true,
requiresInvoice: true,
},
},
],
};
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
@@ -328,6 +384,11 @@ async function mockR89Apis(page: Page) {
return;
}
if (path === `/api/reimbursements/${pendingReimbursementDetail.id}`) {
await fulfillJson(route, pendingReimbursementDetail);
return;
}
if (path === `/api/employees/${mockMe.id}/profile`) {
await fulfillJson(route, {
employee: {
@@ -422,7 +483,8 @@ test.describe("R89 移动底部导航与核心页面", () => {
await expect(page).toHaveURL(/\/mobile\/approvals/);
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await page.getByTestId(`mobile-approvals-task-${pendingTask.id}`).click();
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
await page.getByTestId("mobile-approvals-approve").click();
@@ -430,13 +492,12 @@ test.describe("R89 移动底部导航与核心页面", () => {
await page.getByTestId("workflow-action-confirm-cancel").click();
await page.context().setOffline(true);
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
await page.getByTestId("mobile-approvals-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
await page.getByTestId("workflow-action-confirm-cancel").click();
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("报销审批需联网核对完整报销内容");
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await attachScreenshot(page, testInfo, "r97-mobile-approvals.png");
await page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
await page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
await expect(page).toHaveURL(/\/mobile\/approvals/);
await page.getByTestId("mobile-nav-reimbursements").click();
@@ -315,8 +315,7 @@ test.describe("R7E 审批动作二次确认", () => {
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("退回:");
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("驳回:");
await expect(page.getByTestId("workflow-action-consequence-help")).toHaveCount(0);
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
@@ -324,13 +323,13 @@ test.describe("R7E 审批动作二次确认", () => {
expect(actionCalls.approve).toBe(0);
await page.getByTestId("workflow-task-reject-toggle").click();
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("流程");
await page.getByTestId("workflow-task-reject-comment").fill("资料不符合要求,请重新发起。");
await page.getByTestId("workflow-task-reject").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("流程将终止");
await page.getByTestId("workflow-action-confirm-cancel").click();
expect(actionCalls.reject).toBe(0);
await page.locator("summary", { hasText: "更多" }).click();
await page.getByTestId("workflow-task-transfer-menu-item").click();
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toHaveText("请先选择目标审批人。");
await expect(page.getByTestId("workflow-task-transfer")).toBeDisabled();
@@ -391,6 +390,12 @@ test.describe("R7E 审批动作二次确认", () => {
};
const reimbursementDetail = {
id: reimbursementId,
formNumber: "BX-APPROVAL-001",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "180.00",
description: "客户拜访交通费",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
items: [{
id: "item-1", expenseTypeId: "type-1", expenseDate: "2026-09-01", amount: "180.00",
description: "客户拜访打车", assetPriorApprovalBasis: null,
@@ -417,10 +422,16 @@ test.describe("R7E 审批动作二次确认", () => {
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByRole("heading", { name: "报销单 BX-APPROVAL-001" })).toHaveCount(1);
await expect(page.getByText("客户拜访交通费", { exact: true })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("客户拜访打车");
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("FP-002");
await expect(page.getByTestId("reimbursement-approval-checks")).toContainText("该次提交未记录预算快照");
await expect(page.locator("#reimbursement-workspace-snapshot")).toContainText("该次提交未留存预算快照");
await expect(page.getByText("当前申报金额高于已读取的票面金额合计")).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
await expect(page.getByText("打开完整原始表单")).toHaveCount(0);
await expect(page.getByText(/0 条/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "编辑明细" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "删除明细" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "选择明细" })).toHaveCount(0);
@@ -452,7 +463,6 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await page.locator("summary", { hasText: "更多" }).click();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
});
+95
查看文件
@@ -564,6 +564,75 @@ function scanNavigationTitleCoverage(file, source) {
}
}
const reimbursementWorkspaceShellFiles = new Set([
"client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx",
"client/src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx",
]);
function scanReimbursementWorkspaceShellBoundary(file, source) {
const normalized = normalizeFindingFile(file);
if (!reimbursementWorkspaceShellFiles.has(normalized)) {
return;
}
const patterns = [
{ regex: /\buseQuery\b/g, evidence: "useQuery" },
{ regex: /\buseMutation\b/g, evidence: "useMutation" },
{ regex: /(?:from\s+["'][^"']*\/api\/|\bapiClient\b|\bapi\.(?:get|post|put|patch|delete)\b)/g, evidence: "API client" },
{ regex: /\bfetch\s*\(/g, evidence: "fetch" },
{ regex: /\b(?:hasPermission|roleCodes?|permissionCodes?)\b/g, evidence: "permission/role" },
{ regex: /\b(?:workflowStatus|reimbursementStatus)\b/g, evidence: "business status" },
{ regex: /\bmode\s*(?:={2,3}|!==?)\s*["']/g, evidence: "mode comparison" },
{ regex: /\bisMobile\b/g, evidence: "isMobile branch" },
];
for (const pattern of patterns) {
let match = pattern.regex.exec(source);
while (match) {
addFinding({
rule: "architecture.reimbursement_workspace_shell_boundary",
severity: "error",
file,
line: lineNumber(source, match.index),
message: "报销工作台布局壳只能组合已计算模型和能力,不得查询 API、判断角色/业务模式或用移动端分支推导行为。",
evidence: pattern.evidence,
});
match = pattern.regex.exec(source);
}
}
}
function scanLegacyWorkflowReimbursementBranch(file, source) {
const normalized = normalizeFindingFile(file);
if (!["client/src/pages/WorkflowTaskDetailPage.tsx", "client/src/pages/MobileApprovalsPage.tsx"].includes(normalized)) {
return;
}
const patterns = [
/\bReimbursementItemEvidenceList\b/g,
/\b(?:isReimbursementTask|isSelectedReimbursement|reimbursementDecisionReady|reimbursementEvidence|reimbursementDetailQuery)\b/g,
/\bbuildReimbursementReturnReference\b/g,
/\bselectedTask\?*\.reimbursement\b/g,
/data-testid=["']reimbursement-approval-(?:evidence|checks)["']/g,
/(?:提交时检查|当前票据检查|打开完整原始表单)/g,
];
for (const regex of patterns) {
let match = regex.exec(source);
while (match) {
addFinding({
rule: "architecture.no_legacy_workflow_reimbursement_branch",
severity: "error",
file,
line: lineNumber(source, match.index),
message: "通用审批详情不得保留报销证据、检查或决策就绪分支;报销只能在授权分派层进入工作台适配器。",
evidence: match[0],
});
match = regex.exec(source);
}
}
}
function scanFileSource(file, source) {
scanForbiddenCopy(file, source);
scanCompetitorReferenceCopy(file, source);
@@ -580,6 +649,8 @@ function scanFileSource(file, source) {
scanReimbursementRawCodeDisplay(file, source);
scanDuplicateBusinessIdentifierDisplay(file, source);
scanNavigationTitleCoverage(file, source);
scanReimbursementWorkspaceShellBoundary(file, source);
scanLegacyWorkflowReimbursementBranch(file, source);
}
function scanFile(file) {
@@ -628,6 +699,30 @@ function runSelfTest() {
expectedRules: [],
expectedSuppressedRules: ["data.no_uuid_primary_display"],
},
{
file: "client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx",
source: `
import { useQuery } from "@tanstack/react-query";
export function ReimbursementDesktopWorkspaceShell({ mode, roleCodes }) {
const query = useQuery({ queryKey: ["bad"] });
void fetch("/api/reimbursements/bad");
return mode === "approval" && roleCodes ? <div>{query.data}</div> : null;
}
`,
expectedRules: ["architecture.reimbursement_workspace_shell_boundary"],
expectedSuppressedRules: [],
},
{
file: "client/src/pages/WorkflowTaskDetailPage.tsx",
source: `
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
export function Fixture({ selectedTask }) {
return selectedTask?.reimbursement ? <><ReimbursementItemEvidenceList evidence={reimbursementEvidence} />提交时检查</> : null;
}
`,
expectedRules: ["architecture.no_legacy_workflow_reimbursement_branch"],
expectedSuppressedRules: [],
},
];
for (const testCase of cases) {