R5-M1-M5 收口正式审批规则与移动反馈

这个提交包含在:
2026-05-10 19:09:09 +08:00
父节点 f6e0d5ae78
当前提交 8da16f5d97
共修改 24 个文件,包含 824 行新增和 99 行删除
@@ -60,7 +60,7 @@ test.describe("R104 public visual acceptance", () => {
await page.goto("/mobile");
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
await expect(page.getByTestId("mobile-nav-approvals")).toBeVisible();
await expect(page.getByTestId("mobile-nav-capture")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
await expect(page.getByTestId("mobile-nav-me")).toBeVisible();
await expectNoDesignRegressionCopy(page);
await page.screenshot({ path: screenshotPath("mobile-home.png"), fullPage: true });
@@ -77,7 +77,7 @@ test.describe("R105 pretrial visual acceptance", () => {
await page.goto("/mobile");
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
await expect(page.getByTestId("mobile-nav-capture")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
await expectNoDesktopTableOnMobile(page);
await expectNoLegacyDesignCopy(page);
await expectNotBlackSurface(page.getByTestId("mobile-nav-home"));
@@ -469,7 +469,12 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await expect(mobile.page.getByTestId("mobile-nav-home")).toBeVisible();
await expectMobilePageContract(mobile.page);
await mobile.page.getByTestId("mobile-nav-capture").click();
await mobile.page.getByTestId("mobile-nav-reimbursements").click();
await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements$/);
await expect(mobile.page.getByTestId("mobile-reimbursements-list")).toBeVisible();
await expectMobilePageContract(mobile.page);
await mobile.page.goto("/mobile/capture");
await expect(mobile.page).toHaveURL(/\/mobile\/capture$/);
await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toBeAttached();
await expect(mobile.page.getByTestId("mobile-capture-image-file")).toBeAttached();
@@ -0,0 +1,260 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r5-real-device-feedback-mobile-ux");
const WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const employee = {
id: "11111111-1111-4111-8111-111111111111",
employeeNo: "HYD000006",
name: "王顺利",
phone: "13800000000",
email: "[email protected]",
departmentId: "dept-1",
directManagerId: "manager-1",
status: "active",
notificationPushEnabled: true,
roleCodes: ["employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 50,
},
wecomBinding: null,
};
function envelope(data: unknown) {
return { requestId: "mock-r5-mobile-ux", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function screenshotPath(fileName: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
return path.join(SCREENSHOT_DIR, `${fileName}.png`);
}
async function mockApis(page: Page) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathName = url.pathname;
if (pathName === "/api/auth/login" || pathName === "/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-r5-token", expiresIn: 900 })),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, employee);
return;
}
if (pathName === `/api/employees/${employee.id}/profile`) {
await fulfillJson(route, {
employee: {
...employee,
title: "负责人",
avatarUrl: null,
department: { id: "dept-1", name: "管理部" },
directManager: { id: "manager-1", name: "马建勇" },
},
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
assignedAssets: [],
organization: { reportingChain: [], directReports: [] },
});
return;
}
if (pathName === "/api/reimbursements" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
scope: "own",
items: [
{
id: "r5-reimbursement-1",
formNumber: null,
status: "draft",
reimbursementDate: "2026-05-10",
totalAmount: "168.30",
description: "R5 移动交互验收",
payment: {},
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
project: null,
pendingWorkflow: null,
createdAt: "2026-05-10T08:00:00.000Z",
updatedAt: "2026-05-10T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/reimbursements/r5-reimbursement-1") {
await fulfillJson(route, {
id: "r5-reimbursement-1",
formNumber: null,
submitterId: employee.id,
ownerId: employee.id,
departmentId: "dept-1",
department: { id: "dept-1", name: "管理部" },
status: "draft",
createdAt: "2026-05-10T08:00:00.000Z",
submittedAt: null,
reimbursementDate: "2026-05-10",
totalAmount: "168.30",
description: "R5 移动交互验收",
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
project: null,
owner: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
invoices: [],
attachments: [],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
items: [
{
id: "item-1",
expenseTypeId: "expense-type-1",
expenseDate: "2026-05-10",
amount: "168.30",
description: "正式运行口径验证",
expenseType: { id: "expense-type-1", code: "office", name: "办公费", category: "office", enabled: true },
},
],
});
return;
}
if (pathName === "/api/expense-types") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
items: [{ id: "expense-type-1", code: "office", name: "办公费", category: "office", enabled: true }],
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/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=%2Fmobile",
bindEnabled: true,
defaultReturnTo: "/mobile",
status: "ready",
statusReasons: [],
});
return;
}
await fulfillJson(route, {});
});
}
async function login(page: Page) {
await page.goto("/login");
const accountLogin = page.getByRole("button", { name: "使用账号密码登录" });
if (await accountLogin.isVisible().catch(() => false)) {
await accountLogin.click();
}
await page.getByTestId("login-identifier").fill(employee.email);
await page.getByTestId("login-password").fill("Password1234");
await page.getByTestId("login-submit").click();
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
}
async function collectMobileMetrics(page: Page) {
return page.evaluate(() => {
const bottomNav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav");
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
const stickyRect = stickyActions?.getBoundingClientRect();
return {
width: window.innerWidth,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
bottomNavVisible: Boolean(bottomNav && bottomNav.getBoundingClientRect().height > 0),
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
text: document.body?.innerText ?? "",
};
});
}
test.describe("R5 真机反馈移动交互", () => {
test.use({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
userAgent: WECOM_UA,
locale: "zh-CN",
serviceWorkers: "block",
});
test("中文头像首字、报销底部 Tab 和详情深层页交互符合企业微信体验", async ({ page }) => {
await mockApis(page);
await login(page);
await page.goto("/mobile/me");
await expect(page.getByText("王", { exact: true })).toBeVisible();
await page.screenshot({ path: screenshotPath("mobile-me-avatar"), fullPage: true });
await page.goto("/mobile/reimbursements");
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
await page.screenshot({ path: screenshotPath("mobile-reimbursements-tab"), fullPage: true });
const listMetrics = await collectMobileMetrics(page);
expect(listMetrics.bottomNavVisible).toBe(true);
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0);
await expect(page.getByRole("link", { name: "返回" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-invoice-add")).toBeVisible();
await expect(page.getByTestId("reimbursement-submit")).toBeVisible();
await page.screenshot({ path: screenshotPath("mobile-reimbursement-detail-sticky"), fullPage: true });
const detailMetrics = await collectMobileMetrics(page);
expect(detailMetrics.bottomNavVisible).toBe(false);
expect(detailMetrics.stickyActionsVisible).toBe(true);
expect(detailMetrics.tableCount).toBe(0);
expect(detailMetrics.scrollWidth).toBeLessThanOrEqual(detailMetrics.width + 2);
expect(detailMetrics.text).not.toContain("发票池");
expect(detailMetrics.text).not.toContain("绑定整单");
});
});
@@ -309,6 +309,16 @@ async function mockR89Apis(page: Page) {
return;
}
if (path === "/api/reimbursements") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: 0,
items: [],
});
return;
}
if (path === `/api/employees/${mockMe.id}/profile`) {
await fulfillJson(route, {
employee: {
@@ -385,7 +395,7 @@ test.describe("R89 企业微信移动入口", () => {
test.describe("R89 移动底部导航与核心页面", () => {
test.use(browserAgnosticDevice("iPhone 13"));
test("密码登录后可完成移动首页、审批、票据和我的页关键路径", async ({ page }, testInfo) => {
test("密码登录后可完成移动首页、审批、报销和我的页关键路径", async ({ page }, testInfo) => {
await mockR89Apis(page);
await loginByPassword(page);
@@ -396,7 +406,7 @@ test.describe("R89 移动底部导航与核心页面", () => {
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
await expect(page.getByTestId("mobile-nav-approvals")).toBeVisible();
await expect(page.getByTestId("mobile-nav-capture")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toBeVisible();
await expect(page.getByTestId("mobile-nav-me")).toBeVisible();
await page.getByTestId("mobile-nav-approvals").click();
@@ -408,7 +418,12 @@ test.describe("R89 移动底部导航与核心页面", () => {
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
await attachScreenshot(page, testInfo, "r97-mobile-approvals.png");
await page.getByTestId("mobile-nav-capture").click();
await page.getByTestId("mobile-nav-reimbursements").click();
await expect(page).toHaveURL(/\/mobile\/reimbursements/);
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
await attachScreenshot(page, testInfo, "r97-mobile-reimbursements.png");
await page.goto("/mobile/capture");
await expect(page).toHaveURL(/\/mobile\/capture/);
await expect(page.getByRole("heading", { name: "票据上传" })).toBeVisible();
await expect(page.getByRole("button", { name: /选择 PDF/ })).toBeVisible();