修复报销详情时区并按 KISS 收敛页面(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-31 16:44:31 +08:00
父节点 35b44749a5
当前提交 39c3e808a3
共修改 10 个文件,包含 223 行新增和 227 行删除
@@ -36,6 +36,62 @@ and verification contracts. Do not copy broad aesthetic guidance from
with `AUTH_005`.
- Run `cd client && npm run typecheck && npm run build` for frontend changes.
## Scenario: UTC Date-Time Display In The Company Time Zone
### 1. Scope / Trigger
- Trigger: a frontend page displays an API timestamp such as attachment upload,
approval action, payment or audit time. API timestamps remain ISO 8601 UTC;
the company-facing OA displays date-times in `Asia/Shanghai`.
### 2. Signatures
- Shared formatter: `formatDisplayDateTime(value: string | null | undefined): string`.
- Date-only values continue to use `formatDisplayDate`; do not turn business
dates such as reimbursement or invoice dates into timezone-shifted instants.
### 3. Contracts
- Date-time pages reuse `client/src/lib/displayFormat.ts`; do not add a
page-local formatter for the same contract.
- `Intl.DateTimeFormat` must set `timeZone: "Asia/Shanghai"` explicitly so the
result does not depend on the browser, workstation or CI timezone.
- Database storage and API ISO strings remain unchanged.
### 4. Validation & Error Matrix
- Missing value -> `-`.
- Invalid date-time -> the shared display fallback; never throw during render.
- Valid UTC value -> Shanghai calendar date and clock time.
- Browser outside China -> the same Shanghai result as a browser in China.
### 5. Good/Base/Bad Cases
- Good: `2026-05-27T09:10:00.000Z` displays as `2026年5月27日 17:10`.
- Base: a null attachment or approval time displays `-`.
- Bad: `value.slice(0, 19).replace("T", " ")`, which presents UTC clock
fields as if they were local time.
### 6. Tests Required
- Focused Playwright uses synthetic UTC data and a browser `timezoneId` outside
Shanghai, then asserts the exact Shanghai attachment/approval time.
- Run `cd client && npm run typecheck && npm run build`.
### 7. Wrong vs Correct
#### Wrong
```ts
const displayed = value.slice(0, 19).replace("T", " ");
```
#### Correct
```ts
const displayed = formatDisplayDateTime(value);
```
## Convention: Frontend Design Token Sources
**What**: Runtime visual tokens use the sources that are actually consumed by
@@ -156,3 +156,12 @@
- 现有 `manager`、`ceo` 和 `finance` 角色已经包含 `expense.view_all`,因此本轮不调整角色、权限矩阵、后端报销详情鉴权或 workflow 数据结构。
- 不把整个摘要卡片改造成无提示的隐式链接;使用明确命名的链接按钮,避免与待办列表卡片的既有任务选择交互混淆。
- 请假、采购、差旅审批详情存在同类业务详情缺口,但本轮只修复真实报销链;其他类型另轮统一处理。
## 10. 报销详情时区与 KISS 收敛
- 后端继续用 ISO 8601 UTC 字符串传输时间,前端不得用 `slice(0, 19)` 假装本地时间。PC 报销详情、移动报销详情和 PC 审批任务详情复用 `formatDisplayDateTime`;共享函数显式使用 `Asia/Shanghai`,使公司内部 OA 不依赖浏览器所在时区。
- 不新增页面级时间工具。合成 UTC 样例通过页面文本断言验证上海时间,覆盖附件上传时间和审批任务时间。
- PC 将“当前状态与下一步”和“业务摘要”收敛为单一首屏概况,保留总额、状态、编号、日期、报销人、说明与真正可执行入口;删除解释同一事实的二级标题和辅助句。
- PC 的明细、发票、附件各保留一个可扫描标题;附件上传限制只在可编辑时出现。关联事项仅在已有项目/差旅关联或可编辑时展示,预算/风险区域仅在存在真实提示时展示。
- 移动端头部已经包含状态,因此概况卡不重复状态;只保留金额、日期、报销人和说明。只读状态不显示编辑教学或空“操作”卡,编辑态仍保留新增、上传、删除和底部提交能力。
- 采用当前设计系统和现有组件,不引入新颜色、字体、动效、布局系统或后端字段;新版 `ui-ux-pro-max` 仅用于内容优先、密度和渐进披露判断。
@@ -91,6 +91,14 @@
- [x] ARM dead-man timer 保持 enabled/active,最近一次自动运行是 `success/0`;本次尝试立即手工复跑被已收口的最小权限策略拒绝,未恢复临时 sudo、未改 ARM。不得把该证据写成“独立 dead-man 已对新批次即时复跑”。
- [x] 公网 `/ready` 构建标识匹配,已发布的移动端和 PC 资源分别包含“查看完整报销单”和“查看报销单”;未读取、截图或输出真实报销内容。
## 6.3 报销详情时区与 KISS 收敛
- [x] 让共享日期时间格式化显式使用 `Asia/Shanghai`,PC/移动报销详情与 PC 审批任务详情移除本地字符串截取函数并复用共享实现。
- [x] PC 合并重复概况,收敛明细、发票、附件标题和解释;无关联或无预算/风险内容时隐藏对应辅助区块。
- [x] 移动概况删除重复状态、计数教学和冗余下一步文案;只读无动作时不渲染空操作卡,编辑能力保持不变。
- [x] 用合成数据补附件 UTC→上海时间和 KISS 信息层级聚焦回归;运行前端 typecheck、build 与相关 Playwright。
- [x] 运行 Trellis task/governance、流程合同、文档审计、敏感扫描和 `git diff --check`;生产部署另行批准。
## 7. 完成条件
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
@@ -128,3 +136,7 @@
- 发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配活动构建,4 个 timer 均 enabled/active;新 automatic backup daily 和源端 watchdog 均为 `success/0`。
- ARM 证据边界:新批次已由 receiver 校验并 finalize;ARM dead-man timer enabled/active、最近自动结果 `success/0`,但本次没有扩大 sudo 权限来即时手工复跑独立 dead-man。
- 公网入口:生产移动资源存在“查看完整报销单”,PC 资源存在“查看报销单”;只验证静态发布资源和构建身份,不读取真实报销数据。
- 报销详情时区:共享日期时间格式化固定 `Asia/Shanghai`;聚焦 Playwright 将浏览器强制设为洛杉矶后,合成 UTC 附件与审批时间仍分别显示为上海 `17:10`、`17:05`,1/1 通过。
- KISS 视觉复核:PC 与移动合成截图确认重复概况、状态、教学和空辅助区已收敛;未读取生产真实报销。Wave3 移动整段 1/1 通过;PC 大用例先通过本轮报销/审批断言,随后在未改动的合同页因找不到“筛选”按钮超时,因此不把该大用例记录为全绿。
- 编辑态回归:R111 移动发票/附件能力 1/1 通过;PC 首轮因旧断言仍要求空“预算与审核提示”显示而失败,按新合同更新断言后,PC 发票与附件流程 1/1 通过。
- 本地质量门禁:前端 typecheck/build 通过;task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。
@@ -37,6 +37,8 @@
- 第一笔真实业务由员工本人正常发起,目标链路为“员工提交 → 真实主管审批 → 财务处理”;付款批次仍遵守既有 maker-checker。
- 当前小团队取消报销金额分层,所有金额统一按“直属主管 → finance”审批;不得保留无人持有的 `finance_mgr` 节点,也不得因马建勇同时是主管和 CEO 而让同一人重复审批。
- 真实首笔报销暴露审批可读性阻断:待办列表卡片能够进入审批任务详情,但任务详情只显示单号、申请人和金额摘要,没有通往费用明细、发票和附件的业务详情入口。移动端必须在审批任务详情区提供明确的“查看完整报销单”入口,复用既有 `/mobile/reimbursements/:id` 路由和 `state.from` 回跳约定;PC 审批任务详情同步提供报销单入口。
- 报销详情当前把后端 ISO UTC 时间直接截取为字符串,附件和审批记录会在中国时区显示早 8 小时;报销链路的日期时间必须复用共享格式化能力并固定按 `Asia/Shanghai` 展示,不修改数据库时间或 API ISO 合同。
- PC 与移动报销详情应按 KISS 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。
## Scope
@@ -63,6 +65,9 @@
- [x] 生产审批规则已收敛为唯一全金额规则,499.99、500、5000 三个边界均解析为 `department_manager → finance`,且没有 CEO 重复节点或 `finance_mgr` 空节点。
- [ ] 移动审批任务详情中的报销摘要区提供明确的“查看完整报销单”入口,进入现有移动报销详情并能通过既有 `state.from` 返回原审批任务。
- [ ] PC 审批任务详情提供对应的“查看报销单”入口;两端均不修改待办列表卡片的既有点击行为,不修改审批状态机、权限矩阵或后端数据合同。
- [x] PC、移动报销详情和 PC 审批任务详情中的 ISO 时间按 `Asia/Shanghai` 正确展示,UTC 样例不再直接显示原始小时;数据库与 API 时间合同不变。
- [x] PC 报销详情合并重复概况,附件区不再出现“附件材料/补充附件”双标题;无关联、无预算/风险内容时不显示空辅助区块。
- [x] 移动报销详情顶部不重复状态和教学文案;无实际操作时不显示仅含“当前状态不可编辑”的操作卡,编辑态上传与提交能力保持可用。
## Out of Scope
@@ -82,3 +87,4 @@
- 生产只读 SQL/Prisma 只返回布尔值和计数,不输出手机号、邮箱、企微 userid、完整账号或原始 payload。
- 使用既有报销 workflow/权限聚焦回归验证主管解析和 `expense.approve`,不写生产业务单据。
- 前端运行 `cd client && npm run typecheck && npm run build`,并用聚焦 Playwright 覆盖:移动审批任务详情进入完整报销单、返回原任务后审批动作仍可见,以及 PC 审批任务详情进入报销单。
- 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。
+2
查看文件
@@ -10,6 +10,7 @@ export function formatDisplayDate(value: string | null | undefined) {
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) return "-";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "long",
day: "numeric",
@@ -23,6 +24,7 @@ export function formatDisplayDateTime(value: string | null | undefined) {
if (Number.isNaN(timestamp)) return formatDisplayDate(value);
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "long",
day: "numeric",
@@ -59,6 +59,7 @@ import {
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
@@ -122,13 +123,6 @@ function formatDate(value: string | null | undefined) {
return value || "未填写";
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
}
function workflowActionLabel(action: string) {
const labels: Record<string, string> = {
start: "提交",
@@ -171,34 +165,6 @@ function itemLabel(item: ReimbursementItem) {
return desc ? `${typeName} · ${desc}` : typeName;
}
function resolveMobileReimbursementNextStep(input: {
status: ReimbursementStatus;
canEdit: boolean;
itemCount: number;
invoiceCount: number;
attachmentCount: number;
}) {
if (input.canEdit && input.itemCount === 0) {
return "先添加报销明细,再绑定发票或补充附件。";
}
if (input.canEdit && input.invoiceCount === 0) {
return "建议先添加或上传发票,提交前会做完整性检查。";
}
if (input.status === "draft" || input.status === "rejected") {
return "核对金额、发票和附件后,使用底部按钮提交审批。";
}
if (input.status === "reviewing") {
return "正在审批中,可查看节点和历史;需要补材料时按审批意见处理。";
}
if (input.status === "approved") {
return "已通过审批,等待财务付款和归档。";
}
if (input.status === "paid") {
return "已完成付款,本页用于查看发票、附件和审批留痕。";
}
return input.attachmentCount > 0 ? "查看附件、发票和审批记录。" : "查看当前报销状态和审批记录。";
}
export function MobileReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
@@ -848,14 +814,7 @@ export function MobileReimbursementDetailPage() {
const displayCode = resolveReimbursementDisplayCode(detail);
const showSubmitAction = Boolean(canEdit && canSubmitExpense);
const showStickyActions = Boolean(showSubmitAction || canOpenInvoiceDialog);
const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit);
const detailNextStep = resolveMobileReimbursementNextStep({
status: detail.status,
canEdit,
itemCount: detail.items.length,
invoiceCount: detail.invoices.length,
attachmentCount: detail.attachments?.length ?? 0,
});
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
return (
<PageScaffold
@@ -883,31 +842,23 @@ export function MobileReimbursementDetailPage() {
<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 className="min-w-0">
<h2 className="mobile-section-title">当前办理摘要</h2>
<p className="mobile-list-card__subtitle mt-1">{detailNextStep}</p>
</div>
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
{STATUS_LABEL[detail.status]}
</span>
</div>
<div>
<h2 className="mobile-section-title">报销概况</h2>
</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">{formatCurrency(detail.totalAmount)}</div>
</div>
<div className="mobile-metric-grid mt-4 text-sm">
<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 font-semibold text-zinc-900">{detail.items.length}</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 font-semibold text-zinc-900">{detail.invoices.length}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">日期</div>
<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>
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
</section>
@@ -1092,7 +1043,7 @@ export function MobileReimbursementDetailPage() {
<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">PDF/JPG/PNG/WebP,单次最多 5 个</p>
{canManageAttachments ? <p className="mt-1 text-xs text-zinc-500">PDF/JPG/PNG/WebP,单次最多 5 个</p> : null}
</div>
{canManageAttachments ? (
<Button
@@ -1154,7 +1105,7 @@ export function MobileReimbursementDetailPage() {
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
@@ -1218,7 +1169,7 @@ export function MobileReimbursementDetailPage() {
<article key={item.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
<div className="shrink-0 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
<div className="shrink-0 text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
@@ -1253,7 +1204,6 @@ export function MobileReimbursementDetailPage() {
删除草稿
</Button>
) : null}
{!canEdit ? <div className="text-sm text-zinc-500">当前状态不可编辑。</div> : null}
</div>
</section>
) : null}
+62 -132
查看文件
@@ -21,7 +21,6 @@ import {
withdrawReimbursement,
linkReimbursementProject,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import {
@@ -41,7 +40,6 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -65,7 +63,7 @@ import {
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate } from "../lib/displayFormat";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
@@ -79,11 +77,6 @@ import { buildPrecheckDialogMessage } from "../lib/precheck";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
type SelectedInvoiceMap = Record<string, string>;
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
}
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
@@ -119,18 +112,6 @@ function budgetControlModeLabel(controlMode: string | null | undefined) {
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
}
function reimbursementStatusLabel(status: ReimbursementStatus) {
const labels: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已批准",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? status;
}
function materialCompletenessLabel(count: number, noun: string) {
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
}
@@ -981,6 +962,9 @@ export function ReimbursementDetailPage() {
}
return "查看记录";
})();
const showRelatedRecords = canEditItems || Boolean(detail.project || detail.linkedTravelRequest);
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
const showApprovalRecords = Boolean(detail.workflowProgress || detail.approvalHistory.length);
const primaryAction = (() => {
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
if (!itemCount) {
@@ -1090,10 +1074,6 @@ export function ReimbursementDetailPage() {
meta={(
<>
<StatusBadge status={detail.status} />
<span className="info-chip">报销日期:{detail.reimbursementDate ?? "-"}</span>
<span className="info-chip">报销人:{detail.owner?.name ?? "-"}</span>
<span className="info-chip">提交人:{detail.submitter?.name ?? "-"}</span>
<span className="info-chip">金额:{detail.totalAmount ?? "-"}</span>
</>
)}
actions={(
@@ -1106,37 +1086,49 @@ export function ReimbursementDetailPage() {
<div className="detail-workspace-grid">
<div className="space-y-5">
<SectionPanel title="当前状态与下一步">
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div className="shrink-0">
<StatusBadge status={detail.status} />
</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div className="detail-fact-grid xl:grid-cols-4" data-testid="reimbursement-summary">
<div className="detail-fact-card">
<div className="detail-fact-card__label">状态</div>
<div className="detail-fact-card__value">{reimbursementStatusLabel(detail.status)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">处理人</div>
<div className="detail-fact-card__value">{currentHandlerText}</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">¥{detail.totalAmount ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料完整性</div>
<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">
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
</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>
{detail.description ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{detail.description}
</div>
) : null}
</div>
{actionError ? (
@@ -1189,7 +1181,7 @@ export function ReimbursementDetailPage() {
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__value">
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
{payment.paidBy?.name ?? "-"} · {formatDisplayDateTime(payment.paidAt)}
</div>
</div>
</div>
@@ -1200,10 +1192,7 @@ export function ReimbursementDetailPage() {
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">更多入口</h4>
<p className="text-xs text-zinc-500">低频操作集中在这里,材料和关联事项已放在主内容区。</p>
</div>
<h4 className="text-base font-semibold text-zinc-900">更多</h4>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
@@ -1231,42 +1220,11 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
<SectionPanel title="业务摘要">
<div className="detail-command-panel" data-testid="reimbursement-business-summary">
<div className="detail-fact-grid xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销编号</div>
<div className="detail-fact-card__value">{displayCode}</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>
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{detail.description || "未填写报销说明"}
</div>
</div>
</SectionPanel>
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
<SectionPanel title="明细与发票">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
<p className="text-xs text-zinc-500">明细变更会自动重算总金额。</p>
</div>
{canEditItems ? (
<Button
@@ -1278,9 +1236,7 @@ export function ReimbursementDetailPage() {
>
新增明细
</Button>
) : (
<p className="text-xs text-zinc-500">当前状态不可编辑明细</p>
)}
) : null}
</div>
{itemError ? (
@@ -1411,9 +1367,9 @@ export function ReimbursementDetailPage() {
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div>
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
<p className="mt-1 text-xs text-zinc-500">提交前至少添加 1 张发票;重复或不可用发票会给出原因。</p>
{canEditItems ? <p className="mt-1 text-xs text-zinc-500">提交前至少添加 1 张发票。</p> : null}
</div>
{canOpenInvoiceBindDialog ? (
<Button
@@ -1495,14 +1451,11 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="附件材料" description="订单、付款截图、情况说明等补充材料;PDF 发票仍在发票区处理。">
<SectionPanel title="附件材料">
<div className="space-y-4 text-sm" data-testid="reimbursement-attachments">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">补充附件</h3>
<p className="text-xs text-zinc-500">合同、订单、支付截图或情况说明;PDF/JPG/PNG/WebP,单次最多 5 个。</p>
</div>
{canEditItems ? (
<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"
@@ -1540,8 +1493,8 @@ export function ReimbursementDetailPage() {
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
</div>
) : null}
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
@@ -1550,7 +1503,7 @@ export function ReimbursementDetailPage() {
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
@@ -1585,13 +1538,13 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="关联事项" description="只显示项目、差旅等关联摘要;具体流程回到对应模块处理。">
{showRelatedRecords ? (
<SectionPanel title="关联事项">
<div className="space-y-4" data-testid="reimbursement-related-records">
<div className="detail-command-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
<p className="text-xs text-zinc-500">用于项目执行面板汇总,不在报销详情复制项目流程。</p>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
@@ -1655,14 +1608,12 @@ export function ReimbursementDetailPage() {
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
未关联差旅申请。
</div>
)}
) : null}
</div>
</SectionPanel>
) : null}
{showReviewHints ? (
<SectionPanel title="预算与审核提示">
<div className="space-y-4">
{budgetHint ? (
@@ -1688,12 +1639,13 @@ export function ReimbursementDetailPage() {
</div>
) : null}
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
</SectionPanel>
) : null}
<SectionPanel title="审批与处理记录" description="审批进度和历史只用于追踪,不在这里复制审批或付款动作。">
{showApprovalRecords ? (
<SectionPanel title="审批与处理记录">
<div id="reimbursement-workflow-progress-panel" className="space-y-4">
{detail.workflowProgress ? (
<div className="detail-command-panel">
@@ -1724,21 +1676,17 @@ export function ReimbursementDetailPage() {
))}
</div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
当前没有进行中的审批节点。
</div>
)}
) : null}
{detail.approvalHistory.length ? (
<div className="detail-command-panel">
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
{detail.approvalHistory.length ? (
<div className="detail-timeline mt-3" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
<span className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
@@ -1747,39 +1695,21 @@ export function ReimbursementDetailPage() {
</div>
))}
</div>
) : (
<div className="mt-3 text-sm text-zinc-500">暂无审批记录。</div>
)}
</div>
) : null}
</div>
</SectionPanel>
) : null}
</div>
<div className="space-y-5">
<DetailSideMeta title="当前可做">
{["draft", "rejected"].includes(detail.status) ? (
<div>补齐明细和 PDF 发票后提交审批;附件作为补充材料放在主内容区。</div>
) : null}
{detail.status === "reviewing" ? (
<div>审批动作只在“待我处理”或任务详情执行;如需修改,可撤回后补正。</div>
) : null}
{detail.status === "approved" ? (
<div className="space-y-3">
<div>本页只显示付款状态;付款登记、批量付款和回单处理请进入付款上下文。</div>
{canPay ? (
{detail.status === "approved" && canPay ? (
<DetailSideMeta title="付款">
<Button asChild variant="secondary" size="sm">
<Link to="/payment-batches">进入付款批次</Link>
</Button>
) : null}
</div>
) : null}
{detail.status === "paid" ? (
<div>已付款报销用于查证据、看回单状态和打印归档,不再编辑或重复付款。</div>
) : null}
{detail.status === "cancelled" ? (
<div>该报销已取消,仅保留查看和归档记录。</div>
) : null}
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
@@ -1858,7 +1788,7 @@ export function ReimbursementDetailPage() {
{item.sellerName || "未识别销售方"} · {item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDateTime(item.createdAt)}
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
</div>
</div>
</div>
+7 -13
查看文件
@@ -33,6 +33,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
function entityTitle(entityType: string | undefined) {
@@ -61,13 +62,6 @@ function correctionKindLabel(kind: string | undefined | null) {
return "考勤修正";
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
}
function workflowInstanceStatusLabel(status: string | undefined | null) {
if (!status) {
return "-";
@@ -311,7 +305,7 @@ export function WorkflowTaskDetailPage() {
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels || "-"}</span>} />
<MetricCard title="创建时间" value={<span className="text-base">{formatDateTime(detail.task.createdAt)}</span>} />
<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} />
</StatStrip>
<dl className="detail-fact-grid" data-testid="workflow-task-summary">
@@ -352,8 +346,8 @@ export function WorkflowTaskDetailPage() {
label="时间"
value={
detail.attendanceCorrection.kind === "missed_punch"
? formatDateTime(detail.attendanceCorrection.missedPunchAt)
: `${formatDateTime(detail.attendanceCorrection.startAt)} → ${formatDateTime(detail.attendanceCorrection.endAt)}`
? formatDisplayDateTime(detail.attendanceCorrection.missedPunchAt)
: `${formatDisplayDateTime(detail.attendanceCorrection.startAt)} → ${formatDisplayDateTime(detail.attendanceCorrection.endAt)}`
}
/>
<DetailField label="外出地点" value={detail.attendanceCorrection.locationSummary ?? "-"} />
@@ -507,9 +501,9 @@ export function WorkflowTaskDetailPage() {
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
<div>创建时间:{formatDisplayDateTime(detail.task.createdAt)}</div>
<div>完成时间:{formatDisplayDateTime(detail.task.completedAt)}</div>
<div>发起时间:{formatDisplayDateTime(detail.workflowInstance?.startedAt)}</div>
</>
)}
testId="workflow-task-action-panel"
@@ -4,7 +4,7 @@ import type { Page, Route } from "@playwright/test";
import { expect, test } from "./helpers/fixtures";
test.use({ serviceWorkers: "block" });
test.use({ serviceWorkers: "block", timezoneId: "America/Los_Angeles" });
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-detail-mobile-visual-wave3");
const taskOutputDir = path.resolve(
@@ -1075,6 +1075,30 @@ test.beforeEach(async ({ page }) => {
await login(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.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 page.goto("/workflow/tasks/task-wave3");
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/reimbursements/reim-wave3");
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("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 1440, height: 960 });
@@ -1083,6 +1107,12 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
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.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("reimbursement-first-screen-actions").locator("a,button")).toHaveCount(3);
await expect(page.getByTestId("reimbursement-first-screen-actions")).toContainText("看审批进度");
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
@@ -1097,6 +1127,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
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 page.getByTestId("workflow-task-reimbursement-detail-link").click();
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
@@ -1220,6 +1251,12 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
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.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("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.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
@@ -521,7 +521,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("报销详情 BX20260508001")).toBeVisible();
await expect(page.getByText("预算与审核提示")).toBeVisible();
await expect(page.getByText("预算与审核提示")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
await expect(page.getByText("ERP")).toHaveCount(0);