重构移动端轻动作页面(05-30-mobile-light-action-surfaces)

这个提交包含在:
2026-05-30 13:52:20 +08:00
父节点 fe0df2397c
当前提交 695f5c2c75
共修改 8 个文件,包含 138 行新增和 308 行删除
+9 -261
查看文件
@@ -388,9 +388,6 @@ export function MobileApprovalsPage() {
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("");
@@ -441,14 +438,6 @@ export function MobileApprovalsPage() {
setTaskTab(resolveTaskTab(searchParams.get("taskTab")));
}, [searchParams]);
useEffect(() => {
if (taskTab === "completed") {
setBatchMode(false);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
}
}, [taskTab]);
function handleTaskSelection(taskId: string) {
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`, { state: { from: currentMobileSource } });
}
@@ -569,11 +558,6 @@ export function MobileApprovalsPage() {
[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],
@@ -627,11 +611,6 @@ export function MobileApprovalsPage() {
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]);
const canTransfer = Boolean(
canUseRemote &&
selectedTaskAllowsActions &&
@@ -785,100 +764,6 @@ 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)) {
navigate("/mobile/approvals", { replace: true });
}
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({
@@ -967,33 +852,7 @@ export function MobileApprovalsPage() {
}
}, [canUseRemote, retryableQueueItems, syncMutation]);
function toggleBatchTask(taskId: string) {
setSelectedBatchTaskIds((current) =>
current.includes(taskId) ? current.filter((id) => id !== taskId) : [...current, taskId],
);
}
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 allVisibleSelected =
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
const listTitle = taskTab === "pending" ? "待我处理" : "已处理";
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
const approvalFocusText = taskTab === "pending"
@@ -1004,7 +863,7 @@ export function MobileApprovalsPage() {
const selectedActionText = selectedTask
? selectedTaskAllowsActions
? canUseRemote
? "核对摘要和历史后,可在底部直接通过、驳回或退回。"
? "核对摘要和历史后,可在底部通过、驳回或退回。"
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
: "当前任务已处理,本页只展示业务摘要和处理历史。"
: "";
@@ -1033,7 +892,7 @@ export function MobileApprovalsPage() {
/>
) : (
<PageHeader
title={canAccessRemoteApprovals ? "待办" : "进度"}
title={canAccessRemoteApprovals ? "待我处理" : "进度"}
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
/>
)}
@@ -1127,8 +986,8 @@ export function MobileApprovalsPage() {
<div className="mt-1 text-xl font-semibold text-zinc-900">{currentQueueItems.length}</div>
</div>
<div>
<div className="text-xs text-zinc-500">批量模式</div>
<div className="mt-1 text-sm font-semibold text-zinc-900">{batchMode ? `已选 ${selectedBatchTaskIds.length}` : "未开启"}</div>
<div className="text-xs text-zinc-500">处理方式</div>
<div className="mt-1 text-sm font-semibold text-zinc-900">逐条核对</div>
</div>
</div>
</section>
@@ -1161,7 +1020,7 @@ export function MobileApprovalsPage() {
<details className="mobile-subcard bg-white text-sm">
<summary className="cursor-pointer select-none font-medium text-zinc-700">
筛选与批量
筛选
</summary>
<div className="mt-3 space-y-3">
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
@@ -1174,82 +1033,13 @@ export function MobileApprovalsPage() {
</TabsList>
</Tabs>
{taskTab === "pending" ? (
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
<Button
type="button"
size="sm"
variant={batchMode ? "default" : "secondary"}
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={() => {
setBatchMode((current) => !current);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
}}
>
{batchMode ? "退出批量" : "批量"}
</Button>
{batchMode ? (
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={toggleSelectAllVisibleTasks}
>
{allVisibleSelected ? "清空选择" : "全选当前"}
</Button>
) : null}
<span className="text-sm text-zinc-500 sm:ml-1">
当前 {filteredTaskCards.length} 条
{batchMode ? `,已选 ${selectedBatchTaskIds.length}` : ""}
</span>
</div>
) : (
<p className="text-sm text-zinc-500">仅显示最近 30 天。</p>
)}
<p className="text-sm text-zinc-500">
{taskTab === "pending" ? `当前 ${filteredTaskCards.length} 条` : "仅显示最近 30 天。"}
</p>
</div>
</details>
</div>
{batchMode ? (
<div className="mobile-subcard bg-zinc-50 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-zinc-700">已选 {selectedBatchTaskIds.length} 条</div>
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
onClick={() => handleBatchAction("approve")}
>
{canUseRemote ? "批量通过" : "保存通过草稿"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
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" />
@@ -1282,48 +1072,6 @@ export function MobileApprovalsPage() {
<div className="mobile-page-stack">
{filteredTaskCards.map((task) => {
const isSelected = selectedTaskId === task.id;
const isBatchSelected = selectedBatchTaskIds.includes(task.id);
if (batchMode && taskTab === "pending") {
return (
<div
key={task.id}
className={`mobile-list-card grid grid-cols-[auto_minmax(0,1fr)] gap-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={`选择${entityLabel(task.entityType)}待办`}
/>
<button
type="button"
className="min-h-16 min-w-0 flex-1 text-left"
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)}
>
<div className="mobile-list-card__header mobile-list-card__header--approval">
<div className="mobile-list-card__title">{task.title}</div>
<span className="mobile-status-slot mobile-status-slot--approval">
<StatusBadge
className="mobile-status-badge"
status={task.status}
label={workflowStatusLabel(task.status)}
/>
</span>
</div>
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
</div>
);
}
return (
<button