diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md
index b81d0ae2..494a445a 100644
--- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md
+++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md
@@ -92,6 +92,52 @@ const displayed = value.slice(0, 19).replace("T", " ");
const displayed = formatDisplayDateTime(value);
```
+## Scenario: Shared RMB Display
+
+### 1. Scope / Trigger
+
+- Trigger: a reimbursement, invoice, fee-control or payment UI displays a CNY amount received as a string, number or nullable field.
+
+### 2. Signatures
+
+- Shared formatter: `formatMoney(value: string | number | null | undefined): string` in `client/src/lib/displayFormat.ts`.
+
+### 3. Contracts
+
+- Reuse the shared formatter across PC and mobile consumers; do not add page-local `Intl.NumberFormat` helpers for the same amount.
+- Valid amounts display the CNY symbol, thousands separators and exactly two decimal places. Missing, empty or non-finite values display `-`; they must not become `¥0.00`.
+
+### 4. Validation & Error Matrix
+
+- `1860` / `"1860"` -> `¥1,860.00`.
+- `"550.5"` -> `¥550.50`.
+- `null`, `""`, invalid text or a non-finite number -> `-`.
+
+### 5. Good/Base/Bad Cases
+
+- Good: a PC detail total, mobile detail total and invoice summary all use `formatMoney`.
+- Base: a real zero amount displays `¥0.00`.
+- Bad: use string interpolation such as `` `¥${amount}` `` or coerce a missing amount with `Number(value ?? 0)`.
+
+### 6. Tests Required
+
+- Focused frontend regression covers integer, fractional and thousands-separated amounts on PC and mobile.
+- Run `cd client && npm run typecheck && npm run build`.
+
+### 7. Wrong vs Correct
+
+#### Wrong
+
+```tsx
+¥{detail.totalAmount ?? "-"}
+```
+
+#### Correct
+
+```tsx
+{formatMoney(detail.totalAmount)}
+```
+
## Convention: Frontend Design Token Sources
**What**: Runtime visual tokens use the sources that are actually consumed by
@@ -235,6 +281,7 @@ reference, not a copy of Feishu or Ant Design branding.
- Mobile reimbursement target: `/mobile/reimbursements/:reimbursementId`.
- PC reimbursement target: `/reimbursements/:reimbursementId`.
- Mobile source state: ``.
+- PC source state: ``; `currentPcSource` is the trusted internal `pathname + search` of the current reimbursement list or approval task.
### 3. Contracts
@@ -242,6 +289,8 @@ reference, not a copy of Feishu or Ant Design branding.
- When `WorkflowTaskDetail.reimbursement` exists, the task detail exposes an explicitly named `查看完整报销单` or `查看报销单` link near the reimbursement summary.
- Reuse the existing business detail route and its permissions. Do not duplicate reimbursement items, invoice or attachment viewers inside the approval page merely to add reachability.
- 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.
### 4. Validation & Error Matrix
@@ -260,7 +309,7 @@ reference, not a copy of Feishu or Ant Design branding.
### 6. Tests Required
- Mobile E2E: task detail -> reimbursement detail -> items/evidence visible -> back to the same task -> approval actions still visible.
-- PC E2E: task detail link -> existing reimbursement detail route and overview 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`.
diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md
index c845a4fd..51a80ac8 100644
--- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md
+++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md
@@ -290,3 +290,11 @@ processingProgress.stages[] = {
- 后端补充进度 DTO 的 pending/approved/paid、退回/驳回、batch 五种状态、回单有无、三个来源独立缺失、未来节点无姓名和付款链接权限测试;另覆盖第三个未知职责节点、两个同角色节点互不覆盖、未来快照节点尚无 task、实例存在但 tasks 为空四条边界。
- 前端覆盖 PC/移动四块顺序、单头差旅填写/只读、材料列表与合并入口、变长审批节点紧凑时间轴、加载反馈、企微原件预览和 375px/横屏;组件只按数组 `map` 渲染,不按固定长度或位置解释职责。
- 若 JPEG/并发修复未通过目标镜像与合成火车票验收,回滚应用代码到现有 PNG 串行路径,保留原件降级;不删生产数据或回滚整库。
+
+### 13.7 报销 UX 最小收口
+
+- 报销列表把当前 `pathname + search` 作为 `location.state.from` 传给报销详情或审批任务;审批任务进入报销详情时把当前任务路径作为来源。PC 详情仅接受 `/reimbursements` 列表和 `/workflow/tasks/:id` 两类内部来源,拒绝绝对 URL、协议相对 URL、hash、当前页和其他模块路径。
+- 报销详情在 `EntityOverviewHero` 之前渲染一个文字明确的左侧返回控件;返回采用 replace,避免“返回后浏览器后退又回详情”的循环。无可信来源时降级 `/reimbursements`。
+- `displayFormat.ts` 新增可接受 `string | number | null | undefined` 的人民币格式化函数;PC/移动详情删除各自金额 formatter,统一复用。列表页本轮不做横向重构,避免扩大已批准范围。
+- 移动材料区继续承接发票添加;固定底栏删除同名次级按钮,只渲染提交主动作。固定栏的 safe-area、loading、禁用和提交校验保持不变。
+- 回退只需还原前端提交;不涉及 API、数据库、权限、workflow、生产配置或真实数据。
diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md
index 43cd523c..65ba2bce 100644
--- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md
+++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md
@@ -1,5 +1,13 @@
# 杨俊报销就绪:执行计划
+## 0.1 报销 UX 最小收口(本轮)
+
+- [x] PC 报销列表和审批任务向报销详情传递受控来源;详情标题左上方提供明确返回并删除业务动作区重复列表入口。
+- [x] `displayFormat.ts` 新增共享人民币金额格式,PC/移动报销详情统一复用。
+- [x] 移动草稿底部固定栏删除重复“添加发票”,材料区入口和提交主动作保持可用。
+- [x] 聚焦 Playwright 覆盖列表筛选返回、审批任务返回、直接打开降级、金额格式与移动单一入口;再运行 typecheck、build 和治理门禁。
+- [x] 本轮只做本地实现与验证;未获部署批准前不发布、不修改生产配置或真实业务数据。
+
## 1. 生产写入前门禁
- [x] 只读复核活动 server 构建标识、Compose 项目、4 个 systemd service 当前 `BUILD_SHA`、timer enabled/active、最近源端备份与 ARM 新鲜度;输出仅限状态和计数。
@@ -343,3 +351,6 @@
- 双端验证:client typecheck/生产 build 通过;R111 PC/企微 2/2、Wave3 只读详情 1/1 及移动视觉聚焦 2/2 通过,覆盖材料常显、只读无删除、四块顺序、五阶段无“第 N 步”、44px 触控和无横向溢出。视觉复核确认 PC 主内容不再因空侧栏过窄。
- 只读 review:按 workflow task、PaymentBatchItem/batch、回单计数三源逐项复核映射与权限;修正“workflow 已通过但尚未建批次”原本无当前节点的问题,使其明确落在“待财务创建付款批次”。确认未来 checker 不预填、普通申请人无批次链接、共享批次不暴露其他单据/金额/材料;review 未修改状态机、权限矩阵或生产数据。
- 最终治理:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤未部署、未推送、未修改生产配置、真实报销状态、付款批次、回单或业务数据。
+- PC/移动 UX 最小收口:PC 从带 `status/page/scope` 的报销列表进入审批任务后可返回同一查询,从审批任务进入报销详情后显示“返回审批详情”,直接打开详情显示“返回报销列表”;业务动作区不再重复列表入口。共享 `formatMoney` 统一总额、明细、发票和标准提示为人民币两位小数;移动底栏只保留“提交审批”,材料区继续保留“添加发票”。
+- UX 聚焦验证:Wave3 返回/金额/视觉相关 5 项通过,R111 PC/企微材料相关 2 项通过;typecheck 与生产 build 通过,截图确认 PC 返回位于标题上方、移动底栏为单一主按钮。旧 R5 综合移动用例 2 项在进入报销详情前因首页快捷入口既有期望“至少 4 个”而失败,实际为 2 个;该失败不由本轮改动引起,不作为本轮详情回归失败,也未修改其首页基线断言。
+- UX 治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。稳定的 PC 单层来源返回和共享人民币展示合同已写入 `frontend-react-vite-design` spec。本步骤未部署、未推送、未修改生产配置或真实业务数据。
diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md
index a0dc4492..22f0ffae 100644
--- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md
+++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md
@@ -54,6 +54,9 @@
- 草稿/驳回报销出现差旅类明细且四项差旅概要不完整时,PC 与移动端应直接显示带标签的填写表单,不先展示四个“未填写”再要求用户点击编辑;概要完整后恢复紧凑只读摘要并保留编辑入口。不新增“普通/差旅”选择,不改变现有字段、差旅判定、提交校验或差旅申请关联规则。
- “处理进度”必须使用移动端友好的纵向时间轴。申请提交、付款批次复核、付款与回单是固定业务阶段;中间审批段按当前 workflow 实例的真实任务顺序逐项展开。当前生产规则仍自然形成“申请提交、主管审批、财务复核、付款批次复核、付款与回单”五阶段,但未来新增或重复角色审批节点时不得丢失、覆盖或错误提前显示付款。节点名使用职责而非序号;已完成节点显示实际处理人/时间/结果,当前节点显示实际待办人(可取得时),未来节点不得预填姓名。退回、驳回和有内容的意见必须展开,普通已完成节点压成一行。
- 处理进度是跨状态机只读投影,不把付款批次塞入 workflow。服务端从 workflow 快照/任务/历史、报销付款字段、当前关联付款批次和回单计数生成安全 DTO;已发生/当前审批逐条使用真实 task,尚未创建的未来审批按提交快照补 pending 占位,workflow 实例存在但任务尚未落库时也按快照展示,不因延迟建任务而出现空白;仅在 workflow 整体缺失时使用主管/财务默认占位。任一来源缺失时仅把对应下游节点降级为“待处理”,前面已完成节点继续返回。审批节点标题按“非序号化 step name → assignee role/快照规则职责映射 → 审批”降级,永远不向界面显示原始 step ID、角色码或“步骤 N”。付款批次为共享对象,节点文案必须写“所在付款批次…”,不能暗示单张报销自身再次被审批。
+- PC 报销详情必须提供位于标题左上方、来源感知且可预测的返回入口:从带筛选的报销列表进入时返回原列表 URL,从审批任务进入时返回原审批任务;刷新、收藏或非法来源降级到报销列表。返回入口不得继续混在提交、催办、打印或“更多”等业务动作中,来源只接受受信任的 OA 内部报销列表或审批任务路径。
+- PC 与移动报销详情的人民币金额必须复用 `client/src/lib/displayFormat.ts` 中的共享格式化函数,统一显示货币符号、千分位和两位小数;缺失或非法金额显示中性占位,不把缺失值伪装成 0 元。
+- 移动草稿详情的“添加发票”只保留在“报销材料”区;底部固定动作栏只保留“提交审批”。不删除发票上传/选择能力,不改变提交前检查、材料权限或审批状态机。
## Scope
@@ -103,6 +106,9 @@
- [x] “处理进度”按五阶段纵向显示,不出现“第 N 步”;当前责任、实际处理人、时间、结果及必要意见清楚,未来节点无姓名,付款批次共享语义准确。
- [x] workflow、付款批次或回单任一来源缺失时进度 DTO 和页面均安全降级,已知节点不丢失;批次 draft/reviewed/executed/failed/cancelled 与回单有/无均有合成回归。
- [x] workflow 中第三个未知职责节点和多个同角色节点均按真实顺序完整显示且互不覆盖;实例存在但任务为空时审批段仍显示安全占位,前端不假设固定节点数。
+- [x] PC 从报销列表或审批任务进入报销详情后,标题左上方显示明确的来源返回入口;返回可恢复列表查询参数或原审批任务,直接打开详情则安全回报销列表,业务动作区不再重复显示“报销列表”。
+- [x] PC/移动报销详情的总额、明细和发票金额统一使用共享人民币格式,覆盖整数、小数、千分位、空值和非法值。
+- [x] 移动草稿详情“报销材料”区保留“添加发票”,底部固定栏不再重复该动作且“提交审批”仍可见、可达并满足安全区/触控尺寸合同。
## Out of Scope
diff --git a/client/src/lib/displayFormat.ts b/client/src/lib/displayFormat.ts
index 9f85e5d9..62ce8a36 100644
--- a/client/src/lib/displayFormat.ts
+++ b/client/src/lib/displayFormat.ts
@@ -33,6 +33,20 @@ export function formatDisplayDateTime(value: string | null | undefined) {
}).format(timestamp);
}
+export function formatMoney(value: string | number | null | undefined) {
+ if (value == null || value === "") return "-";
+
+ const amount = typeof value === "number" ? value : Number(value);
+ if (!Number.isFinite(amount)) return "-";
+
+ return new Intl.NumberFormat("zh-CN", {
+ style: "currency",
+ currency: "CNY",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amount);
+}
+
export function formatCompactIdentifier(
value: string | number | null | undefined,
options: { head?: number; tail?: number; minLength?: number } = {},
diff --git a/client/src/lib/pcDetailNavigation.ts b/client/src/lib/pcDetailNavigation.ts
new file mode 100644
index 00000000..c11b1c0d
--- /dev/null
+++ b/client/src/lib/pcDetailNavigation.ts
@@ -0,0 +1,23 @@
+export type PcDetailNavigationState = {
+ from?: string;
+};
+
+export function buildPcDetailSource(pathname: string, search: string) {
+ return `${pathname}${search}`;
+}
+
+export function resolvePcDetailSource(value: unknown, currentPath: string) {
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.includes("#")) {
+ return null;
+ }
+
+ const url = new URL(value, "https://hyb-oa.invalid");
+ const isReimbursementList = url.pathname === "/reimbursements";
+ const isWorkflowTask = /^\/workflow\/tasks\/[^/]+$/.test(url.pathname);
+ if (!isReimbursementList && !isWorkflowTask) {
+ return null;
+ }
+
+ const source = `${url.pathname}${url.search}`;
+ return source === currentPath ? null : source;
+}
diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx
index 3b294daa..56451c93 100644
--- a/client/src/pages/MobileReimbursementDetailPage.tsx
+++ b/client/src/pages/MobileReimbursementDetailPage.tsx
@@ -68,7 +68,7 @@ import {
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
-import { formatDisplayDateTime } from "../lib/displayFormat";
+import { formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
@@ -119,17 +119,6 @@ function todayDate() {
return new Date().toISOString().slice(0, 10);
}
-function formatCurrency(value: string | null | undefined) {
- const numberValue = Number(value ?? 0);
- if (!Number.isFinite(numberValue)) {
- return "¥0.00";
- }
- return new Intl.NumberFormat("zh-CN", {
- style: "currency",
- currency: "CNY",
- }).format(numberValue);
-}
-
function formatDate(value: string | null | undefined) {
return value || "未填写";
}
@@ -875,7 +864,7 @@ export function MobileReimbursementDetailPage() {
const displayCode = resolveReimbursementDisplayCode(detail);
const showSubmitAction = Boolean(canEdit && canSubmitExpense);
- const showStickyActions = Boolean(showSubmitAction || canOpenInvoiceDialog);
+ const showStickyActions = showSubmitAction;
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
return (
@@ -910,7 +899,7 @@ export function MobileReimbursementDetailPage() {
合计金额
-
{formatCurrency(detail.totalAmount)}
+
{formatMoney(detail.totalAmount)}
@@ -1041,7 +1030,7 @@ export function MobileReimbursementDetailPage() {
{item.expenseType?.name ?? "费用"}
{formatDate(item.expenseDate)}
-
{formatCurrency(item.amount)}
+
{formatMoney(item.amount)}
{item.description ? {item.description}
: null}
{canEdit ? (
@@ -1192,7 +1181,7 @@ export function MobileReimbursementDetailPage() {
发票
{formatInvoiceDisplayTitle(invoice)}
- {[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatCurrency(invoice.totalAmount)].filter(Boolean).join(" · ")}
+ {[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
明细:{resolveLinkedItemLabel(invoice.linkedItemId)}
{invoiceHint ? {invoiceHint} : null}
@@ -1316,17 +1305,6 @@ export function MobileReimbursementDetailPage() {
data-testid="mobile-reimbursement-sticky-actions"
>
- {canOpenInvoiceDialog ? (
-
- ) : null}
{showSubmitAction ? (
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx
index 0e7c669c..a1adc44e 100644
--- a/client/src/pages/ReimbursementDetailPage.tsx
+++ b/client/src/pages/ReimbursementDetailPage.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
-import { Link, useNavigate, useParams } from "react-router-dom";
+import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ChevronRight } from "lucide-react";
+import { ArrowLeft, ChevronRight } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
@@ -72,7 +72,12 @@ import {
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
-import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
+import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
+import {
+ buildPcDetailSource,
+ resolvePcDetailSource,
+ type PcDetailNavigationState,
+} from "../lib/pcDetailNavigation";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
@@ -100,6 +105,7 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
+ const location = useLocation();
const navigate = useNavigate();
const { permissions, me, accessToken } = useAuth();
const queryClient = useQueryClient();
@@ -129,6 +135,10 @@ export function ReimbursementDetailPage() {
amount: "",
description: "",
});
+ const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
+ const navigationState = location.state as PcDetailNavigationState | null;
+ const backSource = resolvePcDetailSource(navigationState?.from, currentDetailSource) ?? "/reimbursements";
+ const backLabel = backSource.startsWith("/workflow/tasks/") ? "返回审批详情" : "返回报销列表";
const runGuarded = useUnsavedChangesGuard(
Boolean(itemEditor) ||
selectedInvoiceFiles.length > 0 ||
@@ -749,7 +759,7 @@ export function ReimbursementDetailPage() {
{message}
);
@@ -1042,13 +1052,10 @@ export function ReimbursementDetailPage() {
);
}
- return (
-
- );
+ return null;
})();
+ const hasMoreActions = detail.status !== "paid" || canDeleteReimbursement;
const secondaryActions = [
detail.status === "reviewing" && canRemindPendingWorkflow && pendingTaskId ? (
- ) : detail.status === "paid" ? (
+ ) : null,
+ detail.status === "paid" ? (
- ) : (
-