feat(08-05-interaction-pattern-unification): 统一前端交互与反馈模式

这个提交包含在:
2026-08-07 06:50:40 +08:00
父节点 d4e9597b37
当前提交 94f1bad336
共修改 97 个文件,包含 784 行新增和 836 行删除
@@ -99,7 +99,7 @@ test("E2E 涉密边界:敏感摘要不可搜索,编号可命中", async ({ p
await expect(page.getByText(sensitivePhrase)).toHaveCount(0);
await page.getByTestId("global-search-trigger").click();
const searchInput = page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办");
const searchInput = page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项");
await searchInput.fill(sensitivePhrase);
await expect(page.getByRole("button", { name: new RegExp(contractNo) })).toHaveCount(0);
@@ -960,7 +960,7 @@ test.describe("MIF7 移动信息架构整改", () => {
}
});
test("审批人首页只保留轻待办摘要,审批页按待我处理和已处理分组", async ({ browser }) => {
test("审批人首页只保留轻待办摘要,审批页按待我审批和已处理分组", async ({ browser }) => {
const notifications = buildNotifications();
const mobile = await newMobilePage(browser, 390);
try {
@@ -968,7 +968,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await loginForMobile(mobile.page, "manager");
await mobile.page.goto("/mobile");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("待办");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
await expect(mobile.page.getByTestId("mobile-action-approvals")).toHaveCount(0);
@@ -976,7 +976,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-pending-summary-count")).toHaveText("1");
await expect(mobile.page.getByTestId("mobile-pending-summary-primary")).toHaveCount(1);
await expect(mobile.page.getByTestId("mobile-pending-summary-primary")).not.toContainText("待处理");
await expect(mobile.page.getByRole("link", { name: "查看全部待办" })).toBeVisible();
await expect(mobile.page.getByRole("link", { name: "查看全部审批" })).toBeVisible();
await mobile.page.screenshot({ path: screenshotPath("home-manager-pending-summary"), fullPage: true });
const badgeMetrics = await mobile.page.evaluate(() => {
@@ -1026,7 +1026,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await expectApprovalActionsStable(mobile.page);
await mobile.page.goto("/mobile/approvals");
await expect(mobile.page.getByRole("tab", { name: "待我处理" })).toBeVisible();
await expect(mobile.page.getByRole("tab", { name: "待我审批" })).toBeVisible();
await expect(mobile.page.getByRole("tab", { name: "已处理" })).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-queue")).toHaveCount(0);
await mobile.page.getByRole("tab", { name: "已处理" }).click();
+2 -2
查看文件
@@ -42,7 +42,7 @@ test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳
await page.getByTestId("global-search-trigger").click();
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
await searchResponse;
await expect(page.getByRole("heading", { name: "人员" })).toBeVisible();
@@ -69,7 +69,7 @@ test("E2E 全局搜索:查看更多结果会关闭弹窗并跳到分组列表
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill("海音");
await searchResponse;
const documentsSection = page.locator("section").filter({ has: page.getByRole("heading", { name: "业务单据" }) });
@@ -0,0 +1,101 @@
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { ApiError } from "../../src/api/types";
import { resolveErrorMessage } from "../../src/lib/resolveErrorMessage";
function source(relativePath: string) {
return readFileSync(path.resolve(process.cwd(), relativePath), "utf8");
}
function sourceFiles(relativeDirectory: string): string[] {
const absoluteDirectory = path.resolve(process.cwd(), relativeDirectory);
return readdirSync(absoluteDirectory, { withFileTypes: true }).flatMap((entry) => {
const relativePath = path.join(relativeDirectory, entry.name);
if (entry.isDirectory()) {
return sourceFiles(relativePath);
}
return /\.(ts|tsx)$/.test(entry.name) ? [relativePath] : [];
});
}
test("R12 普通用户错误消息只经过共享解析入口且不显示内部 code", () => {
const resolver = source("src/lib/resolveErrorMessage.ts");
expect(resolver).toContain("mapApiErrorMessage(error.code, error.details, error.message)");
expect(resolver).not.toMatch(/`\$\{error\.code\}:/);
const ordinaryFiles = ["src/pages", "src/components", "src/lib"]
.flatMap(sourceFiles)
.filter((file) => file !== "src/pages/AuditLogsPage.tsx");
const residuals = ordinaryFiles.filter((file) => {
const text = source(file);
return /\$\{[^}]*\.code\}:|\{[^}\n]*\.code\}:/.test(text);
});
const directMapperConsumers = ordinaryFiles.filter((file) => {
if (file === "src/lib/resolveErrorMessage.ts") return false;
return source(file).includes("mapApiErrorMessage");
});
const rawThrownMessageConsumers = ordinaryFiles.filter((file) => {
if (file === "src/lib/resolveErrorMessage.ts") return false;
return /\b(?:error|err)\.message\b/.test(source(file));
});
expect(residuals).toEqual([]);
expect(directMapperConsumers).toEqual([]);
expect(rawThrownMessageConsumers).toEqual([]);
expect(resolveErrorMessage(new ApiError({ status: 400, code: "VALID_001", message: "VALID_001: 请补充审批意见" }))).toBe("请补充审批意见");
expect(resolveErrorMessage(new Error("500: 保存失败"), "保存失败,请稍后重试")).toBe("保存失败");
expect(resolveErrorMessage(new Error("Failed to fetch /api/workflow/tasks"), "加载审批任务失败")).toBe("加载审批任务失败");
expect(resolveErrorMessage(new Error("读取失败 /tmp/private/payload.json"), "读取资料失败")).toBe("读取资料失败");
});
test("R10 移动审批与移动报销复用同一底部动作容器", () => {
const sharedActionBar = source("src/components/patterns/MobileStickyActionBar.tsx");
const businessEditTemplate = source("src/components/patterns/BusinessEditTemplate.tsx");
const mobileApprovals = source("src/pages/MobileApprovalsPage.tsx");
const mobileReimbursement = source("src/pages/MobileReimbursementDetailPage.tsx");
expect(sharedActionBar).toContain("mobile-sticky-action-surface fixed inset-x-0 bottom-0");
expect(sharedActionBar).toContain("env(safe-area-inset-bottom,0px)");
expect(businessEditTemplate).toContain("MOBILE_STICKY_ACTION_PAGE_CLASS");
expect(businessEditTemplate).toContain("<MobileStickyActionBar");
expect(mobileApprovals).toContain("<MobileStickyActionBar");
expect(mobileReimbursement).toContain("<MobileStickyActionBar");
expect(mobileApprovals).not.toContain('className="mobile-sticky-action-surface fixed');
expect(mobileReimbursement).not.toContain('className="mobile-sticky-action-surface fixed');
});
test("R1/R6 未知状态和个人事项状态不回落到技术枚举", () => {
const statusBadge = source("src/components/common/StatusBadge.tsx");
const todosPage = source("src/pages/TodosPage.tsx");
expect(statusBadge).toContain('label: "未知状态"');
expect(statusBadge).not.toContain('label: status, className: "badge-neutral"');
expect(todosPage).toContain('todo: "待处理"');
expect(todosPage).toContain('doing: "进行中"');
expect(todosPage).toContain('done: "已完成"');
expect(todosPage).toContain('cancelled: "已取消"');
});
test("R4 WelcomeDialog 只在存在权限过滤后的入口时打开", () => {
const portalHome = source("src/pages/PortalHomePage.tsx");
expect(portalHome).toContain("open={showWelcome && welcomeLinks.length > 0}");
expect(portalHome).toContain("if (roleCapabilities.canApproveTasks && hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS))");
});
test("R9/R11 工作台加载失败提供重试且主要入口具有焦点样式", () => {
const portalHome = source("src/pages/PortalHomePage.tsx");
expect(portalHome).toContain('data-testid="portal-load-error"');
expect(portalHome).toContain('data-testid="portal-load-retry"');
expect(portalHome).toContain("portalQuery.refetch()");
expect(portalHome).toContain("focus-visible:ring-2 focus-visible:ring-blue-600");
});
test("R14 差旅列表不再自动选择第一条记录", () => {
const travelRequests = source("src/pages/TravelRequestsPage.tsx");
expect(travelRequests).not.toContain("listQuery.data?.items?.[0]?.id");
expect(travelRequests).toContain('const [searchParams, setSearchParams] = useSearchParams()');
});
+67 -9
查看文件
@@ -189,7 +189,7 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
? {
main: [
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/workflow/tasks", label: "待我处理", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/workflow/tasks", label: "待我审批", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "contracts", href: "/contracts-registry", label: "合同", icon: "description", placement: "main", permissionCodes: [], featureFlagKey: null },
@@ -262,14 +262,37 @@ function mockAttendanceToday(page: import("@playwright/test").Page) {
});
}
function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" | "manager") {
function mockPortalHome(
page: import("@playwright/test").Page,
mode: "employee" | "manager",
options: { failuresBeforeSuccess?: number } = {},
) {
let requestCount = 0;
return page.route("**/api/portal/home", async (route) => {
requestCount += 1;
if (requestCount <= (options.failuresBeforeSuccess ?? 0)) {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
requestId: "mock-request",
error: {
code: "SYS_001",
message: "GET /api/portal/home failed",
details: {},
},
}),
});
return;
}
const data = mode === "manager"
? {
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "李研发" },
cards: [
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "pending_approvals", title: "待我审批", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" },
{ key: "weekly_missing", title: "未提交周报", value: 2, href: "/weekly-reports", severity: "warning" },
{ key: "weekly_help", title: "需协助员工", value: 1, href: "/weekly-reports", severity: "high" },
@@ -307,7 +330,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "陈工程" },
cards: [
{ key: "my_todos", title: "我的待办", value: 4, href: "/todos", severity: "warning" },
{ key: "my_todos", title: "我的事项", value: 4, href: "/todos", severity: "warning" },
{ key: "today_presence", title: "今日状态", value: "在岗", href: "/team", severity: "normal" },
{ key: "my_weekly_report", title: "本周周报", value: "待填写", href: "/weekly-reports", severity: "warning" },
{ key: "my_projects", title: "我的项目", value: 2, href: "/projects", severity: "warning" },
@@ -524,12 +547,47 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
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: /我的事项 4 条.*查看事项/ })).toBeVisible();
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
await expect(page.getByText("当前焦点")).not.toBeVisible();
await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多入口" })).not.toBeVisible();
const portalSectionOrder = await page.getByRole("heading", { name: /今日待处理|常用入口/ }).evaluateAll((headings) =>
headings.map((heading) => heading.textContent?.trim()),
);
expect(portalSectionOrder).toEqual(["今日待处理", "常用入口"]);
const todoLink = page.getByRole("link", { name: /我的事项 4 条.*查看事项/ });
const weeklyLink = page.getByRole("link", { name: /本周周报待填写.*填写周报/ });
await todoLink.focus();
await expect(todoLink).toBeFocused();
await page.keyboard.press("Tab");
await expect(weeklyLink).toBeFocused();
});
test("Portal 加载失败持久显示自然中文并可重新加载", async ({ page }) => {
await mockPasswordLogin(page);
await mockNavigation(page, "employee");
await mockPortalHome(page, "employee", { failuresBeforeSuccess: 2 });
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["employee"], "陈工程");
await login(page, EMPLOYEE);
const errorRegion = page.getByTestId("portal-load-error");
await expect(errorRegion).toContainText("服务暂不可用,请稍后重试或联系管理员");
await expect(errorRegion).not.toContainText("SYS_001");
await expect(errorRegion).not.toContainText("/api/portal/home");
const retryButton = page.getByTestId("portal-load-retry");
await retryButton.focus();
await expect(retryButton).toBeFocused();
await retryButton.click();
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(errorRegion).toHaveCount(0);
});
test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page }) => {
@@ -543,7 +601,7 @@ test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page
await login(page, MANAGER);
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: /待我处理 3 条.*去处理/ })).toBeVisible();
await expect(page.getByRole("link", { name: /待我审批 3 条.*去处理/ })).toBeVisible();
await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
await expect(page.getByRole("link", { name: /项目风险未关闭.*处理风险/ })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
@@ -653,9 +711,9 @@ test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清
const welcomeDialog = page.getByTestId(TEST_IDS.onboarding.welcomeDialog);
await expect(welcomeDialog).toBeVisible();
await expect(welcomeDialog.getByRole("heading", { name: "常用入口" })).toBeVisible();
await expect(welcomeDialog.getByRole("link")).toHaveCount(3);
await expect(welcomeDialog.getByRole("link", { name: /处理待办/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /提交报销/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link")).toHaveCount(2);
await expect(welcomeDialog.getByRole("link", { name: /待我审批/ })).toHaveCount(0);
await expect(welcomeDialog.getByRole("link", { name: /报销/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /请假/ })).toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /个人设置/ })).not.toBeVisible();
await expect(welcomeDialog.getByRole("link", { name: /通知中心/ })).not.toBeVisible();
+2 -2
查看文件
@@ -81,7 +81,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(managerTaskRow).toBeVisible();
@@ -94,7 +94,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
// 6) finance approve, then pay from the payment batch context
await login(page, FINANCE);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(financeTaskRow).toBeVisible();
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
@@ -225,10 +225,10 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
}
});
await runStep("manager", "待我处理与快捷审批入口", async () => {
await runStep("manager", "待我审批与快捷审批入口", async () => {
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
if (managerTaskId) {
await expect(page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: managerTaskId }).first()).toBeVisible();
@@ -163,7 +163,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
}, { timeout: 30_000 });
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
await createTodoResponse;
await expect(page.getByText("已从周报生成待办")).toBeVisible();
await expect(page.getByText("已从周报生成事项")).toBeVisible();
const submitResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
@@ -177,7 +177,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
await expect(todoRow).toContainText("done", { timeout: 30_000 });
await expect(todoRow).toContainText("已完成", { timeout: 30_000 });
return `weekStart=${weekStart}; token=${uniqueToken}`;
});
@@ -209,7 +209,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname === "/api/search" && response.ok();
}, { timeout: 30_000 });
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill(String(suffix));
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、事项").fill(String(suffix));
await searchResponse;
const resultButton = page.getByRole("button", { name: new RegExp(announcementTitle) }).first();
@@ -609,7 +609,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
async function approveTaskViaBrowser(role: LiveSmokeRole, taskId: string, expectedText: RegExp | string) {
const user = await loginRole(page, role);
try {
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
await page.goto(`/workflow/tasks/${taskId}`);
await workflowDetail.assertLoaded();
@@ -178,7 +178,7 @@ async function mockWave2Apis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
],
admin: [],
@@ -206,7 +206,7 @@ async function mockWave2Apis(page: Page) {
workspace: { id: "workspace-wave2", persisted: true, profile: "small_team_internal" },
greeting: { name: visualMe.name },
cards: [
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "pending_approvals", title: "待我审批", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "团队状态", value: "7/9 在岗", href: "/team", severity: "normal" },
{ key: "weekly_missing", title: "周报缺口", value: 1, href: "/weekly-reports", severity: "warning" },
{ key: "weekly_help", title: "需协助", value: 1, href: "/weekly-reports", severity: "normal" },
@@ -317,7 +317,7 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: /待我处理.*去处理/ })).toBeVisible();
await expect(page.getByRole("link", { name: /待我审批.*去处理/ })).toBeVisible();
await capture(page, "02-portal");
await page.goto("/reimbursements?status=reviewing&scope=all");
@@ -680,7 +680,7 @@ async function mockWave3Apis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("notifications", "/notifications", "通知", "notifications"),
],
@@ -1274,6 +1274,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const pcInvoicePreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const pcAttachmentPreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await pcInvoicePreview.focus();
await expect(pcInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcAttachmentPreview).toBeFocused();
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
@@ -1294,6 +1300,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const mobileInvoicePreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const mobileAttachmentPreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await mobileInvoicePreview.focus();
await expect(mobileInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileAttachmentPreview).toBeFocused();
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
@@ -1486,7 +1498,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天待办" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
@@ -1514,7 +1526,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
await page.goto("/mobile/approvals");
await expect(page.locator("h1", { hasText: "待我处理" })).toBeVisible();
await expect(page.locator("h1", { hasText: "待我审批" })).toBeVisible();
await expect(page.getByTestId("mobile-approvals-focus")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
@@ -244,7 +244,7 @@ async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApp
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待办");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待我审批");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
+1 -1
查看文件
@@ -86,7 +86,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.locator(`a[href="/workflow/tasks/${currentTaskId}"]`).first()).toHaveCount(1, { timeout: 30_000 });
await page.goto(`/workflow/tasks/${currentTaskId}`);
await expect(page.getByTestId("workflow-task-overview")).toContainText("采购申请");
@@ -526,7 +526,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await mobile.page.getByTestId("mobile-nav-approvals").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
await expect(mobile.page.getByRole("heading", { level: 1, name: "待我处理", exact: true })).toBeVisible();
await expect(mobile.page.getByRole("heading", { level: 1, name: "待我审批", exact: true })).toBeVisible();
await expect(mobile.page.locator(".mobile-status-slot--approval").first()).toContainText("待处理");
await expectMobilePageContract(mobile.page);
@@ -537,7 +537,7 @@ test.describe("R6 移动响应式体系", () => {
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${device.label} home action count`).toBeGreaterThanOrEqual(2);
const bottomTabTexts = new Set(["首页", "待办", "进度", "报销", "我的"]);
const bottomTabTexts = new Set(["首页", "待我审批", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(bottomTabTexts.has(action.text), `${device.label} ${action.text} not duplicate bottom tab`).toBe(false);
expect(action.width, `${device.label} ${action.text} action width`).toBeGreaterThanOrEqual(44);
@@ -526,7 +526,7 @@ async function exerciseMobileRendering(page: Page, key: string, label: string) {
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2);
const bottomTabLabels = new Set(["首页", "待办", "进度", "报销", "我的"]);
const bottomTabLabels = new Set(["首页", "待我审批", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
expect(bottomTabLabels.has(action.key), `${label} ${action.key} not bottom tab key`).toBe(false);
@@ -214,7 +214,7 @@ async function mockR89Apis(page: Page) {
navigationItem("team", "/team", "团队", "groups"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
@@ -73,7 +73,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(managerTaskRow).toBeVisible();
@@ -86,7 +86,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, MANAGER.name);
await login(page, secondApprovalUser);
await (await openNavLink(page, "待我处理")).click();
await (await openNavLink(page, "待我审批")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementDisplayCode }).first();
await expect(financeTaskRow).toBeVisible();
+4 -4
查看文件
@@ -117,7 +117,7 @@ async function mockVisualApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("approvals", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("approvals", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"),
navigationItem("contracts", "/contracts-registry", "合同", "description"),
@@ -410,15 +410,15 @@ test.describe("visual shell", () => {
await loginVisualUser(page);
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await expect(page.getByTestId("mobile-action-capture")).toBeVisible();
await expect(page.getByTestId("mobile-attendance-summary-card")).toBeVisible();
await expect(page.getByTestId("mobile-attendance-summary-card")).toHaveCount(0);
await attachScreenshot(page, testInfo, "mobile-today.png");
});
test("待我处理手机卡片布局基线", async ({ page }, testInfo) => {
test("待我审批手机卡片布局基线", async ({ page }, testInfo) => {
await mockVisualApis(page);
await page.setViewportSize({ width: 390, height: 844 });
await loginVisualUser(page);
+3 -3
查看文件
@@ -9,7 +9,7 @@ function formatWeekStart(offsetDays: number) {
return value.toISOString().slice(0, 10);
}
test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待办", async ({ page }) => {
test("E2E 周报事项联动:保存 → 生成事项 → 提交 → 完成事项", async ({ page }) => {
const uniqueToken = `Playwright 周报联动 ${Date.now()}`;
const weekStart = formatWeekStart(35 + Number(String(Date.now()).slice(-1)));
@@ -41,7 +41,7 @@ test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待
});
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
await createTodoResponse;
await expect(page.getByText("已从周报生成待办")).toBeVisible();
await expect(page.getByText("已从周报生成事项")).toBeVisible();
const submitResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
@@ -56,7 +56,7 @@ test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
await expect(todoRow).toContainText("done", { timeout: 30_000 });
await expect(todoRow).toContainText("已完成", { timeout: 30_000 });
await logout(page, EMPLOYEE.name);
});
@@ -60,7 +60,7 @@ const pendingTask = {
duration: "1 天",
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
allowedActions: ["approve", "reject", "return", "transfer"],
};
const pendingTaskDetail = {
@@ -190,7 +190,7 @@ async function mockWorkflowConfirmApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
],
admin: [],
backup: [],
@@ -262,7 +262,12 @@ async function mockWorkflowConfirmApis(page: Page) {
}
if (path === "/api/workflow/approvers") {
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
await fulfillJson(route, {
page: 1,
pageSize: 100,
total: 1,
items: [{ id: "employee-transfer-1", employeeNo: "E-transfer", name: "周审批" }],
});
return;
}
@@ -293,6 +298,8 @@ test.describe("R7E 审批动作二次确认", () => {
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("退回:");
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("驳回:");
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
@@ -306,6 +313,27 @@ test.describe("R7E 审批动作二次确认", () => {
await page.getByTestId("workflow-action-confirm-cancel").click();
expect(actionCalls.reject).toBe(0);
await page.locator("summary", { hasText: "更多" }).click();
await page.getByTestId("workflow-task-transfer-menu-item").click();
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toHaveText("请先选择目标审批人。");
await expect(page.getByTestId("workflow-task-transfer")).toBeDisabled();
const transferApprover = page.getByTestId("workflow-task-transfer-approver");
const transferReason = page.getByTestId("workflow-task-transfer-reason");
await transferApprover.focus();
await expect(transferApprover).toBeFocused();
await page.keyboard.press("Tab");
await expect(transferReason).toBeFocused();
await transferApprover.selectOption("employee-transfer-1");
await transferReason.fill("理由太短");
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toContainText("至少 10 个字");
await expect(page.getByTestId("workflow-task-transfer")).toBeDisabled();
await transferReason.fill("当前事项需转交对应审批人继续处理");
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toHaveText("转审理由已满足要求。");
await expect(page.getByTestId("workflow-task-transfer")).toBeEnabled();
await transferReason.focus();
await page.keyboard.press("Tab");
await expect(page.getByTestId("workflow-task-transfer")).toBeFocused();
await page.getByTestId("workflow-task-return-toggle").click();
await page.getByTestId("workflow-task-return-comment").fill("请补充附件后重新提交。");
await page.getByTestId("workflow-task-return").click();
@@ -141,7 +141,7 @@ async function mockWorkflowBoundaryApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我审批", "fact_check"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
],
admin: [],