收口报销详情返回与金额展示(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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 } = {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
</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 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">
|
||||
@@ -1041,7 +1030,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<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">{formatCurrency(item.amount)}</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}
|
||||
{canEdit ? (
|
||||
@@ -1192,7 +1181,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<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) : "", formatCurrency(invoice.totalAmount)].filter(Boolean).join(" · ")}
|
||||
{[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}
|
||||
@@ -1316,17 +1305,6 @@ export function MobileReimbursementDetailPage() {
|
||||
data-testid="mobile-reimbursement-sticky-actions"
|
||||
>
|
||||
<div className="mobile-sticky-action-grid">
|
||||
{canOpenInvoiceDialog ? (
|
||||
<Button
|
||||
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
|
||||
data-testid="mobile-reimbursement-sticky-invoice-add"
|
||||
variant={showSubmitAction ? "secondary" : "default"}
|
||||
type="button"
|
||||
onClick={() => closeInvoiceDialog(true)}
|
||||
>
|
||||
添加发票
|
||||
</Button>
|
||||
) : null}
|
||||
{showSubmitAction ? (
|
||||
<Button
|
||||
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
|
||||
@@ -1513,7 +1491,7 @@ export function MobileReimbursementDetailPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{[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(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<div className="card-std space-y-3 p-5">
|
||||
<p className="text-sm text-red-600">{message}</p>
|
||||
<Button asChild variant="secondary">
|
||||
<Link to="/reimbursements">返回报销列表</Link>
|
||||
<Link replace to={backSource}>{backLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -1042,13 +1052,10 @@ export function ReimbursementDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button asChild data-testid="reimbursement-primary-action">
|
||||
<Link to="/reimbursements">返回列表</Link>
|
||||
</Button>
|
||||
);
|
||||
return null;
|
||||
})();
|
||||
|
||||
const hasMoreActions = detail.status !== "paid" || canDeleteReimbursement;
|
||||
const secondaryActions = [
|
||||
detail.status === "reviewing" && canRemindPendingWorkflow && pendingTaskId ? (
|
||||
<Button
|
||||
@@ -1061,29 +1068,36 @@ export function ReimbursementDetailPage() {
|
||||
>
|
||||
{remindMutation.isPending ? "催办中..." : "催办审批"}
|
||||
</Button>
|
||||
) : detail.status === "paid" ? (
|
||||
) : null,
|
||||
detail.status === "paid" ? (
|
||||
<Button key="print" asChild variant="secondary">
|
||||
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button key="back" asChild variant="secondary" data-testid="reimbursement-back">
|
||||
<Link to="/reimbursements">报销列表</Link>
|
||||
) : null,
|
||||
hasMoreActions ? (
|
||||
<Button
|
||||
key="more"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="reimbursement-more-toggle"
|
||||
onClick={() => setShowSecondaryDetail((value) => !value)}
|
||||
>
|
||||
{showSecondaryDetail ? "收起更多" : "更多"}
|
||||
</Button>
|
||||
),
|
||||
<Button
|
||||
key="more"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="reimbursement-more-toggle"
|
||||
onClick={() => setShowSecondaryDetail((value) => !value)}
|
||||
>
|
||||
{showSecondaryDetail ? "收起更多" : "更多"}
|
||||
</Button>,
|
||||
) : 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={(
|
||||
@@ -1112,7 +1126,7 @@ export function ReimbursementDetailPage() {
|
||||
<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 className="detail-fact-card__value">{formatMoney(detail.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">当前处理</div>
|
||||
@@ -1224,9 +1238,6 @@ export function ReimbursementDetailPage() {
|
||||
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="secondary" data-testid="reimbursement-secondary-back">
|
||||
<Link to="/reimbursements">报销列表</Link>
|
||||
</Button>
|
||||
{canDeleteReimbursement ? (
|
||||
<Button
|
||||
data-testid="reimbursement-delete-draft"
|
||||
@@ -1307,7 +1318,7 @@ export function ReimbursementDetailPage() {
|
||||
</TableCell>
|
||||
<TableCell>{item.expenseDate ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.amount}</div>
|
||||
<div>{formatMoney(item.amount)}</div>
|
||||
{precheckStandardChecks
|
||||
.filter((check) => check.itemId === item.id)
|
||||
.map((check) => (
|
||||
@@ -1315,8 +1326,8 @@ export function ReimbursementDetailPage() {
|
||||
key={check.standardId}
|
||||
className={`mt-1 text-xs ${check.exceeded ? (check.controlMode === "block" ? "text-red-600" : "text-amber-600") : "text-zinc-500"}`}
|
||||
>
|
||||
标准 ¥{check.maxAmount}/{check.perUnit}
|
||||
{check.exceeded ? `,超出 ¥${check.exceededAmount}` : ""}
|
||||
标准 {formatMoney(check.maxAmount)}/{check.perUnit}
|
||||
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
@@ -1453,7 +1464,7 @@ export function ReimbursementDetailPage() {
|
||||
<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) : "", `¥${invoice.totalAmount ?? "-"}`].filter(Boolean).join(" · ")}
|
||||
{[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}
|
||||
@@ -1645,7 +1656,7 @@ export function ReimbursementDetailPage() {
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div>
|
||||
<div className="text-zinc-600">
|
||||
{[resolveInvoicePartyText(item), item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "", `金额 ¥${item.totalAmount ?? "-"}`].filter(Boolean).join(" · ")}
|
||||
{[resolveInvoicePartyText(item), item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "", `金额 ${formatMoney(item.totalAmount)}`].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
|
||||
@@ -1769,7 +1780,7 @@ export function ReimbursementDetailPage() {
|
||||
<div key={`${item.itemId}-${item.standardId}`} className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-amber-700">
|
||||
<div className="font-medium">{item.expenseTypeName}</div>
|
||||
<div>
|
||||
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit} / 超出 ¥{item.exceededAmount}
|
||||
当前 {formatMoney(item.amount)} / 标准 {formatMoney(item.maxAmount)}/{item.perUnit} / 超出 {formatMoney(item.exceededAmount)}
|
||||
</div>
|
||||
<div>{item.standardName}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -20,6 +20,7 @@ import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { buildPcDetailSource } from "../lib/pcDetailNavigation";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -146,6 +147,7 @@ function resolveListAction(item: ReimbursementListItem) {
|
||||
|
||||
export function ReimbursementsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -171,6 +173,7 @@ export function ReimbursementsPage() {
|
||||
const canShowListActions = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
|
||||
const showAdvancedTools = scope === "all" || showProxyOwnerSelector;
|
||||
const shouldLoadCreateOptions = canCreateExpense;
|
||||
const currentListSource = buildPcDetailSource(location.pathname, location.search);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["reimbursements", "list", status, page, scope],
|
||||
@@ -230,7 +233,7 @@ export function ReimbursementsPage() {
|
||||
} else {
|
||||
toast.success("报销单已创建");
|
||||
}
|
||||
navigate(`/reimbursements/${response.id}`);
|
||||
navigate(`/reimbursements/${response.id}`, { state: { from: currentListSource } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error, "创建报销单失败"));
|
||||
@@ -571,6 +574,7 @@ export function ReimbursementsPage() {
|
||||
>
|
||||
<Link
|
||||
to={listAction.to}
|
||||
state={{ from: currentListSource }}
|
||||
data-testid="reimbursement-mobile-detail-link"
|
||||
>
|
||||
{listAction.mobileLabel}
|
||||
@@ -672,6 +676,7 @@ export function ReimbursementsPage() {
|
||||
>
|
||||
<Link
|
||||
to={listAction.to}
|
||||
state={{ from: currentListSource }}
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
{listAction.label}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -34,6 +34,11 @@ import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import {
|
||||
buildPcDetailSource,
|
||||
resolvePcDetailSource,
|
||||
type PcDetailNavigationState,
|
||||
} from "../lib/pcDetailNavigation";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
||||
|
||||
function entityTitle(entityType: string | undefined) {
|
||||
@@ -118,6 +123,7 @@ type SecondaryWorkflowAction = "reject" | "return" | "transfer";
|
||||
|
||||
export function WorkflowTaskDetailPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
@@ -130,6 +136,9 @@ export function WorkflowTaskDetailPage() {
|
||||
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
const [transferConfirmOpen, setTransferConfirmOpen] = useState(false);
|
||||
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),
|
||||
@@ -263,10 +272,10 @@ export function WorkflowTaskDetailPage() {
|
||||
<p className="text-sm text-red-600">{message}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate("/workflow/tasks")}
|
||||
onClick={() => navigate(backSource, { replace: true })}
|
||||
type="button"
|
||||
>
|
||||
返回待办列表
|
||||
{backSource.startsWith("/reimbursements") ? "返回报销列表" : "返回待办列表"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -292,7 +301,9 @@ export function WorkflowTaskDetailPage() {
|
||||
)}
|
||||
actions={(
|
||||
<Button asChild variant="secondary" data-testid="workflow-task-back">
|
||||
<Link to="/workflow/tasks">返回待办列表</Link>
|
||||
<Link replace to={backSource}>
|
||||
{backSource.startsWith("/reimbursements") ? "返回报销列表" : "返回待办列表"}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -397,6 +408,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<Button asChild variant="secondary">
|
||||
<Link
|
||||
data-testid="workflow-task-reimbursement-detail-link"
|
||||
state={{ from: currentTaskSource }}
|
||||
to={`/reimbursements/${encodeURIComponent(detail.reimbursement.id)}`}
|
||||
>
|
||||
查看报销单
|
||||
|
||||
@@ -1093,6 +1093,9 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
|
||||
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);
|
||||
@@ -1109,6 +1112,8 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
|
||||
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("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);
|
||||
@@ -1134,7 +1139,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
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").locator("a,button")).toHaveCount(2);
|
||||
await expect(page.getByTestId("reimbursement-first-screen-actions")).toContainText("看审批进度");
|
||||
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
|
||||
await page.getByTestId("reimbursement-more-toggle").click();
|
||||
@@ -1152,7 +1157,8 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
|
||||
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
|
||||
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
|
||||
await page.goBack();
|
||||
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 page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
|
||||
@@ -1199,6 +1205,22 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
|
||||
});
|
||||
|
||||
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 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$/);
|
||||
});
|
||||
|
||||
test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
|
||||
for (const viewport of [
|
||||
{ width: 768, height: 1024 },
|
||||
|
||||
@@ -586,9 +586,9 @@ async function collectVisualMetrics(page: Page) {
|
||||
const activeRgb = activeStyle ? parseRgb(activeStyle.backgroundColor) : null;
|
||||
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
||||
const stickyRect = stickyActions?.getBoundingClientRect();
|
||||
const addButton = document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']");
|
||||
const materialAddButton = document.querySelector("[data-testid='reimbursement-invoice-add']");
|
||||
const stickyAddButton = document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']");
|
||||
const submitButton = document.querySelector("[data-testid='reimbursement-submit']");
|
||||
const addRect = addButton?.getBoundingClientRect();
|
||||
const submitRect = submitButton?.getBoundingClientRect();
|
||||
const bodyText = document.body?.innerText ?? "";
|
||||
|
||||
@@ -605,8 +605,9 @@ async function collectVisualMetrics(page: Page) {
|
||||
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
|
||||
stickyBottomGap: stickyRect ? Math.round(window.innerHeight - stickyRect.bottom) : null,
|
||||
stickyHeight: stickyRect ? Math.round(stickyRect.height) : 0,
|
||||
stickyButtonsSameRow: Boolean(addRect && submitRect && Math.abs(addRect.top - submitRect.top) <= 2),
|
||||
stickyButtonMinHeight: Math.min(addRect?.height ?? 0, submitRect?.height ?? 0),
|
||||
materialInvoiceAddVisible: Boolean(materialAddButton?.getBoundingClientRect().height),
|
||||
stickyInvoiceAddVisible: Boolean(stickyAddButton?.getBoundingClientRect().height),
|
||||
stickyButtonMinHeight: submitRect?.height ?? 0,
|
||||
activeElementTestId: document.activeElement?.getAttribute("data-testid") ?? "",
|
||||
bodyText,
|
||||
};
|
||||
@@ -732,7 +733,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
expect(beforeDialogMetrics.navHeight).toBe(0);
|
||||
expect(beforeDialogMetrics.stickyActionsVisible).toBe(true);
|
||||
expect(beforeDialogMetrics.stickyBottomGap).toBeLessThanOrEqual(2);
|
||||
expect(beforeDialogMetrics.stickyButtonsSameRow).toBe(true);
|
||||
expect(beforeDialogMetrics.materialInvoiceAddVisible).toBe(true);
|
||||
expect(beforeDialogMetrics.stickyInvoiceAddVisible).toBe(false);
|
||||
expect(beforeDialogMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await mobile.page.getByTestId("reimbursement-invoice-add").click();
|
||||
|
||||
@@ -274,7 +274,6 @@ async function collectMobileMetrics(page: Page) {
|
||||
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
||||
const stickyRect = stickyActions?.getBoundingClientRect();
|
||||
const stickyButtons = [
|
||||
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
|
||||
document.querySelector("[data-testid='reimbursement-submit']"),
|
||||
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
||||
const actionItems = ["approvals", "capture", "reimbursements", "me", "leave"].map((key) => {
|
||||
@@ -350,7 +349,7 @@ test.describe("R5 真机反馈移动交互", () => {
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
|
||||
test("中文头像首字、底部 Tab 固定和详情深层页双按钮符合企业微信体验", async ({ page }) => {
|
||||
test("中文头像首字、底部 Tab 固定和详情深层页单一主按钮符合企业微信体验", async ({ page }) => {
|
||||
await mockApis(page);
|
||||
await login(page);
|
||||
|
||||
@@ -417,7 +416,8 @@ test.describe("R5 真机反馈移动交互", () => {
|
||||
await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "返回" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-invoice-add")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-submit")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursement-detail-sticky`), fullPage: true });
|
||||
|
||||
|
||||
@@ -484,7 +484,6 @@ async function collectMetrics(page: Page) {
|
||||
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
||||
const stickyRect = stickyActions?.getBoundingClientRect();
|
||||
const stickyButtons = [
|
||||
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
|
||||
document.querySelector("[data-testid='reimbursement-submit']"),
|
||||
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
||||
return {
|
||||
|
||||
@@ -545,7 +545,6 @@ async function collectMetrics(page: Page) {
|
||||
return before.display !== "none" && before.visibility !== "hidden" && opacity > 0.01 && height > 0;
|
||||
});
|
||||
const stickyButtons = [
|
||||
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
|
||||
document.querySelector("[data-testid='reimbursement-submit']"),
|
||||
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
||||
return {
|
||||
|
||||
@@ -433,7 +433,6 @@ async function collectRenderingMetrics(page: Page) {
|
||||
});
|
||||
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
||||
const stickyButtons = [
|
||||
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
|
||||
document.querySelector("[data-testid='reimbursement-submit']"),
|
||||
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
||||
const mobileShell = document.querySelector(".hyb-mobile-shell");
|
||||
|
||||
在新工单中引用
屏蔽一个用户