- 返回
-
- ) : null}
- />
+ {isPushDetail ? (
+
+ ) : null
+ }
+ fallbackTo="/mobile/approvals"
+ testId="mobile-approvals-detail-header"
+ />
+ ) : (
+
+ )}
{!isOnline && canAccessRemoteApprovals ? (
@@ -916,12 +964,22 @@ export function MobileApprovalsPage() {
-
我发起的
+
我发起的进度
报销、请假、采购进度
{employeeProgressCards.length} 条
+
+
+ {EMPLOYEE_PROGRESS_TABS.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
{employeeProgressLoading ? 加载中...
: null}
{employeeProgressError ? (
@@ -934,7 +992,13 @@ export function MobileApprovalsPage() {
{employeeProgressCards.map((item) => (
-
+
{item.title}
@@ -960,7 +1024,7 @@ export function MobileApprovalsPage() {
setTaskTab(value as MobileTaskTab)}>
- 待处理
+ 待我审批
已处理
@@ -970,7 +1034,7 @@ export function MobileApprovalsPage() {
data-testid="mobile-approvals-search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
- placeholder="搜索待办"
+ placeholder="搜索审批"
/>
@@ -1086,10 +1150,10 @@ export function MobileApprovalsPage() {
{taskTab === "completed"
? "最近 30 天暂无符合筛选条件的已处理任务。"
: !canAccessRemoteApprovals && isOnline
- ? "暂无审批待办;收到企业微信审批消息后可从这里查看。"
+ ? "暂无待我审批;收到企业微信审批消息后可从这里查看。"
: canUseRemote
- ? "暂无符合筛选条件的待办任务。"
- : "暂无符合筛选条件的已缓存待办。"}
+ ? "暂无符合筛选条件的审批任务。"
+ : "暂无符合筛选条件的已缓存审批。"}
) : null}
@@ -1324,6 +1388,7 @@ export function MobileApprovalsPage() {
) : null}
+ {!isPushDetail ? (
离线草稿
@@ -1405,6 +1470,7 @@ export function MobileApprovalsPage() {
))}
+ ) : null}
);
}
diff --git a/client/src/pages/MobileLeavePage.tsx b/client/src/pages/MobileLeavePage.tsx
index 5ae57d1d..97588820 100644
--- a/client/src/pages/MobileLeavePage.tsx
+++ b/client/src/pages/MobileLeavePage.tsx
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
-import { Link, useParams } from "react-router-dom";
+import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -17,6 +17,7 @@ import {
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
+import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { Button } from "../components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -45,6 +46,13 @@ const STATUS_LABEL: Record = {
cancelled: "已取消",
};
+function leaveProgressFallback(status: LeaveRequestStatus | undefined) {
+ if (status === "draft") return "/mobile/approvals?progress=draft";
+ if (status === "approved") return "/mobile/approvals?progress=approved";
+ if (status === "rejected" || status === "cancelled") return "/mobile/approvals?progress=returned";
+ return "/mobile/approvals?progress=reviewing";
+}
+
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
@@ -277,15 +285,16 @@ export function MobileLeavePage() {
};
if (leaveRequestId) {
+ const detail = detailQuery.data;
+
return (
-
- 返回
-
- }
+ subtitle={detail?.leaveType?.name ?? "请假"}
+ fallbackTo={leaveProgressFallback(detail?.status)}
+ status={detail ? {STATUS_LABEL[detail.status]} : null}
+ testId="mobile-leave-detail-header"
/>
{detailQuery.isLoading ? 加载中...
: null}
diff --git a/client/src/pages/MobileNotificationsPage.tsx b/client/src/pages/MobileNotificationsPage.tsx
index 14775de9..901f6ae8 100644
--- a/client/src/pages/MobileNotificationsPage.tsx
+++ b/client/src/pages/MobileNotificationsPage.tsx
@@ -1,4 +1,4 @@
-import { useNavigate, useSearchParams } from "react-router-dom";
+import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
@@ -16,7 +16,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" },
- { label: "审批待办", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
+ { label: "审批事件", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
{ label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
@@ -36,10 +36,23 @@ function formatTime(value: string) {
return value.slice(0, 19).replace("T", " ");
}
+function notificationTypeLabel(type: string) {
+ if (type.startsWith("workflow.")) return "审批事件";
+ if (type.startsWith("reimbursement.")) return "报销事件";
+ if (type.startsWith("leave.")) return "请假事件";
+ if (type.startsWith("purchase_request.")) return "采购事件";
+ if (type.startsWith("travel_request.")) return "差旅事件";
+ if (type.startsWith("asset.")) return "资产事件";
+ if (type.startsWith("inventory_check.")) return "盘点事件";
+ return "系统事件";
+}
+
export function MobileNotificationsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
+ const currentMobileSource = `${location.pathname}${location.search}`;
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true";
@@ -84,7 +97,7 @@ export function MobileNotificationsPage() {
if (!item.readAt) {
await markReadMutation.mutateAsync(item.id);
}
- navigate(targetLink);
+ navigate(targetLink, { state: { from: currentMobileSource } });
} catch (error) {
toast.error(resolveErrorMessage(error));
}
@@ -193,6 +206,7 @@ export function MobileNotificationsPage() {
>
+
{notificationTypeLabel(item.type)}
{item.title}
{item.body ?
{item.body}
: null}
@@ -202,7 +216,7 @@ export function MobileNotificationsPage() {
{formatTime(item.createdAt)}
- {targetLink ? "查看详情" : "暂无直达详情"}
+ {targetLink ? "直达详情" : "暂无直达详情"}
);
})}
diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx
index 2fe800d7..0f01d3f7 100644
--- a/client/src/pages/MobilePortalPage.tsx
+++ b/client/src/pages/MobilePortalPage.tsx
@@ -1,4 +1,4 @@
-import { Link } from "react-router-dom";
+import { Link, useLocation, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -8,11 +8,12 @@ import {
type AttendanceTodayResponse,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages";
-import { fetchNotifications } from "../api/notifications";
+import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
+import { fetchPortalHome } from "../api/portal";
import { isApiError } from "../api/types";
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
-import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
+import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
import { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
import { PageHeader } from "../components/common/PageHeader";
@@ -21,6 +22,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
import { Button } from "../components/ui/button";
import {
MOBILE_CAPTURE_PERMISSIONS,
+ MOBILE_ASSET_SCAN_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
@@ -89,6 +91,16 @@ function formatWorkMinutes(value: number | null | undefined) {
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
+function notificationTypeLabel(type: string) {
+ if (type.startsWith("workflow.")) return "审批事件";
+ if (type.startsWith("reimbursement.")) return "报销事件";
+ if (type.startsWith("leave.")) return "请假事件";
+ if (type.startsWith("purchase_request.")) return "采购事件";
+ if (type.startsWith("travel_request.")) return "差旅事件";
+ if (type.startsWith("asset.")) return "资产事件";
+ return "系统事件";
+}
+
function attendanceExemptionLabel(reason: unknown) {
if (reason === "leave") return "今日请假";
if (reason === "travel") return "今日差旅";
@@ -147,11 +159,17 @@ function ActionTile({ action }: { action: MobileAction }) {
export function MobilePortalPage() {
const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const location = useLocation();
const { me, permissions } = useAuth();
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
+ const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
+ const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
+ const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
+ const currentMobileSource = `${location.pathname}${location.search}`;
const attendanceTodayQuery = useQuery({
queryKey: ["mobile", "portal", "attendance", "today"],
@@ -176,6 +194,22 @@ export function MobilePortalPage() {
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
});
+ const portalHomeQuery = useQuery({
+ enabled: canUseFinance,
+ queryKey: ["mobile", "portal", "home"],
+ queryFn: () => fetchPortalHome(),
+ });
+
+ const markHomeNotificationReadMutation = useMutation({
+ mutationFn: (notificationId: string) => markNotificationRead(notificationId),
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "notifications"] }),
+ queryClient.invalidateQueries({ queryKey: ["notifications"] }),
+ ]);
+ },
+ });
+
const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async (response) => {
@@ -212,51 +246,51 @@ export function MobilePortalPage() {
const pendingApprovalCount = canAccessApprovals
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
: 0;
- const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
const pendingTasks = pendingCountQuery.data?.items ?? [];
+ const urgentPendingTask = pendingTasks[0] ?? null;
const notifications = notificationsQuery.data?.items ?? [];
const homeNotifications = notifications
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
.slice(0, 3);
- const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
+ const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
- const primaryActions = [
- canAccessReimbursements
+ async function openHomeNotification(item: NotificationItem) {
+ const targetLink = resolveMobileNotificationTargetLink(item);
+ if (!targetLink) {
+ navigate("/mobile/notifications", { state: { from: currentMobileSource } });
+ return;
+ }
+
+ try {
+ if (!item.readAt) {
+ await markHomeNotificationReadMutation.mutateAsync(item.id);
+ }
+ navigate(targetLink, { state: { from: currentMobileSource } });
+ } catch (error) {
+ toast.error(resolveErrorMessage(error));
+ }
+ }
+
+ const actions = [
+ canCreateExpense && canAccessReimbursements
? {
- key: "reimbursements",
- label: "报销",
- meta: "新建与进度",
+ key: "new-reimbursement",
+ label: "新建报销",
+ meta: "填写明细",
icon: "request_quote",
- to: "/mobile/reimbursements",
+ to: "/mobile/reimbursements?intent=create",
+ tone: "primary",
}
: null,
canAccessCapture
? {
key: "capture",
- label: "传票据",
+ label: "上传票据",
meta: "上传 PDF",
icon: "receipt_long",
to: "/mobile/capture",
- tone: "primary",
}
: null,
- {
- key: "approvals",
- label: canAccessApprovals ? "审批" : "进度",
- meta: canAccessApprovals ? "待办与已处理" : "我的申请进度",
- icon: "fact_check",
- to: "/mobile/approvals",
- tone: "primary",
- },
- {
- key: "me",
- label: "我的",
- meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知",
- icon: "person",
- to: "/mobile/me",
- },
- ].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
- const secondaryActions = [
canAccessLeave
? {
key: "leave",
@@ -266,8 +300,16 @@ export function MobilePortalPage() {
to: "/mobile/leave",
}
: null,
- ].filter((item): item is MobileAction => Boolean(item));
- const actions = [...primaryActions, ...secondaryActions].slice(0, 4);
+ canAccessAssetScan
+ ? {
+ key: "asset-scan",
+ label: "资产扫码",
+ meta: "扫码盘点",
+ icon: "qr_code_scanner",
+ to: "/mobile/assets/scan",
+ }
+ : null,
+ ].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
@@ -275,57 +317,85 @@ export function MobilePortalPage() {
{canAccessApprovals && pendingApprovalCount > 0 ? (
-
-
待处理
-
{pendingApprovalCount} 待批
-
- {workflowSummaryQuery.data ? (
-
-
-
{workflowSummaryQuery.data.pendingTotal}
-
待办
-
-
-
0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
- {workflowSummaryQuery.data.overdueTotal}
-
-
超时
-
-
-
{workflowEntityTypeCount}
-
类型
-
+
+
- ) : null}
-
- {pendingTasks.slice(0, 3).map((task) => {
- const entityType = task.workflowInstance?.entityType;
- const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
- return (
-
-
-
+
+
+ {pendingApprovalCount}
+
+ {workflowSummaryQuery.data?.overdueTotal ? (
+
超时 {workflowSummaryQuery.data.overdueTotal}
+ ) : null}
+
+
+ {urgentPendingTask ? (
+
+ {(() => {
+ const task = urgentPendingTask;
+ const entityType = task.workflowInstance?.entityType;
+ const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
+ return (
+
+
+
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
待处理
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
-
-
- );
- })}
-
+
+
+ );
+ })()}
+
+ ) : null}
) : null}
+ {canUseFinance ? (
+
+
+ {portalHomeQuery.isLoading ? 读取中...
: null}
+ {portalHomeQuery.isError ? (
+
+ {resolveErrorMessage(portalHomeQuery.error)}
+
+ ) : null}
+
+
+
待付款
+
{financeSummary?.pendingBatchCount ?? 0}
+
+
+
异常
+
+ {(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
+
+
+
+
+
+ ) : null}
+
常用
@@ -402,17 +472,22 @@ export function MobilePortalPage() {
{homeNotifications.map((item) => (
-
void openHomeNotification(item)}
+ data-testid="mobile-home-notification-card"
>
-
{item.title}
+
+
{notificationTypeLabel(item.type)}
+
{item.title}
+
{!item.readAt ?
新 : null}
{formatDateTime(item.createdAt)}
-
+
))}
diff --git a/client/src/pages/MobilePurchaseRequestDetailPage.tsx b/client/src/pages/MobilePurchaseRequestDetailPage.tsx
index fc84b2d8..8d47f8c3 100644
--- a/client/src/pages/MobilePurchaseRequestDetailPage.tsx
+++ b/client/src/pages/MobilePurchaseRequestDetailPage.tsx
@@ -1,10 +1,9 @@
-import { Link, useParams } from "react-router-dom";
+import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
-import { PageHeader } from "../components/common/PageHeader";
+import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
-import { Button } from "../components/ui/button";
import {
formatApprovalHistoryTime,
resolvePurchaseDisplayCode,
@@ -36,6 +35,13 @@ function formatBytes(value: number) {
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
+function purchaseProgressFallback(status: string | undefined) {
+ if (status === "draft") return "/mobile/approvals?progress=draft";
+ if (status === "approved") return "/mobile/approvals?progress=approved";
+ if (status === "rejected" || status === "cancelled") return "/mobile/approvals?progress=returned";
+ return "/mobile/approvals?progress=reviewing";
+}
+
export function MobilePurchaseRequestDetailPage() {
const { purchaseRequestId = "" } = useParams();
@@ -50,14 +56,12 @@ export function MobilePurchaseRequestDetailPage() {
return (
- : null}
- actions={
-
- }
+ : null}
+ fallbackTo={purchaseProgressFallback(detail?.status)}
+ testId="mobile-purchase-detail-header"
/>
{!purchaseRequestId ? (
diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx
index 5ee14429..e18686dc 100644
--- a/client/src/pages/MobileReimbursementDetailPage.tsx
+++ b/client/src/pages/MobileReimbursementDetailPage.tsx
@@ -33,6 +33,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
+import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
@@ -92,6 +93,10 @@ const STATUS_TONE: Record = {
cancelled: "bg-zinc-100 text-zinc-500",
};
+function reimbursementFallback(status: ReimbursementStatus) {
+ return `/mobile/reimbursements?status=${encodeURIComponent(status)}`;
+}
+
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
@@ -795,18 +800,17 @@ export function MobileReimbursementDetailPage() {
)}
data-testid="mobile-reimbursement-detail"
>
-
-
-
-
- {STATUS_LABEL[detail.status]}
-
-
+
+ {STATUS_LABEL[detail.status]}
+
+ }
+ testId="mobile-reimbursement-detail-header"
+ />
合计金额
@@ -828,49 +832,6 @@ export function MobileReimbursementDetailPage() {
{detail.description ? {detail.description}
: null}
- {detail.status === "reviewing" || detail.approvalHistory.length ? (
-
-
-
-
审批
-
- 当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
-
-
- {canRemindPendingWorkflow && pendingTaskId ? (
-
- ) : null}
-
-
- {detail.approvalHistory.length ? (
-
- {detail.approvalHistory.map((item) => (
-
-
-
{workflowActionLabel(item.action)}
-
{formatDateTime(item.createdAt)}
-
-
- {item.stepName ?? "-"} · {item.actorName || "系统"}
-
- {item.comment ? {item.comment}
: null}
-
- ))}
-
- ) : (
- 暂无审批记录。
- )}
-
- ) : null}
-
报销明细
@@ -1139,6 +1100,49 @@ export function MobileReimbursementDetailPage() {
)}
+ {detail.status === "reviewing" || detail.approvalHistory.length ? (
+
+
+
+
审批
+
+ 当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
+
+
+ {canRemindPendingWorkflow && pendingTaskId ? (
+
+ ) : null}
+
+
+ {detail.approvalHistory.length ? (
+
+ {detail.approvalHistory.map((item) => (
+
+
+
{workflowActionLabel(item.action)}
+
{formatDateTime(item.createdAt)}
+
+
+ {item.stepName ?? "-"} · {item.actorName || "系统"}
+
+ {item.comment ? {item.comment}
: null}
+
+ ))}
+
+ ) : (
+ 暂无审批记录。
+ )}
+
+ ) : null}
+
{showSecondaryActions ? (
操作
diff --git a/client/src/pages/MobileReimbursementsPage.tsx b/client/src/pages/MobileReimbursementsPage.tsx
index 2bb763b1..7e42d87e 100644
--- a/client/src/pages/MobileReimbursementsPage.tsx
+++ b/client/src/pages/MobileReimbursementsPage.tsx
@@ -1,5 +1,5 @@
-import { useMemo, useState } from "react";
-import { Link, useNavigate } from "react-router-dom";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -35,6 +35,10 @@ const STATUS_LABEL: Record = {
cancelled: "已取消",
};
+function isReimbursementStatus(value: string | null): value is ReimbursementStatus {
+ return ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"].includes(value ?? "");
+}
+
function formatCurrency(value: string | null | undefined) {
const numberValue = Number(value ?? 0);
if (!Number.isFinite(numberValue)) {
@@ -53,11 +57,13 @@ function formatDate(value: string | null | undefined) {
function ReimbursementCard({
item,
canDeleteDraft,
+ currentMobileSource,
isDeleting,
onDelete,
}: {
item: ReimbursementListItem;
canDeleteDraft: boolean;
+ currentMobileSource: string;
isDeleting: boolean;
onDelete: (item: ReimbursementListItem) => void;
}) {
@@ -89,7 +95,9 @@ function ReimbursementCard({
{canDeleteDraft ? (