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

这个提交包含在:
2026-05-12 21:03:36 +08:00
父节点 b6b7b77c41
当前提交 9da0ca9632
共修改 26 个文件,包含 1556 行新增和 258 行删除
+222 -90
查看文件
@@ -1,8 +1,11 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
import { isApiError } from "../api/types";
import {
approveWorkflowTask,
@@ -26,6 +29,9 @@ import { Spinner } from "../components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import {
cacheWorkflowTaskDetail,
enqueueWorkflowActionDraft,
@@ -61,6 +67,14 @@ type VisibleTaskCard = {
detail: WorkflowTaskDetail | null;
};
type EmployeeProgressCard = {
id: string;
title: string;
subtitle: string;
status: string;
to: string;
};
const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
{ value: "all", label: "全部" },
{ value: "leave", label: "请假" },
@@ -120,15 +134,8 @@ function businessStatusLabel(status: string | null | undefined) {
return status ? labels[status] ?? status : "-";
}
function isInternalId(value: string | null | undefined) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
}
function publicReference(value: string | null | undefined) {
if (!value || isInternalId(value)) {
return null;
}
return value;
return compactBusinessReference(value);
}
function resolveErrorMessage(error: unknown) {
@@ -209,13 +216,19 @@ function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
const summary = task.businessSummary;
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
const metric = summary?.amount ?? summary?.duration ?? null;
const title = businessPrimaryLabel({
entityType,
reference,
title: summary?.title ?? task.stepName,
fallback: task.stepName,
});
const subtitle = task.status === "completed"
? joinBusinessMeta([`节点:${task.stepName}`, `完成于 ${formatDateTime(task.completedAt)}`])
: joinBusinessMeta([`节点:${task.stepName}`, summary?.applicantName ? `申请人:${summary.applicantName}` : null]);
return {
id: task.id,
title: summary?.title ?? task.stepName,
subtitle:
task.status === "completed"
? `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"} · 完成于 ${formatDateTime(task.completedAt)}`
: `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"}`,
title,
subtitle,
status: task.status,
entityType,
entityId: task.workflowInstance?.entityId ?? null,
@@ -252,11 +265,66 @@ function matchesSearch(card: VisibleTaskCard, keyword: string) {
return haystack.includes(searchValue);
}
function toEmployeeProgressCards(input: {
reimbursements: ReimbursementListItem[];
leaveRequests: LeaveRequestItem[];
purchaseRequests: PurchaseRequestSummary[];
}): EmployeeProgressCard[] {
const reimbursementCards = input.reimbursements.map((item) => ({
id: `reimbursement-${item.id}`,
title: businessPrimaryLabel({
entityType: "reimbursement",
reference: item.formNumber,
title: resolveReimbursementDisplayCode(item),
}),
subtitle: joinBusinessMeta([
item.owner?.name ? `报销人:${item.owner.name}` : null,
item.totalAmount ? `金额:${item.totalAmount}` : null,
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
]),
status: item.status,
to: `/mobile/reimbursements/${encodeURIComponent(item.id)}`,
}));
const leaveCards = input.leaveRequests.map((item) => ({
id: `leave-${item.id}`,
title: joinBusinessMeta(["请假", item.leaveType?.name]) || "请假",
subtitle: joinBusinessMeta([
`${item.startDate} 至 ${item.endDate}`,
`${item.requestedDays} 天`,
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
]),
status: item.status,
to: `/mobile/leave/${encodeURIComponent(item.id)}`,
}));
const purchaseCards = input.purchaseRequests.map((item) => ({
id: `purchase-${item.id}`,
title: businessPrimaryLabel({
entityType: "purchase_request",
reference: item.requestNo,
title: resolvePurchaseDisplayCode(item),
}),
subtitle: joinBusinessMeta([
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
item.totalAmount ? `金额:${item.totalAmount}` : null,
item.updatedAt ? `更新:${formatDateTime(item.updatedAt)}` : null,
]),
status: item.status,
to: `/mobile/purchase-requests/${encodeURIComponent(item.id)}`,
}));
return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8);
}
export function MobileApprovalsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const routeParams = useParams();
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? "";
const [searchParams] = useSearchParams();
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
const isPushDetail = Boolean(deepLinkTaskId);
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
@@ -274,13 +342,30 @@ export function MobileApprovalsPage() {
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
const [isOnline, setIsOnline] = useState(true);
const autoSyncRequestedRef = useRef(false);
const detailSectionRef = useRef<HTMLElement | null>(null);
const ownerEmployeeId = me?.id ?? null;
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
const reimbursementProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "reimbursements"],
queryFn: () => fetchReimbursements({ scope: "own", page: 1, pageSize: 5 }),
});
const leaveProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "leave"],
queryFn: () => fetchLeaveRequests({ scope: "own", page: 1, pageSize: 5 }),
});
const purchaseProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "purchase"],
queryFn: () => fetchPurchaseRequests({ mine: true, page: 1, pageSize: 5 }),
});
useEffect(() => {
setSelectedTaskId(deepLinkTaskId);
}, [deepLinkTaskId]);
@@ -293,30 +378,8 @@ export function MobileApprovalsPage() {
}
}, [taskTab]);
function updateTaskSelection(taskId: string) {
setSelectedTaskId(taskId);
const nextParams = new URLSearchParams(searchParams);
if (taskId) {
nextParams.set("taskId", taskId);
} else {
nextParams.delete("taskId");
}
setSearchParams(nextParams, { replace: true });
}
function scrollDetailIntoViewOnMobile() {
if (typeof window === "undefined" || !window.matchMedia("(max-width: 767px)").matches) {
return;
}
window.requestAnimationFrame(() => {
detailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
function handleTaskSelection(taskId: string) {
updateTaskSelection(taskId);
scrollDetailIntoViewOnMobile();
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`);
}
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
@@ -391,8 +454,12 @@ export function MobileApprovalsPage() {
.filter((item) => item.detail.task.status === "pending")
.map((item) => ({
id: item.detail.task.id,
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
subtitle: `业务:${entityLabel(item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType)} · 缓存于 ${formatDateTime(item.savedAt)}`,
title: businessPrimaryLabel({
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType,
reference: item.taskSnapshot.reference,
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
}),
subtitle: joinBusinessMeta([`节点:${item.taskSnapshot.stepName ?? item.detail.task.stepName}`, `缓存于 ${formatDateTime(item.savedAt)}`]),
status: item.detail.task.status,
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
entityId: item.taskSnapshot.entityId,
@@ -432,21 +499,50 @@ export function MobileApprovalsPage() {
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
const selectedDetailReference = selectedDisplayReference ??
publicReference(selectedTask?.reimbursement?.formNumber) ??
publicReference(selectedTask?.purchaseRequest?.requestNo) ??
publicReference(selectedTask?.travelRequest?.requestNo);
const selectedDetailTitle = selectedTask
? businessPrimaryLabel({
entityType: selectedTask.workflowInstance?.entityType,
reference: selectedDetailReference,
title: selectedTaskCard?.title ?? selectedTask.task.stepName,
})
: "";
const selectedApplicantName = selectedTaskCard?.applicantName ??
selectedTask?.reimbursement?.submitter?.name ??
selectedTask?.leaveRequest?.owner?.name ??
selectedTask?.purchaseRequest?.applicant?.name ??
selectedTask?.travelRequest?.applicant?.name ??
null;
const selectedMetric = selectedTaskCard?.metric ??
selectedTask?.reimbursement?.totalAmount ??
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
selectedTask?.purchaseRequest?.totalAmount ??
selectedTask?.travelRequest?.estimatedAmount ??
null;
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
const employeeProgressCards = useMemo(
() =>
toEmployeeProgressCards({
reimbursements: reimbursementProgressQuery.data?.items ?? [],
leaveRequests: leaveProgressQuery.data?.items ?? [],
purchaseRequests: purchaseProgressQuery.data?.items ?? [],
}),
[leaveProgressQuery.data?.items, purchaseProgressQuery.data?.items, reimbursementProgressQuery.data?.items],
);
const employeeProgressLoading =
reimbursementProgressQuery.isLoading || leaveProgressQuery.isLoading || purchaseProgressQuery.isLoading;
const employeeProgressError =
reimbursementProgressQuery.error ?? leaveProgressQuery.error ?? purchaseProgressQuery.error ?? null;
useEffect(() => {
const visibleIds = new Set(filteredTaskCards.map((task) => task.id));
setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId)));
}, [filteredTaskCards]);
useEffect(() => {
if (selectedTaskId || !filteredTaskCards.length) {
return;
}
updateTaskSelection(filteredTaskCards[0].id);
}, [filteredTaskCards, selectedTaskId]);
const canTransfer = Boolean(
canUseRemote &&
selectedTaskAllowsActions &&
@@ -566,7 +662,7 @@ export function MobileApprovalsPage() {
setTransferToApproverId("");
setError("");
setMessage("");
updateTaskSelection("");
navigate("/mobile/approvals", { replace: true });
toast.success("操作成功");
},
onError: (actionError) => {
@@ -656,7 +752,7 @@ export function MobileApprovalsPage() {
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
setMessage(summary);
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
updateTaskSelection("");
navigate("/mobile/approvals", { replace: true });
}
if (result.failed > 0) {
@@ -793,11 +889,16 @@ export function MobileApprovalsPage() {
return (
<PageScaffold className="space-y-4">
<PageHeader
title="移动审批"
subtitle={deepLinkTaskId ? "已定位指定待办。" : "处理待办。"}
title={isPushDetail ? "审批详情" : canAccessRemoteApprovals ? "审批" : "审批进度"}
subtitle={isPushDetail ? "查看并处理指定待办。" : canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
actions={isPushDetail ? (
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">返回</Link>
</Button>
) : null}
/>
{!isOnline ? (
{!isOnline && canAccessRemoteApprovals ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
@@ -811,6 +912,43 @@ export function MobileApprovalsPage() {
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null}
{!isPushDetail && !canAccessRemoteApprovals ? (
<section className="mobile-card space-y-3" data-testid="mobile-approvals-progress">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">我发起的</h3>
<p className="mt-1 text-sm text-zinc-500">报销、请假、采购进度</p>
</div>
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
</div>
{employeeProgressLoading ? <div className="mobile-empty-state">加载中...</div> : null}
{employeeProgressError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(employeeProgressError)}
</p>
) : null}
{!employeeProgressLoading && !employeeProgressError && !employeeProgressCards.length ? (
<div className="mobile-empty-state">暂无申请进度</div>
) : null}
<div className="space-y-2">
{employeeProgressCards.map((item) => (
<Link className="mobile-list-card block" data-testid="mobile-approval-progress-card" key={item.id} to={item.to}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.title}</div>
{item.subtitle ? <div className="mt-1 truncate text-xs text-zinc-500">{item.subtitle}</div> : null}
</div>
<StatusBadge status={item.status} />
</div>
</Link>
))}
</div>
</section>
) : null}
{!isPushDetail && canAccessRemoteApprovals ? (
<section className="mobile-card space-y-4" data-testid="mobile-approvals-list">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -986,9 +1124,8 @@ export function MobileApprovalsPage() {
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
<div className="mt-1 text-xs text-zinc-500">
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
@@ -1011,9 +1148,8 @@ export function MobileApprovalsPage() {
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
<div className="mt-1 text-xs text-zinc-500">
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
@@ -1021,8 +1157,10 @@ export function MobileApprovalsPage() {
})}
</div>
</section>
) : null}
<section ref={detailSectionRef} className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
{isPushDetail ? (
<section className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
<h3 className="text-base font-semibold text-zinc-900">处理</h3>
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
@@ -1046,44 +1184,37 @@ export function MobileApprovalsPage() {
{selectedTask ? (
<>
<div className="mobile-list-card text-sm">
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
<div>步骤:{selectedTask.task.stepName}</div>
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
<div>业务单号:{selectedDisplayReference ?? "-"}</div>
<div>业务状态:{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
<div>申请人:{selectedTaskCard?.applicantName ?? "-"}</div>
<div>金额/天数:{selectedTaskCard?.metric ?? "-"}</div>
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-semibold text-zinc-900">{selectedDetailTitle}</div>
<div className="mt-1 text-xs text-zinc-500">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div>
</div>
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
</div>
<div className="mt-3 space-y-1 text-zinc-600">
<div>{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}</div>
<div>{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}</div>
{selectedTask.task.completedAt ? <div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div> : null}
</div>
{selectedTask.reimbursement ? (
<>
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
<div>金额:{selectedTask.reimbursement.totalAmount ?? "-"}</div>
<div>提交人:{selectedTask.reimbursement.submitter?.name ?? "-"}</div>
</>
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
</div>
) : null}
{selectedTask.leaveRequest ? (
<>
<div>假期类型:{selectedTask.leaveRequest.leaveType?.name ?? "-"}</div>
<div>申请人:{selectedTask.leaveRequest.owner?.name ?? "-"}</div>
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
</>
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
</div>
) : null}
{selectedTask.purchaseRequest ? (
<>
<div>采购单号:{selectedTask.purchaseRequest.requestNo ?? "-"}</div>
<div>申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}</div>
<div>供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}</div>
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
</>
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
</div>
) : null}
{selectedTask.travelRequest ? (
<>
<div>差旅单号:{selectedTask.travelRequest.requestNo ?? "-"}</div>
<div>申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}</div>
<div>出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
<div>预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}</div>
</>
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`出差类型:${selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}`, selectedTask.travelRequest.purpose])}
</div>
) : null}
</div>
@@ -1191,6 +1322,7 @@ export function MobileApprovalsPage() {
</>
) : null}
</section>
) : null}
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
<div className="flex items-center justify-between gap-3">