R7E-16D 审批动作二次确认
Refs: R7E-16D BIZ8-12B QAOPS8-17
这个提交包含在:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,10 +13,13 @@ type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "destructive";
|
||||
testId?: string;
|
||||
confirmTestId?: string;
|
||||
cancelTestId?: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -28,6 +31,9 @@ export function ConfirmDialog({
|
||||
confirmLabel = "确认",
|
||||
cancelLabel = "取消",
|
||||
variant = "default",
|
||||
testId,
|
||||
confirmTestId,
|
||||
cancelTestId,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -44,7 +50,7 @@ export function ConfirmDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogContent className="max-w-sm" data-testid={testId}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
@@ -54,6 +60,7 @@ export function ConfirmDialog({
|
||||
variant="secondary"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid={cancelTestId}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
@@ -62,6 +69,7 @@ export function ConfirmDialog({
|
||||
variant={variant === "destructive" ? "destructive" : "default"}
|
||||
disabled={pending}
|
||||
onClick={handleConfirm}
|
||||
data-testid={confirmTestId}
|
||||
type="button"
|
||||
>
|
||||
{pending ? "处理中..." : confirmLabel}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export type WorkflowConfirmAction = "approve" | "reject" | "return";
|
||||
|
||||
export type WorkflowConfirmCopy = {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
variant: "default" | "destructive";
|
||||
};
|
||||
|
||||
const CONFIRM_COPY: Record<WorkflowConfirmAction, WorkflowConfirmCopy> = {
|
||||
approve: {
|
||||
title: "确认通过审批?",
|
||||
description: "确认后将进入下一审批节点;如果没有下一节点,单据将审批通过。系统会写入审批历史。",
|
||||
confirmLabel: "确认通过",
|
||||
variant: "default",
|
||||
},
|
||||
reject: {
|
||||
title: "确认驳回审批?",
|
||||
description: "驳回后当前流程将终止或进入驳回态。申请人通常需要复制或重新发起;本次驳回意见会写入审批历史。",
|
||||
confirmLabel: "确认驳回",
|
||||
variant: "destructive",
|
||||
},
|
||||
return: {
|
||||
title: "确认退回审批?",
|
||||
description: "退回后单据会回到申请人修改态,保留原单号和审批历史;申请人修改后可重新提交。",
|
||||
confirmLabel: "确认退回",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export function getWorkflowConfirmCopy(action: WorkflowConfirmAction, options: { offlineDraft?: boolean } = {}) {
|
||||
const copy = CONFIRM_COPY[action];
|
||||
if (!options.offlineDraft) {
|
||||
return copy;
|
||||
}
|
||||
|
||||
return {
|
||||
...copy,
|
||||
title: action === "approve" ? "确认保存通过草稿?" : "确认保存驳回草稿?",
|
||||
description: `${copy.description} 当前离线时只会保存为待同步草稿,联网同步成功后才会正式提交。`,
|
||||
confirmLabel: action === "approve" ? "保存通过草稿" : "保存驳回草稿",
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
@@ -33,6 +34,8 @@ 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 { cn } from "../lib/utils";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
||||
import {
|
||||
cacheWorkflowTaskDetail,
|
||||
enqueueWorkflowActionDraft,
|
||||
@@ -390,12 +393,16 @@ export function MobileApprovalsPage() {
|
||||
const [transferToApproverId, setTransferToApproverId] = useState("");
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
||||
const confirmCopy = confirmAction
|
||||
? getWorkflowConfirmCopy(confirmAction, { offlineDraft: !canUseRemote && confirmAction !== "return" })
|
||||
: null;
|
||||
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
||||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||
const employeeProgressStatus = progressStatusQuery(progressTab);
|
||||
@@ -631,6 +638,26 @@ export function MobileApprovalsPage() {
|
||||
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }),
|
||||
});
|
||||
|
||||
function requestMobileWorkflowAction(action: WorkflowConfirmAction) {
|
||||
if (!canUseRemote && action === "return") {
|
||||
setError("当前离线不支持退回,请联网后再处理。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "reject" && !comment.trim()) {
|
||||
setError("驳回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "return" && !comment.trim()) {
|
||||
setError("退回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
async function refreshDraftQueueCache() {
|
||||
if (!ownerEmployeeId) {
|
||||
return [];
|
||||
@@ -962,9 +989,18 @@ export function MobileApprovalsPage() {
|
||||
const allVisibleSelected =
|
||||
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
|
||||
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
||||
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<>
|
||||
<PageScaffold
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
showMobileApprovalActions
|
||||
? "mobile-fixed-action-page pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)]"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{isPushDetail ? (
|
||||
<MobileDetailHeader
|
||||
title="审批详情"
|
||||
@@ -1401,48 +1437,41 @@ export function MobileApprovalsPage() {
|
||||
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
|
||||
) : null}
|
||||
|
||||
<div className={canUseRemote ? "mobile-sticky-action-grid mobile-approval-action-grid" : "mobile-sticky-action-grid"}>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
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
|
||||
className="min-h-12 w-full"
|
||||
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
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => actionMutation.mutate("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
<div
|
||||
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
|
||||
data-testid="mobile-approvals-sticky-actions"
|
||||
>
|
||||
<div className="mobile-sticky-action-grid mobile-approval-action-grid">
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => requestMobileWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => requestMobileWorkflowAction("reject")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => requestMobileWorkflowAction("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canTransfer ? (
|
||||
@@ -1587,5 +1616,33 @@ export function MobileApprovalsPage() {
|
||||
</section>
|
||||
) : null}
|
||||
</PageScaffold>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
if (canUseRemote) {
|
||||
await actionMutation.mutateAsync(confirmAction);
|
||||
return;
|
||||
}
|
||||
if (confirmAction !== "return") {
|
||||
await queueOfflineDraft(confirmAction);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
@@ -27,6 +28,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
||||
|
||||
function entityTitle(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
@@ -114,6 +116,7 @@ export function WorkflowTaskDetailPage() {
|
||||
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
||||
const [selectedApprover, setSelectedApprover] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(taskId),
|
||||
@@ -184,6 +187,23 @@ export function WorkflowTaskDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
|
||||
|
||||
function requestWorkflowAction(action: WorkflowConfirmAction) {
|
||||
if (action === "reject" && rejectComment.trim().length < 1) {
|
||||
setActionError("驳回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "return" && returnComment.trim().length < 1) {
|
||||
setActionError("退回必须填写意见");
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError("");
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="card-std p-6">
|
||||
@@ -229,6 +249,7 @@ export function WorkflowTaskDetailPage() {
|
||||
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`任务详情 ${detail.task.stepName}`}
|
||||
@@ -365,7 +386,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||
onClick={() => mutation.mutate("approve")}
|
||||
onClick={() => requestWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
@@ -384,7 +405,7 @@ export function WorkflowTaskDetailPage() {
|
||||
data-testid="workflow-task-reject"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||
onClick={() => mutation.mutate("reject")}
|
||||
onClick={() => requestWorkflowAction("reject")}
|
||||
type="button"
|
||||
>
|
||||
提交驳回
|
||||
@@ -403,7 +424,7 @@ export function WorkflowTaskDetailPage() {
|
||||
data-testid="workflow-task-return"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||
onClick={() => mutation.mutate("return")}
|
||||
onClick={() => requestWorkflowAction("return")}
|
||||
type="button"
|
||||
>
|
||||
提交退回
|
||||
@@ -482,5 +503,27 @@ export function WorkflowTaskDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
await mutation.mutateAsync(confirmAction);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type WorkflowTaskSummary,
|
||||
} from "../api/workflow";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
@@ -26,6 +28,7 @@ import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const WORKFLOW_STATUS_OPTIONS = [
|
||||
@@ -130,6 +133,7 @@ function isAlreadyProcessedTaskError(error: unknown) {
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||
@@ -194,8 +198,10 @@ export function WorkflowTasksPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
const taskItems = query.data?.items ?? [];
|
||||
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
@@ -318,7 +324,7 @@ export function WorkflowTasksPage() {
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => approveMutation.mutate(task.id)}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
@@ -381,7 +387,7 @@ export function WorkflowTasksPage() {
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => approveMutation.mutate(task.id)}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
@@ -418,5 +424,27 @@ export function WorkflowTasksPage() {
|
||||
testIdPrefix="workflow-tasks"
|
||||
/>
|
||||
</ListWorkspace>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmApproveTaskId(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmApproveTaskId) {
|
||||
return;
|
||||
}
|
||||
await approveMutation.mutateAsync(confirmApproveTaskId);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ export class WorkflowTaskDetailPageObject {
|
||||
return this.page.getByTestId(TEST_IDS.workflow.actionError);
|
||||
}
|
||||
|
||||
get actionConfirmDialog() {
|
||||
return this.page.getByTestId("workflow-action-confirm-dialog");
|
||||
}
|
||||
|
||||
get actionConfirmSubmit() {
|
||||
return this.page.getByTestId("workflow-action-confirm-submit");
|
||||
}
|
||||
|
||||
get transferApproverSelect() {
|
||||
return this.page.getByTestId(TEST_IDS.workflow.transferApprover);
|
||||
}
|
||||
@@ -52,15 +60,21 @@ export class WorkflowTaskDetailPageObject {
|
||||
|
||||
async approve() {
|
||||
await this.approveBtn.click();
|
||||
await expect(this.actionConfirmDialog).toContainText("确认后将进入下一审批节点");
|
||||
await this.actionConfirmSubmit.click();
|
||||
}
|
||||
|
||||
async reject(comment: string) {
|
||||
await this.rejectCommentInput.fill(comment);
|
||||
await this.rejectBtn.click();
|
||||
await expect(this.actionConfirmDialog).toContainText("当前流程将终止");
|
||||
await this.actionConfirmSubmit.click();
|
||||
}
|
||||
|
||||
async returnTask(comment: string) {
|
||||
await this.returnCommentInput.fill(comment);
|
||||
await this.returnBtn.click();
|
||||
await expect(this.actionConfirmDialog).toContainText("回到申请人修改态");
|
||||
await this.actionConfirmSubmit.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
await expect(page.getByText("当前离线")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByRole("main").getByText("当前离线,通过草稿已加入待同步队列。")).toBeVisible();
|
||||
|
||||
await page.context().setOffline(false);
|
||||
@@ -119,6 +121,8 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 });
|
||||
await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明");
|
||||
await page.getByTestId("mobile-approvals-reject").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByRole("main").getByText("当前离线,驳回草稿已加入待同步队列。")).toBeVisible();
|
||||
|
||||
await approveWorkflowTaskViaApi(managerApi.api, task.id);
|
||||
|
||||
@@ -92,6 +92,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("对象类型:采购申请");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("申请人:Employee");
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(已处理|已完成|completed)/);
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
@@ -481,6 +481,8 @@ test.describe.serial("R108 企业微信移动端视觉专项", () => {
|
||||
await manager390.page.getByTestId("mobile-approvals-comment").fill("R108 移动审批操作区验证");
|
||||
await auditMobilePage(manager390.page, { label: "企微 390-审批详情", route: `/mobile/approvals?taskId=${encodeURIComponent(task.id)}`, screenshotName: "mobile-390-approvals-detail" });
|
||||
await manager390.page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(manager390.page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await manager390.page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(manager390.page.getByText("操作成功")).toBeVisible({ timeout: 30_000 });
|
||||
await manager390.context.close();
|
||||
|
||||
|
||||
@@ -416,7 +416,19 @@ test.describe("R89 移动底部导航与核心页面", () => {
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||
await page.getByTestId("mobile-approvals-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await attachScreenshot(page, testInfo, "r97-mobile-approvals.png");
|
||||
await page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
|
||||
await expect(page).toHaveURL(/\/mobile\/approvals/);
|
||||
|
||||
await page.getByTestId("mobile-nav-reimbursements").click();
|
||||
await expect(page).toHaveURL(/\/mobile\/reimbursements/);
|
||||
|
||||
@@ -28,6 +28,8 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
|
||||
await expect(taskRow).toBeVisible({ timeout: 30_000 });
|
||||
await taskRow.getByTestId("workflow-task-detail-link").click();
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("已完成");
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { login, MANAGER } from "./helpers/auth";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return { requestId: "mock-workflow-confirm", data };
|
||||
}
|
||||
|
||||
async function fulfillJson<T>(route: Route, data: T) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
const mockMe = {
|
||||
id: "employee-confirm-manager",
|
||||
employeeNo: "M-confirm",
|
||||
name: "Manager",
|
||||
email: "[email protected]",
|
||||
phone: null,
|
||||
roleCodes: ["manager"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 70,
|
||||
},
|
||||
};
|
||||
|
||||
const pendingTask = {
|
||||
id: "task-confirm-1",
|
||||
instanceId: "instance-confirm-1",
|
||||
stepId: "manager-review",
|
||||
stepName: "主管审批",
|
||||
assigneeId: mockMe.id,
|
||||
assigneeRole: "manager",
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
createdAt: "2026-05-15T09:00:00.000Z",
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
entityType: "leave",
|
||||
entityId: "leave-confirm-1",
|
||||
currentStatus: "reviewing",
|
||||
},
|
||||
businessSummary: {
|
||||
reference: "QJ-CONFIRM-001",
|
||||
title: "R7E 审批确认测试",
|
||||
applicantName: "Employee",
|
||||
amount: null,
|
||||
duration: "1 天",
|
||||
businessStatus: "reviewing",
|
||||
},
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
};
|
||||
|
||||
const pendingTaskDetail = {
|
||||
task: {
|
||||
id: pendingTask.id,
|
||||
instanceId: pendingTask.instanceId,
|
||||
stepId: pendingTask.stepId,
|
||||
stepName: pendingTask.stepName,
|
||||
assigneeId: pendingTask.assigneeId,
|
||||
assigneeRole: pendingTask.assigneeRole,
|
||||
status: pendingTask.status,
|
||||
outcome: null,
|
||||
comment: null,
|
||||
createdAt: pendingTask.createdAt,
|
||||
completedAt: null,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: pendingTask.instanceId,
|
||||
entityType: "leave",
|
||||
currentStatus: "reviewing",
|
||||
currentStepId: "manager-review",
|
||||
contextData: { standardViolations: [] },
|
||||
startedAt: "2026-05-15T09:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: null,
|
||||
leaveRequest: {
|
||||
id: "leave-confirm-1",
|
||||
status: "reviewing",
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-06-01",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
requestedDays: 1,
|
||||
reason: "R7E 审批动作确认",
|
||||
attachment: null,
|
||||
leaveType: { id: "annual", code: "annual", name: "年假" },
|
||||
owner: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "Employee" },
|
||||
},
|
||||
purchaseRequest: null,
|
||||
travelRequest: null,
|
||||
allowedActions: pendingTask.allowedActions,
|
||||
history: [],
|
||||
};
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
async function mockWorkflowConfirmApis(page: Page) {
|
||||
const actionCalls = {
|
||||
approve: 0,
|
||||
reject: 0,
|
||||
return: 0,
|
||||
};
|
||||
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" || path === "/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-workflow-confirm-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" },
|
||||
body: JSON.stringify(envelope({})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/me") {
|
||||
await fulfillJson(route, mockMe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-confirm",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
|
||||
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/notifications") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 5),
|
||||
total: 0,
|
||||
unreadOnly: url.searchParams.get("unreadOnly") === "1",
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
hero: { title: "工作台", subtitle: "审批动作确认测试" },
|
||||
metrics: [],
|
||||
todos: [],
|
||||
notifications: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/tasks/summary") {
|
||||
await fulfillJson(route, {
|
||||
scope: "mine",
|
||||
pendingTotal: actionCalls.approve > 0 ? 0 : 1,
|
||||
overdueTotal: 0,
|
||||
earliestPendingAt: pendingTask.createdAt,
|
||||
averagePendingAgeHours: 1,
|
||||
byEntityType: [],
|
||||
byStep: [],
|
||||
byAssigneeRole: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/tasks") {
|
||||
const status = url.searchParams.get("status") ?? "pending";
|
||||
const items = status === "pending" && actionCalls.approve === 0 ? [pendingTask] : [];
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: items.length,
|
||||
items,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = path.match(/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return)$/);
|
||||
if (actionMatch) {
|
||||
const action = actionMatch[1] as keyof typeof actionCalls;
|
||||
actionCalls[action] += 1;
|
||||
await fulfillJson(route, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) {
|
||||
await fulfillJson(route, pendingTaskDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/approvers") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
return actionCalls;
|
||||
}
|
||||
|
||||
test.describe("R7E 审批动作二次确认", () => {
|
||||
test("PC 列表快捷通过必须二次确认后才提交", async ({ page }) => {
|
||||
const actionCalls = await mockWorkflowConfirmApis(page);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto("/workflow/tasks");
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
|
||||
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: "QJ-CONFIRM-001" });
|
||||
await expect(taskRow).toHaveCount(1);
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect.poll(() => actionCalls.approve).toBe(1);
|
||||
});
|
||||
|
||||
test("PC 审批详情通过、驳回、退回都先解释动作后果", async ({ page }) => {
|
||||
const actionCalls = await mockWorkflowConfirmApis(page);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-task-reject-comment").fill("资料不符合要求,请重新发起。");
|
||||
await page.getByTestId("workflow-task-reject").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("流程将终止");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.reject).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-task-return-comment").fill("请补充附件后重新提交。");
|
||||
await page.getByTestId("workflow-task-return").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("回到申请人修改态");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect.poll(() => actionCalls.return).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,8 @@ test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(taskRows).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
在新工单中引用
屏蔽一个用户