R106/A1-A6 固化自动视觉巡检门禁
这个提交包含在:
+1
@@ -20,6 +20,7 @@ client/test-results
|
||||
client/playwright-report
|
||||
.playwright-cli/
|
||||
output/
|
||||
artifacts/ad-hoc-visual-audit-*/
|
||||
|
||||
# Vitest coverage artifacts
|
||||
server/coverage
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
clipped: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
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<SeededAuditData> {
|
||||
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<PageMetrics> {
|
||||
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<Record<string, unknown>> = [];
|
||||
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<Record<string, unknown>> = [];
|
||||
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<BrowserContext>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 截图。"
|
||||
|
+47
-3
@@ -36,7 +36,7 @@ qa_require_cmd() {
|
||||
|
||||
qa_usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|smoke-live>
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|r106-visual-local|smoke-live>
|
||||
|
||||
可选环境变量:
|
||||
QA_PORT / QA_HOST
|
||||
@@ -44,6 +44,7 @@ Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local
|
||||
QA_DB_CLIENT=host|docker
|
||||
QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1
|
||||
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
|
||||
QA_SKIP_CLIENT_BUILD=1 复用已有 client/dist(低内存远程机排查视觉问题时使用)
|
||||
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
|
||||
USAGE
|
||||
}
|
||||
@@ -257,6 +258,7 @@ qa_cleanup() {
|
||||
|
||||
qa_export_app_env() {
|
||||
export NODE_ENV=test
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=4096}"
|
||||
export HOST="$QA_HOST"
|
||||
export PORT="$QA_PORT"
|
||||
export APP_ORIGIN="$QA_BASE_URL"
|
||||
@@ -288,6 +290,13 @@ qa_export_app_env() {
|
||||
}
|
||||
|
||||
qa_prepare_db() {
|
||||
echo ">>> 生成并校验 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户