diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index f61696b9..2cdec8c3 100644 --- a/.agents/skills/hyb-oa-delivery/SKILL.md +++ b/.agents/skills/hyb-oa-delivery/SKILL.md @@ -41,9 +41,10 @@ Default behavior: 6. Verify with the narrowest relevant automated tests, then expand by risk. 7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes. New or changed mobile pages must join the unified responsive system and the 320/360/375/390/393/414/430px 统一视觉矩阵 rather than adding page-specific layout fixes. 8. Dedicated `/mobile` pages must use the shared mobile slot/card/list/sticky-action system from `docs/product-design-rules.md`: fixed icon/avatar/status slots, `minmax(0, 1fr)` copy slots, `mobile-card`/mobile list cards, mobile tokens, and no desktop `card-std p-4` as a mobile main container. -9. Update Issue CSV statuses only after the stated `Test_Method` passes. -10. Commit in Chinese batches with Issue IDs. -11. 面向用户可测试的改动,最终必须明确“是否已部署”。只有部署与公网检查通过后,才能告知用户可以去企业微信或公网试运行环境测试。 +9. Mobile homepage, approval, notification, and business cards must follow the display dedupe rules in `docs/product-design-rules.md`: one pending/progress summary per page, notifications as event stream with direct target links, bottom badges as counts only, and one visible business identifier/status per card or row. +10. Update Issue CSV statuses only after the stated `Test_Method` passes. +11. Commit in Chinese batches with Issue IDs. +12. 面向用户可测试的改动,最终必须明确“是否已部署”。只有部署与公网检查通过后,才能告知用户可以去企业微信或公网试运行环境测试。 ## Default Verification Matrix @@ -107,4 +108,5 @@ Final responses should include: - Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views. - After MUD6, `R6-ISSUE-001` is treated as resolved based on representative real-device confirmation; future mobile regressions should be recorded as new Issues rather than reopening the old WCM6/MME6 gate unless the same production behavior returns. - After AP6, approval reminder, overdue reminder, and workflow productivity summary are current product facts, not open planning items. Reuse `POST /api/workflow/tasks/:taskId/remind`, `GET /api/workflow/tasks/summary`, `job:workflow-overdue-reminders`, existing workflow history, audit logs, notifications, and WeCom personal app delivery semantics. Do not add a parallel reminder channel, change approval status from reminders, or expand into complex BPM/SLA escalation without a new Issue. +- After DG7/MRF7, mobile approval is an "approval and progress" entry: employees may use it for submitted-item progress, while approvers use it for assigned tasks. Do not reintroduce disabled approval tabs for ordinary employees, duplicated home pending lists, or notification cards that only bounce users back to the notification center when a business target exists. - After RI6, LP6, ME6 automation, QG6, and R7/OPS7/DOC7 draft materials, keep `ME6-3` open until representative real-device testing passes or the user explicitly accepts that risk. R7 draft rows may be `DONE`, but `R7E-*` true execution rows must stay `TODO` until the user confirms participants, roles, window, rollback contact, issue owner, and daily closeout. Do not treat QG6 or public mobile visual automation as a real-device substitute, and do not start real R7 from documentation alone. diff --git a/client/src/components/mobile/MobileBottomNav.tsx b/client/src/components/mobile/MobileBottomNav.tsx index 27b94b2d..91f98afe 100644 --- a/client/src/components/mobile/MobileBottomNav.tsx +++ b/client/src/components/mobile/MobileBottomNav.tsx @@ -37,12 +37,11 @@ export function MobileBottomNav() { >
{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}不可用`} > - {item.label} + {label} ); @@ -83,26 +82,26 @@ export function MobileBottomNav() { > {({ isActive }) => ( <> - + + {badge ? ( + + {badge} + + ) : null} - {item.label} + {label} - {badge ? ( - - {badge} - - ) : null} )} diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index 461f0695..d02e4a4f 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -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: "管理入口" }, diff --git a/client/src/layouts/AppShellLayout.tsx b/client/src/layouts/AppShellLayout.tsx index 02acb2a2..3ce69077 100644 --- a/client/src/layouts/AppShellLayout.tsx +++ b/client/src/layouts/AppShellLayout.tsx @@ -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) diff --git a/client/src/lib/businessDisplay.ts b/client/src/lib/businessDisplay.ts new file mode 100644 index 00000000..92406d03 --- /dev/null +++ b/client/src/lib/businessDisplay.ts @@ -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 = { + reimbursement: "报销单", + leave: "请假", + purchase_request: "采购单", + travel_request: "差旅单", +}; + +const ENTITY_SHORT_LABELS: Record = { + 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) { + return items.map((item) => item?.trim()).filter((item): item is string => Boolean(item)).join(" · "); +} diff --git a/client/src/lib/notificationLinks.ts b/client/src/lib/notificationLinks.ts index 1342e0c5..587bf51b 100644 --- a/client/src/lib/notificationLinks.ts +++ b/client/src/lib/notificationLinks.ts @@ -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 "-"; diff --git a/client/src/lib/runtimeContext.ts b/client/src/lib/runtimeContext.ts index e4f3942c..c9a24d4b 100644 --- a/client/src/lib/runtimeContext.ts +++ b/client/src/lib/runtimeContext.ts @@ -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; } diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index 418131ba..a806bf48 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -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("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(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 ( + 返回 + + ) : null} /> - {!isOnline ? ( + {!isOnline && canAccessRemoteApprovals ? (

当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。

@@ -811,6 +912,43 @@ export function MobileApprovalsPage() {

{message}

) : null} + {!isPushDetail && !canAccessRemoteApprovals ? ( +
+
+
+

我发起的

+

报销、请假、采购进度

+
+ {employeeProgressCards.length} 条 +
+ + {employeeProgressLoading ?
加载中...
: null} + {employeeProgressError ? ( +

+ {resolveErrorMessage(employeeProgressError)} +

+ ) : null} + {!employeeProgressLoading && !employeeProgressError && !employeeProgressCards.length ? ( +
暂无申请进度
+ ) : null} + +
+ {employeeProgressCards.map((item) => ( + +
+
+
{item.title}
+ {item.subtitle ?
{item.subtitle}
: null} +
+ +
+ + ))} +
+
+ ) : null} + + {!isPushDetail && canAccessRemoteApprovals ? (
@@ -986,9 +1124,8 @@ export function MobileApprovalsPage() {
{task.subtitle}
- {task.reference ?
业务单号:{task.reference}
: null}
- 申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)} + {joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
{task.isCached ?
来源:离线缓存
: null} @@ -1011,9 +1148,8 @@ export function MobileApprovalsPage() {
{task.subtitle}
- {task.reference ?
业务单号:{task.reference}
: null}
- 申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)} + {joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
{task.isCached ?
来源:离线缓存
: null} @@ -1021,8 +1157,10 @@ export function MobileApprovalsPage() { })}
+ ) : null} -
+ {isPushDetail ? ( +

处理

{!selectedTaskId ?

请先选择任务。

: null} @@ -1046,44 +1184,37 @@ export function MobileApprovalsPage() { {selectedTask ? ( <>
-
任务状态:{workflowStatusLabel(selectedTask.task.status)}
-
步骤:{selectedTask.task.stepName}
-
对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}
-
业务单号:{selectedDisplayReference ?? "-"}
-
业务状态:{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}
-
申请人:{selectedTaskCard?.applicantName ?? "-"}
-
金额/天数:{selectedTaskCard?.metric ?? "-"}
-
创建时间:{formatDateTime(selectedTask.task.createdAt)}
-
完成时间:{formatDateTime(selectedTask.task.completedAt)}
+
+
+
{selectedDetailTitle}
+
{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}
+
+ +
+
+
{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}
+
{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}
+ {selectedTask.task.completedAt ?
完成时间:{formatDateTime(selectedTask.task.completedAt)}
: null} +
{selectedTask.reimbursement ? ( - <> -
报销单号:{selectedTask.reimbursement.formNumber ?? "-"}
-
金额:{selectedTask.reimbursement.totalAmount ?? "-"}
-
提交人:{selectedTask.reimbursement.submitter?.name ?? "-"}
- +
+ {joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])} +
) : null} {selectedTask.leaveRequest ? ( - <> -
假期类型:{selectedTask.leaveRequest.leaveType?.name ?? "-"}
-
申请人:{selectedTask.leaveRequest.owner?.name ?? "-"}
-
请假天数:{selectedTask.leaveRequest.requestedDays}
- +
+ {joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])} +
) : null} {selectedTask.purchaseRequest ? ( - <> -
采购单号:{selectedTask.purchaseRequest.requestNo ?? "-"}
-
申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}
-
供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}
-
金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}
- +
+ {joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])} +
) : null} {selectedTask.travelRequest ? ( - <> -
差旅单号:{selectedTask.travelRequest.requestNo ?? "-"}
-
申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}
-
出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}
-
预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}
- +
+ {joinBusinessMeta([`出差类型:${selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}`, selectedTask.travelRequest.purpose])} +
) : null}
@@ -1191,6 +1322,7 @@ export function MobileApprovalsPage() { ) : null}
+ ) : null}
diff --git a/client/src/pages/MobileLeavePage.tsx b/client/src/pages/MobileLeavePage.tsx index 0f889747..5ae57d1d 100644 --- a/client/src/pages/MobileLeavePage.tsx +++ b/client/src/pages/MobileLeavePage.tsx @@ -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({
) : null} - {canEdit || canSubmit ? ( + {showActions && (canEdit || canSubmit) ? (
{canEdit ? ( + } + /> + + {detailQuery.isLoading ?
加载中...
: null} + {detailQuery.isError ? ( +
{resolveErrorMessage(detailQuery.error)}
+ ) : null} + {detailQuery.data ? ( + submitMutation.mutate(nextItem)} + showActions={false} + /> + ) : null} + + ); + } + return ( diff --git a/client/src/pages/MobileMePage.tsx b/client/src/pages/MobileMePage.tsx index 5bf368af..a4bacd42 100644 --- a/client/src/pages/MobileMePage.tsx +++ b/client/src/pages/MobileMePage.tsx @@ -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() { - {canAccessApprovals ? : null} + {canAccessAssets ? : null} {canAccessProjects ? : null}
@@ -140,7 +140,7 @@ export function MobileMePage() { ) : null} - + +

+ 关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。 +

- ) : null} - - {!item.readAt ? ( - - ) : null} - - +
{targetLink ? "查看详情" : "暂无直达详情"}
+ ); })} diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index 0cdb39ed..2fe800d7 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -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 ( + {canAccessApprovals && pendingApprovalCount > 0 ? ( +
+
+
待处理
+ {pendingApprovalCount} 待批 +
+ {workflowSummaryQuery.data ? ( +
+
+
{workflowSummaryQuery.data.pendingTotal}
+
待办
+
+
+
0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}> + {workflowSummaryQuery.data.overdueTotal} +
+
超时
+
+
+
{workflowEntityTypeCount}
+
类型
+
+
+ ) : null} +
+ {pendingTasks.slice(0, 3).map((task) => { + const entityType = task.workflowInstance?.entityType; + const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId); + return ( + +
+
+ {reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })} +
+ 待处理 +
+
+ {joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])} +
+ + ); + })} +
+ +
+ ) : null} +
-
待处理
- {pendingApprovalCount > 0 ? {pendingApprovalCount} 待批 : null} +
常用
{actions.map((action) => ( @@ -338,58 +382,6 @@ export function MobilePortalPage() {
) : null} - {canAccessApprovals ? ( -
-
-
待审批
- -
- - {pendingCountQuery.isLoading ?

读取中...

: null} - {pendingCountQuery.isError ? ( -

- {resolveErrorMessage(pendingCountQuery.error)} -

- ) : null} - {workflowSummaryQuery.data ? ( -
-
-
{workflowSummaryQuery.data.pendingTotal}
-
待办
-
-
-
0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}> - {workflowSummaryQuery.data.overdueTotal} -
-
超时
-
-
-
{workflowEntityTypeCount}
-
类型
-
-
- ) : null} - {!pendingCountQuery.isLoading && !pendingTasks.length ? ( -

没有待处理审批。

- ) : null} - -
- {pendingTasks.map((task) => ( - -
{task.stepName}
-
{workflowEntityLabel(task.workflowInstance?.entityType)}
- - ))} -
-
- ) : null} -
通知
@@ -404,15 +396,15 @@ export function MobilePortalPage() { {resolveErrorMessage(notificationsQuery.error)}

) : null} - {!notificationsQuery.isLoading && !notifications.length ? ( + {!notificationsQuery.isLoading && !homeNotifications.length ? (

暂无通知。

) : null}
- {notifications.map((item) => ( + {homeNotifications.map((item) => (
diff --git a/client/src/pages/MobileProfilePage.tsx b/client/src/pages/MobileProfilePage.tsx index 32826e06..e89e1e8c 100644 --- a/client/src/pages/MobileProfilePage.tsx +++ b/client/src/pages/MobileProfilePage.tsx @@ -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() { - + +

+ 关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。 +

diff --git a/client/src/pages/MobilePurchaseRequestDetailPage.tsx b/client/src/pages/MobilePurchaseRequestDetailPage.tsx new file mode 100644 index 00000000..fc84b2d8 --- /dev/null +++ b/client/src/pages/MobilePurchaseRequestDetailPage.tsx @@ -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 ( + + : null} + actions={ + + } + /> + + {!purchaseRequestId ? ( +
缺少采购申请 ID。
+ ) : null} + + {detailQuery.isLoading ?
加载中...
: null} + + {detailQuery.isError ? ( +
{resolveErrorMessage(detailQuery.error)}
+ ) : null} + + {detail ? ( + <> +
+
+
+

{displayCode}

+

{detail.title || "未命名采购申请"}

+
+ +
+
+
+
金额
+
+ {formatCurrency(detail.totalAmount, detail.currencyCode)} +
+
+
+
申请人
+
{detail.applicant?.name ?? "-"}
+
+
+
+ {resolvePurchaseStatusHint(detail)} +
+
+ +
+

业务信息

+
+
{joinBusinessMeta([`供应商:${detail.supplier?.name ?? "-"}`, `期望到货:${formatDate(detail.expectedDeliveryDate)}`])}
+
{joinBusinessMeta([`法体:${detail.legalEntity?.name ?? "-"}`, `成本中心:${detail.costCenter?.name ?? "-"}`])}
+
{joinBusinessMeta([`预算池:${detail.budgetPool?.name ?? "未匹配"}`, `当前节点:${detail.pendingWorkflow?.task?.stepName ?? "-"}`])}
+ {detail.reason ?

{detail.reason}

: null} +
+
+ +
+

采购明细

+
+ {detail.items.length ? detail.items.map((item) => ( +
+
{item.description}
+
+ {joinBusinessMeta([`数量:${item.quantity}`, `单价:${formatCurrency(item.unitPrice, detail.currencyCode)}`, `金额:${formatCurrency(item.lineAmount, detail.currencyCode)}`])} +
+ {item.specification ?
{item.specification}
: null} +
+ )) :
暂无采购明细
} +
+
+ +
+

附件

+
+ {detail.attachments.length ? detail.attachments.map((attachment) => ( +
+
{attachment.originalName}
+
+ {joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])} +
+
+ )) :
暂无附件
} +
+
+ +
+

审批记录

+
+ {detail.approvalHistory.length ? detail.approvalHistory.map((item) => ( +
+
+
{workflowActionLabel(item.action)}
+ {formatApprovalHistoryTime(item.createdAt)} +
+
{item.actorName ?? item.actorEmployeeNo ?? "-"}
+ {item.comment ?
{item.comment}
: null} +
+ )) :
暂无审批记录
} +
+
+ + ) : null} +
+ ); +} + +export default MobilePurchaseRequestDetailPage; diff --git a/client/src/pages/ProfilePage.tsx b/client/src/pages/ProfilePage.tsx index c494357c..78c1bd1b 100644 --- a/client/src/pages/ProfilePage.tsx +++ b/client/src/pages/ProfilePage.tsx @@ -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() {
) : null} - +

适用于审批待办、报销付款、资产流转等业务提醒。关闭后不会停止站内通知,也不会移除你的业务权限。

diff --git a/client/src/pwa.ts b/client/src/pwa.ts index a81aaccb..46336dc7 100644 --- a/client/src/pwa.ts +++ b/client/src/pwa.ts @@ -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)); } diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index f9663f31..1e069f21 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -36,6 +36,7 @@ import { MobileNotificationsPage, MobilePortalPage, MobileProfilePage, + MobilePurchaseRequestDetailPage, MobileReimbursementDetailPage, MobileReimbursementsPage, NotificationChannelsPage, @@ -345,6 +346,26 @@ export function AppRouter() { } /> + + {withRouteFallback()} + + } + /> + + + {withRouteFallback()} + + } + /> + + {withRouteFallback()} - + + } + /> + + + {withRouteFallback()} + } /> diff --git a/client/src/router/lazy/MobilePurchaseRequestDetailPage.ts b/client/src/router/lazy/MobilePurchaseRequestDetailPage.ts new file mode 100644 index 00000000..e609f8b8 --- /dev/null +++ b/client/src/router/lazy/MobilePurchaseRequestDetailPage.ts @@ -0,0 +1 @@ +export { MobilePurchaseRequestDetailPage as default } from "../../pages/MobilePurchaseRequestDetailPage"; diff --git a/client/src/router/lazy/index.ts b/client/src/router/lazy/index.ts index 1f600bcb..6d2d57ef 100644 --- a/client/src/router/lazy/index.ts +++ b/client/src/router/lazy/index.ts @@ -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")); diff --git a/client/src/styles/mobile-touch.css b/client/src/styles/mobile-touch.css index d36e52db..9b80b444 100644 --- a/client/src/styles/mobile-touch.css +++ b/client/src/styles/mobile-touch.css @@ -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); diff --git a/client/tests/e2e/dg7-mobile-information-dedupe.spec.ts b/client/tests/e2e/dg7-mobile-information-dedupe.spec.ts new file mode 100644 index 00000000..3ba54655 --- /dev/null +++ b/client/tests/e2e/dg7-mobile-information-dedupe.spec.ts @@ -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("[data-testid='mobile-action-grid'] a")).map((tile) => { + const icon = tile.querySelector("[data-testid$='-icon']"); + const label = tile.querySelector("[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("[data-testid='mobile-nav-approvals']"); + const icon = nav?.querySelector("[aria-hidden='true']"); + const badge = nav?.querySelector("[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(); + } + }); +}); diff --git a/docs/agent-development-practices.md b/docs/agent-development-practices.md index 712dfaba..5aacc401 100644 --- a/docs/agent-development-practices.md +++ b/docs/agent-development-practices.md @@ -88,6 +88,8 @@ AGENTS.md 只放硬约束和入口,不复制长规则。 - 新移动页默认纳入统一响应式 token 和跨视口视觉矩阵,覆盖 320、360、375、390、393、414、430px;不得只按某一台手机打补丁。 - 专用 `/mobile` 路由默认使用统一移动槽位、`mobile-card`、移动列表卡片和吸底操作;不得把桌面 `card-std p-4` 作为手机主容器,也不得为单个页面自造另一套间距、卡片或图标布局。 - 角色差异采用“同框架、按角色裁剪内容和动作”:员工、审批人、财务、管理员、负责人看到不同入口和动作,但不创建互不一致的角色专属移动 UI。 +- 信息展示必须先去重再上屏:首页只保留一处待办/进度摘要,通知只作为事件流并直达业务详情,底部角标只表达数量;同一卡片或同一行只展示一次业务编号和一次状态标签。 +- 审批入口默认承载“待我审批”和“我发起的进度”两种视角;普通员工不能看到不可点或无权限的审批入口,审批人/财务/负责人只能看到服务端允许的动作。 - 真机验证暂停时记录为 `PAUSED`;自动化通过不能替代代表性真机确认,也不能关闭仍待人工确认的移动阻断。 截至 R7P,RI6 报销票据体验、LP6 请假采购体验、ME6 自动化/部署收口、QG6 QA 治理以及 R7/OPS7/DOC7 草案材料均已完成;`ME6-3` 代表性企业微信真机抽查因设备不便继续待确认。后续默认进入等待门禁状态:不得关闭 `ME6-3`,不得把 QG6 或公网移动视觉矩阵当成真机替代,也不得从文档草案直接启动真实 R7。R7 真实执行必须保持 `R7E-*` 为 TODO,直到用户确认最终人员、角色、窗口、回滚联系人、问题登记负责人和每日收口节奏,并补足真机门禁或明确接受风险。 diff --git a/docs/product-design-rules.md b/docs/product-design-rules.md index 879e970e..d0a579ce 100644 --- a/docs/product-design-rules.md +++ b/docs/product-design-rules.md @@ -32,6 +32,7 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共 - PC 高密度、移动轻任务是双端分工底线;不得把桌面表格和批量管理能力原样搬到手机端。 - 企业微信只做入口、绑定、免登录、个人应用消息和 deeplink,不替代 OA 正式审批页。 - 审批体验以“待办聚合、清晰状态、明确下一步、可催办和可追溯”为目标;AP6 v1 已完成催办、超时提醒和审批效率摘要,后续只在独立 Issue 中增强配置治理、升级处理或更复杂的节点 SLA。 +- 移动信息分层必须清楚:`待办` 是需要我处理的任务,`进度` 是我发起的单据状态,`通知` 是事件消息,`详情` 是业务事实源;首页、审批中心、通知中心不得把同一批待办重复铺开。 - 竞品资料只用于抽象原则;不得复制钉钉、飞书、企业微信、WeUI 或 Ant Design 的页面文案、视觉细节和专有交互。 参考资料: @@ -64,6 +65,17 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共 - 空状态只说明现状和下一步,例如“暂无草稿”“暂无待处理审批”。 - 表单说明放在字段错误、帮助文本或 hover/折叠信息里,不放在首屏大段文案。 +## 信息层级与去重规则 + +- 首页只做工作台摘要:同一页面最多出现一处待办或进度列表;底部 Tab 红点只表达全局数量,通知列表只表达事件,不作为待办列表副本。 +- 审批入口承载两类视角:审批人看“待我审批/已处理”,员工看“我发起的/审批进度”;普通员工不能看到不可点或无权限的审批入口。 +- 通知卡片表达“发生了什么”,点击应直达审批任务或业务详情;目标不存在、已删除或无权限时显示可理解提示,不跳回首页。 +- 同一卡片或同一表格行只展示一次主标识。标题为“报销单 BX20260512001”时,正文不再出现“业务单号:BX20260512001”。 +- 状态只展示一次,优先使用右侧状态标签;不要在标题、副标题和正文里重复写“已通过/业务状态:已通过/状态:已通过”。 +- 卡片固定槽位:第一行左侧为业务类型加业务编号或摘要,第一行右侧为状态;第二行放申请人、金额/天数、日期;第三行可选当前节点或更新时间。 +- PC 高密度表格允许保留独立“单号/编号”列,但同一行的标题或摘要不得再拼接同一编号;确需复制编号时放在单号列或详情页摘要区。 +- UUID、taskId、entityId、内部状态码和调试字段不进入用户主展示;审计页、管理员排障页和打印留痕页按权限例外处理。 + ## 色彩规则 - 主色:品牌蓝/青色系,用于主按钮、选中态、链接和轻量提示。 @@ -104,12 +116,14 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共 - 手机端默认隐藏 PC 侧边栏和顶栏,使用移动首页、底部导航和轻量卡片。 - 菜单不应占用大量空间;常用入口放底部导航,更多入口放“我的”。 - 移动底部导航固定为“首页、审批、报销、我的”;票据上传作为首页快捷入口和报销页内操作,不作为底部一级 Tab。 +- 移动“审批”是审批与进度入口,不只服务审批人;员工进入后查看我发起的报销、请假、采购进度,审批人进入后处理待我审批。 - 移动端必须按响应式规则自动适配,不按单个机型写补丁;验收至少覆盖 320、360、375、390、393、414、430px 宽度。 - 移动首页一级快捷入口使用图标宫格:图标在上、短标题在下,4 项等宽,辅助说明不在小屏常驻展示,避免图标和文字挤压;标题单行省略,不允许逐字换行。 - 移动端页面边距、卡片间距、底部导航高度和吸底操作高度使用统一 token;不通过 viewport 缩放字体解决拥挤问题。 - 次级入口和信息字段可按最小宽度自动从两列变一列;核心操作按钮必须保持明确可点,不得被内容、安全区或底部栏遮挡。 - 报销详情、审批详情等深层 Push 页隐藏底部导航,使用顶部返回和上下文吸底操作,避免底部 Tab 在深层页面假选中。 - 企业微信个人应用消息 deeplink 应直达移动待办或业务详情;首次进入如需点击进入工作台,应保持后续可直接回到目标页。 +- 首页最近通知只展示事件消息,不能重复完整待办卡片;如果某条待办已在首页待处理摘要出现,通知区域不再重复显示同一待办内容。 - 移动列表使用卡片和状态标签,首屏优先展示“谁提交、什么事项、金额/天数、当前状态和下一步”。 - 列表使用卡片,不直接显示桌面表格。 - 详情页按摘要、业务字段、发票/附件、审批流、日志分区展示;普通员工移动端不默认展示后台审计细节。 diff --git a/docs/trialrun/manual-trialrun-report.md b/docs/trialrun/manual-trialrun-report.md index 29c9583c..87b9d084 100644 --- a/docs/trialrun/manual-trialrun-report.md +++ b/docs/trialrun/manual-trialrun-report.md @@ -15,7 +15,7 @@ | --- | --- | | 公网地址 | `https://oa.hyinda.com` | | 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;ME6 自动化、部署和公网视觉复检已完成,`ME6-3` 代表性企业微信真机抽查因设备不便暂缓;QG6 非业务 QA 治理已完成但不替代真机 | -| 当前代码基线 | QG6 QA 治理升级已部署到 `https://oa.hyinda.com`,并完成 `/ready`、public bundle、live smoke readiness、公网移动视觉矩阵复检;R7/OPS7/DOC7 草案材料已准备;真机抽查待用户确认 | +| 当前代码基线 | DG7/MRF7 移动信息架构与展示去重治理已部署到 `https://oa.hyinda.com`,并完成 `/ready`、public bundle、live smoke readiness、公网移动视觉矩阵复检;`ME6-3` 代表性企业微信真机抽查仍待用户确认 | | 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` | | 回滚联系人 | `wangshunli` | | 问题登记负责人 | `wangshunli` | @@ -104,6 +104,8 @@ QG6 QA 治理收口(2026-05-12):非真机阶段 QA 治理升级已完成 R7/OPS7/DOC7 非真机准备(2026-05-12):已创建 `issues/2026-05-12_17-49-03-r7-expanded-trialrun-prep-ops-doc-materials.csv`。R7 草案见 `docs/trialrun/r7-expanded-trialrun-prep.md`,只固定范围、角色占位、门禁、问题分级、每日收口和退出标准;`R7E-1..R7E-3` 继续保持 `TODO`,真实 R7 未启动。OPS7 运维交接 Runbook 见 `docs/trialrun/ops7-runbook.md`,只记录部署、回滚、备份恢复、账号交接、live readiness 和企业微信通知检查清单,不执行生产配置变更或数据库恢复。DOC7 用户手册与 FAQ 见 `docs/trialrun/r7-user-guide-and-faq.md`,只描述当前已实现的报销、请假、采购、审批、企业微信入口和财务台账能力,不承诺预算、ERP、AI、完整差旅、复杂 BPM 或审批设计器。`ME6-3` 真机门禁仍未关闭;R7 真实启动仍需用户确认最终人员、窗口,并补足真机门禁或明确接受风险。 +DG7/MRF7 移动信息架构与展示去重治理收口(2026-05-12):已根据 iPhone 12、iPhone 14 和 Redmi K60 企业微信真机反馈建立首页/审批/通知/业务卡片去重规约,并完成移动端实现与部署。员工底部“审批”入口改为“进度”视角,展示我发起的报销、请假、采购进度且不显示审批动作;审批人仍使用“审批”视角。首页只保留一处待处理摘要,首页通知不再重复完整待办;底部审批角标移到图标右上外侧,不覆盖图标或文字。审批详情改为 `/mobile/approvals/:taskId` Push 页并隐藏底部 Tab;通知中心卡片整体可点,先标记已读再直达报销、请假、采购或审批移动详情;采购和请假通知不再回首页或新建表单。PC/移动卡片展示引入共享业务主标识工具,同一卡片不重复业务编号或状态;`check-ui-design-rules.mjs` 已增加重复业务编号扫描规则。验证结果:`client typecheck/build`、`check-ui-design-rules --self-test/--strict`、DG7 专项 3 passed、WCM6 11 passed、R124/R6 响应式/MUD6 15 passed。已执行 `bash deploy/trialrun/deploy.sh`,公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness PASS,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-205559`;公网移动视觉矩阵 7 passed,报告 `artifacts/qa-flow/dg7-mobile-information-dedupe-governance-visual-matrix-20260512-205634`。因当前无法做完整三机复测,`ME6-3` 继续保持 TODO,不用本轮自动化替代真机门禁。 + ## 结论 R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。 diff --git a/issues/2026-05-12_19-44-12-mobile-oa-information-dedupe-governance.csv b/issues/2026-05-12_19-44-12-mobile-oa-information-dedupe-governance.csv new file mode 100644 index 00000000..bec2679f --- /dev/null +++ b/issues/2026-05-12_19-44-12-mobile-oa-information-dedupe-governance.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +DG7-1,移动信息架构与展示去重规约同步,"把首页/审批/通知/卡片去重规则固化到产品设计规则、交付实践和项目 Skill。","1) 规约明确首页只保留一处待办摘要;2) 审批入口承载待我审批和我发起的进度;3) 通知不作为待办列表副本;4) 同一卡片主标识和状态只展示一次。","流程级验证:validate_issues_csv.py 校验本 CSV;quick_validate.py 校验 Skill;rg 核对 首页/审批/通知/卡片去重/业务编号 等关键术语已进入事实源。",python3|rg|manual,DONE,DONE,DONE,docs/product-design-rules.md|docs/agent-development-practices.md|.agents/skills/hyb-oa-delivery/SKILL.md,[],只改文档与 Skill;不改业务逻辑。 +MRF7-2,移动首页与审批入口真机反馈修复,"处理员工审批入口不可点、首页待办重复、底部角标位置、首页图标文字间距和审批详情内嵌列表等真机反馈。","1) 普通员工底部审批可进入审批与进度页且无审批动作;2) 首页最多一处待办/进度摘要;3) 底部审批角标锚定图标右上角;4) 审批详情以 Push 详情呈现并隐藏底部 Tab;5) 首页宫格图文间距在 320-430px 稳定。","流程级验证:Playwright 以员工、审批人、财务账号覆盖 /mobile 与 /mobile/approvals;移动视口 320/360/375/390/393/414/430;断言底部审批可点、首页无三处重复待办、角标贴近图标、审批详情隐藏底部 Tab。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/layouts/AppShellLayout.tsx|client/src/styles/mobile-touch.css,"[""DG7-1""]",不改服务端权限;前端展示裁剪不能代替服务端授权。 +DIG7-3,全局业务卡片展示去重,"梳理移动与 PC 高频卡片/列表,统一主标题、副标题、状态、编号展示,移除同卡片重复业务单号/状态。","1) 移动审批卡片标题已有业务编号时不再重复显示业务单号;2) 报销、请假、采购、通知卡片遵循主标识只出现一次;3) UUID 不进入主展示;4) PC 高频表格不在同一行重复同一编号。","流程级验证:check-ui-design-rules --strict;Playwright 覆盖移动审批/报销/请假/采购/通知和 PC 审批/报销/请假/采购/通知;人工核对同一卡片无重复单号和状态。",npm|playwright|manual,DONE,DONE,DONE,client/src/lib|client/src/pages|client/src/components,"[""MRF7-2""]",展示治理,不改变接口或数据模型。 +NLG7-4,通知直达与移动详情落点治理,"统一 notification target 到移动详情的映射,通知中心不再作为业务详情替代页。","1) 首页通知卡片点击优先直达移动目标;2) 通知中心卡片整体可点并先标记已读;3) 采购通知进入移动采购详情;4) 请假通知进入请假记录详情;5) 无目标或无权限时不跳首页并显示可理解提示。","流程级验证:构造审批/报销/请假/采购通知 mock;点击首页通知和通知中心卡片;验证目标路径、已读状态和无目标提示。",playwright|npm,DONE,DONE,DONE,client/src/lib/notificationLinks.ts|client/src/lib/runtimeContext.ts|client/src/pages/MobileNotificationsPage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobilePurchaseRequestDetailPage.tsx,"[""MRF7-2"",""DIG7-3""]",不新增通知通道;继续复用企业微信个人应用消息 deeplink 语义。 +QG7-5,展示去重 QA 扫描增强,"扩展设计规则扫描,覆盖同卡片重复单号/编号、通知与待办重复、移动页面桌面表格残留等风险。","1) check-ui-design-rules 能扫描新增风险;2) 保留合理白名单;3) 严格模式通过;4) 规则说明不记录敏感信息。","流程级验证:node scripts/qa/check-ui-design-rules.mjs --strict;构造或复用 fixture 确认新增规则可命中;git diff --check。",node|manual,DONE,DONE,DONE,scripts/qa/check-ui-design-rules.mjs|client/tests/e2e,"[""DIG7-3"",""NLG7-4""]",静态扫描只能兜底;动态重复仍需视觉和人工复核。 +QG7-6,本地回归部署与真机门禁回填,"完成本地验证、部署公网、复检 ready/bundle/live smoke,并记录 ME6-3 仍待代表性真机复测。","1) client typecheck/build 通过;2) 移动视觉专项通过;3) 公网 /ready、bundle、live readiness 通过;4) CSV 状态仅在 Test_Method 通过后回填 DONE;5) ME6-3 不伪造关闭。","流程级验证:cd client && npm run typecheck && npm run build;移动 Playwright 矩阵;bash deploy/trialrun/deploy.sh;curl /ready;public bundle 检查;run-live-smoke-readiness.sh;更新 manual-trialrun-report。",npm|playwright|bash|curl,DONE,DONE,DONE,client/tests/e2e|docs/trialrun/manual-trialrun-report.md|deploy/trialrun,"[""QG7-5""]",面向用户可测改动必须部署后再提示真机复测;R7 真实执行仍等待 ME6-3 或风险接受。 diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs index 6d275ca2..e29c89ab 100644 --- a/scripts/qa/check-ui-design-rules.mjs +++ b/scripts/qa/check-ui-design-rules.mjs @@ -410,6 +410,43 @@ function scanReimbursementRawCodeDisplay(file, source) { } } +function scanDuplicateBusinessIdentifierDisplay(file, source) { + const normalized = normalizeFindingFile(file); + if (!/^client\/src\/(?:pages|components)\//.test(normalized)) { + return; + } + + const patterns = [ + { + regex: /业务单号[::][\s\S]{0,160}\{task\.reference\}/g, + message: "审批卡片标题已有业务对象时,不应在同一卡片再次展示业务单号。", + }, + { + regex: /业务:\$\{entityLabel\([^}]+\)\}\s*·\s*\$\{reference/g, + message: "审批卡片副标题不应重复拼接业务类型和业务编号,应使用固定主标题槽位。", + }, + { + regex: /业务单号[::][\s\S]{0,220}(?:报销单号|采购单号|差旅单号)[::]/g, + message: "同一详情摘要不应同时展示通用业务单号和具体业务单号。", + }, + ]; + + for (const pattern of patterns) { + let match = pattern.regex.exec(source); + while (match) { + addFinding({ + rule: "display.no_duplicate_business_identifier", + severity: "error", + file, + line: lineNumber(source, match.index), + message: pattern.message, + evidence: match[0].replace(/\s+/g, " ").slice(0, 180), + }); + match = pattern.regex.exec(source); + } + } +} + function extractStringList(source, name) { const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source); if (!assignment) { @@ -460,6 +497,7 @@ function scanFileSource(file, source) { scanUuidPrimaryDisplay(file, source); scanRawBusinessIdentifiers(file, source); scanReimbursementRawCodeDisplay(file, source); + scanDuplicateBusinessIdentifierDisplay(file, source); scanNavigationTitleCoverage(file, source); } @@ -475,7 +513,7 @@ function runSelfTest() { file: "client/src/pages/MobileBadFixture.tsx", source: ` export function MobileBadFixture() { - return
任务ID:{task.id}
; + return
任务ID:{task.id}
业务单号:{task.reference}
; } `, expectedRules: [ @@ -483,6 +521,7 @@ function runSelfTest() { "mobile.no_pc_shell", "copy.no_english_upload_labels", "data.no_uuid_primary_display", + "display.no_duplicate_business_identifier", ], expectedSuppressedRules: [], },