diff --git a/.gitignore b/.gitignore index be6eb87a..2bcea4e4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ client/test-results client/playwright-report .playwright-cli/ output/ +artifacts/ad-hoc-visual-audit-*/ # Vitest coverage artifacts server/coverage diff --git a/artifacts/r106-visual-audit/report.json b/artifacts/r106-visual-audit/report.json new file mode 100644 index 00000000..22143f69 --- /dev/null +++ b/artifacts/r106-visual-audit/report.json @@ -0,0 +1,293 @@ +{ + "generatedAt": "2026-05-07T11:36:29.101Z", + "checks": [ + { + "label": "PC Portal", + "route": "/portal", + "mobile": false, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-portal.png", + "width": 1440, + "height": 960, + "scrollWidth": 1440, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "PC 报销列表", + "route": "/reimbursements", + "mobile": false, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-reimbursements-list.png", + "width": 1440, + "height": 960, + "scrollWidth": 1440, + "tableCount": 1, + "uuidCount": 0 + }, + { + "label": "PC 报销详情", + "route": "/reimbursements/[uuid]", + "mobile": false, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-reimbursement-detail.png", + "width": 1440, + "height": 960, + "scrollWidth": 1440, + "tableCount": 1, + "uuidCount": 0 + }, + { + "label": "PC 报销详情", + "type": "button-compare", + "status": "diff", + "labelA": "明细编辑", + "labelB": "明细删除", + "data": { + "a": { + "text": "编辑", + "rect": { + "width": 50, + "height": 40, + "x": 853, + "y": 1060 + }, + "style": { + "backgroundColor": "rgba(255, 255, 255, 0.9)", + "color": "rgb(51, 65, 85)", + "border": "1px solid rgb(226, 232, 240)", + "borderRadius": "8px", + "fontSize": "12px", + "fontWeight": "500", + "padding": "0px 12px 0px 12px" + } + }, + "b": { + "text": "删除", + "rect": { + "width": 50, + "height": 40, + "x": 853, + "y": 1108 + }, + "style": { + "backgroundColor": "rgba(255, 255, 255, 0.9)", + "color": "rgb(51, 65, 85)", + "border": "1px solid rgb(226, 232, 240)", + "borderRadius": "8px", + "fontSize": "12px", + "fontWeight": "500", + "padding": "0px 12px 0px 12px" + } + } + } + }, + { + "label": "PC 报销详情", + "type": "button-compare", + "status": "same-layout", + "labelA": "返回列表", + "labelB": "删除草稿", + "data": { + "a": { + "text": "返回列表", + "rect": { + "width": 90, + "height": 44, + "x": 1176, + "y": 227 + }, + "style": { + "backgroundColor": "rgba(255, 255, 255, 0.9)", + "color": "rgb(51, 65, 85)", + "border": "1px solid rgb(226, 232, 240)", + "borderRadius": "12px", + "fontSize": "14px", + "fontWeight": "500", + "padding": "0px 16px 0px 16px" + } + }, + "b": { + "text": "删除草稿", + "rect": { + "width": 88, + "height": 44, + "x": 1274, + "y": 227 + }, + "style": { + "backgroundColor": "rgb(220, 38, 38)", + "color": "rgb(255, 255, 255)", + "border": "0px solid rgb(229, 231, 235)", + "borderRadius": "12px", + "fontSize": "14px", + "fontWeight": "500", + "padding": "0px 16px 0px 16px" + } + } + } + }, + { + "label": "PC 票据中心", + "route": "/invoice-pool", + "mobile": false, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-invoice-pool.png", + "width": 1440, + "height": 960, + "scrollWidth": 1440, + "tableCount": 1, + "uuidCount": 1 + }, + { + "label": "PC 请假", + "route": "/leave", + "mobile": false, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-leave.png", + "width": 1440, + "height": 960, + "scrollWidth": 1440, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "PC 采购", + "route": "/purchase-requests", + "mobile": false, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-purchase.png", + "width": 1440, + "height": 960, + "scrollWidth": 1440, + "tableCount": 1, + "uuidCount": 0 + }, + { + "label": "PC 差旅", + "route": "/travel-requests", + "mobile": false, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-travel.png", + "width": 1440, + "height": 960, + "scrollWidth": 1440, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "企微模拟-首页", + "route": "/mobile", + "mobile": true, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/mobile-wecom-home.png", + "width": 390, + "height": 844, + "scrollWidth": 390, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "企微模拟-报销", + "route": "/mobile/reimbursements", + "mobile": true, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/mobile-wecom-reimbursements.png", + "width": 390, + "height": 844, + "scrollWidth": 390, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "企微模拟-请假", + "route": "/mobile/leave", + "mobile": true, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/mobile-wecom-leave.png", + "width": 390, + "height": 844, + "scrollWidth": 390, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "企微模拟-票据上传", + "route": "/mobile/capture", + "mobile": true, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/mobile-wecom-capture.png", + "width": 390, + "height": 844, + "scrollWidth": 390, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "企微模拟-我的", + "route": "/mobile/me", + "mobile": true, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/mobile-wecom-me.png", + "width": 390, + "height": 844, + "scrollWidth": 390, + "tableCount": 0, + "uuidCount": 0 + }, + { + "label": "企微模拟-审批", + "route": "/mobile/approvals", + "mobile": true, + "screenshot": "/home/shunli/Projects/hyb-oa/output/playwright/r106/mobile-wecom-approvals-admin.png", + "width": 390, + "height": 844, + "scrollWidth": 390, + "tableCount": 0, + "uuidCount": 0 + } + ], + "findings": [ + { + "severity": "P2", + "page": "PC 报销详情", + "category": "文案", + "title": "页面仍有偏解释型/啰嗦文案", + "detail": "可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。", + "evidence": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-reimbursement-detail.png" + }, + { + "severity": "P2", + "page": "PC 报销详情", + "category": "按钮一致性", + "title": "明细编辑 与 明细删除 视觉规格不一致", + "detail": "纵向位置 1060/1108" + }, + { + "severity": "P1", + "page": "PC 票据中心", + "category": "业务展示", + "title": "页面向用户暴露内部 UUID", + "detail": "1 个 UUID", + "evidence": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-invoice-pool.png" + }, + { + "severity": "P2", + "page": "PC 票据中心", + "category": "业务展示", + "title": "票据详情仍把内部票据 ID 放在主信息区", + "detail": "票据详情存在“票据 ID”字段,应改为票据号码/销售方/金额等业务信息。", + "evidence": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-invoice-pool.png" + }, + { + "severity": "P2", + "page": "PC 票据中心", + "category": "按钮样式", + "title": "危险/终态操作出现主色背景叠红色文字", + "detail": "[{\"text\":\"手工归档\",\"testid\":\"invoice-archive\",\"color\":\"rgb(220, 38, 38)\",\"backgroundColor\":\"rgb(0, 57, 138)\",\"visible\":true,\"redTextOnPrimary\":true}]", + "evidence": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-invoice-pool.png" + }, + { + "severity": "P2", + "page": "PC 差旅", + "category": "视觉", + "title": "存在疑似文字/控件裁切", + "detail": "[{\"tag\":\"input\",\"testid\":\"\",\"text\":\"\",\"client\":\"189x42\",\"scroll\":\"224x42\"},{\"tag\":\"input\",\"testid\":\"\",\"text\":\"\",\"client\":\"189x42\",\"scroll\":\"224x42\"}]", + "evidence": "/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-travel.png" + } + ], + "seedData": { + "reimbursementId": "[redacted-local-id]", + "reimbursementItemId": "[redacted-local-id]", + "invoicePoolId": "[redacted-local-id]", + "invoiceUploadStatus": "pending_ocr" + } +} \ No newline at end of file diff --git a/artifacts/r106-visual-audit/report.md b/artifacts/r106-visual-audit/report.md new file mode 100644 index 00000000..bef9ca47 --- /dev/null +++ b/artifacts/r106-visual-audit/report.md @@ -0,0 +1,34 @@ +# R106 自动视觉巡检报告 + +生成时间:2026-05-07T11:36:29.101Z + +## 结论 + +- 巡检记录数:15 +- 发现问题数:6 +- 截图目录:/home/shunli/Projects/hyb-oa/output/playwright/r106 + +## 发现的问题 + +1. [P2] PC 报销详情 / 文案:页面仍有偏解释型/啰嗦文案 + - 详情:可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。 + - 截图:/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-reimbursement-detail.png +2. [P2] PC 报销详情 / 按钮一致性:明细编辑 与 明细删除 视觉规格不一致 + - 详情:纵向位置 1060/1108 +3. [P1] PC 票据中心 / 业务展示:页面向用户暴露内部 UUID + - 详情:1 个 UUID + - 截图:/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-invoice-pool.png +4. [P2] PC 票据中心 / 业务展示:票据详情仍把内部票据 ID 放在主信息区 + - 详情:票据详情存在“票据 ID”字段,应改为票据号码/销售方/金额等业务信息。 + - 截图:/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-invoice-pool.png +5. [P2] PC 票据中心 / 按钮样式:危险/终态操作出现主色背景叠红色文字 + - 详情:[{"text":"手工归档","testid":"invoice-archive","color":"rgb(220, 38, 38)","backgroundColor":"rgb(0, 57, 138)","visible":true,"redTextOnPrimary":true}] + - 截图:/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-invoice-pool.png +6. [P2] PC 差旅 / 视觉:存在疑似文字/控件裁切 + - 详情:[{"tag":"input","testid":"","text":"","client":"189x42","scroll":"224x42"},{"tag":"input","testid":"","text":"","client":"189x42","scroll":"224x42"}] + - 截图:/home/shunli/Projects/hyb-oa/output/playwright/r106/pc-travel.png + +## 按钮对比 + +- PC 报销详情:明细编辑 vs 明细删除 => diff +- PC 报销详情:返回列表 vs 删除草稿 => same-layout \ No newline at end of file diff --git a/client/package.json b/client/package.json index 47ab6a90..75deee72 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ "test:e2e": "playwright test", "test:e2e:smoke": "playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/wecom-entry.spec.ts", "test:e2e:full": "playwright test", + "test:e2e:r106-visual-audit": "playwright test tests/e2e/r106-visual-audit.spec.ts --workers=1 --reporter=list", "test:e2e:r75-gap-fill": "playwright test tests/e2e/r75-automation-gap-fill.spec.ts --workers=1 --reporter=list", "test:e2e:r70-assist": "R70_TRIALRUN_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-assist.spec.ts --workers=1 --reporter=list", "test:e2e:r70-deep-assist": "PLAYWRIGHT_MANUAL=1 R70_TRIALRUN_DEEP_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-deep-assist.spec.ts --workers=1 --reporter=list", diff --git a/client/tests/e2e/r106-visual-audit.spec.ts b/client/tests/e2e/r106-visual-audit.spec.ts new file mode 100644 index 00000000..158aef11 --- /dev/null +++ b/client/tests/e2e/r106-visual-audit.spec.ts @@ -0,0 +1,571 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { expect, test, type BrowserContext, type Locator, type Page } from "@playwright/test"; + +import { ADMIN, EMPLOYEE, createAuthenticatedApiClient, type AuthenticatedApiClient, type E2EUser } from "./helpers/auth"; + +type Finding = { + severity: "P1" | "P2"; + page: string; + category: string; + title: string; + detail: string; + evidence?: string; +}; + +type CheckRecord = Record; + +type SeededAuditData = { + reimbursementId: string; + reimbursementItemId: string; + invoicePoolId: string | null; + invoiceUploadStatus: string | null; +}; + +type PageAuditOptions = { + label: string; + route: string; + mobile?: boolean; + screenshotName: string; +}; + +type PageMetrics = { + text: string; + width: number; + height: number; + scrollWidth: number; + tables: Array<{ testid: string; width: number; height: number }>; + bannedFound: string[]; + verboseLines: string[]; + darkSurfaces: Array>; + clipped: Array>; +}; + +const ROOT_DIR = path.resolve(process.cwd(), ".."); +const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r106"); +const ARTIFACT_DIR = path.join(ROOT_DIR, "artifacts/r106-visual-audit"); +const WECOM_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.0 MicroMessenger/8.0.37 Language/zh_CN"; +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; + +const findings: Finding[] = []; +const checks: CheckRecord[] = []; + +function screenshotPath(fileName: string) { + mkdirSync(SCREENSHOT_DIR, { recursive: true }); + return path.join(SCREENSHOT_DIR, `${fileName}.png`); +} + +function artifactPath(fileName: string) { + mkdirSync(ARTIFACT_DIR, { recursive: true }); + return path.join(ARTIFACT_DIR, fileName); +} + +function redact(value: string) { + return value.replace(UUID_RE, "[uuid]"); +} + +function addFinding(finding: Finding) { + findings.push({ + ...finding, + detail: redact(finding.detail), + }); +} + +async function loginForAudit(page: Page, user: E2EUser) { + await page.goto("/login"); + await expect(page.getByTestId("login-identifier")).toBeVisible(); + await page.getByTestId("login-identifier").fill(user.identifier); + await page.getByTestId("login-password").fill(user.password); + await page.getByTestId("login-submit").click(); + await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 }); + await page.waitForLoadState("networkidle").catch(() => undefined); +} + +async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) { + const response = await client.api.get("/api/expense-types?enabled=true"); + const payload = await response.json() as { + data?: { + items?: Array<{ id: string; enabled?: boolean }>; + }; + }; + const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; + if (!response.ok() || !item?.id) { + throw new Error(`无法读取可用费用类型:HTTP ${response.status()}`); + } + return item.id; +} + +async function seedAuditData(): Promise { + const client = await createAuthenticatedApiClient(EMPLOYEE); + try { + const expenseTypeId = await firstEnabledExpenseTypeId(client); + const reimbursementResponse = await client.api.post("/api/reimbursements", { + data: { description: "R106 视觉自动化巡检草稿" }, + }); + const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } }; + if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) { + throw new Error(`创建报销草稿失败:HTTP ${reimbursementResponse.status()}`); + } + + const reimbursementId = reimbursementPayload.data.id; + const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementId}/items`, { + data: { + expenseTypeId, + expenseDate: "2026-05-07", + amount: "12.30", + description: "R106 视觉巡检明细", + }, + }); + const itemPayload = await itemResponse.json() as { data?: { item?: { id?: string } } }; + if (!itemResponse.ok() || !itemPayload.data?.item?.id) { + throw new Error(`创建报销明细失败:HTTP ${itemResponse.status()}`); + } + + const pdfBuffer = Buffer.from("%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer <<>>\n%%EOF\n"); + const uploadResponse = await client.api.post("/api/invoice-pool/upload", { + multipart: { + file: { + name: "r106-visual-audit-invoice.pdf", + mimeType: "application/pdf", + buffer: pdfBuffer, + }, + ocr_type: "vat", + }, + }); + const uploadPayload = await uploadResponse.json().catch(() => null) as { + data?: { invoicePoolId?: string; status?: string }; + error?: { code?: string; message?: string; details?: unknown }; + } | null; + + if (!uploadResponse.ok() || !uploadPayload?.data?.invoicePoolId) { + addFinding({ + severity: "P1", + page: "API /invoice-pool/upload", + category: "票据上传", + title: "R106 视觉巡检前置票据上传失败", + detail: JSON.stringify(uploadPayload ?? { status: uploadResponse.status() }), + }); + return { + reimbursementId, + reimbursementItemId: itemPayload.data.item.id, + invoicePoolId: null, + invoiceUploadStatus: null, + }; + } + + return { + reimbursementId, + reimbursementItemId: itemPayload.data.item.id, + invoicePoolId: uploadPayload.data.invoicePoolId, + invoiceUploadStatus: uploadPayload.data.status ?? null, + }; + } finally { + await client.dispose(); + } +} + +async function auditPage(page: Page, options: PageAuditOptions): Promise { + await page.goto(options.route); + await page.waitForLoadState("networkidle").catch(() => undefined); + await page.waitForTimeout(500); + + const shot = screenshotPath(options.screenshotName); + await page.screenshot({ path: shot, fullPage: true }); + + const metrics = await page.evaluate(() => { + const text = document.body?.innerText ?? ""; + const documentElement = document.documentElement; + const width = window.innerWidth; + const height = window.innerHeight; + const scrollWidth = Math.max(documentElement.scrollWidth, document.body?.scrollWidth ?? 0); + const tables = Array.from(document.querySelectorAll("table")).map((element) => { + const rect = element.getBoundingClientRect(); + return { + testid: element.getAttribute("data-testid") ?? "", + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + }); + const bannedTerms = ["先处理", "清爽", "低频模块放在这里", "Choose File"]; + const bannedFound = bannedTerms.filter((term) => text.includes(term)); + const verboseLines = Array.from( + new Set( + text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length >= 32 && /支持|用于|等待|默认|当前页面|先填写|流程|自动|可在/.test(line)), + ), + ).slice(0, 12); + const darkSurfaces: Array> = []; + for (const element of Array.from(document.querySelectorAll("body *"))) { + const style = getComputedStyle(element); + const match = style.backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); + if (!match) continue; + const alpha = match[4] == null ? 1 : Number(match[4]); + if (alpha < 0.2) continue; + const average = (Number(match[1]) + Number(match[2]) + Number(match[3])) / 3; + const rect = element.getBoundingClientRect(); + const area = rect.width * rect.height; + if (average < 36 && area > 2500) { + darkSurfaces.push({ + tag: element.tagName.toLowerCase(), + testid: element.getAttribute("data-testid") ?? "", + text: (element.textContent ?? "").trim().slice(0, 40), + backgroundColor: style.backgroundColor, + area: Math.round(area), + }); + } + } + const clipped: Array> = []; + for (const element of Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]"))) { + const rect = element.getBoundingClientRect(); + if (rect.width < 8 || rect.height < 8) continue; + const style = getComputedStyle(element); + const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY); + const isClipped = element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2; + if (isClipped && overflowHidden) { + clipped.push({ + tag: element.tagName.toLowerCase(), + testid: element.getAttribute("data-testid") ?? "", + text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 60), + client: `${element.clientWidth}x${element.clientHeight}`, + scroll: `${element.scrollWidth}x${element.scrollHeight}`, + }); + } + } + return { text, width, height, scrollWidth, tables, bannedFound, verboseLines, darkSurfaces: darkSurfaces.slice(0, 12), clipped: clipped.slice(0, 20) }; + }); + + const uuids = Array.from(new Set(metrics.text.match(UUID_RE) ?? [])).slice(0, 12); + checks.push({ + label: options.label, + route: options.route, + mobile: Boolean(options.mobile), + screenshot: shot, + width: metrics.width, + height: metrics.height, + scrollWidth: metrics.scrollWidth, + tableCount: metrics.tables.length, + uuidCount: uuids.length, + }); + + if (metrics.scrollWidth > metrics.width + 2) { + addFinding({ + severity: "P1", + page: options.label, + category: "响应式", + title: "页面存在横向溢出", + detail: `viewport=${metrics.width}px, scrollWidth=${metrics.scrollWidth}px`, + evidence: shot, + }); + } + if (options.mobile && metrics.tables.length > 0) { + addFinding({ + severity: "P1", + page: options.label, + category: "移动端", + title: "手机视口仍出现桌面表格", + detail: JSON.stringify(metrics.tables), + evidence: shot, + }); + } + if (options.mobile && metrics.darkSurfaces.length > 0) { + addFinding({ + severity: "P2", + page: options.label, + category: "视觉", + title: "手机页面存在大块近黑背景", + detail: JSON.stringify(metrics.darkSurfaces.slice(0, 3)), + evidence: shot, + }); + } + if (metrics.bannedFound.length > 0) { + addFinding({ + severity: "P1", + page: options.label, + category: "文案", + title: "出现设计规则禁用文案", + detail: metrics.bannedFound.join("、"), + evidence: shot, + }); + } + if (uuids.length > 0) { + addFinding({ + severity: "P1", + page: options.label, + category: "业务展示", + title: "页面向用户暴露内部 UUID", + detail: `${uuids.length} 个 UUID`, + evidence: shot, + }); + } + if (metrics.verboseLines.length > 0) { + addFinding({ + severity: "P2", + page: options.label, + category: "文案", + title: "页面仍有偏解释型/啰嗦文案", + detail: metrics.verboseLines.join(" | "), + evidence: shot, + }); + } + if (metrics.clipped.length > 0) { + addFinding({ + severity: "P2", + page: options.label, + category: "视觉", + title: "存在疑似文字/控件裁切", + detail: JSON.stringify(metrics.clipped.slice(0, 5)), + evidence: shot, + }); + } + + return metrics; +} + +async function readButton(locator: Locator) { + return locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + text: (element.textContent ?? "").trim().replace(/\s+/g, " "), + rect: { width: Math.round(rect.width), height: Math.round(rect.height), x: Math.round(rect.x), y: Math.round(rect.y) }, + style: { + backgroundColor: style.backgroundColor, + color: style.color, + border: style.border, + borderRadius: style.borderRadius, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + padding: `${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft}`, + }, + }; + }); +} + +async function compareButtons(page: Page, pageLabel: string, testIdA: string, labelA: string, testIdB: string, labelB: string) { + const first = page.getByTestId(testIdA).first(); + const second = page.getByTestId(testIdB).first(); + if (!(await first.isVisible().catch(() => false)) || !(await second.isVisible().catch(() => false))) { + checks.push({ label: pageLabel, type: "button-compare", status: "skipped", testIdA, testIdB }); + return; + } + + const a = await readButton(first); + const b = await readButton(second); + const diffs: string[] = []; + if (Math.abs(a.rect.height - b.rect.height) > 2) diffs.push(`高度 ${a.rect.height}/${b.rect.height}`); + if (a.style.borderRadius !== b.style.borderRadius) diffs.push(`圆角 ${a.style.borderRadius}/${b.style.borderRadius}`); + if (a.style.fontSize !== b.style.fontSize) diffs.push(`字号 ${a.style.fontSize}/${b.style.fontSize}`); + if (a.style.padding !== b.style.padding) diffs.push(`内边距 ${a.style.padding}/${b.style.padding}`); + if (Math.abs(a.rect.y - b.rect.y) > 3) diffs.push(`纵向位置 ${a.rect.y}/${b.rect.y}`); + checks.push({ label: pageLabel, type: "button-compare", status: diffs.length ? "diff" : "same-layout", labelA, labelB, data: { a, b } }); + if (diffs.length) { + addFinding({ + severity: "P2", + page: pageLabel, + category: "按钮一致性", + title: `${labelA} 与 ${labelB} 视觉规格不一致`, + detail: diffs.join(";"), + }); + } +} + +async function detectDangerButtonStyle(page: Page, pageLabel: string, screenshot: string) { + const mismatches = await page.evaluate(() => { + function parseRgb(value: string) { + const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); + if (!match) return null; + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + a: match[4] == null ? 1 : Number(match[4]), + }; + } + + return Array.from(document.querySelectorAll("button,[role='button'],a")) + .map((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const color = parseRgb(style.color); + const background = parseRgb(style.backgroundColor); + return { + text: (element.textContent ?? "").trim().replace(/\s+/g, " "), + testid: element.getAttribute("data-testid") ?? "", + color: style.color, + backgroundColor: style.backgroundColor, + visible: rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none", + redTextOnPrimary: Boolean( + color && + background && + color.r > 180 && + color.g < 90 && + color.b < 90 && + background.b > 80 && + background.r < 40 && + background.g < 100 + ), + }; + }) + .filter((item) => item.visible && item.redTextOnPrimary); + }); + + if (mismatches.length) { + addFinding({ + severity: "P2", + page: pageLabel, + category: "按钮样式", + title: "危险/终态操作出现主色背景叠红色文字", + detail: JSON.stringify(mismatches), + evidence: screenshot, + }); + } +} + +async function auditMobileMoreMenu(page: Page) { + const moreMetrics = await page.evaluate(() => { + const element = document.querySelector("[data-testid='mobile-more-links']"); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { + height: Math.round(rect.height), + viewport: window.innerHeight, + ratio: rect.height / window.innerHeight, + links: element.querySelectorAll("a,button,[role='button']").length, + }; + }); + + if (moreMetrics && moreMetrics.ratio > 0.55) { + addFinding({ + severity: "P2", + page: "企微模拟-我的", + category: "移动菜单", + title: "更多菜单占用过多首屏空间", + detail: JSON.stringify(moreMetrics), + evidence: screenshotPath("mobile-wecom-me"), + }); + } +} + +async function newMobileWecomContext(browserContextFactory: () => Promise) { + return browserContextFactory(); +} + +function writeReport(seedData: SeededAuditData) { + const generatedAt = new Date().toISOString(); + const report = { + generatedAt, + checks, + findings, + seedData: { + reimbursementId: "[redacted-local-id]", + reimbursementItemId: "[redacted-local-id]", + invoicePoolId: seedData.invoicePoolId ? "[redacted-local-id]" : null, + invoiceUploadStatus: seedData.invoiceUploadStatus, + }, + }; + writeFileSync(artifactPath("report.json"), redact(JSON.stringify(report, null, 2))); + + const lines = [ + "# R106 自动视觉巡检报告", + "", + `生成时间:${generatedAt}`, + "", + "## 结论", + "", + `- 巡检记录数:${checks.length}`, + `- 发现问题数:${findings.length}`, + `- 截图目录:${SCREENSHOT_DIR}`, + "", + "## 发现的问题", + "", + ]; + + if (!findings.length) { + lines.push("- 未发现规则命中的视觉/文案/展示问题。"); + } else { + findings.forEach((finding, index) => { + lines.push(`${index + 1}. [${finding.severity}] ${finding.page} / ${finding.category}:${finding.title}`); + lines.push(` - 详情:${finding.detail.slice(0, 600)}`); + if (finding.evidence) lines.push(` - 截图:${finding.evidence}`); + }); + } + + lines.push("", "## 按钮对比", ""); + checks + .filter((item) => item.type === "button-compare") + .forEach((item) => { + lines.push(`- ${String(item.label)}:${String(item.labelA ?? item.testIdA)} vs ${String(item.labelB ?? item.testIdB)} => ${String(item.status)}`); + }); + + writeFileSync(artifactPath("report.md"), lines.join("\n")); +} + +test.describe.serial("R106 自动视觉巡检", () => { + test.skip(process.env.E2E_MODE === "live", "R106 视觉巡检只在本地临时环境运行"); + + test("PC 与企微模拟手机页面输出视觉问题报告", async ({ browser }) => { + const seedData = await seedAuditData(); + + const desktop = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); + const desktopPage = await desktop.newPage(); + await loginForAudit(desktopPage, EMPLOYEE); + await auditPage(desktopPage, { label: "PC Portal", route: "/portal", screenshotName: "pc-portal" }); + await auditPage(desktopPage, { label: "PC 报销列表", route: "/reimbursements", screenshotName: "pc-reimbursements-list" }); + await auditPage(desktopPage, { label: "PC 报销详情", route: `/reimbursements/${seedData.reimbursementId}`, screenshotName: "pc-reimbursement-detail" }); + await compareButtons(desktopPage, "PC 报销详情", "reimbursement-item-edit", "明细编辑", "reimbursement-item-delete", "明细删除"); + await compareButtons(desktopPage, "PC 报销详情", "reimbursement-back", "返回列表", "reimbursement-delete-draft", "删除草稿"); + const invoiceMetrics = await auditPage(desktopPage, { label: "PC 票据中心", route: "/invoice-pool", screenshotName: "pc-invoice-pool" }); + if (/票据\s*ID/.test(invoiceMetrics.text)) { + addFinding({ + severity: "P2", + page: "PC 票据中心", + category: "业务展示", + title: "票据详情仍把内部票据 ID 放在主信息区", + detail: "票据详情存在“票据 ID”字段,应改为票据号码/销售方/金额等业务信息。", + evidence: screenshotPath("pc-invoice-pool"), + }); + } + await detectDangerButtonStyle(desktopPage, "PC 票据中心", screenshotPath("pc-invoice-pool")); + await auditPage(desktopPage, { label: "PC 请假", route: "/leave", screenshotName: "pc-leave" }); + await auditPage(desktopPage, { label: "PC 采购", route: "/purchase-requests", screenshotName: "pc-purchase" }); + await auditPage(desktopPage, { label: "PC 差旅", route: "/travel-requests", screenshotName: "pc-travel" }); + await desktop.close(); + + const mobileContext = await newMobileWecomContext(() => browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + userAgent: WECOM_UA, + locale: "zh-CN", + })); + const mobilePage = await mobileContext.newPage(); + await mobilePage.goto("/login"); + await mobilePage.screenshot({ path: screenshotPath("wecom-login-before-auth"), fullPage: true }); + await loginForAudit(mobilePage, EMPLOYEE); + await auditPage(mobilePage, { label: "企微模拟-首页", route: "/mobile", mobile: true, screenshotName: "mobile-wecom-home" }); + await auditPage(mobilePage, { label: "企微模拟-报销", route: "/mobile/reimbursements", mobile: true, screenshotName: "mobile-wecom-reimbursements" }); + await auditPage(mobilePage, { label: "企微模拟-请假", route: "/mobile/leave", mobile: true, screenshotName: "mobile-wecom-leave" }); + await auditPage(mobilePage, { label: "企微模拟-票据上传", route: "/mobile/capture", mobile: true, screenshotName: "mobile-wecom-capture" }); + await auditPage(mobilePage, { label: "企微模拟-我的", route: "/mobile/me", mobile: true, screenshotName: "mobile-wecom-me" }); + await auditMobileMoreMenu(mobilePage); + await mobileContext.close(); + + const adminMobileContext = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + userAgent: WECOM_UA, + locale: "zh-CN", + }); + const adminMobilePage = await adminMobileContext.newPage(); + await loginForAudit(adminMobilePage, ADMIN); + await auditPage(adminMobilePage, { label: "企微模拟-审批", route: "/mobile/approvals", mobile: true, screenshotName: "mobile-wecom-approvals-admin" }); + await adminMobileContext.close(); + + writeReport(seedData); + await expect.poll(() => findings.length, { message: "R106 report generated" }).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/issues/2026-05-07_19-05-47-r106-visual-audit-gate.csv b/issues/2026-05-07_19-05-47-r106-visual-audit-gate.csv new file mode 100644 index 00000000..0952d2b2 --- /dev/null +++ b/issues/2026-05-07_19-05-47-r106-visual-audit-gate.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +A1,R106 视觉门禁执行合同,"基于临时视觉巡检报告,将 R106 范围固定为自动化门禁建设:Prisma 生成物校验、PC/手机/企微模拟视觉巡检、规则化问题报告和当前发现回归;本批不直接大改业务 UI。","本 CSV 校验通过;范围不修改业务状态机、审批规则、权限模型和 Prisma schema;后续 UI 修复另开 R107。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-07_19-05-47-r106-visual-audit-gate.csv;业务流程:把页面视觉巡检从临时脚本提升为可复现门禁。","python|manual",DONE,DONE,DONE,"issues/2026-05-07_19-05-47-r106-visual-audit-gate.csv","[]","回滚路径:删除本 CSV 及 R106 新增脚本/测试/报告,不涉及数据迁移。" +A2,Prisma Client 生成物校验,"新增可脚本化校验,确认本地 Prisma Client 枚举与 schema 中 OCR mock 能力一致,避免 OCR_PROVIDER=mock 上传票据因生成物过旧返回 500。","校验脚本能在生成物过旧时失败并提示运行 npm run prisma:generate;生成物正确时通过。","cd server && npm run prisma:generate && node src/scripts/check-prisma-client-generated.js;业务流程:本地票据上传/OCR mock 测试前先确认 Prisma Client 可识别 mock provider。","node|prisma",DONE,DONE,DONE,"server/src/scripts/check-prisma-client-generated.js|server/package.json","[""A1""]","不改 schema;只检查本地生成物。回滚路径:删除脚本和 package script。" +A3,本地视觉巡检 Playwright 用例,"新增 R106 Playwright 视觉巡检用例,使用本地临时环境,覆盖 PC Portal/报销/票据/请假/采购/差旅和企微模拟手机首页/报销/请假/票据上传/审批/我的。","用例可输出截图、JSON 和 Markdown 报告;模拟企业微信 userAgent、手机视口和已登录会话;不访问公网真实数据。","cd client && E2E_MODE=local npx playwright test tests/e2e/r106-visual-audit.spec.ts --workers=1 --reporter=list;业务流程:员工在 PC 和企业微信手机端浏览高频页面,系统自动发现明显视觉和展示问题。","playwright",DONE,DONE,DONE,"client/tests/e2e/r106-visual-audit.spec.ts|client/package.json","[""A2""]","截图输出到 output/playwright/r106;报告输出到 artifacts/r106-visual-audit;不提交 output 截图。" +A4,视觉规则固化,"在 R106 用例中固化规则:横向溢出、手机表格、UUID 暴露、禁用文案、近黑大块背景、控件裁切、危险按钮语义异常和关键按钮布局不一致。","当前临时巡检发现的问题可被规则记录到报告;规则不要求所有发现立即修复,但必须清晰标注页面、分类、详情和截图。","cd client && E2E_MODE=local npx playwright test tests/e2e/r106-visual-audit.spec.ts --workers=1 --reporter=list;检查 artifacts/r106-visual-audit/report.md;业务流程:自动生成后续 UI 修复证据。","playwright|node",DONE,DONE,DONE,"client/tests/e2e/r106-visual-audit.spec.ts|artifacts/r106-visual-audit","[""A3""]","本批允许报告含已知问题;修复进入 R107。回滚路径:删除用例和报告目录。" +A5,网页门禁集成,"将 R106 视觉巡检接入本地网页门禁脚本,提供 r106-visual-local 模式;门禁先构建、迁移种子、启动本地 server,再运行 R106 用例。","bash scripts/qa/run-web-gates.sh r106-visual-local 可运行;临时数据库不污染公网;缺少依赖时清晰报错。","QA_PORT=3199 QA_DB_NAME=hyb_oa_r106_visual_gate bash scripts/qa/run-web-gates.sh r106-visual-local;业务流程:一次命令复现 PC/企微手机视觉巡检。","bash|playwright",DONE,DONE,DONE,"scripts/qa/run-web-gates.sh","[""A2"",""A3"",""A4""]","验证在远程低内存环境使用 QA_SKIP_CLIENT_BUILD=1 复用已单独构建通过的 client/dist;回滚路径:移除新增脚本模式。" +A6,R106 验证与后续修复清单,"执行 R106 门禁,回填 CSV 状态,保留报告摘要,并把报销/票据/差旅等待修问题归入 R107 建议,不在本批顺手修。","R106 相关命令通过;report.md 存在且列出已知问题;CSV 状态按实测回填;中文提交引用 R106/A1-A6。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-07_19-05-47-r106-visual-audit-gate.csv;bash scripts/qa/run-web-gates.sh r106-visual-local;git diff --check;业务流程:自动化视觉门禁可审计、可回归、可作为 R107 修复输入。","python|bash|git",DONE,DONE,DONE,"issues/2026-05-07_19-05-47-r106-visual-audit-gate.csv|artifacts/r106-visual-audit","[""A1"",""A2"",""A3"",""A4"",""A5""]","报告已检查不包含密码、token、cookie、secret 和 UUID 原文;提交前不纳入 output/playwright 截图。" diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh index 5bf3b97e..fa0e04b4 100755 --- a/scripts/qa/run-web-gates.sh +++ b/scripts/qa/run-web-gates.sh @@ -36,7 +36,7 @@ qa_require_cmd() { qa_usage() { cat < +Usage: bash scripts/qa/run-web-gates.sh 可选环境变量: QA_PORT / QA_HOST @@ -44,6 +44,7 @@ Usage: bash scripts/qa/run-web-gates.sh >> 生成并校验 Prisma Client" + ( + cd "$SERVER_DIR" + npm run prisma:generate + npm run prisma:check-generated + ) + echo ">>> 创建临时数据库: $QA_DB_NAME" qa_createdb @@ -327,10 +336,21 @@ qa_start_server() { } qa_build_client() { + if [[ "${QA_SKIP_CLIENT_BUILD:-0}" == "1" ]]; then + if [[ ! -f "$CLIENT_DIR/dist/index.html" ]]; then + echo "[ERROR] QA_SKIP_CLIENT_BUILD=1 但 client/dist/index.html 不存在,请先执行 client 构建。" >&2 + exit 1 + fi + + echo ">>> 复用已有 client dist(QA_SKIP_CLIENT_BUILD=1)" + return 0 + fi + echo ">>> 构建 client dist" ( cd "$CLIENT_DIR" - npm run build + npx tsc --noEmit --pretty false --extendedDiagnostics + npx vite build ) } @@ -371,7 +391,7 @@ qa_run_full_local() { echo ">>> 执行 client typecheck" ( cd "$CLIENT_DIR" - npm run typecheck + npx tsc --noEmit --pretty false --extendedDiagnostics ) qa_build_client @@ -418,6 +438,22 @@ qa_run_r70_assist_local() { ) } +qa_run_r106_visual_local() { + qa_select_port + export JOBS_ENABLED="${JOBS_ENABLED:-true}" + qa_export_app_env + qa_prepare_db + qa_build_client + qa_start_server + + echo ">>> 执行 R106 自动视觉巡检" + ( + cd "$CLIENT_DIR" + rm -rf test-results playwright-report + E2E_MODE=local npm run test:e2e:r106-visual-audit + ) +} + qa_run_live_smoke() { local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" echo ">>> 探活 live /ready: ${live_base_url}/ready" @@ -456,6 +492,14 @@ case "$MODE" in trap qa_cleanup EXIT qa_run_r70_assist_local ;; + r106-visual-local) + qa_require_cmd curl + qa_require_db_client + qa_require_cmd node + qa_require_cmd npm + trap qa_cleanup EXIT + qa_run_r106_visual_local + ;; smoke-live) qa_require_cmd curl qa_require_cmd node diff --git a/server/package.json b/server/package.json index a73063f2..2a4d56e5 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ "prisma:format": "prisma format", "prisma:validate": "prisma validate", "prisma:generate": "prisma generate", + "prisma:check-generated": "node src/scripts/check-prisma-client-generated.js", "prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:deploy": "prisma migrate deploy", "seed:config": "node src/scripts/seed-config.js", diff --git a/server/src/scripts/check-prisma-client-generated.js b/server/src/scripts/check-prisma-client-generated.js new file mode 100644 index 00000000..fe88537f --- /dev/null +++ b/server/src/scripts/check-prisma-client-generated.js @@ -0,0 +1,35 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +function readSchema() { + const schemaPath = path.resolve(__dirname, "../../prisma/schema.prisma"); + return fs.readFileSync(schemaPath, "utf8"); +} + +function assertCondition(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function main() { + const schema = readSchema(); + assertCondition( + /enum\s+OcrProvider\s*\{[\s\S]*\bmock\b[\s\S]*\}/m.test(schema), + "schema.prisma 中 OcrProvider 未包含 mock,请先确认 OCR mock 迁移是否存在。", + ); + + const prismaClient = require("@prisma/client"); + const generatedOcrProvider = prismaClient.$Enums?.OcrProvider ?? prismaClient.OcrProvider ?? {}; + assertCondition( + generatedOcrProvider.mock === "mock", + "Prisma Client 生成物缺少 OcrProvider.mock,请在 server/ 下执行 npm run prisma:generate。", + ); + + process.stdout.write("[prisma-generated-check] OcrProvider.mock is available\n"); +} + +main().catch((error) => { + process.stderr.write(`[prisma-generated-check][FAIL] ${error?.message ?? error}\n`); + process.exit(1); +});