diff --git a/artifacts/r96-ui/visual-audit-report.md b/artifacts/r96-ui/visual-audit-report.md new file mode 100644 index 00000000..633eb00c --- /dev/null +++ b/artifacts/r96-ui/visual-audit-report.md @@ -0,0 +1,42 @@ +# R96 UI 精简视觉回归报告 + +日期:2026-05-06 + +## 范围 + +- 登录页极简化 +- 桌面应用壳层与 Portal 首页文案降噪 +- 移动首页任务化 +- 移动审批与票据上传关键路径 +- 票据中心、报销详情、审批详情的说明文字收敛 +- 管理页术语降噪 + +## 自动化验证 + +| 验证项 | 命令 | 结果 | +| --- | --- | --- | +| Issue CSV 校验 | `python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_16-24-02-r96-ui-simplification.csv` | PASS | +| TypeScript | `cd client && npm run typecheck` | PASS | +| 生产构建 | `cd client && npm run build` | PASS | +| 移动入口与关键路径 | `cd client && E2E_BASE_URL=http://127.0.0.1:5177 npx playwright test tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list` | PASS,3/3 | +| 手机票据与报销可用性 | `cd client && E2E_BASE_URL=http://127.0.0.1:5177 npx playwright test tests/e2e/r92-mobile-invoice-usability.spec.ts --workers=1 --reporter=list` | PASS,3/3 | +| 视觉壳层基线 | `cd client && E2E_BASE_URL=http://127.0.0.1:5177 npx playwright test tests/e2e/visual-shell.spec.ts --workers=1 --reporter=list` | PASS,3/3 | +| Diff 空白检查 | `git diff --check` | PASS | + +## 视觉证据 + +Playwright 附件已覆盖: + +- `r96-wecom-login.png` +- `r96-mobile-today.png` +- `r96-mobile-approvals.png` +- `r96-mobile-capture.png` +- `login-shell.png` +- `app-shell.png` +- `mobile-today.png` + +截图附件保存在本地 `client/test-results/`,该目录为测试产物目录,不纳入 git。 + +## 结论 + +本轮 UI 精简没有改认证、审批、OCR、报销状态机或后端 API。自动化结果显示登录、移动首页、移动审批、票据上传和手机票据/报销关键路径均可用。 diff --git a/client/tests/e2e/r89-mobile-login-navigation.spec.ts b/client/tests/e2e/r89-mobile-login-navigation.spec.ts index f68c292a..7dae20ca 100644 --- a/client/tests/e2e/r89-mobile-login-navigation.spec.ts +++ b/client/tests/e2e/r89-mobile-login-navigation.spec.ts @@ -1,4 +1,4 @@ -import { devices, expect, test, type Page, type Route } from "@playwright/test"; +import { devices, expect, test, type Page, type Route, type TestInfo } from "@playwright/test"; function browserAgnosticDevice(name: keyof typeof devices) { const device = devices[name]; @@ -26,6 +26,13 @@ async function fulfillJson(route: Route, data: unknown, status = 200) { }); } +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { + await testInfo.attach(name, { + body: await page.screenshot({ fullPage: true }), + contentType: "image/png", + }); +} + const mockMe = { id: "employee-mock-1", employeeNo: "M001", @@ -359,13 +366,14 @@ test.describe("R89 企业微信移动入口", () => { userAgent: "Mozilla/5.0 iPhone wxwork/4.1.20 MicroMessenger/7.0.1", }); - test("企业微信移动场景默认生成移动工作台入口", async ({ page }) => { + test("企业微信移动场景默认生成移动工作台入口", async ({ page }, testInfo) => { await mockR89Apis(page); await page.goto("/login"); await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible(); await expect(page.getByTestId("wecom-entry-primary")).toBeVisible(); await expect(page.getByTestId("login-password-primary-panel")).toHaveCount(0); + await attachScreenshot(page, testInfo, "r96-wecom-login.png"); await page.getByTestId("wecom-entry-primary").click(); await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=%2Fmobile/); @@ -375,13 +383,14 @@ test.describe("R89 企业微信移动入口", () => { test.describe("R89 移动底部导航与核心页面", () => { test.use(browserAgnosticDevice("iPhone 13")); - test("密码登录后可完成移动首页、审批、票据和我的页关键路径", async ({ page }) => { + test("密码登录后可完成移动首页、审批、票据和我的页关键路径", async ({ page }, testInfo) => { await mockR89Apis(page); await loginByPassword(page); await expect(page).toHaveURL(/\/mobile$/); await expect(page.getByTestId("mobile-today-focus")).toBeVisible(); await expect(page.getByTestId("topbar-profile-trigger")).toBeHidden(); + await attachScreenshot(page, testInfo, "r96-mobile-today.png"); await expect(page.getByTestId("mobile-nav-home")).toBeVisible(); await expect(page.getByTestId("mobile-nav-approvals")).toBeVisible(); @@ -395,16 +404,18 @@ test.describe("R89 移动底部导航与核心页面", () => { await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible(); await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible(); await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible(); + await attachScreenshot(page, testInfo, "r96-mobile-approvals.png"); await page.getByTestId("mobile-nav-capture").click(); await expect(page).toHaveURL(/\/mobile\/capture/); - await expect(page.getByText("拍照或选择文件")).toBeVisible(); + await expect(page.getByText("拍照或选择文件", { exact: true })).toBeVisible(); await page.getByTestId("mobile-capture-file").setInputFiles({ name: "r89-invoice.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n% R89"), }); await expect(page.getByTestId("mobile-capture-upload")).toBeEnabled(); + await attachScreenshot(page, testInfo, "r96-mobile-capture.png"); await page.getByTestId("mobile-nav-me").click(); await expect(page).toHaveURL(/\/mobile\/me/); diff --git a/client/tests/e2e/visual-shell.spec.ts b/client/tests/e2e/visual-shell.spec.ts index 4a2d9f84..44e7b437 100644 --- a/client/tests/e2e/visual-shell.spec.ts +++ b/client/tests/e2e/visual-shell.spec.ts @@ -1,6 +1,193 @@ -import { expect, test, type Page, type TestInfo } from "@playwright/test"; +import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test"; -import { EMPLOYEE, login } from "./helpers/auth"; +function envelope(data: unknown) { + return { requestId: "mock-r96-ui", data }; +} + +async function fulfillJson(route: Route, data: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(envelope(data)), + }); +} + +const visualMe = { + id: "employee-r96", + employeeNo: "R96001", + name: "Employee", + phone: "13800000000", + email: "employee@hyinda.com", + departmentId: "dept-r96", + directManagerId: null, + status: "active", + notificationPushEnabled: true, + roleCodes: ["employee"], + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 50, + }, + wecomBinding: null, +}; + +function navigationItem(key: string, href: string, label: string, icon: string) { + return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; +} + +async function mockVisualApis(page: Page) { + 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-r96-access-token", expiresIn: 900, mustChangePassword: false })), + }); + return; + } + + if (path === "/api/me") { + await fulfillJson(route, visualMe); + return; + } + + if (path === "/api/system/navigation") { + await fulfillJson(route, { + workspace: { + id: "workspace-r96", + 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", "Portal", "home_app_logo"), + navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"), + navigationItem("approvals", "/workflow/tasks", "审批待办", "fact_check"), + navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"), + ], + admin: [], + backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")], + }, + recommendedRolePresets: [], + }); + return; + } + + if (path === "/api/portal/home") { + await fulfillJson(route, { + workspace: { id: "workspace-r96", persisted: true, profile: "small_team_internal" }, + greeting: { name: visualMe.name }, + cards: [ + { key: "pending_tasks", title: "待处理", value: 1, href: "/workflow/tasks", severity: "warning" }, + { key: "my_weekly_report", title: "周报", value: "待填写", href: "/weekly-reports" }, + ], + pendingTasks: { count: 1, href: "/workflow/tasks" }, + myApplications: [{ domain: "reimbursement", count: 2, href: "/reimbursements" }], + riskSummary: null, + announcements: [{ id: "ann-r96", title: "试运行提醒", summary: "今天先处理待办。", href: null }], + quickLinks: [{ key: "invoice", title: "上传票据", href: "/mobile/capture" }], + todayPresence: { date: "2026-05-06", status: "office", label: "在岗", note: null, href: "/team" }, + teamPresence: null, + weeklyReport: { weekStart: "2026-05-04", status: "draft", needsHelp: false, href: "/weekly-reports" }, + weeklySummary: null, + todoSummary: { openCount: 1, highPriorityCount: 0, href: "/todos" }, + projectSummary: null, + peopleLifecycle: null, + adoptionSummary: null, + recentActivities: [], + teamStatusBoard: [], + }); + return; + } + + if (path === "/api/attendance/me/today") { + await fulfillJson(route, { + workDate: "2026-05-06", + needClockIn: true, + exemptionReason: null, + item: null, + }); + return; + } + + if (path === "/api/workflow/tasks") { + await fulfillJson(route, { + items: [ + { + id: "task-r96", + stepName: "经理审批", + status: "pending", + createdAt: "2026-05-06T09:00:00.000Z", + completedAt: null, + workflowInstance: { entityType: "reimbursement", entityId: "rb-r96" }, + }, + ], + total: 1, + page: 1, + pageSize: 3, + }); + return; + } + + if (path === "/api/notifications") { + await fulfillJson(route, { + items: [ + { + id: "notice-r96", + title: "审批提醒", + body: "有 1 条待办。", + targetType: "workflow_task", + targetId: "task-r96", + readAt: null, + createdAt: "2026-05-06T09:10:00.000Z", + }, + ], + total: 1, + page: 1, + pageSize: 3, + }); + return; + } + + await fulfillJson(route, {}); + }); +} async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { await testInfo.attach(name, { @@ -11,19 +198,25 @@ async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { test.describe("visual shell", () => { test("登录页入口布局基线", async ({ page }, testInfo) => { + await mockVisualApis(page); await page.setViewportSize({ width: 1440, height: 1024 }); await page.goto("/login"); - await expect(page.getByRole("heading", { name: "海音达 OA" })).toBeVisible(); - await expect(page.getByText("统一入口").first()).toBeVisible(); + await expect(page.getByText("海音达工作台").first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "登录工作台" })).toBeVisible(); await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); await attachScreenshot(page, testInfo, "login-shell.png"); }); test("登录后壳层布局基线", async ({ page }, testInfo) => { + await mockVisualApis(page); await page.setViewportSize({ width: 1440, height: 1024 }); - await login(page, EMPLOYEE); + await page.goto("/login"); + await page.getByTestId("login-identifier").fill("employee@hyinda.com"); + await page.getByTestId("login-password").fill("EmployeePassword1234"); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/portal/); await expect(page.locator("aside")).toBeVisible(); await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible(); @@ -32,4 +225,21 @@ test.describe("visual shell", () => { await attachScreenshot(page, testInfo, "app-shell.png"); }); + + test("移动首页任务化布局基线", async ({ page }, testInfo) => { + await mockVisualApis(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await page.getByTestId("login-identifier").fill("employee@hyinda.com"); + await page.getByTestId("login-password").fill("EmployeePassword1234"); + await page.getByTestId("login-submit").click(); + await page.goto("/mobile"); + + 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-card")).toBeVisible(); + + await attachScreenshot(page, testInfo, "mobile-today.png"); + }); });