From 5491e922e6fbc5d6d07ca8c8462067e7f63288d9 Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 14 May 2026 21:53:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20R7E=20=E7=9C=9F=E6=9C=BA?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E5=9B=9E=E6=B5=81=E4=B8=8E=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=20WF8-11A=20R7E-16A=20R7E-16B=20BIZ8-12A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/hyb-oa-delivery/SKILL.md | 2 +- client/src/api/portal.ts | 1 + client/src/components/mobile/MobileIcon.tsx | 2 + client/src/lib/pdfFiles.ts | 22 +++++ client/src/pages/MobileCapturePage.tsx | 17 +++- client/src/pages/MobileMePage.tsx | 4 +- client/src/pages/MobilePortalPage.tsx | 39 +++++---- .../pages/MobileReimbursementDetailPage.tsx | 18 +++- client/src/pages/ReimbursementDetailPage.tsx | 18 +++- .../pages/invoice-pool/InvoiceUploadCard.tsx | 8 +- client/src/styles/mobile-touch.css | 19 +++-- .../e2e/dg7-mobile-information-dedupe.spec.ts | 15 +++- client/tests/e2e/r105-pretrial-visual.spec.ts | 2 +- .../tests/e2e/r110-mobile-pc-routing.spec.ts | 2 +- .../r111-invoice-reimbursement-guard.spec.ts | 2 +- .../e2e/r124-mobile-entry-matrix.spec.ts | 13 ++- docs/trialrun/人工试运行报告.md | 14 ++++ ...2026-05-13_17-11-44-r7e-human-trialrun.csv | 8 +- ...-13_18-23-52-wf8-approval-notification.csv | 3 +- ...5-13_18-23-53-biz8-finance-ocr-project.csv | 3 +- issues/README.md | 2 + server/src/routes/auth.js | 25 +++--- server/src/routes/portal.js | 5 +- .../src/services/notifications/detailUrl.js | 1 - server/tests/unit/routes/auth.login.test.js | 84 +++++++++++++++++++ server/tests/unit/services/imEnqueue.test.js | 8 +- 26 files changed, 281 insertions(+), 56 deletions(-) create mode 100644 client/src/lib/pdfFiles.ts diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index 01d195ba..051ae0df 100644 --- a/.agents/skills/hyb-oa-delivery/SKILL.md +++ b/.agents/skills/hyb-oa-delivery/SKILL.md @@ -34,7 +34,7 @@ Default behavior: ## Delivery Loop 1. Check `git status --short`; do not revert unrelated user changes. -2. Identify or create the current Issue CSV before non-trivial work; after R5, distinguish R6 preparation from actual trial-run execution. +2. Identify the current active continuous Issue CSV before non-trivial work. Same-type trial-run feedback, real-device feedback, single-surface defects, and small follow-up fixes must append rows or update Notes in the active flow instead of creating one-off CSVs. Create a new CSV only when no active same-type flow exists, the current flow is closed, the change crosses risk/business domains, or the user explicitly asks for a new contract; if creating one, state why the active flows cannot carry it. After R5, distinguish R6 preparation from actual trial-run execution. 3. State the implementation approach before editing code; if already approved, proceed. 4. Keep changes scoped to the current Issue. 5. Prefer existing project patterns, official SDKs, and the main UI/layout hierarchy in `docs/产品设计规则.md`; for page work, classify the page into DG9 A/B/C layering and reuse the matching workbench/mobile sample before adding local layout code. Do not mix multiple competitor visual systems or copy competitor flows/text directly. diff --git a/client/src/api/portal.ts b/client/src/api/portal.ts index 319ef97d..30ec9d73 100644 --- a/client/src/api/portal.ts +++ b/client/src/api/portal.ts @@ -83,6 +83,7 @@ export type PortalHomePayload = { pendingBatchCount: number; failedItemCount: number; riskCount: number; + paymentBatchEnabled?: boolean; href: string; } | null; adoptionSummary: diff --git a/client/src/components/mobile/MobileIcon.tsx b/client/src/components/mobile/MobileIcon.tsx index 4fb4f63d..7d92270b 100644 --- a/client/src/components/mobile/MobileIcon.tsx +++ b/client/src/components/mobile/MobileIcon.tsx @@ -19,12 +19,14 @@ import { UserCog, UserRound, Users, + WalletCards, type LucideIcon, } from "lucide-react"; import { cn } from "../../lib/utils"; const MOBILE_ICON_MAP: Record = { account_tree: Network, + account_balance_wallet: WalletCards, badge: IdCard, cloud_upload: CloudUpload, event_available: CalendarCheck, diff --git a/client/src/lib/pdfFiles.ts b/client/src/lib/pdfFiles.ts new file mode 100644 index 00000000..cde8f965 --- /dev/null +++ b/client/src/lib/pdfFiles.ts @@ -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}。`; +} diff --git a/client/src/pages/MobileCapturePage.tsx b/client/src/pages/MobileCapturePage.tsx index 62926418..2e7f23e9 100644 --- a/client/src/pages/MobileCapturePage.tsx +++ b/client/src/pages/MobileCapturePage.tsx @@ -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() { { - setSelectedFiles(Array.from(event.target.files ?? [])); + handlePdfSelection(event.target.files); + event.target.value = ""; }} type="file" /> diff --git a/client/src/pages/MobileMePage.tsx b/client/src/pages/MobileMePage.tsx index 41b1a5a1..fab12130 100644 --- a/client/src/pages/MobileMePage.tsx +++ b/client/src/pages/MobileMePage.tsx @@ -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() { {canAccessPurchase ? : null} + {canAccessFinance ? : null} {canAccessAssets ? : null} {canAccessProjects ? : null} diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index 87a65c10..70d0a5b1 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -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 ? ( -
+
财务
- 复核与付款 + {paymentBatchEnabled ? "复核与付款" : "审批与台账"}
{portalHomeQuery.isLoading ?

读取中...

: null} {portalHomeQuery.isError ? ( @@ -389,22 +390,30 @@ export function MobilePortalPage() { {resolveErrorMessage(portalHomeQuery.error)}

) : null} -
-
-
待付款
-
{financeSummary?.pendingBatchCount ?? 0}
-
-
-
异常
-
- {(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)} + {!portalHomeQuery.isLoading && !portalHomeQuery.isError && paymentBatchEnabled ? ( +
+
+
待付款
+
{financeSummary?.pendingBatchCount ?? 0}
+
+
+
异常
+
+ {(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)} +
+
+
+
台账/导出
+
可用
-
-
台账/导出
-
可用
+ ) : null} + {!portalHomeQuery.isLoading && !portalHomeQuery.isError && !paymentBatchEnabled ? ( +
+
付款批次未启用
+
可查看已流转到财务审批的报销单,待付款和异常批次暂不展示。
-
+ ) : null}
) : null} diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 6518ff23..83bc9e8c 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -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 ? ( diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index b698d8c8..25696898 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -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 = ""; + }} />