V1-V6 收口移动视口与 PDF 发票上传

这个提交包含在:
2026-05-10 23:30:13 +08:00
父节点 3c9cd3aa41
当前提交 491b42a232
共修改 47 个文件,包含 387 行新增和 244 行删除
+4
查看文件
@@ -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>
+1 -1
查看文件
@@ -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: "账号设置" },
+27 -2
查看文件
@@ -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 />
+4 -48
查看文件
@@ -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
+1 -1
查看文件
@@ -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">
+2 -2
查看文件
@@ -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>
);
}
+16
查看文件
@@ -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;
}
}
+6 -6
查看文件
@@ -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();
+3 -3
查看文件
@@ -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 } };
+3 -3
查看文件
@@ -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",