收敛审批动作面板并清理层叠遗留 [approval-action-panel-kiss-layering]
这个提交包含在:
@@ -83,6 +83,7 @@ function hasVisibleNode(node: ReactNode): boolean {
|
||||
export function BusinessDetailActionPanel({
|
||||
title = "操作",
|
||||
status,
|
||||
beforeActions,
|
||||
primaryAction,
|
||||
secondaryActions,
|
||||
moreActions,
|
||||
@@ -93,6 +94,7 @@ export function BusinessDetailActionPanel({
|
||||
}: {
|
||||
title?: string;
|
||||
status?: ReactNode;
|
||||
beforeActions?: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
secondaryActions?: ReactNode;
|
||||
moreActions?: ReactNode;
|
||||
@@ -102,21 +104,23 @@ export function BusinessDetailActionPanel({
|
||||
testId?: string;
|
||||
}) {
|
||||
const hasStatus = hasVisibleNode(status);
|
||||
const hasBeforeActions = hasVisibleNode(beforeActions);
|
||||
const hasPrimaryAction = hasVisibleNode(primaryAction);
|
||||
const hasSecondaryActions = hasVisibleNode(secondaryActions);
|
||||
const hasMoreActions = hasVisibleNode(moreActions);
|
||||
const hasSummary = hasVisibleNode(summary);
|
||||
const hasChildren = hasVisibleNode(children);
|
||||
const hasActionContent = hasPrimaryAction || hasSecondaryActions || hasMoreActions;
|
||||
const hasContentBeforeSummary = hasActionContent || hasChildren;
|
||||
const hasContentBeforeSummary = hasBeforeActions || hasActionContent || hasChildren;
|
||||
|
||||
if (!hasStatus && !hasActionContent && !hasSummary && !hasChildren) {
|
||||
if (!hasStatus && !hasBeforeActions && !hasActionContent && !hasSummary && !hasChildren) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg:top-24", className)}>
|
||||
<div className="detail-action-stack" data-testid={testId}>
|
||||
{hasBeforeActions ? <div>{beforeActions}</div> : null}
|
||||
{hasPrimaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
|
||||
{hasSecondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
|
||||
{hasMoreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
|
||||
|
||||
@@ -665,43 +665,10 @@
|
||||
@apply border p-3.5;
|
||||
}
|
||||
|
||||
.workflow-action-panel {
|
||||
@apply relative z-30;
|
||||
}
|
||||
|
||||
.workflow-action-bar {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.workflow-action-more {
|
||||
@apply relative z-40;
|
||||
}
|
||||
|
||||
.workflow-action-more > summary {
|
||||
border: 1px solid rgba(203, 213, 225, 0.92);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
@apply flex h-9 cursor-pointer list-none items-center px-3 text-sm font-medium transition-colors hover:bg-slate-50;
|
||||
}
|
||||
|
||||
.workflow-action-more > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-action-more[open] > summary {
|
||||
@apply bg-slate-50;
|
||||
}
|
||||
|
||||
.workflow-action-more > div,
|
||||
.workflow-action-more > button {
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
@apply absolute right-0 top-10 z-50 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50;
|
||||
}
|
||||
|
||||
.notification-inbox {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { reimbursementAmountIntegrityMessage, type ReimbursementAmountSummary } from "./reimbursementWorkspace";
|
||||
|
||||
export function resolveReimbursementApprovalBlockReason({
|
||||
amountIntegrity,
|
||||
detailState,
|
||||
online,
|
||||
snapshotParseStatus,
|
||||
}: {
|
||||
amountIntegrity: ReimbursementAmountSummary["integrity"] | null;
|
||||
detailState: "ready" | "loading" | "error";
|
||||
online: boolean;
|
||||
snapshotParseStatus: string | null | undefined;
|
||||
}) {
|
||||
if (!online) return "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。";
|
||||
if (amountIntegrity && amountIntegrity !== "ready") {
|
||||
return reimbursementAmountIntegrityMessage(amountIntegrity);
|
||||
}
|
||||
if (snapshotParseStatus === "invalid") {
|
||||
return "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。";
|
||||
}
|
||||
if (detailState === "error") return "报销内容未加载完整,暂不能审批,请刷新后重试";
|
||||
if (detailState === "loading") return "正在加载完整报销内容和票据;加载完成前不能作出审批决定。";
|
||||
if (snapshotParseStatus !== "ready") {
|
||||
return "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -32,11 +32,12 @@ import { SectionPanel } from "../../components/patterns/SectionPanel";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay";
|
||||
import { workflowActionLabel } from "../../lib/approvalDisplay";
|
||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
|
||||
import { formatDisplayDateTime } from "../../lib/displayFormat";
|
||||
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
|
||||
import { buildReimbursementWorkspaceModel, reimbursementAmountIntegrityMessage } from "../../lib/reimbursementWorkspace";
|
||||
import { resolveReimbursementApprovalBlockReason } from "../../lib/reimbursementApprovalReadiness";
|
||||
import { buildReimbursementWorkspaceModel } from "../../lib/reimbursementWorkspace";
|
||||
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
|
||||
import { isWecomWebView } from "../../lib/runtimeContext";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
|
||||
@@ -56,20 +57,6 @@ function isAlreadyProcessedTaskError(error: unknown) {
|
||||
return isApiError(error) && error.details?.task === "already_processed";
|
||||
}
|
||||
|
||||
function workflowInstanceStatusLabel(status: string | undefined | null) {
|
||||
const labels: Record<string, string> = {
|
||||
pending: "审批中",
|
||||
active: "审批中",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
terminated: "已终止",
|
||||
cancelled: "已取消",
|
||||
completed: "已完成",
|
||||
};
|
||||
return status ? labels[status] ?? status : "-";
|
||||
}
|
||||
|
||||
function workflowTaskStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
pending: "待处理",
|
||||
@@ -129,6 +116,14 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
&& model.amountSummary.integrity === "ready"
|
||||
&& snapshot?.parseStatus === "ready",
|
||||
);
|
||||
const projectedDecisionBlockReason = resolveReimbursementApprovalBlockReason({
|
||||
amountIntegrity: model?.amountSummary.integrity ?? null,
|
||||
detailState: reimbursementDetailQuery.isError ? "error" : reimbursementDetailQuery.data ? "ready" : "loading",
|
||||
online,
|
||||
snapshotParseStatus: snapshot?.parseStatus,
|
||||
});
|
||||
const decisionBlockReason = projectedDecisionBlockReason
|
||||
?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。";
|
||||
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
|
||||
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
|
||||
const approverQuery = useQuery({
|
||||
@@ -175,11 +170,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
|
||||
if (!decisionReady) {
|
||||
throw new Error(
|
||||
model
|
||||
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,暂时不能处理"
|
||||
: "完整报销内容或提交时检查尚未就绪,暂时不能处理",
|
||||
);
|
||||
throw new Error(decisionBlockReason);
|
||||
}
|
||||
if (action === "approve") return approveWorkflowTask(taskId);
|
||||
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
|
||||
@@ -209,11 +200,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
|
||||
function requestAction(action: WorkflowConfirmAction) {
|
||||
if (!decisionReady) {
|
||||
setActionError(!online
|
||||
? "报销审批需联网核对完整报销内容后才能处理。"
|
||||
: model
|
||||
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"
|
||||
: "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。");
|
||||
setActionError(decisionBlockReason);
|
||||
return;
|
||||
}
|
||||
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
|
||||
@@ -245,13 +232,6 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
const message = !online
|
||||
? "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。"
|
||||
: snapshot?.parseStatus === "invalid"
|
||||
? "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。"
|
||||
: reimbursementDetailQuery.isError
|
||||
? resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")
|
||||
: "正在加载完整报销内容和票据;加载完成前不能作出审批决定。";
|
||||
const disabledActions = (
|
||||
<div className="grid grid-cols-2 gap-2" data-testid="reimbursement-approval-fail-closed-actions">
|
||||
<Button data-testid={layout === "mobile" ? "mobile-approvals-approve" : "workflow-task-approve"} disabled type="button">通过</Button>
|
||||
@@ -262,7 +242,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
);
|
||||
return (
|
||||
<div className={layout === "mobile" ? "mobile-card space-y-3" : "card-std mx-auto max-w-3xl space-y-3 p-5"} role="alert">
|
||||
<p className="text-sm text-zinc-700">{message}</p>
|
||||
<p className="text-sm text-zinc-700">{decisionBlockReason}</p>
|
||||
{disabledActions}
|
||||
{online && reimbursementDetailQuery.isError ? <Button onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button> : null}
|
||||
<Button asChild type="button" variant="secondary"><Link to={layout === "mobile" ? "/mobile/approvals" : "/workflow/tasks"}>返回审批列表</Link></Button>
|
||||
@@ -272,25 +252,26 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
|
||||
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
|
||||
const actionDisabled = actionMutation.isPending || !decisionReady;
|
||||
const issueAnchors = model.issueAnchors.length ? (
|
||||
const visibleIssueAnchors = model.issueAnchors.filter((issue) => issue.label !== decisionBlockReason);
|
||||
const issueAnchors = visibleIssueAnchors.length ? (
|
||||
<div className="space-y-2" data-testid="reimbursement-workspace-issue-anchors">
|
||||
<p className="text-sm font-medium text-zinc-900">需要关注</p>
|
||||
{model.issueAnchors.map((issue) => (
|
||||
{visibleIssueAnchors.map((issue) => (
|
||||
<a className="block text-sm text-zinc-700 underline-offset-4 hover:underline" href={`#${issue.id}`} key={`${issue.id}-${issue.label}`}>{issue.label}</a>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
const statusSummary = (
|
||||
<>
|
||||
<div>节点:{detail.task.stepName}</div>
|
||||
<div>状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||
<div>审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div>
|
||||
<div>{decisionReady ? "完整内容已就绪" : "内容未就绪,审批动作已停用"}</div>
|
||||
</>
|
||||
);
|
||||
const decisionBlockNotice = !decisionReady && decisionBlockReason ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="reimbursement-approval-block-reason" role="alert">
|
||||
{decisionBlockReason}
|
||||
</p>
|
||||
) : null;
|
||||
const actionPanelBeforeActions = issueAnchors || decisionBlockNotice ? (
|
||||
<div className="space-y-3">{issueAnchors}{decisionBlockNotice}</div>
|
||||
) : null;
|
||||
const offlineNotice = !online ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900" role="alert">
|
||||
报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。
|
||||
{decisionBlockReason}
|
||||
</p>
|
||||
) : null;
|
||||
const commentEditor = (
|
||||
@@ -417,6 +398,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
const actionPanel = (
|
||||
<BusinessDetailActionPanel
|
||||
title="审批决定"
|
||||
beforeActions={actionPanelBeforeActions}
|
||||
primaryAction={allowedActions.has("approve") ? <Button data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null}
|
||||
secondaryActions={(
|
||||
<>
|
||||
@@ -425,10 +407,10 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
</>
|
||||
)}
|
||||
moreActions={canTransfer ? <Button className="w-full" data-testid="workflow-task-transfer-menu-item" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
|
||||
summary={statusSummary}
|
||||
summary={null}
|
||||
testId="workflow-task-action-panel"
|
||||
>
|
||||
<div className="space-y-3">{issueAnchors}{commentEditor}</div>
|
||||
{actionError || activeSecondaryAction ? <div className="space-y-3">{commentEditor}</div> : null}
|
||||
</BusinessDetailActionPanel>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -1247,20 +1247,18 @@ test("PC 壳层与详情断点保持结构一致", async () => {
|
||||
expect(topbar).toContain('className="xl:hidden"');
|
||||
expect(topbar).not.toContain('className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单"');
|
||||
expect(detailTemplate).toContain("detail-action-panel lg:sticky lg:top-24");
|
||||
expect(detailTemplate).toMatch(/if \(!hasStatus && !hasActionContent && !hasSummary && !hasChildren\) \{\s*return null;/u);
|
||||
expect(detailTemplate).toMatch(/if \(!hasStatus && !hasBeforeActions && !hasActionContent && !hasSummary && !hasChildren\) \{\s*return null;/u);
|
||||
expect(styles).toMatch(/\.detail-workspace-grid\s*\{[\s\S]*?lg:grid-cols-/u);
|
||||
expect(styles).toMatch(/\.workflow-action-panel\s*\{[\s\S]*?relative z-30/u);
|
||||
});
|
||||
|
||||
test("已完成审批只保留中文只读摘要", async ({ page }) => {
|
||||
test("已完成审批不保留空动作面板", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/workflow/tasks/task-wave3-completed");
|
||||
|
||||
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-action-panel")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-expense-items-table")).toBeVisible();
|
||||
await expect(page.getByTestId("approval-item-evidence").getByRole("button")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("审批角色:财务");
|
||||
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 18:10");
|
||||
await expect(page.getByTestId("workflow-task-approve")).toHaveCount(0);
|
||||
await expect(page.getByTestId("workflow-task-return-toggle")).toHaveCount(0);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { buildReimbursementApprovalEvidence } from "../../src/lib/reimbursementApprovalProjection";
|
||||
import { resolveReimbursementApprovalBlockReason } from "../../src/lib/reimbursementApprovalReadiness";
|
||||
import {
|
||||
buildReimbursementAmountSummary,
|
||||
parseMoneyToMinorUnits,
|
||||
@@ -42,6 +43,53 @@ test("报销金额使用整数分判断完整性且不产生展示用第二总
|
||||
});
|
||||
});
|
||||
|
||||
test("报销审批五类阻断原因由同一投影给出且正常态保持安静", () => {
|
||||
const base = {
|
||||
amountIntegrity: "ready" as const,
|
||||
detailState: "ready" as const,
|
||||
online: true,
|
||||
snapshotParseStatus: "ready",
|
||||
};
|
||||
|
||||
expect(resolveReimbursementApprovalBlockReason(base)).toBeNull();
|
||||
expect(resolveReimbursementApprovalBlockReason({ ...base, online: false })).toBe("报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。");
|
||||
expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "loading" })).toBe("正在加载完整报销内容和票据;加载完成前不能作出审批决定。");
|
||||
expect(resolveReimbursementApprovalBlockReason({ ...base, detailState: "error" })).toBe("报销内容未加载完整,暂不能审批,请刷新后重试");
|
||||
expect(resolveReimbursementApprovalBlockReason({ ...base, snapshotParseStatus: "invalid" })).toBe("提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。");
|
||||
expect(resolveReimbursementApprovalBlockReason({ ...base, snapshotParseStatus: undefined })).toBe("提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。");
|
||||
expect(resolveReimbursementApprovalBlockReason({ ...base, amountIntegrity: "mismatch" })).toBe("报销总额与明细汇总不一致,请联系管理员");
|
||||
expect(resolveReimbursementApprovalBlockReason({ ...base, amountIntegrity: "unavailable" })).toBe("明细金额无法完整核对,请联系管理员");
|
||||
});
|
||||
|
||||
test("动作面板复用唯一阻断投影且预算三态判断保持原合同", () => {
|
||||
const adapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
|
||||
|
||||
expect(adapter.match(/resolveReimbursementApprovalBlockReason\(/g)).toHaveLength(1);
|
||||
expect(adapter).toContain("throw new Error(decisionBlockReason");
|
||||
expect(adapter).toContain("setActionError(decisionBlockReason");
|
||||
expect(adapter).toContain("{decisionBlockReason}");
|
||||
expect(adapter.match(/snapshot\?\.budgetCheckStatus === "missing"/g)).toHaveLength(3);
|
||||
expect(adapter).not.toContain("完整内容已就绪");
|
||||
expect(adapter).not.toContain("内容未就绪,审批动作已停用");
|
||||
expect(adapter).not.toContain("statusSummary");
|
||||
expect(adapter).toContain("actionError || activeSecondaryAction ?");
|
||||
});
|
||||
|
||||
test("旧审批层叠 class 与更多菜单样式无定义或消费者", () => {
|
||||
const styles = source("src/index.css");
|
||||
const productSources = [
|
||||
source("src/components/patterns/BusinessDetailTemplate.tsx"),
|
||||
source("src/pages/WorkflowTaskDetailPage.tsx"),
|
||||
source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx"),
|
||||
].join("\n");
|
||||
|
||||
for (const legacyClass of ["workflow-action-panel", "workflow-action-more"]) {
|
||||
expect(styles).not.toContain(legacyClass);
|
||||
expect(productSources).not.toContain(legacyClass);
|
||||
}
|
||||
expect(productSources).toContain("detail-action-panel lg:sticky lg:top-24");
|
||||
});
|
||||
|
||||
test("PC 与移动明细只展示共享模型中的服务端权威合计", () => {
|
||||
const evidenceList = source("src/components/reimbursements/ReimbursementItemEvidenceList.tsx");
|
||||
const desktopDetail = source("src/pages/ReimbursementDetailPage.tsx");
|
||||
|
||||
@@ -358,6 +358,77 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await expect.poll(() => actionCalls.return).toBe(1);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
entityType: "leave",
|
||||
label: "请假",
|
||||
detail: { leaveRequest: pendingTaskDetail.leaveRequest },
|
||||
expectedText: "R7E 审批动作确认",
|
||||
},
|
||||
{
|
||||
entityType: "attendance_correction",
|
||||
label: "补卡",
|
||||
detail: {
|
||||
attendanceCorrection: {
|
||||
id: "attendance-confirm-1", status: "reviewing", kind: "missed_punch", workDate: "2026-09-02",
|
||||
missedPunchAt: "09:05", startAt: null, endAt: null, locationSummary: "公司", compensationPolicy: "按实际打卡修正",
|
||||
reason: "忘记打卡", owner: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "陈工程" },
|
||||
},
|
||||
},
|
||||
expectedText: "忘记打卡",
|
||||
},
|
||||
{
|
||||
entityType: "purchase_request",
|
||||
label: "采购",
|
||||
detail: {
|
||||
purchaseRequest: {
|
||||
id: "purchase-confirm-1", requestNo: "CG-CONFIRM-001", title: "办公用品采购", status: "reviewing",
|
||||
totalAmount: "800.00", currencyCode: "CNY", expectedDeliveryDate: "2026-09-10", reason: "补充日常库存",
|
||||
applicant: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "陈工程" },
|
||||
supplier: { id: "supplier-confirm", name: "合成供应商" }, legalEntity: { id: "entity-confirm", name: "示例公司" },
|
||||
costCenter: { id: "cost-center-confirm", name: "研发中心" },
|
||||
},
|
||||
},
|
||||
expectedText: "办公用品采购",
|
||||
},
|
||||
{
|
||||
entityType: "travel_request",
|
||||
label: "差旅",
|
||||
detail: {
|
||||
travelRequest: {
|
||||
id: "travel-confirm-1", requestNo: "CL-CONFIRM-001", status: "reviewing", tripType: "domestic",
|
||||
startDate: "2026-09-10", endDate: "2026-09-12", estimatedAmount: "1800.00", purpose: "客户现场交流",
|
||||
applicant: { id: "employee-confirm-applicant", employeeNo: "E-confirm", name: "陈工程" },
|
||||
legalEntity: { id: "entity-confirm", name: "示例公司" }, costCenter: { id: "cost-center-confirm", name: "研发中心" },
|
||||
},
|
||||
},
|
||||
expectedText: "客户现场交流",
|
||||
},
|
||||
]) {
|
||||
test(`1280px ${scenario.label}审批保持通用详情与动作可达`, async ({ page }) => {
|
||||
const taskDetail = {
|
||||
...pendingTaskDetail,
|
||||
workflowInstance: { ...pendingTaskDetail.workflowInstance, entityType: scenario.entityType },
|
||||
reimbursement: null,
|
||||
leaveRequest: null,
|
||||
attendanceCorrection: null,
|
||||
purchaseRequest: null,
|
||||
travelRequest: null,
|
||||
...scenario.detail,
|
||||
};
|
||||
await mockWorkflowConfirmApis(page, { taskDetail });
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(scenario.expectedText);
|
||||
await expect(page.getByRole("heading", { name: "处理历史" })).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-approve")).toBeEnabled();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test("PC 报销审批同屏展示证据、引用退回意见且不暴露维护动作", async ({ page }) => {
|
||||
const reimbursementId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const taskDetail = {
|
||||
@@ -437,13 +508,18 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥180.00");
|
||||
await expect(page.getByText("报销总额与明细汇总不一致,请联系管理员")).toHaveCount(0);
|
||||
await expect(page.getByText("明细金额无法完整核对,请联系管理员")).toHaveCount(0);
|
||||
const actionPanel = page.getByTestId("workflow-task-action-panel");
|
||||
await expect(actionPanel.getByText("完整内容已就绪", { exact: true })).toHaveCount(0);
|
||||
await expect(actionPanel.getByText(/^节点:/)).toHaveCount(0);
|
||||
await expect(actionPanel.getByText(/^状态:/)).toHaveCount(0);
|
||||
await expect(actionPanel.getByText(/^审批角色:/)).toHaveCount(0);
|
||||
await expect(page.getByTestId("workflow-task-approve")).toBeEnabled();
|
||||
await expect(page.getByTestId("workflow-task-return-toggle")).toBeEnabled();
|
||||
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeEnabled();
|
||||
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeEnabled();
|
||||
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
|
||||
await expect(page.getByRole("heading", { name: "处理历史" })).toHaveCount(1);
|
||||
const actionBox = await page.getByTestId("workflow-task-action-panel").boundingBox();
|
||||
const actionBox = await actionPanel.boundingBox();
|
||||
const historyBox = await page.getByTestId("reimbursement-workflow-history").boundingBox();
|
||||
expect(actionBox).not.toBeNull();
|
||||
expect(historyBox).not.toBeNull();
|
||||
@@ -474,7 +550,7 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
workflowInstance: {
|
||||
...pendingTaskDetail.workflowInstance,
|
||||
entityType: "reimbursement",
|
||||
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
|
||||
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "available", parseStatus: "ready" },
|
||||
},
|
||||
reimbursement: {
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", formNumber: "BX-FAIL-001", status: "reviewing",
|
||||
@@ -487,7 +563,7 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
await expect(page.getByRole("alert")).toContainText("服务暂不可用");
|
||||
await expect(page.getByRole("alert")).toContainText("报销内容未加载完整,暂不能审批,请刷新后重试");
|
||||
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
|
||||
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
|
||||
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
|
||||
@@ -501,7 +577,7 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
workflowInstance: {
|
||||
...pendingTaskDetail.workflowInstance,
|
||||
entityType: "reimbursement",
|
||||
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
|
||||
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "available", parseStatus: "ready" },
|
||||
},
|
||||
reimbursement: {
|
||||
id: reimbursementId,
|
||||
@@ -549,6 +625,7 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await page.goto(`/mobile/approvals/${pendingTask.id}`);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00");
|
||||
await expect(page.locator("#reimbursement-workspace-snapshot")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
|
||||
const assertMobileGeometry = async () => {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true);
|
||||
@@ -627,7 +704,15 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await page.goto(`/workflow/tasks/${pendingTask.id}`);
|
||||
|
||||
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00");
|
||||
await expect(page.getByRole("alert").filter({ hasText: scenario.expectedMessage })).toBeVisible();
|
||||
await expect(page.locator("#reimbursement-workspace-snapshot")).toHaveCount(0);
|
||||
const blockReason = page.getByTestId("reimbursement-approval-block-reason");
|
||||
await expect(blockReason).toHaveText(scenario.expectedMessage);
|
||||
const primaryAction = page.getByTestId("workflow-task-approve");
|
||||
const blockReasonBox = await blockReason.boundingBox();
|
||||
const primaryActionBox = await primaryAction.boundingBox();
|
||||
expect(blockReasonBox).not.toBeNull();
|
||||
expect(primaryActionBox).not.toBeNull();
|
||||
expect(blockReasonBox!.y + blockReasonBox!.height).toBeLessThanOrEqual(primaryActionBox!.y);
|
||||
await expect(page.getByText("差额", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
|
||||
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
|
||||
|
||||
在新工单中引用
屏蔽一个用户