diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 2beca0ec..1d486c05 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -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 diff --git a/.trellis/tasks/05-29-portal-task-first-redesign/implement.md b/.trellis/tasks/05-29-portal-task-first-redesign/implement.md index 71fe5b3c..33afe5f7 100644 --- a/.trellis/tasks/05-29-portal-task-first-redesign/implement.md +++ b/.trellis/tasks/05-29-portal-task-first-redesign/implement.md @@ -4,7 +4,7 @@ - `/workflow/tasks` 默认标题改为 `待我处理`,历史入口文案改为 `已处理记录`。 - PC 主体保留表格密度,但改为任务列表式表格,突出事项、申请人、金额/天数、找我原因、等待时间和业务编号。 -- 默认主按钮改为 `查看处理`;`通过` 仅作为低风险次级快捷动作。 -- 低风险快捷通过范围保守限定为请假/考勤:请假不超过 1 天、信息完整、任务未超过默认待办时限且服务端允许 approve。 +- 默认主按钮改为 `查看处理`;列表页不再提供 `通过` 快捷动作。 +- 请假、补卡、外出、加班和打卡属于企业微信主办,OA 待处理列表不按前端实体类型或天数自行开放快捷审批。 - 时间表达从 `更新时间` 改为 `已等待多久` 和 `接收于`。 - 未改后端接口、审批详情页、权限模型、状态机、企业微信、生产部署或真实数据写入。 diff --git a/.trellis/tasks/05-29-portal-task-first-redesign/prd.md b/.trellis/tasks/05-29-portal-task-first-redesign/prd.md index 94ad7300..6fc06cb2 100644 --- a/.trellis/tasks/05-29-portal-task-first-redesign/prd.md +++ b/.trellis/tasks/05-29-portal-task-first-redesign/prd.md @@ -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 表格密度,但改为任务列表式表格,第一列集中展示什么事、为什么找我、等待多久和编号。 - 历史入口:`高级 / 历史` 改为 `已处理记录`;点击后默认进入 `已处理`,历史筛选不混入 `待处理`。 - 时间表达:首屏优先显示 `已等待多久`,绝对接收时间作为次级信息;不再把任务创建时间叫 `更新时间`。 diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index c36fc613..e9690253 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -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() { diff --git a/client/src/pages/ReimbursementsPage.tsx b/client/src/pages/ReimbursementsPage.tsx index c58eac7c..dd275299 100644 --- a/client/src/pages/ReimbursementsPage.tsx +++ b/client/src/pages/ReimbursementsPage.tsx @@ -587,17 +587,6 @@ export function ReimbursementsPage() {
- {canApproveExpense ? ( - - ) : null} {canShowLedgerEntry ? ( - {isLowRiskQuickApproveTask(task) ? ( - - ) : null}
)) @@ -567,18 +485,6 @@ export function WorkflowTasksPage() { {task.status === "pending" ? "查看处理" : "查看详情"} - {isLowRiskQuickApproveTask(task) ? ( - - ) : null} @@ -603,36 +509,5 @@ export function WorkflowTasksPage() { testIdPrefix="workflow-tasks" /> - { - if (!open) { - setConfirmApproveTaskId(null); - } - }} - title={confirmCopy?.title ?? ""} - description={confirmCopy ? ( - <> - {confirmCopy.description} - {confirmApproveTask ? ( - - 当前事项:{displayTaskTitle(confirmApproveTask)} · {taskLeadMeta(confirmApproveTask)} - - ) : 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); - }} - /> - ); } diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index d2716bbc..cdfd43d2 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -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(); diff --git a/client/tests/e2e/workflow-action-confirm-ui.spec.ts b/client/tests/e2e/workflow-action-confirm-ui.spec.ts index d985c982..98961409 100644 --- a/client/tests/e2e/workflow-action-confirm-ui.spec.ts +++ b/client/tests/e2e/workflow-action-confirm-ui.spec.ts @@ -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 }) => { diff --git a/client/tests/e2e/workflow-quick-approve.spec.ts b/client/tests/e2e/workflow-quick-approve.spec.ts index f1b755cc..f15ae35d 100644 --- a/client/tests/e2e/workflow-quick-approve.spec.ts +++ b/client/tests/e2e/workflow-quick-approve.spec.ts @@ -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(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(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: "manager@hyinda.com", + 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>["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); });