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 ? (
))
@@ -567,18 +485,6 @@ export function WorkflowTasksPage() {
{task.status === "pending" ? "查看处理" : "查看详情"}
- {isLowRiskQuickApproveTask(task) ? (
- setConfirmApproveTaskId(task.id)}
- >
- {approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
-
- ) : 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);
});