修复 R7E 真机反馈回流与移动入口 WF8-11A R7E-16A R7E-16B BIZ8-12A

这个提交包含在:
2026-05-14 21:53:20 +08:00
父节点 45900e0673
当前提交 5491e922e6
共修改 26 个文件,包含 281 行新增和 56 行删除
+1
查看文件
@@ -83,6 +83,7 @@ export type PortalHomePayload = {
pendingBatchCount: number;
failedItemCount: number;
riskCount: number;
paymentBatchEnabled?: boolean;
href: string;
} | null;
adoptionSummary:
@@ -19,12 +19,14 @@ import {
UserCog,
UserRound,
Users,
WalletCards,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
const MOBILE_ICON_MAP: Record<string, LucideIcon> = {
account_tree: Network,
account_balance_wallet: WalletCards,
badge: IdCard,
cloud_upload: CloudUpload,
event_available: CalendarCheck,
+22
查看文件
@@ -0,0 +1,22 @@
export const PDF_FILE_ACCEPT = ".pdf,application/pdf";
export function isPdfFile(file: File) {
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
}
export function splitPdfFiles(files: File[] | FileList | null | undefined) {
const source = Array.from(files ?? []);
return {
accepted: source.filter(isPdfFile),
rejected: source.filter((file) => !isPdfFile(file)),
};
}
export function rejectedPdfMessage(rejected: File[]) {
if (!rejected.length) {
return "";
}
const names = rejected.slice(0, 3).map((file) => file.name || "未命名文件").join("、");
const suffix = rejected.length > 3 ? `等 ${rejected.length} 个文件` : "";
return `发票上传仅支持 PDF,已忽略 ${names}${suffix}。`;
}
+15 -2
查看文件
@@ -9,6 +9,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import { Spinner } from "../components/ui/spinner";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { withUploadTimeout } from "../lib/uploadTimeout";
import {
enqueueInvoiceUpload,
@@ -145,6 +146,17 @@ export function MobileCapturePage() {
const queuedItems = queueQuery.data ?? [];
function handlePdfSelection(files: FileList | null) {
const { accepted, rejected } = splitPdfFiles(files);
setSelectedFiles(accepted);
const rejectMessage = rejectedPdfMessage(rejected);
setError(rejectMessage);
setMessage("");
if (rejectMessage) {
toast.warning(rejectMessage);
}
}
const queuedCountLabel = useMemo(() => {
if (!queuedItems.length) return "无待上传项";
return `待上传 ${queuedItems.length} 项`;
@@ -210,12 +222,13 @@ export function MobileCapturePage() {
</label>
<input
id="mobile-capture-pdf-file"
accept="application/pdf"
accept={PDF_FILE_ACCEPT}
className="sr-only"
data-testid="mobile-capture-pdf-file"
multiple
onChange={(event) => {
setSelectedFiles(Array.from(event.target.files ?? []));
handlePdfSelection(event.target.files);
event.target.value = "";
}}
type="file"
/>
+3 -1
查看文件
@@ -6,7 +6,7 @@ import { fetchEmployeeProfile } from "../api/employees";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader";
import { MobileIcon } from "../components/mobile/MobileIcon";
@@ -65,6 +65,7 @@ export function MobileMePage() {
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const canAccessFinance = hasFinanceRole(me?.roleCodes, permissions);
const profileQuery = useQuery({
enabled: Boolean(me?.id),
@@ -128,6 +129,7 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与台账" icon="account_balance_wallet" /> : null}
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
</div>
+24 -15
查看文件
@@ -257,6 +257,7 @@ export function MobilePortalPage() {
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
.slice(0, 3);
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
async function openHomeNotification(item: NotificationItem) {
const targetLink = resolveMobileNotificationTargetLink(item);
@@ -378,10 +379,10 @@ export function MobilePortalPage() {
) : null}
{canUseFinance ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
<section id="finance" className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
<div className="mobile-section-header">
<div className="mobile-section-title">财务</div>
<span className="badge-neutral">复核与付款</span>
<span className="badge-neutral">{paymentBatchEnabled ? "复核与付款" : "审批与台账"}</span>
</div>
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{portalHomeQuery.isError ? (
@@ -389,22 +390,30 @@ export function MobilePortalPage() {
{resolveErrorMessage(portalHomeQuery.error)}
</p>
) : null}
<div className="mobile-metric-grid text-sm">
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">待付款</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeSummary?.pendingBatchCount ?? 0}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">异常</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">
{(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && paymentBatchEnabled ? (
<div className="mobile-metric-grid text-sm">
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">待付款</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeSummary?.pendingBatchCount ?? 0}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">异常</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">
{(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">台账/导出</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">可用</div>
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">台账/导出</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">可用</div>
) : null}
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && !paymentBatchEnabled ? (
<div className="mobile-subcard text-sm text-zinc-600" data-testid="mobile-finance-disabled-state">
<div className="font-medium text-zinc-900">付款批次未启用</div>
<div className="mt-1 leading-5">可查看已流转到财务审批的报销单,待付款和异常批次暂不展示。</div>
</div>
</div>
) : null}
</section>
) : null}
@@ -45,6 +45,7 @@ import {
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatInvoiceDisplayTitle,
@@ -224,6 +225,16 @@ export function MobileReimbursementDetailPage() {
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) {
const { accepted, rejected } = splitPdfFiles(files);
setSelectedInvoiceFiles(accepted);
const message = rejectedPdfMessage(rejected);
setInvoiceActionError(message);
if (message) {
toast.warning(message);
}
}
const bindableInvoicesQuery = useQuery({
enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog),
queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch],
@@ -1266,9 +1277,12 @@ export function MobileReimbursementDetailPage() {
className="sr-only"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept="application/pdf"
accept={PDF_FILE_ACCEPT}
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
onChange={(event) => {
handleInvoicePdfSelection(event.target.files);
event.target.value = "";
}}
/>
{selectedInvoiceFiles.length ? (
+16 -2
查看文件
@@ -63,6 +63,7 @@ import {
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
@@ -806,6 +807,16 @@ export function ReimbursementDetailPage() {
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) {
const { accepted, rejected } = splitPdfFiles(files);
setSelectedInvoiceFiles(accepted);
const message = rejectedPdfMessage(rejected);
setActionError(message);
if (message) {
toast.warning(message);
}
}
const expenseTypes = expenseTypesQuery.data?.items ?? [];
const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => {
@@ -1730,9 +1741,12 @@ export function ReimbursementDetailPage() {
className="hidden"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept="application/pdf"
accept={PDF_FILE_ACCEPT}
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
onChange={(event) => {
handleInvoicePdfSelection(event.target.files);
event.target.value = "";
}}
/>
<Button
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
@@ -1,5 +1,6 @@
import type { FetchMyDelegationsResponse } from "../../api/delegations";
import { Button } from "../../components/ui/button";
import { PDF_FILE_ACCEPT, splitPdfFiles } from "../../lib/pdfFiles";
export type InvoiceUploadCardProps = {
selectedFiles: File[];
@@ -62,10 +63,13 @@ export function InvoiceUploadCard({
<input
id="file-upload"
className="hidden"
accept="application/pdf"
accept={PDF_FILE_ACCEPT}
data-testid="invoice-upload-file"
multiple
onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))}
onChange={(event) => {
setSelectedFiles(splitPdfFiles(event.target.files).accepted);
event.target.value = "";
}}
type="file"
/>
+13 -6
查看文件
@@ -253,8 +253,8 @@
.mobile-list-card__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
grid-template-columns: minmax(0, 1fr) max-content;
gap: 0.5rem;
align-items: start;
min-width: 0;
}
@@ -281,10 +281,17 @@
}
.mobile-status-slot {
flex: none;
max-width: 42%;
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
min-width: max-content;
max-width: min(46vw, 8rem);
justify-self: end;
overflow: visible;
text-align: right;
white-space: nowrap;
}
.mobile-status-slot > * {
min-width: max-content;
white-space: nowrap;
}
@@ -542,6 +542,7 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific
pendingBatchCount: 2,
failedItemCount: 1,
riskCount: 3,
paymentBatchEnabled: false,
href: "/ap-operations",
}
: null,
@@ -765,11 +766,16 @@ async function expectNoMobileShellRegression(page: Page) {
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
visibleTables: Array.from(document.querySelectorAll("table")).filter(isVisible).length,
pcShellGridCount: document.querySelectorAll(".page-shell-grid").length,
clippedStatusSlots: Array.from(document.querySelectorAll<HTMLElement>(".mobile-status-slot"))
.filter(isVisible)
.filter((slot) => slot.scrollWidth > slot.clientWidth + 1)
.map((slot) => slot.innerText.trim()),
};
});
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
expect(metrics.visibleTables).toBe(0);
expect(metrics.pcShellGridCount).toBe(0);
expect(metrics.clippedStatusSlots).toEqual([]);
}
async function expectActionTileGapStable(page: Page) {
@@ -1038,10 +1044,13 @@ test.describe("MIF7 移动信息架构整改", () => {
await loginForMobile(financeMobile.page, "finance");
await financeMobile.page.goto("/mobile");
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toBeVisible();
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("待付款");
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("台账/导出");
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("付款批次未启用");
await expect(financeMobile.page.getByTestId("mobile-finance-disabled-state")).toBeVisible();
await expectNoMobileShellRegression(financeMobile.page);
await financeMobile.page.screenshot({ path: screenshotPath("home-finance-summary"), fullPage: true });
await financeMobile.page.goto("/mobile/me");
await expect(financeMobile.page.getByRole("link", { name: /财务/ })).toBeVisible();
await expectNoMobileShellRegression(financeMobile.page);
} finally {
await financeMobile.context.close();
}
@@ -1052,6 +1061,8 @@ test.describe("MIF7 移动信息架构整改", () => {
await loginForMobile(adminMobile.page, "admin");
await adminMobile.page.goto("/mobile");
await expect(adminMobile.page.getByTestId("mobile-finance-summary")).toHaveCount(0);
await adminMobile.page.goto("/mobile/me");
await expect(adminMobile.page.getByRole("link", { name: /财务/ })).toHaveCount(0);
} finally {
await adminMobile.context.close();
}
@@ -87,7 +87,7 @@ test.describe("R105 pretrial visual acceptance", () => {
await expect(page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible();
const fileInput = page.getByTestId("mobile-capture-pdf-file");
await expect(fileInput).toHaveAttribute("multiple", "");
await expect(fileInput).toHaveAttribute("accept", "application/pdf");
await expect(fileInput).toHaveAttribute("accept", ".pdf,application/pdf");
await expect(fileInput).not.toHaveAttribute("capture", /.+/);
await expect(page.getByTestId("mobile-capture-upload")).toBeVisible();
await expectNoDesktopTableOnMobile(page);
@@ -146,7 +146,7 @@ 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-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")).toHaveAttribute("accept", ".pdf,application/pdf");
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
await expectNoPcShell(mobile.page);
@@ -609,7 +609,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await invoiceDialog.getByRole("button", { name: "上传" }).click();
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")).toHaveAttribute("accept", ".pdf,application/pdf");
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).not.toHaveAttribute("capture", /.+/);
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
@@ -454,6 +454,10 @@ async function collectMobileMetrics(page: Page) {
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
tableCount: Array.from(document.querySelectorAll("table")).filter(isVisible).length,
pcShellGridCount: document.querySelectorAll(".page-shell-grid").length,
clippedStatusSlots: Array.from(document.querySelectorAll<HTMLElement>(".mobile-status-slot"))
.filter(isVisible)
.filter((slot) => slot.scrollWidth > slot.clientWidth + 1)
.map((slot) => slot.innerText.trim()),
text: document.body?.innerText ?? "",
};
});
@@ -465,6 +469,7 @@ async function expectMobilePageContract(page: Page) {
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
expect(metrics.tableCount).toBe(0);
expect(metrics.pcShellGridCount).toBe(0);
expect(metrics.clippedStatusSlots).toEqual([]);
expect(metrics.text).not.toContain("返回仪表盘");
}
@@ -493,8 +498,14 @@ test.describe("R124 企业微信移动入口矩阵", () => {
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-pdf-file")).toHaveAttribute("accept", "application/pdf");
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toHaveAttribute("accept", ".pdf,application/pdf");
await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/);
await mobile.page.getByTestId("mobile-capture-pdf-file").setInputFiles({
name: "camera.jpg",
mimeType: "image/jpeg",
buffer: Buffer.from("r124-image"),
});
await expect(mobile.page.getByRole("main").getByText(/发票上传仅支持 PDF,已忽略 camera.jpg/)).toBeVisible();
await mobile.page.getByTestId("mobile-capture-pdf-file").setInputFiles({
name: "r124-invoice.pdf",
mimeType: "application/pdf",