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

这个提交包含在:
2026-05-12 21:03:36 +08:00
父节点 b6b7b77c41
当前提交 9da0ca9632
共修改 26 个文件,包含 1556 行新增和 258 行删除
@@ -37,12 +37,11 @@ export function MobileBottomNav() {
>
<div className="relative z-[71] grid grid-cols-4 gap-1">
{MOBILE_NAV_ITEMS.map((item) => {
const disabled = item.key === "approvals"
? !canAccessApprovals
: item.anyOf
const disabled = item.anyOf
? !hasAnyPermission(permissions, [...item.anyOf])
: false;
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
const label = item.key === "approvals" && !canAccessApprovals ? "进度" : item.label;
if (disabled) {
return (
@@ -53,14 +52,14 @@ export function MobileBottomNav() {
className="touch-target flex h-12 min-h-0 min-w-0 select-none flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none text-zinc-400"
disabled
aria-disabled="true"
title={`${item.label}不可用`}
title={`${label}不可用`}
>
<MobileIcon name={item.icon} className="size-5" />
<span
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
data-testid={`mobile-nav-${item.key}-label`}
>
{item.label}
{label}
</span>
</button>
);
@@ -83,26 +82,26 @@ export function MobileBottomNav() {
>
{({ isActive }) => (
<>
<span>
<span className="relative">
<MobileIcon name={item.icon} className="size-5" />
{badge ? (
<span
data-testid={`mobile-nav-${item.key}-badge`}
className={cn(
"pointer-events-none absolute left-[calc(100%+1px)] top-0 min-w-4 -translate-y-1/2 rounded-full px-1 text-center text-[10px] font-semibold leading-4 shadow-sm",
isActive ? "bg-red-500 text-white" : "bg-red-500 text-white",
)}
>
{badge}
</span>
) : null}
</span>
<span
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
data-testid={`mobile-nav-${item.key}-label`}
>
{item.label}
{label}
</span>
{badge ? (
<span
data-testid={`mobile-nav-${item.key}-badge`}
className={cn(
"pointer-events-none absolute right-1 top-1 min-w-4 rounded-full px-1 text-[10px] font-semibold leading-4 shadow-sm",
isActive ? "bg-red-500 text-white" : "bg-red-500 text-white",
)}
>
{badge}
</span>
) : null}
</>
)}
</NavLink>
+3
查看文件
@@ -27,7 +27,10 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
{ path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" },
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
+1 -1
查看文件
@@ -11,7 +11,7 @@ import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib
export function AppShellLayout() {
const location = useLocation();
const isMobileRoute = location.pathname.startsWith("/mobile");
const isMobileDeepRoute = /^\/mobile\/reimbursements\/[^/]+$/.test(location.pathname);
const isMobileDeepRoute = /^\/mobile\/(?:reimbursements|approvals|leave|purchase-requests)\/[^/]+$/.test(location.pathname);
const showMobileBottomNav = isMobileRoute && !isMobileDeepRoute;
const mobileEquivalentPath = !isMobileRoute && shouldPreferMobileWorkplace()
? resolveMobileEquivalentPath(location.pathname, location.search)
+56
查看文件
@@ -0,0 +1,56 @@
import { isUuidLike } from "./reimbursementDisplay";
export type BusinessEntityType = "reimbursement" | "leave" | "purchase_request" | "travel_request" | string | null | undefined;
const ENTITY_NOUN_LABELS: Record<string, string> = {
reimbursement: "报销单",
leave: "请假",
purchase_request: "采购单",
travel_request: "差旅单",
};
const ENTITY_SHORT_LABELS: Record<string, string> = {
reimbursement: "报销",
leave: "请假",
purchase_request: "采购",
travel_request: "差旅",
};
export function businessEntityLabel(entityType: BusinessEntityType, options: { noun?: boolean } = {}) {
if (!entityType) {
return options.noun ? "业务单据" : "业务";
}
const labels = options.noun ? ENTITY_NOUN_LABELS : ENTITY_SHORT_LABELS;
return labels[entityType] ?? String(entityType);
}
export function compactBusinessReference(value: string | null | undefined) {
const trimmed = value?.trim();
if (!trimmed || isUuidLike(trimmed)) {
return null;
}
return trimmed;
}
export function businessPrimaryLabel(input: {
entityType: BusinessEntityType;
reference?: string | null;
title?: string | null;
fallback?: string;
}) {
const reference = compactBusinessReference(input.reference);
if (reference) {
return `${businessEntityLabel(input.entityType, { noun: true })} ${reference}`;
}
const title = input.title?.trim();
if (title && !isUuidLike(title)) {
return title;
}
return input.fallback ?? businessEntityLabel(input.entityType, { noun: true });
}
export function joinBusinessMeta(items: Array<string | null | undefined>) {
return items.map((item) => item?.trim()).filter((item): item is string => Boolean(item)).join(" · ");
}
+34
查看文件
@@ -3,6 +3,10 @@ type NotificationTarget = {
targetId: string | null;
};
function encodeSegment(value: string) {
return encodeURIComponent(value);
}
export function resolveNotificationTargetLink(item: NotificationTarget) {
if (!item.targetType || !item.targetId) return null;
@@ -41,6 +45,36 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
return null;
}
export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
if (!item.targetType || !item.targetId) return null;
if (item.targetType === "workflow_task") {
return `/mobile/approvals/${encodeSegment(item.targetId)}`;
}
if (item.targetType === "reimbursement_form") {
return `/mobile/reimbursements/${encodeSegment(item.targetId)}`;
}
if (item.targetType === "leave_request") {
return `/mobile/leave/${encodeSegment(item.targetId)}`;
}
if (item.targetType === "purchase_request") {
return `/mobile/purchase-requests/${encodeSegment(item.targetId)}`;
}
if (item.targetType === "asset" || item.targetType === "asset_card" || item.targetType === "inventory_check") {
return "/mobile/assets/scan";
}
if (item.targetType === "employee") {
return "/mobile/profile";
}
return null;
}
export function formatNotificationRelativeTime(value: string | null) {
if (!value) {
return "-";
+19 -2
查看文件
@@ -43,6 +43,7 @@ function encodeSegment(value: string) {
export function resolveMobileEquivalentPath(pathname: string, search = "") {
const normalizedPath = pathname.replace(/\/+$/, "") || "/";
const searchParams = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
if (normalizedPath === "/" || normalizedPath === "/portal" || normalizedPath === "/dashboard") {
return "/mobile";
@@ -71,6 +72,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
}
if (normalizedPath === "/leave-requests") {
const leaveRequestId = searchParams.get("highlight") ?? searchParams.get("id") ?? searchParams.get("requestId");
if (leaveRequestId) {
return `/mobile/leave/${encodeSegment(leaveRequestId)}`;
}
return "/mobile/leave";
}
@@ -80,7 +85,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
const workflowTaskMatch = normalizedPath.match(/^\/workflow\/tasks\/([^/]+)$/);
if (workflowTaskMatch?.[1]) {
return `/mobile/approvals?${new URLSearchParams({ taskId: workflowTaskMatch[1] }).toString()}`;
return `/mobile/approvals/${encodeSegment(workflowTaskMatch[1])}`;
}
if (normalizedPath === "/workflow/tasks") {
@@ -104,7 +109,6 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
if (
normalizedPath.startsWith("/projects") ||
normalizedPath.startsWith("/contracts-registry") ||
normalizedPath.startsWith("/purchase-requests") ||
normalizedPath.startsWith("/travel-requests") ||
normalizedPath.startsWith("/payment-batches") ||
normalizedPath.startsWith("/ap-operations") ||
@@ -119,5 +123,18 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
return "/mobile";
}
if (normalizedPath === "/purchase-requests") {
const purchaseRequestId = searchParams.get("highlight") ?? searchParams.get("id") ?? searchParams.get("requestId");
if (purchaseRequestId) {
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestId)}`;
}
return "/mobile";
}
const purchaseRequestDetailMatch = normalizedPath.match(/^\/purchase-requests\/([^/]+)$/);
if (purchaseRequestDetailMatch?.[1]) {
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestDetailMatch[1])}`;
}
return search ? `${pathname}${search}` : pathname;
}
+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">
+41 -1
查看文件
@@ -1,9 +1,11 @@
import { useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
createLeaveRequest,
fetchLeaveBalances,
fetchLeaveRequestDetail,
fetchLeaveRequests,
fetchLeaveTypes,
patchLeaveRequest,
@@ -60,11 +62,13 @@ function LeaveRecordCard({
isSaving,
onEdit,
onSubmit,
showActions = true,
}: {
item: LeaveRequestItem;
isSaving: boolean;
onEdit: (item: LeaveRequestItem) => void;
onSubmit: (item: LeaveRequestItem) => void;
showActions?: boolean;
}) {
const canEdit = item.status === "draft" || item.status === "rejected";
const canSubmit = item.status === "draft" || item.status === "rejected";
@@ -104,7 +108,7 @@ function LeaveRecordCard({
</div>
) : null}
{canEdit || canSubmit ? (
{showActions && (canEdit || canSubmit) ? (
<div className="mobile-two-action-grid mt-4">
{canEdit ? (
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
@@ -124,6 +128,7 @@ function LeaveRecordCard({
export function MobileLeavePage() {
const queryClient = useQueryClient();
const { leaveRequestId = "" } = useParams();
const { permissions } = useAuth();
const canCreateLeave = hasPermission(permissions, "leave.create");
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
@@ -158,6 +163,12 @@ export function MobileLeavePage() {
}),
});
const detailQuery = useQuery({
enabled: Boolean(leaveRequestId),
queryKey: ["mobile", "leave", "detail", leaveRequestId],
queryFn: () => fetchLeaveRequestDetail(leaveRequestId),
});
const invalidateLeaveQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
@@ -265,6 +276,35 @@ export function MobileLeavePage() {
window.scrollTo({ top: 0, behavior: "smooth" });
};
if (leaveRequestId) {
return (
<PageScaffold className="space-y-3 pb-4">
<PageHeader
title="请假详情"
actions={
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/leave">返回</Link>
</Button>
}
/>
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
{detailQuery.isError ? (
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
) : null}
{detailQuery.data ? (
<LeaveRecordCard
item={detailQuery.data}
isSaving={false}
onEdit={startEditing}
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
showActions={false}
/>
) : null}
</PageScaffold>
);
}
return (
<PageScaffold className="space-y-3 pb-4">
<PageHeader title="请假" />
+8 -5
查看文件
@@ -52,7 +52,6 @@ function MobileActionLink({
export function MobileMePage() {
const { me, permissions, refreshMe, logout } = useAuth();
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const canAccessAdmin = hasAnyPermission(permissions, [
"*",
"system.settings",
@@ -63,6 +62,7 @@ export function MobileMePage() {
]);
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const profileQuery = useQuery({
enabled: Boolean(me?.id),
@@ -74,7 +74,7 @@ export function MobileMePage() {
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
onSuccess: async () => {
await refreshMe();
toast.success("外部推送偏好已更新");
toast.success("企业微信消息提醒偏好已更新");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
@@ -124,7 +124,7 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" icon="fact_check" /> : null}
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
</div>
@@ -140,7 +140,7 @@ export function MobileMePage() {
</SectionPanel>
) : null}
<SectionPanel title="外部通知推送">
<SectionPanel title="企业微信消息提醒">
<label className="flex items-center gap-3 text-sm text-zinc-800">
<input
type="checkbox"
@@ -149,8 +149,11 @@ export function MobileMePage() {
disabled={!me || notificationPreferenceMutation.isPending}
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
/>
接收外部渠道推送
接收企业微信消息提醒
</label>
<p className="mt-2 text-xs leading-5 text-zinc-500">
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
</p>
</SectionPanel>
<Button type="button" variant="secondary" className="w-full border-red-200 text-red-600 hover:bg-red-50" onClick={() => void logout()}>
+29 -35
查看文件
@@ -1,7 +1,7 @@
import { Link, useSearchParams } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../api/notifications";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
@@ -9,8 +9,7 @@ import { Pagination } from "../components/common/Pagination";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { resolveNotificationTargetLink } from "../lib/notificationLinks";
import { resolveMobileEquivalentPath } from "../lib/runtimeContext";
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
const PAGE_SIZE = 20;
@@ -33,19 +32,13 @@ function resolveNotificationError(error: unknown) {
return "加载通知失败";
}
function resolveMobileNotificationTarget(item: Parameters<typeof resolveNotificationTargetLink>[0]) {
const targetLink = resolveNotificationTargetLink(item);
if (!targetLink) return null;
const [pathname, search = ""] = targetLink.split("?");
return resolveMobileEquivalentPath(pathname, search ? `?${search}` : "");
}
function formatTime(value: string) {
return value.slice(0, 19).replace("T", " ");
}
export function MobileNotificationsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
@@ -82,6 +75,21 @@ export function MobileNotificationsPage() {
},
});
const openNotificationTarget = async (item: NotificationItem, targetLink: string | null) => {
if (!targetLink) {
toast.info("这条通知没有可直达的业务详情");
return;
}
try {
if (!item.readAt) {
await markReadMutation.mutateAsync(item.id);
}
navigate(targetLink);
} catch (error) {
toast.error(resolveErrorMessage(error));
}
};
const markAllReadMutation = useMutation({
mutationFn: () => markAllNotificationsRead(),
onSuccess: async () => {
@@ -174,9 +182,15 @@ export function MobileNotificationsPage() {
) : null}
{notificationItems.map((item) => {
const targetLink = resolveMobileNotificationTarget(item);
const targetLink = resolveMobileNotificationTargetLink(item);
return (
<article data-testid="mobile-notification-card" key={item.id} className="mobile-list-card">
<button
data-testid="mobile-notification-card"
key={item.id}
className="mobile-list-card w-full text-left"
type="button"
onClick={() => void openNotificationTarget(item, targetLink)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
@@ -188,28 +202,8 @@ export function MobileNotificationsPage() {
</div>
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div>
<div className="mobile-two-action-grid mt-3">
{targetLink ? (
<Button asChild size="sm" variant="secondary" data-testid="mobile-notification-target-link">
<Link to={targetLink}>查看</Link>
</Button>
) : null}
{!item.readAt ? (
<Button
className={targetLink ? "" : "col-span-2"}
data-testid="mobile-notification-mark-read"
size="sm"
type="button"
variant="secondary"
disabled={markReadMutation.isPending}
onClick={() => markReadMutation.mutate(item.id)}
>
标为已读
</Button>
) : null}
</div>
</article>
<div className="mt-3 text-xs text-primary">{targetLink ? "查看详情" : "暂无直达详情"}</div>
</button>
);
})}
+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">
+6 -3
查看文件
@@ -96,7 +96,7 @@ export function MobileProfilePage() {
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
onSuccess: async () => {
await refreshMe();
toast.success("外部推送偏好已更新");
toast.success("企业微信消息提醒偏好已更新");
},
onError: (error) => {
toast.error(resolveProfileError(error));
@@ -144,9 +144,9 @@ export function MobileProfilePage() {
</dl>
</SectionPanel>
<SectionPanel title="外部通知推送">
<SectionPanel title="企业微信消息提醒">
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
<span>接收外部渠道推送</span>
<span>接收企业微信消息提醒</span>
<input
type="checkbox"
className="h-5 w-5 rounded border-zinc-300"
@@ -155,6 +155,9 @@ export function MobileProfilePage() {
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
/>
</label>
<p className="mt-2 text-xs leading-5 text-zinc-500">
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
</p>
</SectionPanel>
<SectionPanel title="企业微信绑定">
@@ -0,0 +1,160 @@
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
formatApprovalHistoryTime,
resolvePurchaseDisplayCode,
resolvePurchaseStatusHint,
workflowActionLabel,
} from "../lib/approvalDisplay";
import { joinBusinessMeta } from "../lib/businessDisplay";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
function formatCurrency(value: string | null | undefined, currencyCode = "CNY") {
const amount = Number(value ?? 0);
if (!Number.isFinite(amount)) {
return "-";
}
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
}
function formatDate(value: string | null | undefined) {
return value ? value.slice(0, 10) : "-";
}
function formatBytes(value: number) {
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
export function MobilePurchaseRequestDetailPage() {
const { purchaseRequestId = "" } = useParams();
const detailQuery = useQuery({
enabled: Boolean(purchaseRequestId),
queryKey: ["mobile", "purchase-request", "detail", purchaseRequestId],
queryFn: () => fetchPurchaseRequestDetail(purchaseRequestId),
});
const detail = detailQuery.data ?? null;
const displayCode = detail ? resolvePurchaseDisplayCode(detail) : "采购详情";
return (
<PageScaffold className="space-y-3 pb-4">
<PageHeader
title="采购详情"
meta={detail ? <StatusBadge status={detail.status} /> : null}
actions={
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">返回</Link>
</Button>
}
/>
{!purchaseRequestId ? (
<div className="mobile-card text-sm text-red-600">缺少采购申请 ID。</div>
) : null}
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
{detailQuery.isError ? (
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
) : null}
{detail ? (
<>
<section className="mobile-card" data-testid="mobile-purchase-detail-summary">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-zinc-900">{displayCode}</h2>
<p className="mt-1 text-sm text-zinc-500">{detail.title || "未命名采购申请"}</p>
</div>
<StatusBadge status={detail.status} />
</div>
<div className="mobile-info-grid mt-4 text-sm">
<div>
<div className="text-xs text-zinc-500">金额</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">
{formatCurrency(detail.totalAmount, detail.currencyCode)}
</div>
</div>
<div>
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 truncate font-medium text-zinc-900">{detail.applicant?.name ?? "-"}</div>
</div>
</div>
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600">
{resolvePurchaseStatusHint(detail)}
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
<div className="mt-3 space-y-2 text-sm text-zinc-700">
<div>{joinBusinessMeta([`供应商:${detail.supplier?.name ?? "-"}`, `期望到货:${formatDate(detail.expectedDeliveryDate)}`])}</div>
<div>{joinBusinessMeta([`法体:${detail.legalEntity?.name ?? "-"}`, `成本中心:${detail.costCenter?.name ?? "-"}`])}</div>
<div>{joinBusinessMeta([`预算池:${detail.budgetPool?.name ?? "未匹配"}`, `当前节点:${detail.pendingWorkflow?.task?.stepName ?? "-"}`])}</div>
{detail.reason ? <p className="leading-6 text-zinc-500">{detail.reason}</p> : null}
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-detail-items">
<h3 className="text-base font-semibold text-zinc-900">采购明细</h3>
<div className="mt-3 space-y-2">
{detail.items.length ? detail.items.map((item) => (
<article className="mobile-list-card text-sm" key={item.id}>
<div className="font-medium text-zinc-900">{item.description}</div>
<div className="mt-1 text-xs text-zinc-500">
{joinBusinessMeta([`数量:${item.quantity}`, `单价:${formatCurrency(item.unitPrice, detail.currencyCode)}`, `金额:${formatCurrency(item.lineAmount, detail.currencyCode)}`])}
</div>
{item.specification ? <div className="mt-1 text-xs text-zinc-500">{item.specification}</div> : null}
</article>
)) : <div className="mobile-empty-state">暂无采购明细</div>}
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-detail-attachments">
<h3 className="text-base font-semibold text-zinc-900">附件</h3>
<div className="mt-3 space-y-2">
{detail.attachments.length ? detail.attachments.map((attachment) => (
<article className="mobile-list-card text-sm" key={attachment.id}>
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])}
</div>
</article>
)) : <div className="mobile-empty-state">暂无附件</div>}
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-detail-approval-history">
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
<div className="mt-3 space-y-2">
{detail.approvalHistory.length ? detail.approvalHistory.map((item) => (
<article className="mobile-list-card text-sm" key={item.id}>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</div>
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">{item.actorName ?? item.actorEmployeeNo ?? "-"}</div>
{item.comment ? <div className="mt-1 text-xs text-zinc-500">{item.comment}</div> : null}
</article>
)) : <div className="mobile-empty-state">暂无审批记录</div>}
</div>
</section>
</>
) : null}
</PageScaffold>
);
}
export default MobilePurchaseRequestDetailPage;
+3 -3
查看文件
@@ -73,7 +73,7 @@ export function ProfilePage() {
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
onSuccess: async () => {
await refreshMe();
toast.success("外部推送偏好已更新");
toast.success("企业微信消息提醒偏好已更新");
},
onError: (error) => {
toast.error(resolveProfileError(error));
@@ -137,7 +137,7 @@ export function ProfilePage() {
</SectionPanel>
) : null}
<SectionPanel title="外部通知推送" description="控制是否接收企微等外部渠道推送;关闭后仅保留站内通知,不影响企业微信绑定与工作台登录。">
<SectionPanel title="企业微信消息提醒" description="控制是否接收企业微信个人应用消息;关闭后仍保留 OA 站内通知,不影响企业微信绑定与工作台登录。">
<div className="space-y-3 text-sm text-zinc-600">
<label className="flex items-center gap-3 text-zinc-800">
<input
@@ -147,7 +147,7 @@ export function ProfilePage() {
disabled={!me || notificationPreferenceMutation.isPending}
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
/>
接收外部渠道推送
接收企业微信消息提醒
</label>
<p>适用于审批待办、报销付款、资产流转等业务提醒。关闭后不会停止站内通知,也不会移除你的业务权限。</p>
</div>
+5 -2
查看文件
@@ -65,8 +65,7 @@ export function buildMobileApprovalsPath(taskId?: string | null) {
return "/mobile/approvals";
}
const params = new URLSearchParams({ taskId: normalizedTaskId });
return `/mobile/approvals?${params.toString()}`;
return `/mobile/approvals/${encodeURIComponent(normalizedTaskId)}`;
}
export function extractWorkflowTaskId(pathname: string) {
@@ -79,6 +78,10 @@ export function isOfflineSnapshotAllowedPath(pathname: string) {
return true;
}
if (/^\/mobile\/approvals\/[^/]+$/.test(pathname)) {
return true;
}
return Boolean(extractWorkflowTaskId(pathname));
}
+54 -2
查看文件
@@ -36,6 +36,7 @@ import {
MobileNotificationsPage,
MobilePortalPage,
MobileProfilePage,
MobilePurchaseRequestDetailPage,
MobileReimbursementDetailPage,
MobileReimbursementsPage,
NotificationChannelsPage,
@@ -345,6 +346,26 @@ export function AppRouter() {
}
/>
<Route
path="mobile/leave/:leaveRequestId"
element={
<RequirePermission anyOf={["leave.view_own", "leave.view_all"]}>
{withRouteFallback(<MobileLeavePage />)}
</RequirePermission>
}
/>
<Route
path="mobile/purchase-requests/:purchaseRequestId"
element={
<RequirePermission
anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}
>
{withRouteFallback(<MobilePurchaseRequestDetailPage />)}
</RequirePermission>
}
/>
<Route
path="mobile/assets/scan"
element={
@@ -359,9 +380,40 @@ export function AppRouter() {
<Route
path="mobile/approvals"
element={
<RequireBusinessApprover>
<RequirePermission
anyOf={[
"expense.view_own",
"leave.view_own",
"procurement.view_own",
"travel.view_own",
"expense.approve",
"leave.approve",
"procurement.approve",
"travel.approve",
]}
>
{withRouteFallback(<MobileApprovalsPage />)}
</RequireBusinessApprover>
</RequirePermission>
}
/>
<Route
path="mobile/approvals/:taskId"
element={
<RequirePermission
anyOf={[
"expense.view_own",
"leave.view_own",
"procurement.view_own",
"travel.view_own",
"expense.approve",
"leave.approve",
"procurement.approve",
"travel.approve",
]}
>
{withRouteFallback(<MobileApprovalsPage />)}
</RequirePermission>
}
/>
@@ -0,0 +1 @@
export { MobilePurchaseRequestDetailPage as default } from "../../pages/MobilePurchaseRequestDetailPage";
+1
查看文件
@@ -23,6 +23,7 @@ export const MobileMePage = lazy(() => import("./MobileMePage"));
export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsPage"));
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
export const MobilePurchaseRequestDetailPage = lazy(() => import("./MobilePurchaseRequestDetailPage"));
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
+1 -1
查看文件
@@ -59,7 +59,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
gap: clamp(7px, 2vw, 9px);
border: 1px solid rgb(228 228 231);
border-radius: 14px;
background: rgb(255 255 255);
@@ -0,0 +1,742 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-oa-information-dedupe-governance");
const IOS_WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
type RolePreset = "employee" | "manager";
type MockNotification = {
id: string;
type: string;
title: string;
body: string | null;
targetType: string | null;
targetId: string | null;
readAt: string | null;
createdAt: string;
};
function envelope(data: unknown) {
return { requestId: "mock-dg7-mobile-information-dedupe", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function screenshotPath(fileName: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
return path.join(SCREENSHOT_DIR, fileName.endsWith(".png") ? fileName : `${fileName}.png`);
}
function buildMe(role: RolePreset) {
return {
id: `employee-dg7-${role}`,
employeeNo: role === "manager" ? "M-DG7" : "E-DG7",
name: role === "manager" ? "测试审批人" : "测试员工",
phone: "13800000000",
email: `${role}@hyinda.com`,
departmentId: "dept-dg7",
directManagerId: role === "manager" ? null : "employee-dg7-manager",
status: "active",
notificationPushEnabled: true,
roleCodes: role === "manager" ? ["manager"] : ["employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 60,
},
wecomBinding: {
externalUserId: `wxwork-dg7-${role}`,
createdAt: "2026-05-12T08:00:00.000Z",
},
};
}
function navigationItem(key: string, href: string, label: string, icon: string) {
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
}
const leaveType = {
id: "leave-type-dg7",
code: "annual",
name: "年假",
description: null,
enabled: true,
requiresAttachment: false,
attachmentMimeTypes: [],
sortOrder: 1,
policy: {
id: "leave-policy-dg7",
annualQuotaDays: 5,
enabled: true,
description: null,
},
createdAt: null,
updatedAt: null,
};
const leaveDetail = {
id: "leave-dg7-approved",
ownerId: "employee-dg7-employee",
departmentId: "dept-dg7",
status: "approved",
startDate: "2026-05-12",
endDate: "2026-05-12",
startSession: "am",
endSession: "pm",
requestedDays: 1,
reason: "年假",
attachment: null,
leaveType: {
id: leaveType.id,
code: leaveType.code,
name: leaveType.name,
requiresAttachment: false,
attachmentMimeTypes: [],
},
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
pendingWorkflow: null,
approvalHistory: [
{
id: "history-leave-dg7",
taskId: "task-leave-dg7",
actorId: "employee-dg7-manager",
actorName: "测试审批人",
actorEmployeeNo: "M-DG7",
action: "approve",
comment: "同意",
createdAt: "2026-05-12T09:00:00.000Z",
},
],
createdAt: "2026-05-12T08:00:00.000Z",
updatedAt: "2026-05-12T09:00:00.000Z",
};
const purchaseDetail = {
id: "purchase-dg7-approved",
requestNo: "CG20260512001",
applicantId: "employee-dg7-employee",
submitterId: "employee-dg7-employee",
departmentId: "dept-dg7",
legalEntityId: "legal-dg7",
costCenterId: "cost-dg7",
supplierId: "supplier-dg7",
documentCategoryId: null,
budgetPoolId: null,
title: "办公用品采购",
reason: "补充办公用品",
currencyCode: "CNY",
totalAmount: "320.00",
expectedDeliveryDate: "2026-05-20",
status: "approved",
createdAt: "2026-05-12T08:00:00.000Z",
updatedAt: "2026-05-12T09:00:00.000Z",
applicant: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
department: { id: "dept-dg7", name: "研发部" },
legalEntity: { id: "legal-dg7", code: "HYD", name: "海音达" },
costCenter: { id: "cost-dg7", code: "RD", name: "研发中心", legalEntityId: "legal-dg7" },
supplier: { id: "supplier-dg7", code: "SUP-DG7", name: "测试供应商" },
documentCategory: null,
budgetPool: null,
items: [
{
id: "purchase-item-dg7",
description: "键盘",
specification: "无线",
quantity: "2",
unitPrice: "160.00",
lineAmount: "320.00",
isAssetCandidate: false,
createdAt: null,
updatedAt: null,
},
],
attachments: [
{
id: "attachment-dg7",
originalName: "采购说明.pdf",
mimeType: "application/pdf",
fileSize: 10240,
uploadedBy: "employee-dg7-employee",
createdAt: "2026-05-12T08:10:00.000Z",
uploader: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
},
],
pendingWorkflow: null,
approvalHistory: [
{
id: "history-purchase-dg7",
taskId: "task-purchase-dg7",
actorId: "employee-dg7-manager",
actorName: "测试审批人",
actorEmployeeNo: "M-DG7",
action: "approve",
comment: "同意",
createdAt: "2026-05-12T09:00:00.000Z",
},
],
archiveOrder: null,
permissions: {
canEditDraft: false,
canSubmit: false,
canWithdraw: false,
canArchiveOrder: false,
},
riskFlags: [],
};
function buildNotifications() {
return [
{
id: "notification-task-dg7",
type: "workflow.task_assigned",
title: "报销待审批",
body: "报销单 BX20260512001 等待处理",
targetType: "workflow_task",
targetId: "task-dg7",
readAt: null,
createdAt: "2026-05-12T08:15:00.000Z",
},
{
id: "notification-purchase-dg7",
type: "purchase_request.approved",
title: "采购已通过",
body: "采购单 CG20260512001 已通过",
targetType: "purchase_request",
targetId: purchaseDetail.id,
readAt: null,
createdAt: "2026-05-12T08:10:00.000Z",
},
{
id: "notification-leave-dg7",
type: "leave.approved",
title: "请假已通过",
body: "年假申请已通过",
targetType: "leave_request",
targetId: leaveDetail.id,
readAt: null,
createdAt: "2026-05-12T08:05:00.000Z",
},
] satisfies MockNotification[];
}
function workflowTaskSummary() {
return {
id: "task-dg7",
instanceId: "instance-dg7",
stepId: "step-dg7",
stepName: "主管审批",
assigneeId: "employee-dg7-manager",
assigneeRole: null,
status: "pending",
outcome: null,
createdAt: "2026-05-12T08:00:00.000Z",
completedAt: null,
workflowInstance: {
entityType: "reimbursement",
entityId: "reimbursement-dg7",
currentStatus: "reviewing",
},
businessSummary: {
reference: "BX20260512001",
title: "差旅报销",
applicantName: "测试员工",
amount: "168.00",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return", "transfer"],
};
}
function workflowTaskDetail() {
return {
task: {
id: "task-dg7",
instanceId: "instance-dg7",
stepId: "step-dg7",
stepName: "主管审批",
assigneeId: "employee-dg7-manager",
assigneeRole: null,
status: "pending",
outcome: null,
comment: null,
createdAt: "2026-05-12T08:00:00.000Z",
completedAt: null,
},
workflowInstance: {
id: "instance-dg7",
entityType: "reimbursement",
currentStatus: "reviewing",
currentStepId: "step-dg7",
contextData: { standardViolations: [] },
startedAt: "2026-05-12T08:00:00.000Z",
completedAt: null,
},
reimbursement: {
id: "reimbursement-dg7",
formNumber: "BX20260512001",
status: "reviewing",
reimbursementDate: "2026-05-12",
totalAmount: "168.00",
description: "差旅报销",
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
},
leaveRequest: null,
purchaseRequest: null,
travelRequest: null,
allowedActions: ["approve", "reject", "return", "transfer"],
history: [],
};
}
function reimbursementListItem() {
return {
id: "reimbursement-dg7",
formNumber: "BX20260512001",
status: "reviewing",
reimbursementDate: "2026-05-12",
totalAmount: "168.00",
description: "差旅报销",
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
project: null,
pendingWorkflow: {
instanceId: "instance-dg7",
stepId: "step-dg7",
allowedActions: [],
task: {
id: "task-dg7",
stepName: "主管审批",
assigneeId: "employee-dg7-manager",
assigneeRole: null,
},
},
createdAt: "2026-05-12T08:00:00.000Z",
updatedAt: "2026-05-12T08:00:00.000Z",
};
}
async function mockApis(page: Page, role: RolePreset, notifications: MockNotification[]) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathName = url.pathname;
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "ww-test",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
bindEnabled: true,
defaultReturnTo: "/mobile",
status: "ready",
statusReasons: [],
});
return;
}
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({ accessToken: `mock-dg7-${role}-token`, expiresIn: 900, mustChangePassword: false })),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, buildMe(role));
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-dg7",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "wecom",
notificationStrategy: "in_app",
devicePriority: "mobile",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
],
admin: [],
backup: [],
},
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: "workspace-dg7", persisted: true, profile: "small_team_internal" },
greeting: { name: buildMe(role).name },
cards: [],
pendingTasks: { count: role === "manager" ? 1 : 0, href: "/workflow/tasks" },
myApplications: [],
riskSummary: null,
announcements: [],
quickLinks: [],
recentActivities: [],
});
return;
}
if (pathName === "/api/attendance/me/today") {
await fulfillJson(route, { workDate: "2026-05-12", needClockIn: false, exemptionReason: null, item: null });
return;
}
if (pathName === "/api/workflow/tasks/summary") {
await fulfillJson(route, {
scope: "mine",
pendingTotal: role === "manager" ? 1 : 0,
overdueTotal: 0,
earliestPendingAt: role === "manager" ? "2026-05-12T08:00:00.000Z" : null,
averagePendingAgeHours: role === "manager" ? 1 : 0,
byEntityType: role === "manager" ? [{ key: "reimbursement", pendingTotal: 1, overdueTotal: 0 }] : [],
byStep: role === "manager" ? [{ key: "主管审批", pendingTotal: 1, overdueTotal: 0 }] : [],
byAssigneeRole: [],
});
return;
}
if (pathName === "/api/workflow/tasks") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 100),
total: role === "manager" ? 1 : 0,
items: role === "manager" ? [workflowTaskSummary()] : [],
});
return;
}
if (pathName === "/api/workflow/tasks/task-dg7") {
await fulfillJson(route, workflowTaskDetail());
return;
}
if (pathName === "/api/workflow/approvers") {
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
return;
}
if (pathName === "/api/notifications" && request.method() === "GET") {
const unreadOnly = url.searchParams.get("unreadOnly") === "1" || url.searchParams.get("unreadOnly") === "true";
const typeFilter = url.searchParams.get("type");
const types = typeFilter ? typeFilter.split(",") : null;
const items = notifications.filter((item) => {
if (unreadOnly && item.readAt) return false;
if (types && !types.includes(item.type)) return false;
return true;
});
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: items.length,
unreadOnly,
unreadCount: notifications.filter((item) => !item.readAt).length,
items,
});
return;
}
const markReadMatch = pathName.match(/^\/api\/notifications\/([^/]+)\/mark-read$/);
if (markReadMatch && request.method() === "POST") {
const item = notifications.find((candidate) => candidate.id === markReadMatch[1]);
if (item) {
item.readAt = "2026-05-12T08:20:00.000Z";
}
await fulfillJson(route, { id: markReadMatch[1], readAt: item?.readAt ?? null });
return;
}
if (pathName === "/api/notifications/mark-all-read" && request.method() === "POST") {
for (const item of notifications) {
item.readAt = item.readAt ?? "2026-05-12T08:20:00.000Z";
}
await fulfillJson(route, { updatedCount: notifications.length });
return;
}
if (pathName === "/api/reimbursements" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: 1,
scope: "own",
items: [reimbursementListItem()],
});
return;
}
if (pathName === "/api/leave-types") {
await fulfillJson(route, { items: [leaveType] });
return;
}
if (pathName === "/api/leave-balances") {
await fulfillJson(route, {
employeeId: "employee-dg7-employee",
items: [
{
leaveType,
policy: leaveType.policy,
granted: 5,
adjusted: 0,
reserved: 0,
used: 1,
available: 4,
periodStart: "2026-01-01",
periodEnd: "2026-12-31",
},
],
});
return;
}
if (pathName === `/api/leave-requests/${leaveDetail.id}`) {
await fulfillJson(route, leaveDetail);
return;
}
if (pathName === "/api/leave-requests") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: 1,
scope: "own",
items: [leaveDetail],
});
return;
}
if (pathName === `/api/purchase-requests/${purchaseDetail.id}`) {
await fulfillJson(route, purchaseDetail);
return;
}
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: 1,
mine: true,
items: [purchaseDetail],
});
return;
}
await fulfillJson(route, { message: `unhandled mock api: ${pathName}` }, 404);
});
}
async function loginForMobile(page: Page, role: RolePreset) {
await page.goto("/login");
const accountLoginButton = page.getByRole("button", { name: "使用账号密码登录" });
if (await accountLoginButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
await accountLoginButton.click();
}
await expect(page.getByTestId("login-identifier")).toBeVisible();
await page.getByTestId("login-identifier").fill(`${role}@hyinda.com`);
await page.getByTestId("login-password").fill("password");
await page.getByTestId("login-submit").click();
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
await page.waitForLoadState("networkidle").catch(() => undefined);
}
async function newMobilePage(browser: Browser, width = 390) {
const context = await browser.newContext({
viewport: { width, height: width < 360 ? 568 : 844 },
isMobile: true,
hasTouch: true,
userAgent: IOS_WECOM_UA,
locale: "zh-CN",
serviceWorkers: "block",
});
return { context, page: await context.newPage() };
}
async function expectNoMobileShellRegression(page: Page) {
await page.waitForLoadState("networkidle").catch(() => undefined);
const metrics = await page.evaluate(() => {
function isVisible(element: Element) {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
}
return {
width: window.innerWidth,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
visibleTables: Array.from(document.querySelectorAll("table")).filter(isVisible).length,
pcShellGridCount: document.querySelectorAll(".page-shell-grid").length,
};
});
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
expect(metrics.visibleTables).toBe(0);
expect(metrics.pcShellGridCount).toBe(0);
}
async function expectActionTileGapStable(page: Page) {
const gaps = await page.evaluate(() =>
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] a")).map((tile) => {
const icon = tile.querySelector<HTMLElement>("[data-testid$='-icon']");
const label = tile.querySelector<HTMLElement>("[data-testid$='-label']");
if (!icon || !label) return null;
return label.getBoundingClientRect().top - icon.getBoundingClientRect().bottom;
}).filter((value): value is number => value !== null),
);
expect(gaps.length).toBeGreaterThan(0);
for (const gap of gaps) {
expect(gap).toBeGreaterThanOrEqual(5);
}
}
test.describe("DG7 移动信息架构与展示去重治理", () => {
test.skip(process.env.E2E_MODE === "live", "DG7 本地专项使用 mock 数据验证信息架构。");
test("普通员工底部入口变为进度视角,首页宫格间距在多视口稳定", async ({ browser }) => {
for (const width of [320, 360, 375, 390, 393, 414, 430]) {
const notifications = buildNotifications();
const mobile = await newMobilePage(browser, width);
try {
await mockApis(mobile.page, "employee", notifications);
await loginForMobile(mobile.page, "employee");
await mobile.page.goto("/mobile");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveText("进度");
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
await expectActionTileGapStable(mobile.page);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.getByTestId("mobile-nav-approvals").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
await expect(mobile.page.getByTestId("mobile-approvals-progress")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath(`employee-progress-w${width}`), fullPage: true });
} finally {
await mobile.context.close();
}
}
});
test("审批人首页只保留一处待办摘要,审批详情使用 Push 页并隐藏底部 Tab", async ({ browser }) => {
const notifications = buildNotifications();
const mobile = await newMobilePage(browser, 390);
try {
await mockApis(mobile.page, "manager", notifications);
await loginForMobile(mobile.page, "manager");
await mobile.page.goto("/mobile");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
await expect(mobile.page.getByTestId("mobile-action-approvals")).not.toContainText("待批");
const badgeMetrics = await mobile.page.evaluate(() => {
const nav = document.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals']");
const icon = nav?.querySelector<HTMLElement>("[aria-hidden='true']");
const badge = nav?.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals-badge']");
if (!icon || !badge) return null;
const iconRect = icon.getBoundingClientRect();
const badgeRect = badge.getBoundingClientRect();
return {
iconLeft: iconRect.left,
iconRight: iconRect.right,
badgeLeft: badgeRect.left,
badgeRight: badgeRect.right,
};
});
expect(badgeMetrics).not.toBeNull();
expect(badgeMetrics!.badgeLeft).toBeLessThanOrEqual(badgeMetrics!.iconRight + 4);
expect(badgeMetrics!.badgeRight).toBeGreaterThanOrEqual(badgeMetrics!.iconLeft - 14);
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approvals-detail")).not.toContainText("业务单号");
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toContainText("报销单 BX20260512001");
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
} finally {
await mobile.context.close();
}
});
test("通知中心点击通知直达请假和采购详情,不再回到通知列表或首页", async ({ browser }) => {
const notifications = buildNotifications();
const mobile = await newMobilePage(browser, 390);
try {
await mockApis(mobile.page, "employee", notifications);
await loginForMobile(mobile.page, "employee");
await mobile.page.goto("/mobile/notifications");
await expect(mobile.page.getByTestId("mobile-notification-card")).toHaveCount(3);
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "采购已通过" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/purchase-requests\/purchase-dg7-approved$/);
await expect(mobile.page.getByTestId("mobile-purchase-detail-summary")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.goto("/mobile/notifications");
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "请假已通过" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/leave\/leave-dg7-approved$/);
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("notification-direct-leave-detail"), fullPage: true });
} finally {
await mobile.context.close();
}
});
});