MIF7 移动信息架构整改收口

这个提交包含在:
2026-05-13 00:02:21 +08:00
父节点 39a930399f
当前提交 c83dbb7376
共修改 10 个文件,包含 691 行新增和 206 行删除
+151 -76
查看文件
@@ -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 (
<PageScaffold className="space-y-4">
@@ -275,57 +317,85 @@ export function MobilePortalPage() {
{canAccessApprovals && pendingApprovalCount > 0 ? (
<section className="mobile-card" data-testid="mobile-pending-summary">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待处理</div>
<span className="badge-warning">{pendingApprovalCount} 待批</span>
</div>
{workflowSummaryQuery.data ? (
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
<div className="mt-1 text-zinc-500">待办</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
{workflowSummaryQuery.data.overdueTotal}
</div>
<div className="mt-1 text-zinc-500">超时</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-semibold text-zinc-900">{workflowEntityTypeCount}</div>
<div className="mt-1 text-zinc-500">类型</div>
</div>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-900">待处理</div>
<div className="mt-1 text-xs text-zinc-500">待我审批</div>
</div>
) : null}
<div className="space-y-2">
{pendingTasks.slice(0, 3).map((task) => {
const entityType = task.workflowInstance?.entityType;
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
return (
<Link
key={task.id}
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate font-medium text-zinc-900">
<div className="text-right">
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
{pendingApprovalCount}
</div>
{workflowSummaryQuery.data?.overdueTotal ? (
<div className="mt-1 text-xs font-medium text-amber-700">超时 {workflowSummaryQuery.data.overdueTotal}</div>
) : null}
</div>
</div>
{urgentPendingTask ? (
<div className="space-y-2">
{(() => {
const task = urgentPendingTask;
const entityType = task.workflowInstance?.entityType;
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
return (
<Link
key={task.id}
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
state={{ from: currentMobileSource }}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
data-testid="mobile-pending-summary-primary"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate font-medium text-zinc-900">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
</div>
<span className="badge-warning shrink-0">待处理</span>
</div>
<div className="mt-1 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
</div>
</Link>
);
})}
</div>
</div>
</Link>
);
})()}
</div>
) : null}
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">进入审批</Link>
<Link to="/mobile/approvals">查看全部待办</Link>
</Button>
</section>
) : null}
{canUseFinance ? (
<section className="mobile-card" data-testid="mobile-finance-summary">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">财务</div>
<span className="badge-neutral">复核与付款</span>
</div>
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{portalHomeQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{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)}
</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>
</section>
) : null}
<section className="mobile-card" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">常用</div>
@@ -402,17 +472,22 @@ export function MobilePortalPage() {
<div className="space-y-2">
{homeNotifications.map((item) => (
<Link
<button
key={item.id}
to={resolveMobileNotificationTargetLink(item) ?? "/mobile/notifications"}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
type="button"
className="block w-full rounded-lg border border-zinc-200 px-3 py-2 text-left text-sm"
onClick={() => void openHomeNotification(item)}
data-testid="mobile-home-notification-card"
>
<div className="flex items-center justify-between gap-3">
<div className="truncate font-medium text-zinc-900">{item.title}</div>
<div className="min-w-0">
<div className="text-xs text-zinc-500">{notificationTypeLabel(item.type)}</div>
<div className="mt-0.5 truncate font-medium text-zinc-900">{item.title}</div>
</div>
{!item.readAt ? <span className="badge-warning shrink-0">新</span> : null}
</div>
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</Link>
</button>
))}
</div>
</section>