DG7/MRF7 移动信息架构与展示去重治理

这个提交包含在:
2026-05-12 21:03:36 +08:00
父节点 b6b7b77c41
当前提交 9da0ca9632
共修改 26 个文件,包含 1556 行新增和 258 行删除
+82 -90
查看文件
@@ -13,6 +13,8 @@ import { isApiError } from "../api/types";
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
import { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MobileIcon } from "../components/mobile/MobileIcon";
@@ -22,7 +24,6 @@ import {
MOBILE_LEAVE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
import { buildMobileApprovalsPath } from "../pwa";
type MobileAction = {
key: string;
@@ -97,14 +98,6 @@ function attendanceExemptionLabel(reason: unknown) {
return "无需打卡";
}
function workflowEntityLabel(entityType: string | null | undefined) {
if (entityType === "leave") return "请假";
if (entityType === "reimbursement") return "报销";
if (entityType === "purchase_request") return "采购";
if (entityType === "travel_request") return "差旅";
return "审批";
}
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
if (!attendanceToday) {
return null;
@@ -180,7 +173,7 @@ export function MobilePortalPage() {
const notificationsQuery = useQuery({
queryKey: ["mobile", "portal", "notifications"],
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
});
const attendanceClockInMutation = useMutation({
@@ -219,25 +212,22 @@ export function MobilePortalPage() {
const pendingApprovalCount = canAccessApprovals
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
: 0;
const overdueApprovalCount = canAccessApprovals ? workflowSummaryQuery.data?.overdueTotal ?? 0 : 0;
const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
const pendingTasks = pendingCountQuery.data?.items ?? [];
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 actions: MobileAction[] = [
canAccessApprovals
const primaryActions = [
canAccessReimbursements
? {
key: "approvals",
label: "审批",
meta: overdueApprovalCount > 0
? `${pendingApprovalCount} 待处理 / ${overdueApprovalCount} 超时`
: pendingApprovalCount > 0
? `${pendingApprovalCount} 条待处理`
: "暂无待办",
icon: "fact_check",
to: "/mobile/approvals",
tone: pendingApprovalCount > 0 ? "warning" : "primary",
key: "reimbursements",
label: "报销",
meta: "新建与进度",
icon: "request_quote",
to: "/mobile/reimbursements",
}
: null,
canAccessCapture
@@ -250,15 +240,14 @@ export function MobilePortalPage() {
tone: "primary",
}
: null,
canAccessReimbursements
? {
key: "reimbursements",
label: "报销",
meta: "新建与进度",
icon: "request_quote",
to: "/mobile/reimbursements",
}
: null,
{
key: "approvals",
label: canAccessApprovals ? "审批" : "进度",
meta: canAccessApprovals ? "待办与已处理" : "我的申请进度",
icon: "fact_check",
to: "/mobile/approvals",
tone: "primary",
},
{
key: "me",
label: "我的",
@@ -266,6 +255,8 @@ export function MobilePortalPage() {
icon: "person",
to: "/mobile/me",
},
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
const secondaryActions = [
canAccessLeave
? {
key: "leave",
@@ -275,16 +266,69 @@ export function MobilePortalPage() {
to: "/mobile/leave",
}
: null,
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
].filter((item): item is MobileAction => Boolean(item));
const actions = [...primaryActions, ...secondaryActions].slice(0, 4);
return (
<PageScaffold className="space-y-4">
<PageHeader title="今天" />
{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>
) : 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">
{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>
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">进入审批</Link>
</Button>
</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>
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
<div className="text-base font-semibold text-zinc-900">常用</div>
</div>
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
{actions.map((action) => (
@@ -338,58 +382,6 @@ export function MobilePortalPage() {
</section>
) : null}
{canAccessApprovals ? (
<section className="mobile-card" data-testid="mobile-pending-tasks">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待审批</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">全部</Link>
</Button>
</div>
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{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>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">没有待处理审批。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">{workflowEntityLabel(task.workflowInstance?.entityType)}</div>
</Link>
))}
</div>
</section>
) : null}
<section className="mobile-card" data-testid="mobile-notifications">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">通知</div>
@@ -404,15 +396,15 @@ export function MobilePortalPage() {
{resolveErrorMessage(notificationsQuery.error)}
</p>
) : null}
{!notificationsQuery.isLoading && !notifications.length ? (
{!notificationsQuery.isLoading && !homeNotifications.length ? (
<p className="text-sm text-zinc-500">暂无通知。</p>
) : null}
<div className="space-y-2">
{notifications.map((item) => (
{homeNotifications.map((item) => (
<Link
key={item.id}
to="/mobile/notifications"
to={resolveMobileNotificationTargetLink(item) ?? "/mobile/notifications"}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="flex items-center justify-between gap-3">