From ba29ede573dedb805d06d23c2c7a6fca3e1c789e Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 5 Apr 2026 22:20:08 +0800 Subject: [PATCH] =?UTF-8?q?R51=20=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=EF=BC=9A=E5=85=A5=E5=8F=A3=E9=A1=B5=E3=80=81=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E5=AF=BC=E8=88=AA=E4=B8=8E=E5=AE=A1=E6=89=B9=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/mobile/MobileBottomNav.tsx | 94 +++ client/src/features/mobile/navigation.ts | 35 + client/src/features/navigation/schema.ts | 5 + client/src/layouts/AppShellLayout.tsx | 6 +- client/src/pages/MobileApprovalsPage.tsx | 794 ++++++++++++++---- client/src/pages/MobileMePage.tsx | 98 +++ client/src/pages/MobilePortalPage.tsx | 282 +++++++ client/src/router/index.tsx | 6 + client/src/router/lazy/MobileMePage.ts | 1 + client/src/router/lazy/MobilePortalPage.ts | 1 + client/src/router/lazy/index.ts | 2 + issues/2026-04-05_r51-mobile-enhancement.csv | 7 + 12 files changed, 1168 insertions(+), 163 deletions(-) create mode 100644 client/src/components/mobile/MobileBottomNav.tsx create mode 100644 client/src/features/mobile/navigation.ts create mode 100644 client/src/pages/MobileMePage.tsx create mode 100644 client/src/pages/MobilePortalPage.tsx create mode 100644 client/src/router/lazy/MobileMePage.ts create mode 100644 client/src/router/lazy/MobilePortalPage.ts create mode 100644 issues/2026-04-05_r51-mobile-enhancement.csv diff --git a/client/src/components/mobile/MobileBottomNav.tsx b/client/src/components/mobile/MobileBottomNav.tsx new file mode 100644 index 00000000..0f050d31 --- /dev/null +++ b/client/src/components/mobile/MobileBottomNav.tsx @@ -0,0 +1,94 @@ +import { NavLink } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { fetchWorkflowTasks } from "../../api/workflow"; +import { useAuth } from "../../auth/AuthContext"; +import { hasAnyPermission } from "../../auth/permissions"; +import { + MOBILE_APPROVAL_PERMISSIONS, + MOBILE_NAV_ITEMS, +} from "../../features/mobile/navigation"; +import { cn } from "../../lib/utils"; + +function resolveBadgeLabel(count: number) { + if (count > 99) { + return "99+"; + } + return String(count); +} + +export function MobileBottomNav() { + const { permissions } = useAuth(); + const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]); + + const pendingCountQuery = useQuery({ + enabled: canAccessApprovals, + queryKey: ["mobile", "workflow", "pending-count"], + queryFn: async () => { + const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 }); + return response.total; + }, + refetchInterval: 30_000, + }); + + const pendingCount = pendingCountQuery.data ?? 0; + + return ( + + ); +} diff --git a/client/src/features/mobile/navigation.ts b/client/src/features/mobile/navigation.ts new file mode 100644 index 00000000..94508e86 --- /dev/null +++ b/client/src/features/mobile/navigation.ts @@ -0,0 +1,35 @@ +export const MOBILE_APPROVAL_PERMISSIONS = [ + "expense.approve", + "leave.approve", + "procurement.approve", + "travel.approve", +] as const; + +export const MOBILE_CAPTURE_PERMISSIONS = ["invoice.upload", "invoice.manage"] as const; + +export const MOBILE_ASSET_SCAN_PERMISSIONS = [ + "asset.view_own", + "asset.view_all", + "asset.manage", + "asset.transfer", + "asset.inventory", +] as const; + +export const MOBILE_LEAVE_PERMISSIONS = ["leave.view_own", "leave.view_all"] as const; + +export const MOBILE_REIMBURSEMENT_PERMISSIONS = ["expense.view_own", "expense.view_all"] as const; + +export type MobileNavItem = { + key: "home" | "approvals" | "capture" | "me"; + to: string; + label: string; + icon: string; + anyOf?: readonly string[]; +}; + +export const MOBILE_NAV_ITEMS: MobileNavItem[] = [ + { key: "home", to: "/mobile", label: "首页", icon: "home" }, + { key: "approvals", to: "/mobile/approvals", label: "审批", icon: "fact_check", anyOf: MOBILE_APPROVAL_PERMISSIONS }, + { key: "capture", to: "/mobile/capture", label: "拍照", icon: "photo_camera", anyOf: MOBILE_CAPTURE_PERMISSIONS }, + { key: "me", to: "/mobile/me", label: "我的", icon: "person" }, +]; diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index ad9c31e9..bce342d2 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -16,6 +16,11 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [ { path: "/assets", title: "资产", group: "主导航" }, { path: "/workflow/tasks/", title: "审批详情", group: "备用模块" }, { path: "/workflow/tasks", title: "审批待办", group: "备用模块" }, + { path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" }, + { path: "/mobile/approvals", title: "移动审批", group: "移动工作台" }, + { path: "/mobile/capture", title: "移动拍照上传", group: "移动工作台" }, + { path: "/mobile/me", title: "移动我的", group: "移动工作台" }, + { path: "/mobile", title: "移动首页", group: "移动工作台" }, { path: "/settings/profile", title: "个人设置", group: "账号设置" }, { path: "/system/workspace", title: "工作区设置", group: "管理入口" }, { path: "/people", title: "人员管理", group: "管理入口" }, diff --git a/client/src/layouts/AppShellLayout.tsx b/client/src/layouts/AppShellLayout.tsx index 2960b9c7..c9781dea 100644 --- a/client/src/layouts/AppShellLayout.tsx +++ b/client/src/layouts/AppShellLayout.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { Outlet, useLocation } from "react-router-dom"; import { ErrorBoundary } from "../components/common/ErrorBoundary"; +import { MobileBottomNav } from "../components/mobile/MobileBottomNav"; import { Sidebar } from "../components/navigation/Sidebar"; import { Topbar } from "../components/navigation/Topbar"; import { resolvePageMeta } from "../features/navigation/schema"; @@ -8,6 +9,7 @@ import { writeRecentVisit } from "../lib/recentVisits"; export function AppShellLayout() { const location = useLocation(); + const isMobileRoute = location.pathname.startsWith("/mobile"); useEffect(() => { const pathname = location.pathname; @@ -24,13 +26,15 @@ export function AppShellLayout() {
-
+
+ + {isMobileRoute ? : null}
); diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index f21beda9..1a213355 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; @@ -13,11 +13,16 @@ import { returnWorkflowTask, transferWorkflowTask, type WorkflowTaskDetail, + type WorkflowTaskSummary, } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; 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 { Input } from "../components/ui/input"; import { Spinner } from "../components/ui/spinner"; +import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Textarea } from "../components/ui/textarea"; import { cacheWorkflowTaskDetail, @@ -33,10 +38,33 @@ import { } from "../mobile/workflowActionQueue"; type MobileAction = "approve" | "reject" | "return" | "transfer"; - +type MobileTaskTab = "pending" | "completed"; +type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request"; type ReplayClassification = "failed" | "conflicted"; -function entityLabel(entityType: string | undefined) { +type VisibleTaskCard = { + id: string; + title: string; + subtitle: string; + status: string; + entityType: string | null; + entityId: string | null; + reference: string | null; + allowedActions: MobileAction[]; + completedAt: string | null; + isCached: boolean; + detail: WorkflowTaskDetail | null; +}; + +const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [ + { value: "all", label: "全部" }, + { value: "leave", label: "请假" }, + { value: "reimbursement", label: "报销" }, + { value: "purchase_request", label: "采购" }, + { value: "travel_request", label: "差旅" }, +]; + +function entityLabel(entityType: string | undefined | null) { if (entityType === "leave") { return "请假"; } @@ -52,6 +80,25 @@ function entityLabel(entityType: string | undefined) { return entityType || "未知"; } +function workflowStatusLabel(status: string) { + if (status === "pending") { + return "待处理"; + } + if (status === "completed") { + return "已处理"; + } + if (status === "rejected") { + return "已驳回"; + } + if (status === "cancelled") { + return "已取消"; + } + if (status === "skipped") { + return "已跳过"; + } + return status; +} + function resolveErrorMessage(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; @@ -88,7 +135,11 @@ function queueStatusLabel(status: WorkflowDraftQueueItem["status"]) { return "存在冲突"; } -function formatDateTime(value: string) { +function formatDateTime(value: string | null | undefined) { + if (!value) { + return "-"; + } + try { return new Date(value).toLocaleString("zh-CN"); } catch { @@ -96,6 +147,67 @@ function formatDateTime(value: string) { } } +function normalizeSearchValue(value: string | null | undefined) { + return String(value ?? "").trim().toLowerCase(); +} + +function isWithinRecentDays(value: string | null | undefined, days: number) { + if (!value) { + return false; + } + + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) { + return false; + } + + return timestamp >= Date.now() - days * 24 * 60 * 60 * 1000; +} + +function matchesEntityFilter(entityType: string | null, filter: EntityFilter) { + return filter === "all" || entityType === filter; +} + +function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard { + const entityType = task.workflowInstance?.entityType ?? null; + const entityId = task.workflowInstance?.entityId ?? null; + return { + id: task.id, + title: task.stepName, + subtitle: + task.status === "completed" + ? `业务:${entityLabel(entityType)} · 完成于 ${formatDateTime(task.completedAt)}` + : `业务:${entityLabel(entityType)} · 实体ID:${entityId ?? "-"}`, + status: task.status, + entityType, + entityId, + reference: entityId ?? task.id, + allowedActions: task.allowedActions, + completedAt: task.completedAt, + isCached: false, + detail: null, + }; +} + +function matchesSearch(card: VisibleTaskCard, keyword: string) { + if (!keyword) { + return true; + } + + const searchValue = normalizeSearchValue(keyword); + const haystack = [ + card.title, + entityLabel(card.entityType), + card.id, + card.entityId, + card.reference, + ] + .map((item) => normalizeSearchValue(item)) + .join(" "); + + return haystack.includes(searchValue); +} + export function MobileApprovalsPage() { const queryClient = useQueryClient(); const { me, accessToken, offlineAccessMode } = useAuth(); @@ -103,6 +215,13 @@ export function MobileApprovalsPage() { const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? ""; const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId); + const [taskTab, setTaskTab] = useState("pending"); + const [entityFilter, setEntityFilter] = useState("all"); + const [searchInput, setSearchInput] = useState(""); + const deferredSearch = useDeferredValue(searchInput); + const [batchMode, setBatchMode] = useState(false); + const [selectedBatchTaskIds, setSelectedBatchTaskIds] = useState([]); + const [batchRejectComment, setBatchRejectComment] = useState(""); const [error, setError] = useState(""); const [message, setMessage] = useState(""); const [comment, setComment] = useState(""); @@ -114,11 +233,20 @@ export function MobileApprovalsPage() { const ownerEmployeeId = me?.id ?? null; const canUseRemote = isOnline && Boolean(accessToken); + const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending"; useEffect(() => { setSelectedTaskId(deepLinkTaskId); }, [deepLinkTaskId]); + useEffect(() => { + if (taskTab === "completed") { + setBatchMode(false); + setSelectedBatchTaskIds([]); + setBatchRejectComment(""); + } + }, [taskTab]); + function updateTaskSelection(taskId: string) { setSelectedTaskId(taskId); const nextParams = new URLSearchParams(searchParams); @@ -130,10 +258,21 @@ export function MobileApprovalsPage() { setSearchParams(nextParams, { replace: true }); } + async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", taskId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "pending-count"] }), + ]); + } + const tasksQuery = useQuery({ enabled: canUseRemote, - queryKey: ["mobile", "workflow", "tasks"], - queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 30 }), + queryKey: ["mobile", "workflow", "tasks", remoteTaskStatus], + queryFn: () => fetchWorkflowTasks({ status: remoteTaskStatus, page: 1, pageSize: 100 }), }); const detailQuery = useQuery({ @@ -168,23 +307,80 @@ export function MobileApprovalsPage() { void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }), - queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, detailQuery.data?.task.id] }), + queryClient.invalidateQueries({ + queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, detailQuery.data.task.id], + }), ]); }); }, [detailQuery.data, ownerEmployeeId, queryClient]); - const selectedTask = useMemo(() => { - if (detailQuery.data) { - return detailQuery.data; + const baseTaskCards = useMemo(() => { + if (canUseRemote) { + const remoteItems = (tasksQuery.data?.items ?? []).filter((item) => + taskTab === "completed" ? isWithinRecentDays(item.completedAt, 30) : true, + ); + return remoteItems.map(toVisibleRemoteTask); } - if (!canUseRemote) { - return cachedDetailQuery.data?.detail ?? null; + + if (taskTab === "completed") { + return []; } - return null; - }, [cachedDetailQuery.data?.detail, canUseRemote, detailQuery.data]); + + return (cachedTasksQuery.data ?? []) + .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)}`, + status: item.detail.task.status, + entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null, + entityId: item.taskSnapshot.entityId, + reference: item.taskSnapshot.reference ?? item.detail.task.id, + allowedActions: item.detail.allowedActions, + completedAt: item.detail.task.completedAt, + isCached: true, + detail: item.detail, + })); + }, [cachedTasksQuery.data, canUseRemote, taskTab, tasksQuery.data?.items]); + + const filteredTaskCards = useMemo( + () => + baseTaskCards.filter( + (card) => matchesEntityFilter(card.entityType, entityFilter) && matchesSearch(card, deferredSearch), + ), + [baseTaskCards, deferredSearch, entityFilter], + ); + + const pendingTaskMap = useMemo( + () => new Map(filteredTaskCards.map((task) => [task.id, task])), + [filteredTaskCards], + ); + + const selectedCachedTaskDetail = useMemo( + () => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null, + [filteredTaskCards, selectedTaskId], + ); + + const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail; + const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null; + const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending"); + const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote; + + 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 && selectedTask?.allowedActions.includes("transfer"), ); @@ -208,6 +404,7 @@ export function MobileApprovalsPage() { () => currentQueueItems.filter((item) => item.status !== "conflicted"), [currentQueueItems], ); + async function queueOfflineDraft(action: WorkflowDraftAction) { try { if (!selectedTaskId) { @@ -232,6 +429,7 @@ export function MobileApprovalsPage() { detail: selectedTask, }); await refreshDraftQueueCache(); + await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }); setComment(""); setError(""); setMessage(action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。"); @@ -293,16 +491,13 @@ export function MobileApprovalsPage() { return { action }; }, onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), - queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }), - queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }), - ]); + await invalidateWorkflowQueries(selectedTaskId); setComment(""); setTransferReason(""); setTransferToApproverId(""); setError(""); setMessage(""); + updateTaskSelection(""); toast.success("操作成功"); }, onError: (actionError) => { @@ -313,6 +508,100 @@ export function MobileApprovalsPage() { }, }); + const batchActionMutation = useMutation({ + mutationFn: async (input: { action: WorkflowDraftAction; taskIds: string[]; comment?: string }) => { + if (!input.taskIds.length) { + throw new Error("请先选择至少一条待办"); + } + if (input.action === "reject" && !input.comment?.trim()) { + throw new Error("批量驳回必须填写共享意见"); + } + + let succeeded = 0; + let failed = 0; + let skipped = 0; + const failures: string[] = []; + + for (const taskId of input.taskIds) { + const taskCard = pendingTaskMap.get(taskId); + if (!taskCard) { + skipped += 1; + failures.push(`${taskId}: 当前筛选结果中找不到该待办`); + continue; + } + + if (!taskCard.allowedActions.includes(input.action)) { + skipped += 1; + failures.push(`${taskId}: 当前待办不允许${input.action === "approve" ? "通过" : "驳回"}`); + continue; + } + + try { + if (canUseRemote) { + if (input.action === "approve") { + await approveWorkflowTask(taskId); + } else { + await rejectWorkflowTask(taskId, input.comment!.trim()); + } + } else { + if (!taskCard.detail) { + throw new Error("当前待办尚未缓存,请联网后重试"); + } + await enqueueWorkflowActionDraft({ + taskId, + action: input.action, + comment: input.action === "reject" ? input.comment?.trim() ?? null : null, + ownerEmployeeId, + detail: taskCard.detail, + }); + } + succeeded += 1; + } catch (batchError) { + failed += 1; + failures.push(`${taskId}: ${resolveErrorMessage(batchError)}`); + } + } + + return { ...input, succeeded, failed, skipped, failures }; + }, + onSuccess: async (result) => { + if (canUseRemote) { + await invalidateWorkflowQueries(selectedTaskId); + } else { + await refreshDraftQueueCache(); + await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }); + } + + const actionLabel = result.action === "approve" ? "通过" : "驳回"; + const summary = canUseRemote + ? `批量${actionLabel}完成:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}` + : `离线批量${actionLabel}已入队:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`; + + setBatchMode(false); + setSelectedBatchTaskIds([]); + setBatchRejectComment(""); + setComment(""); + setError(result.failures.length ? result.failures.slice(0, 3).join(";") : ""); + setMessage(summary); + if (canUseRemote && result.taskIds.includes(selectedTaskId)) { + updateTaskSelection(""); + } + + if (result.failed > 0) { + toast.error(summary); + return; + } + + toast.success(summary); + }, + onError: (batchError) => { + const nextError = resolveErrorMessage(batchError); + setError(nextError); + setMessage(""); + toast.error(nextError); + }, + }); + const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting; const syncMutation = useMutation({ @@ -351,11 +640,7 @@ export function MobileApprovalsPage() { return { succeeded, failed, conflicted }; }, onSuccess: async (result) => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }), - queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), - queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }), - ]); + await invalidateWorkflowQueries(selectedTaskId); setError(""); setMessage(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`); if (result.succeeded || result.failed || result.conflicted) { @@ -399,27 +684,43 @@ export function MobileApprovalsPage() { } }, [canUseRemote, retryableQueueItems, syncMutation]); - const pendingTasks = tasksQuery.data?.items ?? []; - const cachedTasks = cachedTasksQuery.data ?? []; - const visibleTaskCards = canUseRemote - ? pendingTasks.map((task) => ({ - id: task.id, - title: task.stepName, - subtitle: `业务:${entityLabel(task.workflowInstance?.entityType)}`, - status: task.status, - })) - : cachedTasks.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)}`, - status: item.detail.task.status, - })); + function toggleBatchTask(taskId: string) { + setSelectedBatchTaskIds((current) => + current.includes(taskId) ? current.filter((id) => id !== taskId) : [...current, taskId], + ); + } - const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null; - const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote; + function toggleSelectAllVisibleTasks() { + const visibleIds = filteredTaskCards.map((task) => task.id); + const allSelected = visibleIds.length > 0 && visibleIds.every((taskId) => selectedBatchTaskIds.includes(taskId)); + if (allSelected) { + setSelectedBatchTaskIds([]); + return; + } + setSelectedBatchTaskIds(visibleIds); + } + + function handleBatchAction(action: WorkflowDraftAction) { + batchActionMutation.mutate({ + action, + taskIds: selectedBatchTaskIds, + comment: action === "reject" ? batchRejectComment : undefined, + }); + } + + const listTitle = taskTab === "pending" ? "待办任务" : "已处理任务"; + const listSubtitle = canUseRemote + ? taskTab === "pending" + ? "在线串行审批,支持进入批量处理模式。" + : "展示最近 30 天已处理任务。" + : taskTab === "pending" + ? "离线状态下仅展示已缓存待办,并支持批量入队。" + : "离线模式下暂不提供已处理历史。"; + const allVisibleSelected = + filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id)); return ( -
+ {message}

) : null} -
-
-

待办任务

+
+
+
+

{listTitle}

+

{listSubtitle}

+
{isOnline ? "在线" : "离线"}
+ setTaskTab(value as MobileTaskTab)}> + + 待处理 + 已处理 + + + + setEntityFilter(value as EntityFilter)}> + + {ENTITY_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+ setSearchInput(event.target.value)} + placeholder="搜索步骤名、业务类型、任务 ID 或业务实体 ID" + /> + + {taskTab === "pending" ? ( +
+ + {batchMode ? ( + + ) : null} + + 当前结果 {filteredTaskCards.length} 条 + {batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""} + +
+ ) : ( +

已处理页仅展示最近 30 天内的 completed 任务。

+ )} +
+ + {batchMode ? ( +
+
+
已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。
+
+ + +
+
+