R51 移动端增强:入口页、底部导航与审批批量处理
这个提交包含在:
+632
-162
@@ -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<MobileTaskTab>("pending");
|
||||
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const deferredSearch = useDeferredValue(searchInput);
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [selectedBatchTaskIds, setSelectedBatchTaskIds] = useState<string[]>([]);
|
||||
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<WorkflowTaskDetail | null>(() => {
|
||||
if (detailQuery.data) {
|
||||
return detailQuery.data;
|
||||
const baseTaskCards = useMemo<VisibleTaskCard[]>(() => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="移动审批"
|
||||
subtitle={deepLinkTaskId ? `深链待办:${deepLinkTaskId}` : "小屏快速处理待办任务,支持离线草稿与联网重放。"}
|
||||
@@ -439,45 +740,202 @@ export function MobileApprovalsPage() {
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-approvals-list">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">待办任务</h3>
|
||||
<section className="card-std space-y-4 p-4" data-testid="mobile-approvals-list">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">{listSubtitle}</p>
|
||||
</div>
|
||||
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||||
</div>
|
||||
|
||||
<Tabs value={taskTab} onValueChange={(value) => setTaskTab(value as MobileTaskTab)}>
|
||||
<TabsList className="flex w-full justify-start gap-1">
|
||||
<TabsTrigger value="pending">待处理</TabsTrigger>
|
||||
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
{ENTITY_FILTER_OPTIONS.map((option) => (
|
||||
<TabsTrigger key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
data-testid="mobile-approvals-search"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="搜索步骤名、业务类型、任务 ID 或业务实体 ID"
|
||||
/>
|
||||
|
||||
{taskTab === "pending" ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={batchMode ? "default" : "secondary"}
|
||||
disabled={!filteredTaskCards.length}
|
||||
onClick={() => {
|
||||
setBatchMode((current) => !current);
|
||||
setSelectedBatchTaskIds([]);
|
||||
setBatchRejectComment("");
|
||||
}}
|
||||
>
|
||||
{batchMode ? "退出批量处理" : "批量处理"}
|
||||
</Button>
|
||||
{batchMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!filteredTaskCards.length}
|
||||
onClick={toggleSelectAllVisibleTasks}
|
||||
>
|
||||
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
|
||||
</Button>
|
||||
) : null}
|
||||
<span className="text-sm text-zinc-500">
|
||||
当前结果 {filteredTaskCards.length} 条
|
||||
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">已处理页仅展示最近 30 天内的 completed 任务。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batchMode ? (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
|
||||
onClick={() => handleBatchAction("approve")}
|
||||
>
|
||||
{canUseRemote ? "批量通过" : "离线批量加入通过草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
|
||||
onClick={() => handleBatchAction("reject")}
|
||||
>
|
||||
{canUseRemote ? "批量驳回" : "离线批量加入驳回草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
className="mt-3"
|
||||
rows={3}
|
||||
value={batchRejectComment}
|
||||
onChange={(event) => setBatchRejectComment(event.target.value)}
|
||||
placeholder="批量驳回共享意见,所有选中任务将复用这条说明。"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canUseRemote && tasksQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div>
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Spinner size="sm" />
|
||||
加载中...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!canUseRemote ? (
|
||||
<p className="text-sm text-zinc-500">离线状态下展示已缓存待办;若未缓存任务详情,可通过深链任务号联网后重试。</p>
|
||||
{canUseRemote && tasksQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(tasksQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!visibleTaskCards.length ? (
|
||||
<p className="text-sm text-zinc-500">{canUseRemote ? "暂无待办任务。" : "暂无已缓存待办。"}</p>
|
||||
{!canUseRemote && taskTab === "completed" ? (
|
||||
<p className="text-sm text-zinc-500">离线模式仅缓存待处理任务详情;已处理历史需联网查看。</p>
|
||||
) : null}
|
||||
|
||||
{!filteredTaskCards.length && !(canUseRemote && tasksQuery.isLoading) ? (
|
||||
<p className="text-sm text-zinc-500">
|
||||
{taskTab === "completed"
|
||||
? "最近 30 天暂无符合筛选条件的已处理任务。"
|
||||
: canUseRemote
|
||||
? "暂无符合筛选条件的待办任务。"
|
||||
: "暂无符合筛选条件的已缓存待办。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{visibleTaskCards.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm ${
|
||||
selectedTaskId === task.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"
|
||||
}`}
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
onClick={() => updateTaskSelection(task.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="font-medium">{task.title}</div>
|
||||
<div className="text-xs text-zinc-500">任务ID: {task.id}</div>
|
||||
<div className="text-xs text-zinc-500">{task.subtitle}</div>
|
||||
<div className="text-xs text-zinc-500">状态:{task.status}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{filteredTaskCards.map((task) => {
|
||||
const isSelected = selectedTaskId === task.id;
|
||||
const isBatchSelected = selectedBatchTaskIds.includes(task.id);
|
||||
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||
if (batchMode && taskTab === "pending") {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex gap-3 rounded-xl border px-3 py-3 ${
|
||||
isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||
checked={isBatchSelected}
|
||||
onChange={() => toggleBatchTask(task.id)}
|
||||
aria-label={`选择任务 ${task.id}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
onClick={() => toggleBatchTask(task.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{task.title}</div>
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">任务ID: {task.id}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
className={`w-full rounded-xl border px-3 py-3 text-left ${
|
||||
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||
}`}
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
onClick={() => updateTaskSelection(task.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{task.title}</div>
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">任务ID: {task.id}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||
{task.reference && task.reference !== task.id ? (
|
||||
<div className="mt-1 text-xs text-zinc-500">业务参考:{task.reference}</div>
|
||||
) : null}
|
||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||
<h3 className="text-base font-semibold text-zinc-900">任务详情与动作</h3>
|
||||
|
||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||
@@ -487,7 +945,9 @@ export function MobileApprovalsPage() {
|
||||
) : null}
|
||||
|
||||
{selectedTaskId && canUseRemote && detailQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</p>
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(detailQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isOfflineUncachedDeepLink ? (
|
||||
@@ -499,10 +959,12 @@ export function MobileApprovalsPage() {
|
||||
{selectedTask ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div>任务状态:{selectedTask.task.status}</div>
|
||||
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
||||
<div>步骤:{selectedTask.task.stepName}</div>
|
||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||
<div>业务参考:{selectedReference?.reference ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
|
||||
{selectedTask.reimbursement ? (
|
||||
<>
|
||||
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
||||
@@ -535,100 +997,108 @@ export function MobileApprovalsPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-comment"
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="驳回 / 退回意见;离线仅支持驳回意见入队。"
|
||||
rows={3}
|
||||
value={comment}
|
||||
/>
|
||||
|
||||
{!canUseRemote ? (
|
||||
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => {
|
||||
if (canUseRemote) {
|
||||
actionMutation.mutate("approve");
|
||||
return;
|
||||
}
|
||||
void queueOfflineDraft("approve");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{canUseRemote ? "通过" : "离线加入通过草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => {
|
||||
if (canUseRemote) {
|
||||
actionMutation.mutate("reject");
|
||||
return;
|
||||
}
|
||||
void queueOfflineDraft("reject");
|
||||
}}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => actionMutation.mutate("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canTransfer ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
value={transferToApproverId}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||
<option key={approver.id} value={approver.id}>
|
||||
{approver.name} ({approver.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{!selectedTaskAllowsActions ? (
|
||||
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
当前任务已处理,仅供查看,不支持再次审批。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
data-testid="mobile-approvals-comment"
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="驳回 / 退回意见;离线仅支持驳回意见入队。"
|
||||
rows={3}
|
||||
value={comment}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{!canUseRemote ? (
|
||||
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => {
|
||||
if (canUseRemote) {
|
||||
actionMutation.mutate("approve");
|
||||
return;
|
||||
}
|
||||
void queueOfflineDraft("approve");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{canUseRemote ? "通过" : "离线加入通过草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => {
|
||||
if (canUseRemote) {
|
||||
actionMutation.mutate("reject");
|
||||
return;
|
||||
}
|
||||
void queueOfflineDraft("reject");
|
||||
}}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => actionMutation.mutate("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canTransfer ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
value={transferToApproverId}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||
<option key={approver.id} value={approver.id}>
|
||||
{approver.name} ({approver.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue">
|
||||
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离线审批草稿队列</h3>
|
||||
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待处理 ${currentQueueItems.length} 项` : "无待处理项"}</span>
|
||||
@@ -655,7 +1125,7 @@ export function MobileApprovalsPage() {
|
||||
<div className="font-medium text-zinc-900">
|
||||
{item.taskSnapshot.reference ?? item.taskId} · {item.action === "approve" ? "通过" : "驳回"}
|
||||
</div>
|
||||
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType ?? undefined)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
|
||||
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
|
||||
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
||||
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
|
||||
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
||||
@@ -704,7 +1174,7 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户