feat(08-05-interaction-pattern-unification): 统一前端交互与反馈模式
这个提交包含在:
@@ -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();
|
||||
|
||||
@@ -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()');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
在新工单中引用
屏蔽一个用户