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 = "";
+ }}
/>