修正企微主办边界与工作台摘要(portal-task-first-redesign)

这个提交包含在:
2026-05-29 23:30:30 +08:00
父节点 86f887afa5
当前提交 8bd184f0b3
共修改 9 个文件,包含 296 行新增和 264 行删除
@@ -278,8 +278,8 @@ and admin tools available without dominating the default path.
### Convention: Approval Task List Defaults
**What**: PC approval task lists such as `/workflow/tasks` should make
`查看处理` the default row action. List-level `通过` is only a secondary shortcut
for low-risk items where the row itself carries enough decision context.
`查看处理` the default row action. List-level `通过` is disabled by default unless
a later Trellis task adds an explicit backend eligibility contract.
**Contracts**:
@@ -289,17 +289,23 @@ for low-risk items where the row itself carries enough decision context.
`高级 / 历史` on the default task surface.
- Time labels should prefer `已等待多久` and `接收于` over database-shaped labels
such as `更新时间` when the source field is task creation/arrival time.
- A quick `通过` button may appear only when the task is pending, the server
allows `approve`, the item is a low-risk leave/attendance task, required row
summary fields are present, and the task has not exceeded the pending window
available to the frontend. Reimbursement, procurement, travel, contract and
other amount/evidence-heavy tasks should route through `查看处理` first.
- The confirm dialog for list quick approval must still explain action
consequences and include the specific task summary.
- HR/attendance items owned by Enterprise WeChat, including leave, missed
punch, outing, overtime and check-in related approvals, must not show OA
list-level quick approval. OA may show mirror/history/status/exception
context and emergency-takeover records according to the business boundary,
but normal approval remains in Enterprise WeChat.
- Reimbursement, procurement, travel, contract and other amount/evidence-heavy
OA-owned tasks should route through `查看处理` first.
- Future list-level quick approval requires the backend to expose an explicit
eligibility flag such as `quickApproveEligible=true`, plus task-domain and
audit semantics in the current Trellis task. Frontend heuristics based only on
entity type, duration or row text are not enough.
- If list quick approval is reintroduced later, its confirm dialog must still
explain action consequences and include the specific task summary.
**Why**: Small-team approvers need to clear work quickly without training
themselves to approve financial or evidence-heavy items from an under-specified
row.
**Why**: Small-team approvers should build the habit of opening the task detail
before approval, and the OA/Enterprise WeChat ownership boundary must stay clear
for HR/attendance workflows.
## Convention: Workbench Onboarding And Profile Scope
@@ -4,7 +4,7 @@
- `/workflow/tasks` 默认标题改为 `待我处理`,历史入口文案改为 `已处理记录`。
- PC 主体保留表格密度,但改为任务列表式表格,突出事项、申请人、金额/天数、找我原因、等待时间和业务编号。
- 默认主按钮改为 `查看处理`;`通过` 仅作为低风险次级快捷动作。
- 低风险快捷通过范围保守限定为请假/考勤:请假不超过 1 天、信息完整、任务未超过默认待办时限且服务端允许 approve。
- 默认主按钮改为 `查看处理`;列表页不再提供 `通过` 快捷动作。
- 请假、补卡、外出、加班和打卡属于企业微信主办,OA 待处理列表不按前端实体类型或天数自行开放快捷审批。
- 时间表达从 `更新时间` 改为 `已等待多久` 和 `接收于`。
- 未改后端接口、审批详情页、权限模型、状态机、企业微信、生产部署或真实数据写入。
@@ -140,13 +140,26 @@
- `git diff --check`:通过。
- 截图证据已保存到 `output/playwright/portal-task-first-redesign/portal-employee.png`、`portal-admin.png`、`portal-finance.png`;员工截图已复核并清除了欢迎弹窗遮罩污染。
2026-05-29 企微主办边界复核后完成补充验证:
- `cd client && npm run typecheck`:通过。
- `cd client && npm run build`:通过。
- `cd client && npx playwright test tests/e2e/workflow-action-confirm-ui.spec.ts tests/e2e/workflow-quick-approve.spec.ts --workers=1 --reporter=list`:3 passed。
- `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts tests/e2e/visual-shell.spec.ts tests/e2e/r102-reimbursement-main-flow.spec.ts --workers=1 --reporter=list`:23 passed。
- `cd client && npx playwright test tests/e2e/pc-core-workflow-visual-wave2.spec.ts --workers=1 --reporter=list`:1 passed。
- 补充自审后重跑 `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts --workers=1 --reporter=list`:15 passed。
- `python3 ./.trellis/scripts/task.py validate 05-29-portal-task-first-redesign`:通过。
- `node scripts/qa/check-trellis-governance.mjs`:通过。
- `git diff --check`:通过。
## Approved Addendum: 待处理页面任务优先整改
2026-05-29 用户确认对 `/workflow/tasks` 做小范围页面整改,目标是让审批人逐单看详情后谨慎处理,只有低风险事项保留列表页快捷通过。
2026-05-29 用户确认对 `/workflow/tasks` 做小范围页面整改,目标是让审批人逐单看详情后谨慎处理。后续业务边界复核确认:请假、补卡、外出、加班和打卡属于企业微信主办,OA 不提供列表页快捷审批。
- 默认目标:逐单看详情后处理;低风险事项才允许快捷通过。
- 按钮层级:`查看处理` 为主按钮;`通过` 作为次级快捷动作。
- 快捷通过范围:仅 `请假 / 考勤` 小事项;请假不超过 1 天、信息完整、未超时且当前用户有 approve 权限。
- 默认目标:逐单看详情后处理;列表页默认不提供 `通过` 快捷动作。
- 按钮层级:`查看处理` 为主按钮;审批动作进入详情页后执行。
- 企微主办边界:请假、补卡、外出、加班、打卡相关事项只做镜像、历史、审计、异常或应急接管展示,正常审批不在 OA 列表页处理。
- 未来若恢复列表快捷通过,必须由后端显式提供 eligibility 字段和审计语义,不能由前端按实体类型、天数或行文案自行判断。
- 主体形态:保留 PC 表格密度,但改为任务列表式表格,第一列集中展示什么事、为什么找我、等待多久和编号。
- 历史入口:`高级 / 历史` 改为 `已处理记录`;点击后默认进入 `已处理`,历史筛选不混入 `待处理`。
- 时间表达:首屏优先显示 `已等待多久`,绝对接收时间作为次级信息;不再把任务创建时间叫 `更新时间`。
+30 -4
查看文件
@@ -28,6 +28,7 @@ type WorkItem = {
severity: "normal" | "warning" | "high";
icon: string;
priority: number;
count: number;
};
type EmptyAction = {
@@ -107,6 +108,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: "warning",
icon: "fact_check",
priority: 100,
count: payload.pendingTasks.count,
});
}
@@ -125,6 +127,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: highPriorityCount > 0 ? "high" : "normal",
icon: "check_circle",
priority: 90,
count: openTodoCount,
});
}
@@ -139,6 +142,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: payload.weeklyReport.needsHelp ? "high" : "warning",
icon: "article",
priority: 72,
count: 1,
});
}
@@ -154,6 +158,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: "high",
icon: "priority_high",
priority: 96,
count: payload.financeSummary.failedItemCount,
});
}
@@ -168,6 +173,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: "high",
icon: "shield_person",
priority: 94,
count: payload.financeSummary.riskCount,
});
}
@@ -182,6 +188,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: "warning",
icon: "payments",
priority: 86,
count: payload.financeSummary.pendingBatchCount,
});
}
}
@@ -197,6 +204,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: "warning",
icon: "workspaces",
priority: 64,
count: payload.projectSummary.highPriorityTaskCount,
});
}
@@ -211,6 +219,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
severity: "warning",
icon: "event_upcoming",
priority: 62,
count: payload.projectSummary.followUpDueTodayCount,
});
}
@@ -231,6 +240,7 @@ function buildProgressItems(payload: PortalHomePayload) {
severity: "normal",
icon: "done_all",
priority: 30,
count: 1,
});
}
@@ -255,6 +265,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
severity: "warning",
icon: "article",
priority: 78,
count: payload.weeklySummary?.missingCount ?? 0,
});
}
@@ -269,6 +280,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
severity: "high",
icon: "support_agent",
priority: 82,
count: payload.weeklySummary?.needsHelpCount ?? 0,
});
}
@@ -283,6 +295,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
severity: "warning",
icon: "groups",
priority: 58,
count: payload.teamPresence?.unspecified ?? 0,
});
}
@@ -297,6 +310,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
severity: "warning",
icon: "flag",
priority: 74,
count: payload.projectSummary?.overdueMilestonesCount ?? 0,
});
}
@@ -311,6 +325,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
severity: "high",
icon: "warning",
priority: 76,
count: payload.projectSummary?.openRiskCount ?? 0,
});
}
@@ -325,6 +340,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
severity: "warning",
icon: "inventory_2",
priority: 60,
count: (payload.peopleLifecycle?.offboardingCount ?? 0) + (payload.peopleLifecycle?.assetReturnPendingCount ?? 0),
});
}
@@ -353,6 +369,7 @@ function buildManagementReminders(
severity: "warning",
icon: "settings",
priority: 88,
count: 1,
});
}
@@ -370,6 +387,7 @@ function buildManagementReminders(
severity: "warning",
icon: "manage_accounts",
priority: 52,
count: inactiveLoginCount,
});
}
@@ -391,6 +409,10 @@ function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabili
return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2);
}
function sumWorkItemCount(items: WorkItem[]) {
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
}
function SummaryStrip({
userName,
actionCount,
@@ -561,6 +583,10 @@ export function PortalHomePage() {
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
const emptyActions = buildEmptyActions(permissions, roleCapabilities);
const primaryItem = todayActionItems[0] ?? null;
const actionCount = sumWorkItemCount(todayActionItems);
const progressCount = sumWorkItemCount(progressItems);
const blockerCount = sumWorkItemCount(teamBlockers);
const reminderCount = sumWorkItemCount(managementReminders);
function handleCloseWelcome() {
clearWelcomeDialogFlag();
@@ -575,10 +601,10 @@ export function PortalHomePage() {
<SummaryStrip
userName={payload.greeting.name}
actionCount={todayActionItems.length}
progressCount={progressItems.length}
blockerCount={teamBlockers.length}
reminderCount={managementReminders.length}
actionCount={actionCount}
progressCount={progressCount}
blockerCount={blockerCount}
reminderCount={reminderCount}
primaryItem={primaryItem}
emptyActions={emptyActions}
/>
+9 -11
查看文件
@@ -587,17 +587,6 @@ export function ReimbursementsPage() {
</div>
<div className="ops-command-bar__actions">
{canApproveExpense ? (
<Button
data-testid="reimbursements-actionable"
size="sm"
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
type="button"
>
待我处理
</Button>
) : null}
{canShowLedgerEntry ? (
<Button
size="sm"
@@ -698,6 +687,15 @@ export function ReimbursementsPage() {
{canViewAll ? (
<>
<Button
data-testid="reimbursements-reviewing-ledger"
size="sm"
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
type="button"
>
审批中台账
</Button>
<Button
data-testid="reimbursements-scope-own"
size="sm"
+2 -127
查看文件
@@ -1,17 +1,14 @@
import { useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
approveWorkflowTask,
fetchWorkflowTasks,
fetchWorkflowTaskSummary,
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 {
@@ -28,7 +25,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
const PAGE_SIZE = 20;
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
@@ -191,18 +187,6 @@ function isTaskDefaultOverdue(task: WorkflowTaskSummary) {
return task.status === "pending" && !isWithinDefaultDueWindow(task.createdAt);
}
function parseDurationDays(value: string | null | undefined) {
if (!value) {
return null;
}
const match = /([0-9]+(?:\.[0-9]+)?)/.exec(value);
if (!match) {
return null;
}
const days = Number(match[1]);
return Number.isFinite(days) ? days : null;
}
function workflowTaskStatusLabel(status: string) {
const match = WORKFLOW_STATUS_OPTIONS.find((item) => item.value === status);
return match?.label ?? status;
@@ -221,37 +205,12 @@ function taskSecondaryMeta(task: WorkflowTaskSummary) {
return [formatReceivedTime(task.createdAt), displayReference(task)].join(" · ");
}
function isLowRiskQuickApproveTask(task: WorkflowTaskSummary) {
if (task.status !== "pending" || !(task.allowedActions ?? []).includes("approve")) {
return false;
}
if (!displayTitle(task) || displayApplicant(task) === "-") {
return false;
}
const entityType = task.workflowInstance?.entityType;
if (entityType === "leave") {
const days = parseDurationDays(task.businessSummary?.duration);
if (days === null || days > 1) {
return false;
}
} else if (entityType !== "attendance_correction") {
return false;
}
return isWithinDefaultDueWindow(task.createdAt);
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const status = normalizeStatusFilter(searchParams.get("status"));
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
const page = Number(searchParams.get("page") ?? "1") || 1;
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(() => status !== "pending" || entityType !== "all");
const queryClient = useQueryClient();
function updateSearchParams(next: {
status?: WorkflowStatusFilter;
@@ -286,35 +245,8 @@ export function WorkflowTasksPage() {
queryFn: () => fetchWorkflowTaskSummary(),
});
const approveMutation = useMutation({
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("审批已通过");
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] });
}
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "审批失败,请稍后重试";
toast.error(message);
},
});
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
const taskItems = query.data?.items ?? [];
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
const confirmApproveTask = confirmApproveTaskId
? taskItems.find((task) => task.id === confirmApproveTaskId) ?? null
: null;
const isDefaultTaskView = status === "pending" && entityType === "all";
const advancedVisible = showAdvanced || !isDefaultTaskView;
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
@@ -339,7 +271,6 @@ export function WorkflowTasksPage() {
}
return (
<>
<ListWorkspace>
<WorkspaceHeader
eyebrow="审批"
@@ -485,7 +416,7 @@ export function WorkflowTasksPage() {
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
asChild
className={`min-h-11 ${isLowRiskQuickApproveTask(task) ? "" : "col-span-2"}`}
className="col-span-2 min-h-11"
size="sm"
data-testid={TEST_IDS.workflow.detailLink}
>
@@ -493,19 +424,6 @@ export function WorkflowTasksPage() {
{task.status === "pending" ? "查看处理" : "查看详情"}
</Link>
</Button>
{isLowRiskQuickApproveTask(task) ? (
<Button
className="min-h-11"
size="sm"
variant="secondary"
type="button"
data-testid={TEST_IDS.workflow.quickApprove}
disabled={approveMutation.isPending && approveMutation.variables === task.id}
onClick={() => setConfirmApproveTaskId(task.id)}
>
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
</Button>
) : null}
</div>
</article>
))
@@ -567,18 +485,6 @@ export function WorkflowTasksPage() {
{task.status === "pending" ? "查看处理" : "查看详情"}
</Link>
</Button>
{isLowRiskQuickApproveTask(task) ? (
<Button
size="sm"
variant="secondary"
type="button"
data-testid={TEST_IDS.workflow.quickApprove}
disabled={approveMutation.isPending && approveMutation.variables === task.id}
onClick={() => setConfirmApproveTaskId(task.id)}
>
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
</Button>
) : null}
</div>
</TableCell>
</TableRow>
@@ -603,36 +509,5 @@ export function WorkflowTasksPage() {
testIdPrefix="workflow-tasks"
/>
</ListWorkspace>
<ConfirmDialog
open={Boolean(confirmCopy)}
onOpenChange={(open) => {
if (!open) {
setConfirmApproveTaskId(null);
}
}}
title={confirmCopy?.title ?? ""}
description={confirmCopy ? (
<>
<span>{confirmCopy.description}</span>
{confirmApproveTask ? (
<span className="mt-2 block rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600">
当前事项:{displayTaskTitle(confirmApproveTask)} · {taskLeadMeta(confirmApproveTask)}
</span>
) : null}
</>
) : undefined}
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);
}}
/>
</>
);
}
+1 -1
查看文件
@@ -508,7 +508,7 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await login(page, EMPLOYEE);
await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 4 件待处理");
await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 7 件待处理");
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible();
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
@@ -273,7 +273,7 @@ async function mockWorkflowConfirmApis(page: Page) {
}
test.describe("R7E 审批动作二次确认", () => {
test("PC 列表快捷通过必须二次确认后才提交", async ({ page }) => {
test("PC 列表页不提供企微主办请假快捷通过", async ({ page }) => {
const actionCalls = await mockWorkflowConfirmApis(page);
await login(page, MANAGER);
@@ -282,17 +282,9 @@ test.describe("R7E 审批动作二次确认", () => {
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("确认后将进入下一审批节点");
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toHaveCount(0);
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 }) => {
+214 -92
查看文件
@@ -1,110 +1,232 @@
import type { Locator, Page } from "@playwright/test";
import type { Page, Route } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { MANAGER, login } from "./helpers/auth";
import { TEST_IDS } from "./helpers/testIds";
function formatDate(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
test.use({ serviceWorkers: "block" });
function envelope<T>(data: T) {
return { requestId: "mock-workflow-boundary", data };
}
async function selectFirstEnabledOption(locator: Locator) {
await expect
.poll(async () => {
const optionValues = await locator.locator("option").evaluateAll((options) =>
options
.map((option) => ({
value: (option as HTMLOptionElement).value,
disabled: (option as HTMLOptionElement).disabled,
}))
.filter((option) => option.value && !option.disabled)
.map((option) => option.value),
);
return optionValues[0] ?? "";
})
.not.toBe("");
const [firstValue] = await locator.locator("option").evaluateAll((options) =>
options
.map((option) => ({
value: (option as HTMLOptionElement).value,
disabled: (option as HTMLOptionElement).disabled,
}))
.filter((option) => option.value && !option.disabled)
.map((option) => option.value),
);
await locator.selectOption(firstValue);
async function fulfillJson<T>(route: Route, data: T) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
async function createLeaveDraft(page: Page, startDate: string, endDate: string, reason: string) {
const createResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(),
);
const manager = {
id: "manager-wecom-boundary",
employeeNo: "M-WECOM",
name: "Manager",
email: "[email protected]",
phone: null,
roleCodes: ["manager"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 70,
},
};
await selectFirstEnabledOption(page.locator("#leave-type"));
await page.locator("#leave-start-date").fill(startDate);
await page.locator("#leave-end-date").fill(endDate);
await page.locator("#leave-start-session").selectOption("am");
await page.locator("#leave-end-session").selectOption("pm");
await page.locator("#leave-reason").fill(reason);
await page.getByRole("button", { name: "创建草稿" }).click();
const pendingLeaveTask = {
id: "task-wecom-leave-1",
instanceId: "instance-wecom-leave-1",
stepId: "manager-review",
stepName: "主管审批",
assigneeId: manager.id,
assigneeRole: "manager",
status: "pending",
outcome: null,
createdAt: new Date().toISOString(),
completedAt: null,
workflowInstance: {
entityType: "leave",
entityId: "leave-wecom-1",
currentStatus: "reviewing",
},
businessSummary: {
reference: "QJ-WECOM-001",
title: "企微主办请假镜像",
applicantName: "Employee",
amount: null,
duration: "1 天",
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
};
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const leaveRequestId = createPayload.data?.id;
if (!leaveRequestId) {
throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`);
}
return leaveRequestId;
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], leaveRequestId: string) {
return apiPostData<{ leaveRequestId: string; currentTaskId: string }>(api, `/api/leave-requests/${leaveRequestId}/submit`, {});
async function mockWorkflowBoundaryApis(page: Page) {
const actionCalls = { approve: 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-boundary-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, manager);
return;
}
if (path === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-wecom-boundary",
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", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
],
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: 1,
overdueTotal: 0,
earliestPendingAt: pendingLeaveTask.createdAt,
averagePendingAgeHours: 1,
byEntityType: [{ key: "leave", pendingTotal: 1 }],
byStep: [],
byAssigneeRole: [],
});
return;
}
if (path === "/api/workflow/tasks") {
const status = url.searchParams.get("status") ?? "pending";
const items = status === "pending" ? [pendingLeaveTask] : [];
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: items.length,
items,
});
return;
}
if (/^\/api\/workflow\/tasks\/[^/]+\/approve$/.test(path)) {
actionCalls.approve += 1;
await fulfillJson(route, { ok: true });
return;
}
await fulfillJson(route, {});
});
return actionCalls;
}
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
const startDate = formatDate(400 + (Date.now() % 3000));
const reason = `Playwright 快捷通过 ${Date.now()}`;
test("审批列表边界:企微主办请假只进入查看处理", async ({ page }) => {
const actionCalls = await mockWorkflowBoundaryApis(page);
try {
await login(page, EMPLOYEE);
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
await login(page, MANAGER);
await page.goto("/workflow/tasks");
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
const submitResult = await submitLeave(employeeClient.api, leaveRequestId);
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: "QJ-WECOM-001" });
await expect(taskRow).toHaveCount(1);
await expect(taskRow).toContainText("请假");
await expect(taskRow).toContainText("企微主办请假镜像");
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toHaveCount(0);
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const taskRows = page.locator(
`[data-testid="${TEST_IDS.workflow.taskRow}"]:has(a[href="/workflow/tasks/${submitResult.currentTaskId}"])`,
);
await expect(taskRows).toHaveCount(1, { timeout: 30_000 });
const taskRow = taskRows.first();
await expect(taskRow).not.toContainText(submitResult.currentTaskId);
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toBeVisible();
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);
await login(page, EMPLOYEE);
await page.goto("/leave-requests");
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 });
} finally {
await employeeClient.dispose();
}
const detailLink = taskRow.getByTestId(TEST_IDS.workflow.detailLink);
await expect(detailLink).toBeVisible();
await expect(detailLink).toHaveAttribute("href", "/workflow/tasks/task-wecom-leave-1");
expect(actionCalls.approve).toBe(0);
});