V1-V6 收口移动视口与 PDF 发票上传
这个提交包含在:
@@ -308,6 +308,10 @@ export function mapApiErrorMessage(
|
||||
return "当前假种要求上传附件后才能提交。";
|
||||
}
|
||||
|
||||
if (details?.file === "pdf_invoice_required") {
|
||||
return "请上传 PDF 发票文件。";
|
||||
}
|
||||
|
||||
if (details?.file === "unsupported_mime_type") {
|
||||
return "附件类型不被当前假种接受,请重新选择。";
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ export function MobileBottomNav() {
|
||||
const pendingCount = pendingCountQuery.data ?? 0;
|
||||
|
||||
return (
|
||||
<nav className="z-20 shrink-0 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-1.5 shadow-[0_-6px_18px_rgba(15,23,42,0.06)] backdrop-blur md:hidden">
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.35rem,env(safe-area-inset-bottom))] pt-1.5 shadow-[0_-6px_18px_rgba(15,23,42,0.08)] backdrop-blur md:hidden"
|
||||
data-testid="mobile-bottom-nav"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{MOBILE_NAV_ITEMS.map((item) => {
|
||||
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
|
||||
@@ -45,13 +48,13 @@ export function MobileBottomNav() {
|
||||
key={item.key}
|
||||
type="button"
|
||||
data-testid={`mobile-nav-${item.key}`}
|
||||
className="touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[11px] font-medium leading-none text-zinc-400"
|
||||
className="touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none text-zinc-400"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
title={`${item.label}不可用`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +67,7 @@ export function MobileBottomNav() {
|
||||
data-testid={`mobile-nav-${item.key}`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[11px] font-medium leading-none transition-colors",
|
||||
"touch-target flex h-12 min-h-0 flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
@@ -86,7 +89,7 @@ export function MobileBottomNav() {
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -26,7 +26,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/workflow/tasks", title: "审批待办", group: "主导航" },
|
||||
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||
{ path: "/mobile/capture", title: "移动拍照上传", group: "移动工作台" },
|
||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
|
||||
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
|
||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||
|
||||
@@ -17,6 +17,28 @@ export function AppShellLayout() {
|
||||
? resolveMobileEquivalentPath(location.pathname, location.search)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileRoute || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
const height = window.visualViewport?.height ?? window.innerHeight;
|
||||
document.documentElement.style.setProperty("--mobile-viewport-height", `${height}px`);
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
window.addEventListener("orientationchange", updateViewportHeight);
|
||||
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
window.removeEventListener("orientationchange", updateViewportHeight);
|
||||
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, [isMobileRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
if (!pathname || pathname === "/login" || pathname.startsWith("/mobile/")) {
|
||||
@@ -33,10 +55,13 @@ export function AppShellLayout() {
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
|
||||
{isMobileRoute ? null : <Sidebar />}
|
||||
|
||||
<main className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "h-[100dvh] overflow-hidden" : "lg:ml-72"}`}>
|
||||
<main
|
||||
className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "overflow-hidden" : "lg:ml-72"}`}
|
||||
style={isMobileRoute ? { height: "var(--mobile-viewport-height, 100vh)", minHeight: "var(--mobile-viewport-height, 100vh)" } : undefined}
|
||||
>
|
||||
{isMobileRoute ? null : <Topbar />}
|
||||
|
||||
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? "min-h-0 overflow-y-auto pb-5 pt-4 sm:px-5 sm:py-5" : "sm:px-7 sm:py-7 lg:px-8"}`}>
|
||||
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? `min-h-0 overflow-y-auto pt-4 sm:px-5 sm:pt-5 ${showMobileBottomNav ? "pb-[calc(var(--mobile-bottom-nav-height,64px)+env(safe-area-inset-bottom,0px)+1rem)]" : "pb-5 sm:pb-6"}` : "sm:px-7 sm:py-7 lg:px-8"}`}>
|
||||
<div className="layout-container max-w-none">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
|
||||
@@ -174,7 +174,7 @@ export function MobileCapturePage() {
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="票据上传"
|
||||
subtitle="拍照或选择文件。"
|
||||
subtitle="上传 PDF 发票文件。"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
@@ -189,7 +189,7 @@ export function MobileCapturePage() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
|
||||
<p className="mt-1 text-sm leading-5 text-zinc-500">图片或 PDF。</p>
|
||||
<p className="mt-1 text-sm leading-5 text-zinc-500">仅支持 PDF 发票。</p>
|
||||
</div>
|
||||
<span className={isOnline ? "shrink-0 text-sm text-emerald-600" : "shrink-0 text-sm text-orange-600"}>
|
||||
{isOnline ? "在线" : "离线"}
|
||||
@@ -197,58 +197,14 @@ export function MobileCapturePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2" data-testid="mobile-capture-file">
|
||||
<label
|
||||
htmlFor="mobile-capture-camera-file"
|
||||
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50"
|
||||
>
|
||||
<span className="material-symbols-outlined flex size-10 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-[22px] text-blue-700">photo_camera</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-zinc-900">拍照上传</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">打开摄像头拍摄发票</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="mobile-capture-camera-file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
data-testid="mobile-capture-camera-file"
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(Array.from(event.target.files ?? []));
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="mobile-capture-image-file"
|
||||
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50"
|
||||
>
|
||||
<span className="material-symbols-outlined flex size-10 shrink-0 items-center justify-center rounded-lg bg-cyan-50 text-[22px] text-cyan-700">image</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-zinc-900">选择图片</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">JPG、PNG、WEBP</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="mobile-capture-image-file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="sr-only"
|
||||
data-testid="mobile-capture-image-file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(Array.from(event.target.files ?? []));
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="mobile-capture-pdf-file"
|
||||
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50"
|
||||
>
|
||||
<span className="material-symbols-outlined flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-[22px] text-emerald-700">picture_as_pdf</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-zinc-900">选择 PDF</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">数电票和扫描件 PDF</span>
|
||||
<span className="block text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">数电票或扫描件 PDF</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
|
||||
@@ -224,7 +224,7 @@ export function MobilePortalPage() {
|
||||
? {
|
||||
key: "capture",
|
||||
label: "传票据",
|
||||
meta: "拍照或选文件",
|
||||
meta: "上传 PDF",
|
||||
icon: "receipt_long",
|
||||
to: "/mobile/capture",
|
||||
tone: "primary",
|
||||
|
||||
@@ -164,7 +164,6 @@ export function MobileReimbursementDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions, accessToken } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileDocumentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||||
@@ -563,9 +562,6 @@ export function MobileReimbursementDetailPage() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
if (fileDocumentInputRef.current) {
|
||||
fileDocumentInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
@@ -697,9 +693,6 @@ export function MobileReimbursementDetailPage() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
if (fileDocumentInputRef.current) {
|
||||
fileDocumentInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
|
||||
@@ -763,7 +756,16 @@ export function MobileReimbursementDetailPage() {
|
||||
const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit);
|
||||
|
||||
return (
|
||||
<PageScaffold className={cn("space-y-4", showStickyActions ? "pb-28" : "pb-6")} data-testid="mobile-reimbursement-detail">
|
||||
<PageScaffold
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
showStickyActions ? "mobile-fixed-action-page" : "",
|
||||
showStickyActions
|
||||
? "pb-[calc(var(--mobile-sticky-action-height,76px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
|
||||
: "pb-6",
|
||||
)}
|
||||
data-testid="mobile-reimbursement-detail"
|
||||
>
|
||||
<header className="flex items-center gap-3" data-testid="mobile-reimbursement-detail">
|
||||
<Button asChild size="sm" variant="secondary">
|
||||
<Link to="/mobile/reimbursements">返回</Link>
|
||||
@@ -1088,12 +1090,13 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
{showStickyActions ? (
|
||||
<div
|
||||
className="sticky bottom-0 z-20 -mx-4 border-t border-zinc-200 bg-white/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
|
||||
className="fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
|
||||
data-testid="mobile-reimbursement-sticky-actions"
|
||||
>
|
||||
<div className={cn("grid gap-2", canOpenInvoiceDialog && showSubmitAction ? "grid-cols-2" : "grid-cols-1")}>
|
||||
{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"
|
||||
@@ -1104,6 +1107,7 @@ export function MobileReimbursementDetailPage() {
|
||||
) : null}
|
||||
{showSubmitAction ? (
|
||||
<Button
|
||||
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
|
||||
data-testid={TEST_IDS.reimbursement.submit}
|
||||
disabled={submitMutation.isPending}
|
||||
type="button"
|
||||
@@ -1118,9 +1122,10 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
||||
<DialogContent
|
||||
className="max-h-[86dvh] overflow-y-auto p-4"
|
||||
className="overflow-y-auto p-4"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
style={{ maxHeight: "min(86vh, calc(var(--mobile-viewport-height, 100vh) - 3rem))" }}
|
||||
>
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>添加发票</DialogTitle>
|
||||
@@ -1165,42 +1170,23 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
{invoicePanel === "upload" ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
htmlFor="mobile-reimbursement-invoice-upload-image"
|
||||
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[28px] text-zinc-700">photo_camera</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选图片</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">适合手机拍发票</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="mobile-reimbursement-invoice-upload-document"
|
||||
htmlFor="mobile-reimbursement-invoice-upload-pdf"
|
||||
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[28px] text-zinc-700">picture_as_pdf</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF/文件</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">适合电子发票</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">仅支持 PDF,减少拍照模糊和错传</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="mobile-reimbursement-invoice-upload-image"
|
||||
id="mobile-reimbursement-invoice-upload-pdf"
|
||||
className="sr-only"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
<input
|
||||
ref={fileDocumentInputRef}
|
||||
id="mobile-reimbursement-invoice-upload-document"
|
||||
className="sr-only"
|
||||
data-testid="reimbursement-invoice-upload-document"
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
@@ -1251,7 +1237,10 @@ export function MobileReimbursementDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[44dvh] space-y-2 overflow-y-auto pr-1">
|
||||
<div
|
||||
className="space-y-2 overflow-y-auto pr-1"
|
||||
style={{ maxHeight: "min(44vh, calc(var(--mobile-viewport-height, 100vh) * 0.44))" }}
|
||||
>
|
||||
{invoiceLibraryLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{invoiceLibraryError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
|
||||
@@ -1606,7 +1606,7 @@ export function ReimbursementDetailPage() {
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
|
||||
<p className="text-xs text-zinc-500">支持 PDF 或图片,可多选。</p>
|
||||
<p className="text-xs text-zinc-500">仅支持 PDF 发票,可多选。</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
@@ -1625,7 +1625,7 @@ export function ReimbursementDetailPage() {
|
||||
className="hidden"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function InvoiceUploadCard({
|
||||
<input
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
accept="application/pdf"
|
||||
data-testid="invoice-upload-file"
|
||||
multiple
|
||||
onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))}
|
||||
@@ -90,6 +90,7 @@ export function InvoiceUploadCard({
|
||||
{selectedFiles.length > 4 ? <div>另有 {selectedFiles.length - 4} 个文件</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-zinc-500">发票上传仅支持 PDF;附件材料可在对应业务单据中上传图片或其他证明文件。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,3 +6,19 @@
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--mobile-viewport-height: 100vh;
|
||||
--mobile-bottom-nav-height: 64px;
|
||||
--mobile-sticky-action-height: 76px;
|
||||
}
|
||||
|
||||
.page-scaffold.mobile-fixed-action-page {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
:root {
|
||||
--mobile-viewport-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({
|
||||
await expect(page.getByRole("dialog", { name: "添加发票" })).toBeVisible();
|
||||
|
||||
const marker = `MOCK_R73_SUCCESS_${Date.now()}`;
|
||||
const invoiceFileName = `r73-success-${Date.now()}.png`;
|
||||
const invoiceFileName = `r73-success-${Date.now()}.pdf`;
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
|
||||
});
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded();
|
||||
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click();
|
||||
@@ -62,9 +62,9 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
||||
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
||||
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `r73-missing-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("MOCK_OCR_MISSING:invoiceCode,sellerName", "utf8"),
|
||||
name: `r73-missing-${Date.now()}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.4\nMOCK_OCR_MISSING:invoiceCode,sellerName\n%%EOF\n", "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
|
||||
@@ -30,15 +30,15 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
// 2) employee: upload invoice and wait OCR
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const invoiceFileName = `e2e-${Date.now()}.png`;
|
||||
const invoiceFileName = `e2e-${Date.now()}.pdf`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
|
||||
@@ -19,11 +19,12 @@ function resolveInvoiceFiles() {
|
||||
const batchFiles = process.env.PLAYWRIGHT_OCR_LIVE_FILES?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) ?? [];
|
||||
const batchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() ?? "";
|
||||
|
||||
if (directFile) {
|
||||
if (directFile && /\.pdf$/i.test(directFile)) {
|
||||
files.add(path.resolve(directFile));
|
||||
}
|
||||
|
||||
for (const file of batchFiles) {
|
||||
if (!/\.pdf$/i.test(file)) continue;
|
||||
files.add(path.resolve(file));
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ function resolveInvoiceFiles() {
|
||||
for (const entry of readdirSync(batchDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
|
||||
const fullPath = path.join(batchDir, entry);
|
||||
if (!statSync(fullPath).isFile()) continue;
|
||||
if (!/\.(pdf|png|jpg|jpeg|webp)$/i.test(entry)) continue;
|
||||
if (!/\.pdf$/i.test(entry)) continue;
|
||||
files.add(path.resolve(fullPath));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +77,7 @@ test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输
|
||||
test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行");
|
||||
|
||||
const invoiceFiles = resolveInvoiceFiles();
|
||||
test.skip(!invoiceFiles.length, "需要提供 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR");
|
||||
test.skip(!invoiceFiles.length, "需要提供 PDF 格式的 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR");
|
||||
|
||||
const results: BatchResult[] = [];
|
||||
const liveOcrUser = resolveLiveOcrUser(EMPLOYEE);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { EMPLOYEE, login } from "../helpers/auth";
|
||||
@@ -9,7 +10,7 @@ test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可
|
||||
test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行");
|
||||
|
||||
const invoiceFilePath = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() ?? "";
|
||||
test.skip(!invoiceFilePath || !existsSync(invoiceFilePath), "需要提供存在的 PLAYWRIGHT_OCR_LIVE_FILE");
|
||||
test.skip(!invoiceFilePath || !existsSync(invoiceFilePath) || path.extname(invoiceFilePath).toLowerCase() !== ".pdf", "需要提供存在的 PDF 格式 PLAYWRIGHT_OCR_LIVE_FILE");
|
||||
|
||||
const liveOcrUser = resolveLiveOcrUser(EMPLOYEE);
|
||||
|
||||
|
||||
@@ -85,8 +85,10 @@ test.describe("R105 pretrial visual acceptance", () => {
|
||||
|
||||
await page.goto("/mobile/capture");
|
||||
await expect(page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible();
|
||||
const fileInput = page.getByTestId("mobile-capture-file");
|
||||
const fileInput = page.getByTestId("mobile-capture-pdf-file");
|
||||
await expect(fileInput).toHaveAttribute("multiple", "");
|
||||
await expect(fileInput).toHaveAttribute("accept", "application/pdf");
|
||||
await expect(fileInput).not.toHaveAttribute("capture", /.+/);
|
||||
await expect(page.getByTestId("mobile-capture-upload")).toBeVisible();
|
||||
await expectNoDesktopTableOnMobile(page);
|
||||
await expectNoLegacyDesignCopy(page);
|
||||
|
||||
@@ -438,8 +438,8 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
|
||||
await employee390.page.context().setOffline(true);
|
||||
await employee390.page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||
await expect(employee390.page.getByText("离线", { exact: true })).toBeVisible();
|
||||
await employee390.page.getByTestId("mobile-capture-file").setInputFiles([
|
||||
{ name: "r108-mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("r108-mobile-a") },
|
||||
await employee390.page.getByTestId("mobile-capture-pdf-file").setInputFiles([
|
||||
{ name: "r108-mobile-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
|
||||
{ name: "r108-mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF\n") },
|
||||
]);
|
||||
await expect(employee390.page.getByText("已选择 2 个")).toBeVisible();
|
||||
|
||||
@@ -161,9 +161,9 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
|
||||
|
||||
await employee.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles([
|
||||
{
|
||||
name: `r109-mobile-${Date.now()}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
buffer: Buffer.from(`r109-mobile-upload-${Date.now()}`),
|
||||
name: `r109-mobile-${Date.now()}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\nr109-mobile-upload-${Date.now()}\n%%EOF\n`),
|
||||
},
|
||||
]);
|
||||
await employee.page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||
|
||||
@@ -145,7 +145,9 @@ test.describe.serial("R110 手机和 PC 路由分流", () => {
|
||||
|
||||
await mobile.page.goto("/invoice-pool");
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/capture$/);
|
||||
await expect(mobile.page.getByTestId("mobile-capture-file")).toBeAttached();
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toBeAttached();
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toHaveAttribute("accept", "application/pdf");
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
|
||||
await expectNoPcShell(mobile.page);
|
||||
|
||||
await mobile.page.goto("/assets");
|
||||
|
||||
@@ -483,16 +483,29 @@ async function collectVisualMetrics(page: Page) {
|
||||
document.querySelector("nav a[aria-current='page']");
|
||||
const activeStyle = activeNav ? getComputedStyle(activeNav) : null;
|
||||
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 submitButton = document.querySelector("[data-testid='reimbursement-submit']");
|
||||
const addRect = addButton?.getBoundingClientRect();
|
||||
const submitRect = submitButton?.getBoundingClientRect();
|
||||
const bodyText = document.body?.innerText ?? "";
|
||||
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
||||
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
|
||||
navCount: document.querySelectorAll("[data-testid^='mobile-nav-']").length,
|
||||
navHeight: navRect ? Math.round(navRect.height) : 0,
|
||||
activeNavBackground: activeStyle?.backgroundColor ?? "",
|
||||
activeNavAverage: activeRgb ? Math.round((activeRgb.r + activeRgb.g + activeRgb.b) / 3) : 255,
|
||||
activeNavAlpha: activeRgb?.a ?? 0,
|
||||
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),
|
||||
activeElementTestId: document.activeElement?.getAttribute("data-testid") ?? "",
|
||||
bodyText,
|
||||
};
|
||||
@@ -560,11 +573,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
|
||||
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
|
||||
expect(beforeDialogMetrics.tableCount).toBe(0);
|
||||
expect(beforeDialogMetrics.navHeight).toBeLessThanOrEqual(68);
|
||||
if (beforeDialogMetrics.activeNavAlpha > 0) {
|
||||
expect(beforeDialogMetrics.activeNavAlpha).toBeGreaterThan(0.9);
|
||||
expect(beforeDialogMetrics.activeNavAverage, beforeDialogMetrics.activeNavBackground).toBeLessThan(245);
|
||||
}
|
||||
expect(beforeDialogMetrics.navCount).toBe(0);
|
||||
expect(beforeDialogMetrics.navHeight).toBe(0);
|
||||
expect(beforeDialogMetrics.stickyActionsVisible).toBe(true);
|
||||
expect(beforeDialogMetrics.stickyBottomGap).toBeLessThanOrEqual(2);
|
||||
expect(beforeDialogMetrics.stickyButtonsSameRow).toBe(true);
|
||||
expect(beforeDialogMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await mobile.page.getByTestId("reimbursement-invoice-add").click();
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
||||
@@ -592,20 +606,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
|
||||
|
||||
await invoiceDialog.getByRole("button", { name: "上传" }).click();
|
||||
await expect(invoiceDialog.getByText("拍照或选图片")).toBeVisible();
|
||||
await expect(invoiceDialog.getByText("选择 PDF/文件")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "image/*");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("capture", "environment");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).toHaveAttribute(
|
||||
"accept",
|
||||
/application\/pdf/,
|
||||
);
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).not.toHaveAttribute(
|
||||
"capture",
|
||||
/.+/,
|
||||
);
|
||||
await expect(invoiceDialog.getByText("选择 PDF 发票")).toBeVisible();
|
||||
await expect(invoiceDialog.getByText("拍照或选图片")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "application/pdf");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).not.toHaveAttribute("capture", /.+/);
|
||||
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-document").setInputFiles({
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||||
name: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF r111 mobile invoice"),
|
||||
|
||||
@@ -476,12 +476,9 @@ test.describe("R124 企业微信移动入口矩阵", () => {
|
||||
|
||||
await mobile.page.goto("/mobile/capture");
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/capture$/);
|
||||
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toBeAttached();
|
||||
await expect(mobile.page.getByTestId("mobile-capture-image-file")).toBeAttached();
|
||||
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-capture-image-file")).toHaveCount(0);
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toBeAttached();
|
||||
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveAttribute("capture", "environment");
|
||||
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveAttribute("accept", "image/*");
|
||||
await expect(mobile.page.getByTestId("mobile-capture-image-file")).not.toHaveAttribute("capture", /.+/);
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toHaveAttribute("accept", "application/pdf");
|
||||
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
|
||||
await mobile.page.getByTestId("mobile-capture-pdf-file").setInputFiles({
|
||||
|
||||
@@ -4,8 +4,22 @@ import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r5-real-device-feedback-mobile-ux");
|
||||
const WECOM_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
const DEVICE_CASES = [
|
||||
{
|
||||
key: "iphone11-ios14",
|
||||
label: "iPhone 11 iOS14",
|
||||
viewport: { width: 414, height: 896 },
|
||||
userAgent:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN",
|
||||
},
|
||||
{
|
||||
key: "redmi-k60",
|
||||
label: "Redmi K60",
|
||||
viewport: { width: 393, height: 873 },
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Linux; Android 14; Redmi K60 Build/UKQ1.230804.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const employee = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -200,61 +214,115 @@ async function login(page: Page) {
|
||||
async function collectMobileMetrics(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const bottomNav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav");
|
||||
const bottomNavRect = bottomNav?.getBoundingClientRect();
|
||||
const bottomNavStyle = bottomNav ? window.getComputedStyle(bottomNav) : null;
|
||||
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
|
||||
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
return {
|
||||
key,
|
||||
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
||||
width: rect?.width ?? 0,
|
||||
height: rect?.height ?? 0,
|
||||
};
|
||||
});
|
||||
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 {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
||||
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
|
||||
bottomNavVisible: Boolean(bottomNav && bottomNav.getBoundingClientRect().height > 0),
|
||||
bottomNavVisible: Boolean(bottomNavRect && bottomNavRect.height > 0),
|
||||
bottomNavFixed: bottomNavStyle?.position === "fixed",
|
||||
bottomNavBottomGap: bottomNavRect ? window.innerHeight - bottomNavRect.bottom : null,
|
||||
navItems,
|
||||
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
|
||||
stickyBottomGap: stickyRect ? window.innerHeight - stickyRect.bottom : null,
|
||||
stickyButtonsSameRow:
|
||||
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
|
||||
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
|
||||
stickyButtonMinWidth: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.width)) : 0,
|
||||
text: document.body?.innerText ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("R5 真机反馈移动交互", () => {
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: WECOM_UA,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
for (const device of DEVICE_CASES) {
|
||||
test.describe(device.label, () => {
|
||||
test.use({
|
||||
viewport: device.viewport,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: device.userAgent,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
|
||||
test("中文头像首字、报销底部 Tab 和详情深层页交互符合企业微信体验", async ({ page }) => {
|
||||
await mockApis(page);
|
||||
await login(page);
|
||||
test("中文头像首字、底部 Tab 固定和详情深层页双按钮符合企业微信体验", async ({ page }) => {
|
||||
await mockApis(page);
|
||||
await login(page);
|
||||
|
||||
await page.goto("/mobile/me");
|
||||
await expect(page.getByText("王", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("mobile-me-avatar"), fullPage: true });
|
||||
await page.goto("/mobile/me");
|
||||
await expect(page.getByText("王", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-me-avatar`), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/reimbursements");
|
||||
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("mobile-reimbursements-tab"), fullPage: true });
|
||||
await page.goto("/mobile");
|
||||
await expect(page.getByTestId("mobile-bottom-nav")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-home-bottom-nav`), fullPage: true });
|
||||
|
||||
const listMetrics = await collectMobileMetrics(page);
|
||||
expect(listMetrics.bottomNavVisible).toBe(true);
|
||||
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
|
||||
const homeMetrics = await collectMobileMetrics(page);
|
||||
expect(homeMetrics.bottomNavVisible).toBe(true);
|
||||
expect(homeMetrics.bottomNavFixed).toBe(true);
|
||||
expect(homeMetrics.bottomNavBottomGap).not.toBeNull();
|
||||
expect(homeMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
|
||||
expect(homeMetrics.scrollWidth).toBeLessThanOrEqual(homeMetrics.width + 2);
|
||||
for (const item of homeMetrics.navItems) {
|
||||
expect(item.visible, `${device.label} ${item.key} nav item visible`).toBe(true);
|
||||
expect(item.width, `${device.label} ${item.key} nav item touch width`).toBeGreaterThanOrEqual(44);
|
||||
expect(item.height, `${device.label} ${item.key} nav item touch height`).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
|
||||
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("reimbursement-submit")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("mobile-reimbursement-detail-sticky"), fullPage: true });
|
||||
await page.goto("/mobile/reimbursements");
|
||||
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursements-tab`), fullPage: true });
|
||||
|
||||
const detailMetrics = await collectMobileMetrics(page);
|
||||
expect(detailMetrics.bottomNavVisible).toBe(false);
|
||||
expect(detailMetrics.stickyActionsVisible).toBe(true);
|
||||
expect(detailMetrics.tableCount).toBe(0);
|
||||
expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2);
|
||||
expect(detailMetrics.text).not.toContain("发票池");
|
||||
expect(detailMetrics.text).not.toContain("绑定整单");
|
||||
});
|
||||
const listMetrics = await collectMobileMetrics(page);
|
||||
expect(listMetrics.bottomNavVisible).toBe(true);
|
||||
expect(listMetrics.bottomNavFixed).toBe(true);
|
||||
expect(listMetrics.bottomNavBottomGap).not.toBeNull();
|
||||
expect(listMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
|
||||
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
|
||||
|
||||
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("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("reimbursement-submit")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath(`${device.key}-mobile-reimbursement-detail-sticky`), fullPage: true });
|
||||
|
||||
const detailMetrics = await collectMobileMetrics(page);
|
||||
expect(detailMetrics.bottomNavVisible).toBe(false);
|
||||
expect(detailMetrics.stickyActionsVisible).toBe(true);
|
||||
expect(detailMetrics.stickyBottomGap).not.toBeNull();
|
||||
expect(detailMetrics.stickyBottomGap!).toBeLessThanOrEqual(2);
|
||||
expect(detailMetrics.stickyButtonsSameRow).toBe(true);
|
||||
expect(detailMetrics.stickyButtonMinHeight).toBeGreaterThanOrEqual(44);
|
||||
expect(detailMetrics.stickyButtonMinWidth).toBeGreaterThanOrEqual(120);
|
||||
expect(detailMetrics.tableCount).toBe(0);
|
||||
expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2);
|
||||
expect(detailMetrics.text).not.toContain("发票池");
|
||||
expect(detailMetrics.text).not.toContain("绑定整单");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -183,9 +183,9 @@ async function createSubmittedReimbursement(api: AuthedApi, label: string): Prom
|
||||
await api.post("/api/invoice-pool/upload", {
|
||||
multipart: {
|
||||
file: {
|
||||
name: `notify-${label}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(`PW_R75_NOTIFY_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}`, "utf8"),
|
||||
name: `notify-${label}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\nPW_R75_NOTIFY_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}\n%%EOF\n`, "utf8"),
|
||||
},
|
||||
ocr_type: "general",
|
||||
},
|
||||
|
||||
@@ -342,9 +342,9 @@ test.describe("R92 手机端票据与报销可用性", () => {
|
||||
await loginThenUseMobileViewport(page);
|
||||
await page.goto("/mobile/capture");
|
||||
|
||||
await page.getByTestId("mobile-capture-file").setInputFiles([
|
||||
{ name: "mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-a") },
|
||||
{ name: "mobile-b.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-b") },
|
||||
await page.getByTestId("mobile-capture-pdf-file").setInputFiles([
|
||||
{ name: "mobile-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
|
||||
{ name: "mobile-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
|
||||
]);
|
||||
await expect(page.getByText("已选择 2 个")).toBeVisible();
|
||||
await attachScreenshot(page, testInfo, "r97-mobile-capture-multi-upload.png");
|
||||
|
||||
@@ -88,9 +88,9 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `r78-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
name: `r78-${Date.now()}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
|
||||
@@ -31,9 +31,9 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `progress-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
name: `progress-${Date.now()}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
|
||||
@@ -52,15 +52,15 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
|
||||
await expect(orderCard).not.toContainText("标记已支付");
|
||||
|
||||
await page.goto("/invoice-pool");
|
||||
const invoiceFileName = `travel-payment-${Date.now()}.png`;
|
||||
const invoiceFileName = `travel-payment-${Date.now()}.pdf`;
|
||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
|
||||
});
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: invoiceFileName,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(`TRAVEL_PAYMENT_${Date.now()}`, "utf8"),
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(`%PDF-1.4\nTRAVEL_PAYMENT_${Date.now()}\n%%EOF\n`, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } };
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: "hyb-oa 移动工作台",
|
||||
short_name: "hyb-oa",
|
||||
description: "海音达工作台移动端入口,支持离线拍照上传与移动审批。",
|
||||
description: "海音达工作台移动端入口,支持离线 PDF 发票上传与移动审批。",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
|
||||
在新工单中引用
屏蔽一个用户