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;
}
}