diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 9887577c..893764ed 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -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 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 8e825ffc..fcd867f5 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md @@ -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` 仅用于内容优先、密度和渐进披露判断。 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 03390e9c..ba2735dd 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md @@ -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。 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 73eaa452..02667578 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md @@ -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 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。 diff --git a/client/src/lib/displayFormat.ts b/client/src/lib/displayFormat.ts index 6a54aeaa..9f85e5d9 100644 --- a/client/src/lib/displayFormat.ts +++ b/client/src/lib/displayFormat.ts @@ -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", diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 87777f7f..68e47d8a 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -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 = { 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 (
-
-

当前办理摘要

-

{detailNextStep}

+
+

报销概况

- - {STATUS_LABEL[detail.status]} -
-
+
合计金额
{formatCurrency(detail.totalAmount)}
-
+
-
明细
-
{detail.items.length}
-
-
-
发票
-
{detail.invoices.length}
-
-
-
日期
+
报销日期
{formatDate(detail.reimbursementDate)}
+
+
报销人
+
{detail.owner?.name ?? "-"}
+
{detail.description ?

{detail.description}

: null} @@ -1092,7 +1043,7 @@ export function MobileReimbursementDetailPage() {

附件材料

-

PDF/JPG/PNG/WebP,单次最多 5 个

+ {canManageAttachments ?

PDF/JPG/PNG/WebP,单次最多 5 个

: null}
{canManageAttachments ? (
@@ -1218,7 +1169,7 @@ export function MobileReimbursementDetailPage() {
{workflowActionLabel(item.action)}
-
{formatDateTime(item.createdAt)}
+
{formatDisplayDateTime(item.createdAt)}
{item.stepName ?? "-"} · {item.actorName || "系统"} @@ -1253,7 +1204,6 @@ export function MobileReimbursementDetailPage() { 删除草稿 ) : null} - {!canEdit ?
当前状态不可编辑。
: null}
) : null} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index b8039ef2..0d6d9569 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -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; -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 = { - 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={( <> - 报销日期:{detail.reimbursementDate ?? "-"} - 报销人:{detail.owner?.name ?? "-"} - 提交人:{detail.submitter?.name ?? "-"} - 金额:{detail.totalAmount ?? "-"} )} actions={( @@ -1106,37 +1086,49 @@ export function ReimbursementDetailPage() {
- +
-
-
-
下一步
-
{nextStepText}
-
-
- -
+
+
下一步
+
{nextStepText}
-
-
-
状态
-
{reimbursementStatusLabel(detail.status)}
-
-
-
处理人
-
{currentHandlerText}
-
-
-
金额
-
¥{detail.totalAmount ?? "-"}
-
-
-
材料完整性
-
- {materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")} +
+
+
+
金额
+
¥{detail.totalAmount ?? "-"}
+
+
+
当前处理
+
{currentHandlerText}
+
+
+
材料
+
+ {materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")} +
+
+
+
报销日期
+
{detail.reimbursementDate ?? "-"}
+
+
+
提交人 / 报销人
+
{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}
+
+
+
关联项目
+
+ {detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"} +
+ {detail.description ? ( +
+ {detail.description} +
+ ) : null}
{actionError ? ( @@ -1189,7 +1181,7 @@ export function ReimbursementDetailPage() {
操作人 / 时间
- {payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)} + {payment.paidBy?.name ?? "-"} · {formatDisplayDateTime(payment.paidAt)}
@@ -1200,10 +1192,7 @@ export function ReimbursementDetailPage() {
-
-

更多入口

-

低频操作集中在这里,材料和关联事项已放在主内容区。

-
+

更多

{detail.status !== "paid" ? ( - ) : ( -

当前状态不可编辑明细

- )} + ) : null}
{itemError ? ( @@ -1411,9 +1367,9 @@ export function ReimbursementDetailPage() {
-
+

发票

-

提交前至少添加 1 张发票;重复或不可用发票会给出原因。

+ {canEditItems ?

提交前至少添加 1 张发票。

: null}
{canOpenInvoiceBindDialog ? (
+ ) : null} - + {showApprovalRecords ? ( +
{detail.workflowProgress ? (
@@ -1724,21 +1676,17 @@ export function ReimbursementDetailPage() { ))}
- ) : ( -
- 当前没有进行中的审批节点。 -
- )} + ) : null} + {detail.approvalHistory.length ? (

审批记录

- {detail.approvalHistory.length ? (
{detail.approvalHistory.map((item) => (
{workflowActionLabel(item.action)} - {formatDateTime(item.createdAt)} + {formatDisplayDateTime(item.createdAt)}
{item.stepName ?? "-"} · {item.actorName || "系统"} @@ -1747,39 +1695,21 @@ export function ReimbursementDetailPage() {
))}
- ) : ( -
暂无审批记录。
- )}
+ ) : null}
+ ) : null}
- - {["draft", "rejected"].includes(detail.status) ? ( -
补齐明细和 PDF 发票后提交审批;附件作为补充材料放在主内容区。
- ) : null} - {detail.status === "reviewing" ? ( -
审批动作只在“待我处理”或任务详情执行;如需修改,可撤回后补正。
- ) : null} - {detail.status === "approved" ? ( -
-
本页只显示付款状态;付款登记、批量付款和回单处理请进入付款上下文。
- {canPay ? ( - - ) : null} -
- ) : null} - {detail.status === "paid" ? ( -
已付款报销用于查证据、看回单状态和打印归档,不再编辑或重复付款。
- ) : null} - {detail.status === "cancelled" ? ( -
该报销已取消,仅保留查看和归档记录。
- ) : null} -
+ {detail.status === "approved" && canPay ? ( + + + + ) : null}
- 原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDateTime(item.createdAt)} + 原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
diff --git a/client/src/pages/WorkflowTaskDetailPage.tsx b/client/src/pages/WorkflowTaskDetailPage.tsx index 81885e99..9dd3fea7 100644 --- a/client/src/pages/WorkflowTaskDetailPage.tsx +++ b/client/src/pages/WorkflowTaskDetailPage.tsx @@ -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() { {detail.task.stepName}} /> {entityTitle(detail.workflowInstance?.entityType)}} /> {allowedActionLabels || "-"}} /> - {formatDateTime(detail.task.createdAt)}} /> + {formatDisplayDateTime(detail.task.createdAt)}} />
@@ -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)}` } /> @@ -507,9 +501,9 @@ export function WorkflowTaskDetailPage() {
节点:{detail.task.stepName}
实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}
当前审批角色:{detail.task.assigneeRole ?? "-"}
-
创建时间:{formatDateTime(detail.task.createdAt)}
-
完成时间:{formatDateTime(detail.task.completedAt)}
-
发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}
+
创建时间:{formatDisplayDateTime(detail.task.createdAt)}
+
完成时间:{formatDisplayDateTime(detail.task.completedAt)}
+
发起时间:{formatDisplayDateTime(detail.workflowInstance?.startedAt)}
)} testId="workflow-task-action-panel" diff --git a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts index aa74656a..2b2e8ad0 100644 --- a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts +++ b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts @@ -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); diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts index d5e20e62..109eb3dc 100644 --- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts +++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts @@ -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);