OAFRONT3 补足PC只读视觉证据
这个提交包含在:
@@ -0,0 +1,485 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, type Page } from "@playwright/test";
|
||||
|
||||
type RoleKey = "employee" | "manager" | "finance" | "finance_mgr" | "admin" | "ceo" | "hr";
|
||||
|
||||
type RouteExpectation = "accessible" | "forbidden_ok" | "optional";
|
||||
|
||||
type PageLayer = "A" | "B" | "C";
|
||||
|
||||
type RoutePlan = {
|
||||
role: RoleKey;
|
||||
route: string;
|
||||
label: string;
|
||||
layer: PageLayer;
|
||||
expectation: RouteExpectation;
|
||||
};
|
||||
|
||||
type RoleResult = {
|
||||
role: RoleKey;
|
||||
login: "PASS" | "SKIP" | "FAIL";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type RouteResult = {
|
||||
role: RoleKey;
|
||||
route: string;
|
||||
label: string;
|
||||
layer: PageLayer;
|
||||
expectation: RouteExpectation;
|
||||
status: "PASS" | "FORBIDDEN_EXPECTED" | "FINDING" | "SKIP";
|
||||
finalPath?: string;
|
||||
screenshot?: string;
|
||||
metrics?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Finding = {
|
||||
severity: "P1" | "P2";
|
||||
role: RoleKey;
|
||||
route: string;
|
||||
label: string;
|
||||
category: string;
|
||||
detail: string;
|
||||
screenshot?: string;
|
||||
};
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||
const REPORT_DIR = resolveRootPath(
|
||||
process.env.OAFRONT3_PC_REPORT_DIR,
|
||||
path.join(ROOT_DIR, "artifacts/qa-flow/oafront3-pc-readonly-visual"),
|
||||
);
|
||||
const SCREENSHOT_DIR = resolveRootPath(
|
||||
process.env.OAFRONT3_PC_OUTPUT_DIR,
|
||||
path.join(ROOT_DIR, "output/playwright/oafront3-pc-readonly"),
|
||||
);
|
||||
const BASE_URL = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
|
||||
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 EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
||||
const LONG_NUMBER_RE = /\b20[0-9]{12,}\b/g;
|
||||
const ENGLISH_PRIMARY_TERMS = [
|
||||
"Portal",
|
||||
"Employee",
|
||||
"Manager",
|
||||
"Admin",
|
||||
"Finance Manager",
|
||||
"Choose File",
|
||||
"mm/dd/yyyy",
|
||||
];
|
||||
|
||||
const ROLE_ENVS: Record<RoleKey, { identifier: string; password: string }> = {
|
||||
employee: { identifier: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", password: "LIVE_SMOKE_EMPLOYEE_PASSWORD" },
|
||||
manager: { identifier: "LIVE_SMOKE_MANAGER_IDENTIFIER", password: "LIVE_SMOKE_MANAGER_PASSWORD" },
|
||||
finance: { identifier: "LIVE_SMOKE_FINANCE_IDENTIFIER", password: "LIVE_SMOKE_FINANCE_PASSWORD" },
|
||||
finance_mgr: { identifier: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", password: "LIVE_SMOKE_FINANCE_MGR_PASSWORD" },
|
||||
admin: { identifier: "LIVE_SMOKE_ADMIN_IDENTIFIER", password: "LIVE_SMOKE_ADMIN_PASSWORD" },
|
||||
ceo: { identifier: "LIVE_SMOKE_CEO_IDENTIFIER", password: "LIVE_SMOKE_CEO_PASSWORD" },
|
||||
hr: { identifier: "LIVE_SMOKE_HR_IDENTIFIER", password: "LIVE_SMOKE_HR_PASSWORD" },
|
||||
};
|
||||
|
||||
const ROUTE_PLANS: RoutePlan[] = [
|
||||
{ role: "employee", route: "/portal", label: "员工工作台", layer: "A", expectation: "accessible" },
|
||||
{ role: "employee", route: "/reimbursements", label: "员工报销列表", layer: "A", expectation: "accessible" },
|
||||
{ role: "employee", route: "/invoice-pool", label: "员工票据中心", layer: "B", expectation: "accessible" },
|
||||
{ role: "employee", route: "/purchase-requests", label: "员工采购申请", layer: "B", expectation: "accessible" },
|
||||
{ role: "employee", route: "/assets", label: "员工资产台账", layer: "B", expectation: "accessible" },
|
||||
{ role: "employee", route: "/payment-batches", label: "员工付款权限负例", layer: "B", expectation: "forbidden_ok" },
|
||||
|
||||
{ role: "manager", route: "/portal", label: "主管工作台", layer: "A", expectation: "accessible" },
|
||||
{ role: "manager", route: "/workflow/tasks", label: "主管审批待办", layer: "A", expectation: "accessible" },
|
||||
{ role: "manager", route: "/purchase-requests", label: "主管采购申请", layer: "B", expectation: "accessible" },
|
||||
{ role: "manager", route: "/projects", label: "主管项目列表", layer: "B", expectation: "optional" },
|
||||
|
||||
{ role: "finance", route: "/portal", label: "财务工作台", layer: "A", expectation: "accessible" },
|
||||
{ role: "finance", route: "/invoice-pool", label: "财务票据中心", layer: "B", expectation: "accessible" },
|
||||
{ role: "finance", route: "/payment-batches", label: "财务付款批次", layer: "B", expectation: "accessible" },
|
||||
{ role: "finance", route: "/ap-operations", label: "财务 AP 运营", layer: "B", expectation: "accessible" },
|
||||
{ role: "finance", route: "/reimbursements", label: "财务报销列表", layer: "A", expectation: "accessible" },
|
||||
|
||||
{ role: "finance_mgr", route: "/portal", label: "财务负责人工作台", layer: "A", expectation: "accessible" },
|
||||
{ role: "finance_mgr", route: "/workflow/tasks", label: "财务负责人审批待办", layer: "A", expectation: "accessible" },
|
||||
{ role: "finance_mgr", route: "/payment-batches", label: "财务负责人付款批次", layer: "B", expectation: "accessible" },
|
||||
{ role: "finance_mgr", route: "/ap-operations", label: "财务负责人 AP 运营", layer: "B", expectation: "accessible" },
|
||||
{ role: "finance_mgr", route: "/audit-logs", label: "财务负责人审计日志", layer: "C", expectation: "accessible" },
|
||||
|
||||
{ role: "admin", route: "/portal", label: "管理员工作台", layer: "A", expectation: "accessible" },
|
||||
{ role: "admin", route: "/assets", label: "管理员资产台账", layer: "B", expectation: "accessible" },
|
||||
{ role: "admin", route: "/contracts-registry", label: "管理员合同台账", layer: "B", expectation: "accessible" },
|
||||
{ role: "admin", route: "/system/workspace", label: "管理员工作区设置", layer: "C", expectation: "accessible" },
|
||||
{ role: "admin", route: "/system/approval-rules", label: "管理员审批规则", layer: "C", expectation: "accessible" },
|
||||
{ role: "admin", route: "/system/feature-flags", label: "管理员功能开关", layer: "C", expectation: "accessible" },
|
||||
|
||||
{ role: "ceo", route: "/portal", label: "负责人工作台", layer: "A", expectation: "accessible" },
|
||||
{ role: "ceo", route: "/workflow/tasks", label: "负责人审批待办", layer: "A", expectation: "accessible" },
|
||||
{ role: "ceo", route: "/budgets", label: "负责人预算视图", layer: "B", expectation: "optional" },
|
||||
|
||||
{ role: "hr", route: "/portal", label: "HR 工作台", layer: "A", expectation: "accessible" },
|
||||
{ role: "hr", route: "/people", label: "HR 人员管理", layer: "C", expectation: "accessible" },
|
||||
{ role: "hr", route: "/wecom-approval-archive", label: "HR 企微归档", layer: "B", expectation: "accessible" },
|
||||
];
|
||||
|
||||
const roleResults: RoleResult[] = [];
|
||||
const routeResults: RouteResult[] = [];
|
||||
const findings: Finding[] = [];
|
||||
|
||||
function resolveRootPath(value: string | undefined, fallback: string) {
|
||||
if (!value?.trim()) return fallback;
|
||||
return path.isAbsolute(value) ? value : path.join(ROOT_DIR, value);
|
||||
}
|
||||
|
||||
function relativePath(file: string | undefined) {
|
||||
if (!file) return undefined;
|
||||
return path.relative(ROOT_DIR, file);
|
||||
}
|
||||
|
||||
function sanitizeRoute(route: string) {
|
||||
return route.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root";
|
||||
}
|
||||
|
||||
function sanitizeDetail(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.replace(EMAIL_RE, "[email]")
|
||||
.replace(UUID_RE, "[uuid]")
|
||||
.replace(LONG_NUMBER_RE, "[long-number]")
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
.replace(/authorization:\s*bearer\s+[^\s]+/gi, "authorization: bearer ***")
|
||||
.replace(/[A-Za-z0-9_+/:=-]{32,}/g, "***")
|
||||
.slice(0, 300);
|
||||
}
|
||||
|
||||
function outputPath(fileName: string) {
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
return path.join(SCREENSHOT_DIR, fileName);
|
||||
}
|
||||
|
||||
function addFinding(finding: Finding) {
|
||||
findings.push({
|
||||
...finding,
|
||||
detail: sanitizeDetail(finding.detail),
|
||||
screenshot: relativePath(finding.screenshot),
|
||||
});
|
||||
}
|
||||
|
||||
function getCredential(role: RoleKey) {
|
||||
const env = ROLE_ENVS[role];
|
||||
const identifier = process.env[env.identifier]?.trim();
|
||||
const password = process.env[env.password]?.trim();
|
||||
if (!identifier || !password) return null;
|
||||
return { identifier, password };
|
||||
}
|
||||
|
||||
async function loginRole(page: Page, role: RoleKey): Promise<RoleResult> {
|
||||
const credential = getCredential(role);
|
||||
if (!credential) {
|
||||
return { role, login: "SKIP", reason: "missing_credentials" };
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(credential.identifier);
|
||||
await page.getByTestId("login-password").fill(credential.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
return { role, login: "PASS" };
|
||||
} catch (error) {
|
||||
const alertText = await page.locator(".text-red-600, .text-red-700, [role='alert']").first().textContent().catch(() => "");
|
||||
return {
|
||||
role,
|
||||
login: "FAIL",
|
||||
reason: sanitizeDetail(alertText || (error instanceof Error ? error.message : String(error))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function readMetrics(page: Page) {
|
||||
return page.evaluate((englishTerms) => {
|
||||
const text = document.body?.innerText ?? "";
|
||||
const root = document.documentElement;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const scrollWidth = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0);
|
||||
const headings = Array.from(document.querySelectorAll("h1,h2"))
|
||||
.map((element) => (element.textContent ?? "").trim().replace(/\s+/g, " "))
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
const visibleTables = Array.from(document.querySelectorAll("table")).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
||||
}).length;
|
||||
const inputsWithDatePlaceholders = Array.from(document.querySelectorAll("input"))
|
||||
.filter((element) => {
|
||||
const textValue = `${element.getAttribute("placeholder") ?? ""} ${element.getAttribute("value") ?? ""}`;
|
||||
return element.getAttribute("type") === "date" || /mm\/dd\/yyyy|yyyy-mm-dd/i.test(textValue);
|
||||
}).length;
|
||||
const clippedControls = Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]"))
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width < 8 || rect.height < 8) return false;
|
||||
const style = getComputedStyle(element);
|
||||
const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY);
|
||||
return overflowHidden && (element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2);
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((element) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
testid: element.getAttribute("data-testid") ?? "",
|
||||
text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 40),
|
||||
}));
|
||||
const englishFound = englishTerms.filter((term) => text.includes(term));
|
||||
return {
|
||||
title: document.title,
|
||||
width,
|
||||
height,
|
||||
scrollWidth,
|
||||
textLength: text.length,
|
||||
headings,
|
||||
visibleTables,
|
||||
uuidCount: (text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi) ?? []).length,
|
||||
longNumberCount: (text.match(/\b20[0-9]{12,}\b/g) ?? []).length,
|
||||
rawStatusTokenCount: (text.match(/\b(active|draft|archived|available|pending|approved|rejected|executed|force_on|force_off)\b/gi) ?? []).length,
|
||||
rawJsonBlockCount: (text.match(/\{\s*"[^"]+"\s*:/g) ?? []).length,
|
||||
englishFound,
|
||||
datePlaceholderCount: inputsWithDatePlaceholders,
|
||||
clippedControls,
|
||||
forbiddenLike: text.includes("无权访问") || text.includes("403") || text.includes("权限不足"),
|
||||
appErrorLike: text.includes("页面加载失败") || text.includes("加载失败") || text.includes("出错了"),
|
||||
navPortalCopy: text.includes("Portal"),
|
||||
};
|
||||
}, ENGLISH_PRIMARY_TERMS);
|
||||
}
|
||||
|
||||
async function auditRoute(page: Page, plan: RoutePlan) {
|
||||
await page.goto(plan.route);
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const screenshot = outputPath(`${plan.role}-${sanitizeRoute(plan.route)}.png`);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
const metrics = await readMetrics(page);
|
||||
const finalPath = new URL(page.url()).pathname;
|
||||
const forbidden = Boolean(metrics.forbiddenLike) || finalPath === "/403";
|
||||
|
||||
let status: RouteResult["status"] = "PASS";
|
||||
if (forbidden && plan.expectation === "forbidden_ok") {
|
||||
status = "FORBIDDEN_EXPECTED";
|
||||
} else if (forbidden && plan.expectation === "accessible") {
|
||||
status = "FINDING";
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "权限/入口",
|
||||
detail: "预期可访问的 PC 路由进入 403 或权限不足页面。",
|
||||
screenshot,
|
||||
});
|
||||
} else if (forbidden) {
|
||||
status = "SKIP";
|
||||
}
|
||||
|
||||
if (!forbidden) {
|
||||
if (metrics.appErrorLike) {
|
||||
status = "FINDING";
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "页面可用性",
|
||||
detail: "页面正文出现加载失败或错误提示。",
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (metrics.scrollWidth > metrics.width + 2) {
|
||||
status = "FINDING";
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "响应式",
|
||||
detail: `PC 视口横向溢出:viewport=${metrics.width}, scrollWidth=${metrics.scrollWidth}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (plan.layer !== "C" && metrics.uuidCount > 0) {
|
||||
status = "FINDING";
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "业务展示",
|
||||
detail: `A/B 类页面出现内部 UUID:count=${metrics.uuidCount}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (plan.layer !== "C" && metrics.rawStatusTokenCount > 0) {
|
||||
addFinding({
|
||||
severity: "P2",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "文案/码值",
|
||||
detail: `A/B 类页面出现英文状态或开关码值:count=${metrics.rawStatusTokenCount}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (plan.layer !== "C" && metrics.longNumberCount > 0) {
|
||||
addFinding({
|
||||
severity: "P2",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "业务展示",
|
||||
detail: `A/B 类页面出现长数字内部流水或时间戳:count=${metrics.longNumberCount}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (plan.layer !== "C" && metrics.rawJsonBlockCount > 0) {
|
||||
status = "FINDING";
|
||||
addFinding({
|
||||
severity: "P1",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "业务展示",
|
||||
detail: `A/B 类页面直接展示 JSON/原始扩展字段:count=${metrics.rawJsonBlockCount}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (plan.layer !== "C" && metrics.englishFound.length > 0) {
|
||||
addFinding({
|
||||
severity: "P2",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "本地化",
|
||||
detail: `A/B 类页面出现英文主展示词:${metrics.englishFound.join(",")}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (metrics.datePlaceholderCount > 0) {
|
||||
addFinding({
|
||||
severity: "P2",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "表单文案",
|
||||
detail: `日期控件存在浏览器英文占位或 ISO 占位:count=${metrics.datePlaceholderCount}`,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (metrics.clippedControls.length > 0) {
|
||||
addFinding({
|
||||
severity: "P2",
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
category: "文字裁切",
|
||||
detail: JSON.stringify(metrics.clippedControls),
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
routeResults.push({
|
||||
role: plan.role,
|
||||
route: plan.route,
|
||||
label: plan.label,
|
||||
layer: plan.layer,
|
||||
expectation: plan.expectation,
|
||||
status,
|
||||
finalPath,
|
||||
screenshot: relativePath(screenshot),
|
||||
metrics: {
|
||||
title: sanitizeDetail(metrics.title),
|
||||
textLength: metrics.textLength,
|
||||
headingCount: metrics.headings.length,
|
||||
tableCount: metrics.visibleTables,
|
||||
uuidCount: metrics.uuidCount,
|
||||
rawStatusTokenCount: metrics.rawStatusTokenCount,
|
||||
longNumberCount: metrics.longNumberCount,
|
||||
rawJsonBlockCount: metrics.rawJsonBlockCount,
|
||||
englishFound: metrics.englishFound,
|
||||
datePlaceholderCount: metrics.datePlaceholderCount,
|
||||
clippedCount: metrics.clippedControls.length,
|
||||
forbiddenLike: metrics.forbiddenLike,
|
||||
appErrorLike: metrics.appErrorLike,
|
||||
navPortalCopy: metrics.navPortalCopy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function writeReport() {
|
||||
mkdirSync(REPORT_DIR, { recursive: true });
|
||||
const blockingFindings = findings.filter((finding) => finding.severity === "P1");
|
||||
const skippedRoles = roleResults.filter((item) => item.login === "SKIP");
|
||||
const failedRoles = roleResults.filter((item) => item.login === "FAIL");
|
||||
const status = failedRoles.length
|
||||
? "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"
|
||||
: blockingFindings.length
|
||||
? "PASS_WITH_FINDINGS"
|
||||
: skippedRoles.length || findings.length
|
||||
? "PASS_WITH_FINDINGS"
|
||||
: "PASS_PC_READONLY_VISUAL_READY";
|
||||
const summary = {
|
||||
issueId: "OAFRONT3-1",
|
||||
mode: "pc-readonly-visual-evidence",
|
||||
status,
|
||||
conclusion: status,
|
||||
baseUrl: BASE_URL,
|
||||
checkedAt: new Date().toISOString(),
|
||||
readOnly: true,
|
||||
viewport: "1440x960",
|
||||
roleCount: roleResults.length,
|
||||
routeCount: routeResults.length,
|
||||
screenshotDir: relativePath(SCREENSHOT_DIR),
|
||||
reportDir: relativePath(REPORT_DIR),
|
||||
roles: roleResults,
|
||||
routes: routeResults,
|
||||
findingCount: findings.length,
|
||||
findings,
|
||||
boundaries: {
|
||||
noBusinessWrites: true,
|
||||
noFileUploads: true,
|
||||
noFormSubmits: true,
|
||||
noExports: true,
|
||||
noWeComWrites: true,
|
||||
noRealPaymentExecution: true,
|
||||
},
|
||||
privacy: "no password/token/secret/full email/full phone/full local sensitive path/raw invoice/contract body/bank receipt/raw payload in JSON report",
|
||||
};
|
||||
writeFileSync(path.join(REPORT_DIR, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
test.describe.serial("OAFRONT3 PC no-write visual evidence", () => {
|
||||
test.skip(process.env.E2E_MODE !== "live", "OAFRONT3 PC 只读视觉证据只在公网 live 模式执行");
|
||||
|
||||
test("角色化访问 PC 关键页面并生成只读视觉证据", async ({ browser }) => {
|
||||
mkdirSync(REPORT_DIR, { recursive: true });
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
const roles = Array.from(new Set(ROUTE_PLANS.map((plan) => plan.role)));
|
||||
|
||||
for (const role of roles) {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, serviceWorkers: "block", locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
const loginResult = await loginRole(page, role);
|
||||
roleResults.push(loginResult);
|
||||
if (loginResult.login !== "PASS") {
|
||||
continue;
|
||||
}
|
||||
for (const plan of ROUTE_PLANS.filter((item) => item.role === role)) {
|
||||
await auditRoute(page, plan);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
writeReport();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"OAFRONT3-0","建立 PC 前端设计证据补强合同","在 OAFRONT2 基础上,专门补足 PC 版设计证据:现有 PC 截图、页面结构指标、视觉 token、文案/码值、权限角色覆盖和关键业务页改进建议;本合同只做证据与建议,不直接改 UI 代码、不部署、不写公网业务数据。","本 CSV 校验通过;每条 PC 建议有截图或代码指标依据、影响业务流程、后续自动/人工验证方式;明确旧截图可能过期,后续实施前需刷新无写入 PC 视觉矩阵。","受影响流程:PC 设计事实源 -> PC 截图/代码证据 -> 问题分类 -> 用户采纳决策;验证步骤:validate_issues_csv.py 本 CSV;check-process-contract general;git diff --check。","python3|node|git|manual","DONE","DONE","DONE","issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv","[]","已通过 validate_issues_csv.py、check-process-contract general、check-ui-design-rules 和 git diff --check。MAGI 审视:复核 OAFRONT2、docs/产品设计规则.md、r93/qg6/r106 等 PC 截图;执行:统计 75 个页面、15 个 ListWorkspace 页面、42 个 PageHeader 页面、11 个超过 1000 行页面、index.css 14 处 gradient/5 处 radial-gradient;提升:形成本 PC 专项建议台账。"
|
||||
"OAFRONT3-1","补一套无公网写入的 PC 视觉证据矩阵","现有 PC 截图有价值但不够成熟:部分来自旧批次,部分用普通员工访问财务/AP/付款得到 403,部分截图显示旧路由标题而当前 schema 已修正;继续判断 PC 设计前需要一套 no-write、角色覆盖完整的当前截图证据。","新增或复用 QA-only PC 视觉矩阵,覆盖 employee、manager、finance、finance_mgr、admin/ceo 角色;只读访问 Portal、报销、审批、采购、合同、资产、票据、付款/AP、项目、配置页;报告标记角色、路由、权限结果,不创建业务数据。","受影响流程:PC 登录 -> 关键页面打开 -> 截图/DOM 指标 -> 设计判断;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com 运行 no-write PC visual evidence;确认 AP/付款不再因普通员工角色误判为设计问题;报告脱敏。","playwright|node|manual","TODO","TODO","TODO","scripts/qa/run-visual-matrix.sh;client/tests/e2e/project-wide-visual.spec.ts;artifacts/qa-flow","[""OAFRONT3-0""]","优先级 P0/P1。不得复用会创建报销草稿的 project-wide prepareRoutes 公网路径;若必须造测试数据,需另问并打 TEST/SMOKE 标记。"
|
||||
"OAFRONT3-2","PC 导航和账号展示本地化不足","PC 截图和代码显示主导航仍有 Portal 英文标签,顶部账号示例仍出现 Employee/Manager/Admin 等英文 smoke 名;这不影响功能,但会削弱国内小团队正式启用时的专业感。","主导航用“工作台”替代 Portal;smoke/演示账号主展示使用中文姓名或中文职能名;真实员工按真实姓名展示;管理员排障页可保留角色码但不作为首页主称呼。","受影响流程:PC 登录 -> 左侧导航 -> 顶部账号 -> 培训演示;验证步骤:PC 截图不再以 Portal/Employee/Manager 作为普通主展示;不写真实员工隐私到仓库。","playwright|manual","TODO","TODO","TODO","client/src/components/navigation/NavLinks.tsx;client/src/features/navigation/schema.ts;seed|server/scripts","[""OAFRONT3-0""]","优先级 P1。证据:NavLinks.tsx 中 /portal label 为 Portal;PC Portal/审批截图顶部出现 Employee/Manager。"
|
||||
"OAFRONT3-1","补一套无公网写入的 PC 视觉证据矩阵","现有 PC 截图有价值但不够成熟:部分来自旧批次,部分用普通员工访问财务/AP/付款得到 403,部分截图显示旧路由标题而当前 schema 已修正;继续判断 PC 设计前需要一套 no-write、角色覆盖完整的当前截图证据。","新增或复用 QA-only PC 视觉矩阵,覆盖 employee、manager、finance、finance_mgr、admin/ceo 角色;只读访问 Portal、报销、审批、采购、合同、资产、票据、付款/AP、项目、配置页;报告标记角色、路由、权限结果,不创建业务数据。","受影响流程:PC 登录 -> 关键页面打开 -> 截图/DOM 指标 -> 设计判断;验证步骤:bash scripts/qa/run-oafront3-pc-readonly-visual.sh;确认 AP/付款不再因普通员工角色误判为设计问题;报告脱敏。","playwright|node|manual","DONE","DONE","DONE","scripts/qa/run-oafront3-pc-readonly-visual.sh;client/tests/e2e/oafront3-pc-readonly-visual.spec.ts;artifacts/qa-flow/oafront3-pc-readonly-visual-*;output/playwright/oafront3-pc-readonly","[""OAFRONT3-0""]","已完成公网 no-write PC 矩阵:7 个角色登录均 PASS,32 张 PC 截图,summary=artifacts/qa-flow/oafront3-pc-readonly-visual-20260525-003341/summary.json,结论 PASS_WITH_FINDINGS,隐私扫描通过;发现 2 个 P1 为付款批次 A/B 页面暴露内部 UUID,P2 包括 26 个 Portal/Employee 等本地化问题、9 个日期控件英文占位/ISO 风险、4 个英文状态/开关码值、3 个长数字内部流水和 1 个合同输入裁切。"
|
||||
"OAFRONT3-2","PC 导航和账号展示本地化不足","PC 截图和代码显示主导航仍有 Portal 英文标签,顶部账号示例仍出现 Employee/Manager/Admin 等英文 smoke 名;这不影响功能,但会削弱国内小团队正式启用时的专业感。","主导航用“工作台”替代 Portal;smoke/演示账号主展示使用中文姓名或中文职能名;真实员工按真实姓名展示;管理员排障页可保留角色码但不作为首页主称呼。","受影响流程:PC 登录 -> 左侧导航 -> 顶部账号 -> 培训演示;验证步骤:PC 截图不再以 Portal/Employee/Manager 作为普通主展示;不写真实员工隐私到仓库。","playwright|manual","TODO","TODO","TODO","client/src/components/navigation/NavLinks.tsx;client/src/features/navigation/schema.ts;seed|server/scripts","[""OAFRONT3-0""]","优先级 P1。证据:NavLinks.tsx 中 /portal label 为 Portal;OAFRONT3 公网 PC 矩阵显示 26 个 A/B 类页面命中 Portal 英文主展示词,AP 页面还命中 Employee。"
|
||||
"OAFRONT3-3","PC Portal 首屏仍偏展示化,工作台密度不足","Portal 页面截图显示 hero 面积和装饰圆纹明显,首屏大块留白较多;对 10 人小科技公司,PC 首页应更像日常处理台,优先呈现待办、草稿/退回、财务待处理、合同/资产提醒、最近动作和异常。","采纳后 Portal 首屏减少装饰 hero 和空白区,强化今日待处理、我的申请进度、财务/合同/资产提醒;不变成重型 ERP;保留 Hyinda 品牌蓝和清晰边界。","受影响流程:PC 登录 -> 今日工作台 -> 进入审批/报销/付款/合同/资产;验证步骤:PC 1440 当前截图与改后截图比对;真实 7 天反馈确认首屏能回答“我今天要处理什么”。","playwright|manual","TODO","TODO","TODO","client/src/pages/PortalHomePage.tsx;client/src/index.css;client/src/components/patterns/WorkbenchHero.tsx","[""OAFRONT3-1""]","优先级 P1/P2。建议先刷新证据和收集真实反馈,再小步收敛 hero,不做一次性重排。"
|
||||
"OAFRONT3-4","PC 票据中心仍暴露内部编号和码值","历史 PC 票据中心截图显示票据流水号位置直接展示 UUID,详情区有状态码 available,表格中发票号码过长且占据主列;这和设计规则“业务对象用业务编号或摘要,不把 UUID 当主编号展示”冲突,也增加隐私报告误泄露风险。","票据列表主标识改为票据编号/发票号码脱敏尾号/销售方+金额摘要;UUID 仅在管理员审计或调试区折叠显示;状态使用“可添加/识别成功/已归档”等中文业务文案;导出/详情报告不泄露完整票面敏感值。","受影响流程:上传 PDF 发票 -> OCR -> 查看票据池 -> 绑定报销/复核;验证步骤:PC 票据池截图;check-ui-design-rules 增加 UUID 主展示/状态码检查;隐私扫描确认报告不含完整票据敏感值。","playwright|node|manual","TODO","TODO","TODO","client/src/pages/InvoicePoolPage.tsx;client/src/pages/invoice-pool/InvoiceListCard.tsx;client/src/pages/invoice-pool/InvoiceDetailPanel.tsx;scripts/qa/check-ui-design-rules.mjs","[""OAFRONT3-1""]","优先级 P1。证据来自 r93 desktop1440-invoice-pool;后续记录问题时不要复述完整发票号或真实票据内容。"
|
||||
"OAFRONT3-5","合同台账录入表单过长且含英文状态/日期占位","合同台账截图显示左侧新建表单很长,右侧列表为空;编辑状态使用 active 等英文码值,日期控件显示 mm/dd/yyyy,占位和状态都偏技术化。正式启用时合同台账是 P0/P1 业务域,需要更清楚的录入路径。","合同台账应分为新建入口、基础信息、归档/责任、列表/筛选/详情;状态展示中文;日期提示符合中文语境;敏感合同正文仍不上传 OA,只记录摘要、归档位置和责任人。","受影响流程:管理员录入合同 -> 关联项目 -> 记录归档位置 -> 查询/到期提醒;验证步骤:PC 合同台账截图;创建 1 条 TEST/SMOKE 合同台账或人工录入验证;权限负例普通员工不可见敏感字段。","playwright|manual","TODO","TODO","TODO","client/src/pages/ContractsRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。证据:ContractsRegistryPage EDITOR_STATUS_OPTIONS 使用 active/draft/archived 原值;历史截图显示 mm/dd/yyyy。"
|
||||
"OAFRONT3-6","资产台账主动作优先级不够清晰","资产台账截图中分类管理表单占据左侧首屏,资产列表和详情在右侧;对 ASSET1D 试录场景,管理员最需要先完成 FA/EA-SYS/EA-REPO/EA-BKP 建卡,但主动作不如分类维护明显。","资产页首屏先表达资产建卡、筛选、列表、详情;分类维护降为配置区或折叠区;不同资产类型显示差异字段;敏感资料只存摘要、hash、责任人和归档位置。","受影响流程:管理员新建资产 -> 筛选资产 -> 查看详情 -> 领用/归还/盘点;验证步骤:PC 资产页截图;ASSET1D UI 试录 1 FA + 1 EA-SYS + 1 EA-REPO + 1 EA-BKP;普通员工权限负例。","playwright|manual","TODO","TODO","TODO","client/src/pages/AssetRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。当前 ASSET1D 正在推进,资产页真实试录反馈应优先于主观视觉调整。"
|
||||
"OAFRONT3-7","采购和项目列表空态占比偏大,下一步动作弱","PC 采购截图显示筛选、列表和详情区域均为空,占据较多空间;项目页类似,项目列表和新建项目提示分区较清楚但空态仍偏大。真实启用前空数据状态应强提示下一步,而不是让页面像未配置。","采购/项目空态应根据权限给出明确下一步:新建采购、补资料、关联项目、联系管理员;列表和详情空态不重复解释;有数据时保持高密度筛选和表格。","受影响流程:员工/管理员打开采购 -> 新建或筛选 -> 关联项目;管理员打开项目 -> 新建项目 -> 关联合同/采购/报销;验证步骤:PC 采购/项目截图;有数据和空数据两种状态截图;无 route-like 标题。","playwright|manual","TODO","TODO","TODO","client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ProjectsPage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT3-1""]","优先级 P2。当前不是 launch blocker,但会影响新用户首次理解。"
|
||||
"OAFRONT3-8","财务/AP/付款 PC 证据角色覆盖不足","现有 r93 员工视角访问 AP/付款得到 403,不能证明财务页面设计成熟或不成熟;而 OAOPS1/OALAUNCH1 将付款批次、回单、AP 导出列为关键启用流程,必须用 finance/finance_mgr 角色补证据。","PC 财务证据覆盖付款批次、AP 运营、票据池、报销财务复核、导出入口;报告区分权限通过、无数据空态、真实设计问题;不执行真实付款、不上传银行流水原件。","受影响流程:财务登录 -> AP/付款批次 -> 上传回单 -> 标记已付款 -> 导出;验证步骤:finance/finance_mgr no-write 截图;OAOPS1 每日巡检;权限负例普通员工 403 仍保留但不当作设计缺陷。","playwright|manual","TODO","TODO","TODO","client/src/pages/ApOperationsPage.tsx;client/src/pages/PaymentBatchesPage.tsx;client/src/pages/ReimbursementsPage.tsx;client/src/pages/InvoicePoolPage.tsx","[""OAFRONT3-1""]","优先级 P0/P1,因为财务闭环是最终启用前关键门禁。"
|
||||
"OAFRONT3-4","PC 票据中心仍暴露内部编号和码值","历史 PC 票据中心截图显示票据流水号位置直接展示 UUID,详情区有状态码 available,表格中发票号码过长且占据主列;这和设计规则“业务对象用业务编号或摘要,不把 UUID 当主编号展示”冲突,也增加隐私报告误泄露风险。","票据列表主标识改为票据编号/发票号码脱敏尾号/销售方+金额摘要;UUID 仅在管理员审计或调试区折叠显示;状态使用“可添加/识别成功/已归档”等中文业务文案;导出/详情报告不泄露完整票面敏感值。","受影响流程:上传 PDF 发票 -> OCR -> 查看票据池 -> 绑定报销/复核;验证步骤:PC 票据池截图;check-ui-design-rules 增加 UUID 主展示/状态码检查;隐私扫描确认报告不含完整票据敏感值。","playwright|node|manual","TODO","TODO","TODO","client/src/pages/InvoicePoolPage.tsx;client/src/pages/invoice-pool/InvoiceListCard.tsx;client/src/pages/invoice-pool/InvoiceDetailPanel.tsx;scripts/qa/check-ui-design-rules.mjs","[""OAFRONT3-1""]","优先级 P1。证据来自 r93 desktop1440-invoice-pool;OAFRONT3 公网 PC 矩阵补充显示员工票据中心仍命中长数字内部流水/时间戳;后续记录问题时不要复述完整发票号或真实票据内容。"
|
||||
"OAFRONT3-5","合同台账录入表单过长且含英文状态/日期占位","合同台账截图显示左侧新建表单很长,右侧列表为空;编辑状态使用 active 等英文码值,日期控件显示 mm/dd/yyyy,占位和状态都偏技术化。正式启用时合同台账是 P0/P1 业务域,需要更清楚的录入路径。","合同台账应分为新建入口、基础信息、归档/责任、列表/筛选/详情;状态展示中文;日期提示符合中文语境;敏感合同正文仍不上传 OA,只记录摘要、归档位置和责任人。","受影响流程:管理员录入合同 -> 关联项目 -> 记录归档位置 -> 查询/到期提醒;验证步骤:PC 合同台账截图;创建 1 条 TEST/SMOKE 合同台账或人工录入验证;权限负例普通员工不可见敏感字段。","playwright|manual","TODO","TODO","TODO","client/src/pages/ContractsRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。证据:ContractsRegistryPage EDITOR_STATUS_OPTIONS 使用 active/draft/archived 原值;历史截图显示 mm/dd/yyyy;OAFRONT3 公网 PC 矩阵新增确认合同台账有 4 个英文状态/开关码值、5 个日期英文占位/ISO 风险和 3 个疑似输入裁切。"
|
||||
"OAFRONT3-6","资产台账主动作优先级不够清晰","资产台账截图中分类管理表单占据左侧首屏,资产列表和详情在右侧;对 ASSET1D 试录场景,管理员最需要先完成 FA/EA-SYS/EA-REPO/EA-BKP 建卡,但主动作不如分类维护明显。","资产页首屏先表达资产建卡、筛选、列表、详情;分类维护降为配置区或折叠区;不同资产类型显示差异字段;敏感资料只存摘要、hash、责任人和归档位置。","受影响流程:管理员新建资产 -> 筛选资产 -> 查看详情 -> 领用/归还/盘点;验证步骤:PC 资产页截图;ASSET1D UI 试录 1 FA + 1 EA-SYS + 1 EA-REPO + 1 EA-BKP;普通员工权限负例。","playwright|manual","TODO","TODO","TODO","client/src/pages/AssetRegistryPage.tsx","[""OAFRONT3-1""]","优先级 P1。当前 ASSET1D 正在推进,资产页真实试录反馈应优先于主观视觉调整;OAFRONT3 公网 PC 矩阵补充显示管理员资产页命中 1 个英文状态/码值、1 个长数字内部流水和 4 个日期英文占位/ISO 风险。"
|
||||
"OAFRONT3-7","采购和项目列表空态占比偏大,下一步动作弱","PC 采购截图显示筛选、列表和详情区域均为空,占据较多空间;项目页类似,项目列表和新建项目提示分区较清楚但空态仍偏大。真实启用前空数据状态应强提示下一步,而不是让页面像未配置。","采购/项目空态应根据权限给出明确下一步:新建采购、补资料、关联项目、联系管理员;列表和详情空态不重复解释;有数据时保持高密度筛选和表格。","受影响流程:员工/管理员打开采购 -> 新建或筛选 -> 关联项目;管理员打开项目 -> 新建项目 -> 关联合同/采购/报销;验证步骤:PC 采购/项目截图;有数据和空数据两种状态截图;无 route-like 标题。","playwright|manual","TODO","TODO","TODO","client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ProjectsPage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT3-1""]","优先级 P2。当前不是 launch blocker,但会影响新用户首次理解;OAFRONT3 公网 PC 矩阵补充显示采购/项目页面仍有长数字内部流水和日期英文占位/ISO 风险。"
|
||||
"OAFRONT3-8","财务/AP/付款 PC 证据角色覆盖不足","现有 r93 员工视角访问 AP/付款得到 403,不能证明财务页面设计成熟或不成熟;而 OAOPS1/OALAUNCH1 将付款批次、回单、AP 导出列为关键启用流程,必须用 finance/finance_mgr 角色补证据。","PC 财务证据覆盖付款批次、AP 运营、票据池、报销财务复核、导出入口;报告区分权限通过、无数据空态、真实设计问题;不执行真实付款、不上传银行流水原件。","受影响流程:财务登录 -> AP/付款批次 -> 上传回单 -> 标记已付款 -> 导出;验证步骤:finance/finance_mgr no-write 截图;OAOPS1 每日巡检;权限负例普通员工 403 仍保留但不当作设计缺陷。","playwright|manual","TODO","TODO","TODO","client/src/pages/ApOperationsPage.tsx;client/src/pages/PaymentBatchesPage.tsx;client/src/pages/ReimbursementsPage.tsx;client/src/pages/InvoicePoolPage.tsx","[""OAFRONT3-1""]","优先级 P0/P1,因为财务闭环是最终启用前关键门禁。OAFRONT3 公网 PC 矩阵已用 finance/finance_mgr 补证据:付款批次页面 2 个角色均出现 P1 内部 UUID 主展示,且各有 10 个英文状态码值和 2 个日期英文占位/ISO 风险;AP 页面命中 Portal/Employee 英文主展示词和日期占位;普通员工付款 403 为预期权限负例。"
|
||||
"OAFRONT3-9","C 类配置页过密但可作为后台模式保留","功能开关和工作区设置截图显示大量开关、flag key、force_on/force_off、main/admin/hidden 等码值;这对管理员排障有价值,但不应扩散到普通业务页。C 类页面需要明确“高密度后台样板”,而非员工化。","C 类页面保留必要码值和审计字段,但应分组、折叠高风险配置、中文化状态摘要;危险操作要确认;普通员工不可见;不把 C 类样式迁移到 A/B 业务页。","受影响流程:管理员配置功能开关/工作区/流程 -> 保存/回滚 -> 审计;验证步骤:admin PC 截图;权限负例;危险操作确认;普通业务页无 raw flag key 主展示。","playwright|manual","TODO","TODO","TODO","client/src/pages/FeatureFlagsPage.tsx;client/src/pages/WorkspaceSetupPage.tsx;client/src/pages/SystemSettingsPage.tsx;client/src/pages/WorkflowDefinitionsPage.tsx","[""OAFRONT3-1""]","优先级 P2。不要在 launch 前为了美观重构后台配置页。"
|
||||
"OAFRONT3-10","PC 全局视觉 token 偏装饰化的量化证据","静态扫描显示 index.css 有 14 处 gradient、5 处 radial-gradient、19 处 shadow 相关声明,workbench-hero 和 section-panel 还有装饰圆纹/顶边渐变;这支持 OAFRONT2 对 PC 视觉偏柔和展示化的判断。","采纳后做 token 级小实验:A/B 类业务页减少装饰性渐变和大圆角,保留品牌蓝、浅背景、清晰边界;C 类后台页面保持可读密度;移动矩阵不回归。","受影响流程:PC 工作台和业务台账扫读;验证步骤:CSS 静态扫描前后对比;PC 代表页截图;移动视觉矩阵;不引入单一蓝色或黑色大块主题。","node|playwright|manual","TODO","TODO","TODO","client/src/index.css;client/src/design/tokens.ts;client/src/design/theme.css;client/src/components/patterns","[""OAFRONT3-0""]","优先级 P2。建议作为视觉 token 试验合同,不逐页手改。"
|
||||
"OAFRONT3-11","页面复杂度阻碍 PC 设计持续演进","静态统计显示 75 个页面中 11 个超过 1000 行、18 个超过 700 行;PC 相关高复杂页面包括 ReimbursementDetail、PurchaseRequests、ApOperations、EmployeeProfile、AssetRegistry、PortalHome、ProjectDetail、LeaveRequest、Reimbursements。复杂度会让设计一致性修复和回归成本上升。","按真实反馈逐页拆分,不改变 API、权限、状态机或路由;优先从业务高频且问题明确的页面开始,例如票据池详情、合同台账、资产台账、财务/AP、Portal。","受影响流程:页面维护 -> 设计修复 -> 回归;验证步骤:client typecheck/build;相关 Playwright;单页改动截图;文件复杂度下降但业务行为不变。","node|typescript|playwright","TODO","TODO","TODO","client/src/pages/ReimbursementDetailPage.tsx;client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ApOperationsPage.tsx;client/src/pages/AssetRegistryPage.tsx;client/src/pages/PortalHomePage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT3-0""]","优先级 P2。launch 前不建议大拆分,除非对应页面出现真实 blocker。"
|
||||
"OAFRONT3-12","PC 文案密度和说明位置需要按页面层级治理","统计显示 PortalHomePage 有 26 处 subtitle/description,ProjectDetail 11 处,Attendance/Profile/Leave 等也较多;PC 端可以比移动承载更多说明,但 A/B 高频页首屏仍应以动作和状态为主。","A 类和 B 类页面首屏压缩说明,只保留业务状态和下一步;C 类配置页保留必要说明;长解释移到帮助、折叠区、字段提示或管理员文档。","受影响流程:员工/审批人/财务打开 PC 高频页 -> 判断下一步;验证步骤:rg subtitle/description 清单;PC 截图首屏;真实反馈确认说明没有挤占主动作。","rg|manual|playwright","TODO","TODO","TODO","client/src/pages/PortalHomePage.tsx;client/src/pages/ProjectDetailPage.tsx;client/src/pages/AttendancePage.tsx;client/src/pages/ProfilePage.tsx;client/src/pages/LeaveRequestPage.tsx","[""OAFRONT3-0""]","优先级 P1/P2。建议随具体页面优化一起清理,不做全局一刀切。"
|
||||
"OAFRONT3-13","用户采纳 PC 改进问答门禁","PC 证据补强后,需要用户决定哪些进入实施:先做 no-write 证据矩阵和财务角色截图,还是直接改 Portal/票据/合同/资产;避免审美重排干扰最终启用。","用户至少对四组给出 A/B/C:1) 先做 PC no-write 当前证据矩阵;2) 先修票据 UUID/状态码和合同/资产高频 P1;3) Portal/视觉 token 是否等 7 天反馈;4) 大页面拆分是否后置。未采纳项不得进入开发。","受影响流程:PC 证据 -> 用户决策 -> 新实施合同 -> 开发/暂缓/否决;验证步骤:人工回填用户选择并 validate CSV。","manual|python3","TODO","TODO","TODO","issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv","[""OAFRONT3-0""]","默认推荐:组1=A,组2=A 但分小合同,组3=B 等 7 天真实反馈,组4=B 后置。"
|
||||
"OAFRONT3-13","用户采纳 PC 改进问答门禁","PC 证据补强后,需要用户决定哪些进入实施:先做 no-write 证据矩阵和财务角色截图,还是直接改 Portal/票据/合同/资产;避免审美重排干扰最终启用。","用户至少对四组给出 A/B/C:1) 先做 PC no-write 当前证据矩阵;2) 先修票据 UUID/状态码和合同/资产高频 P1;3) Portal/视觉 token 是否等 7 天反馈;4) 大页面拆分是否后置。未采纳项不得进入开发。","受影响流程:PC 证据 -> 用户决策 -> 新实施合同 -> 开发/暂缓/否决;验证步骤:人工回填用户选择并 validate CSV。","manual|python3","TODO","TODO","TODO","issues/2026-05-24_23-54-51-oa-pc-frontend-design-evidence-v1.csv","[""OAFRONT3-0""]","当前推荐更新:组1已完成;组2=A 但拆成小合同,优先付款批次 UUID/英文状态/日期、合同台账状态/日期/裁切、资产台账字段密度和日期;组3=B,Portal 先修命名本地化,hero/视觉 token 等 7 天真实反馈;组4=B,大页面拆分后置。"
|
||||
|
||||
|
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
REPORT_DIR="${OAFRONT3_PC_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/oafront3-pc-readonly-visual-$(date +%Y%m%d-%H%M%S)}"
|
||||
OUTPUT_DIR="${OAFRONT3_PC_OUTPUT_DIR:-$ROOT_DIR/output/playwright/oafront3-pc-readonly}"
|
||||
|
||||
load_default_env() {
|
||||
local env_file="${OAFRONT3_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}"
|
||||
if [[ "${OAFRONT3_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$env_file"
|
||||
set +a
|
||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}"
|
||||
echo "[oafront3] loaded masked live smoke env file"
|
||||
}
|
||||
|
||||
write_fallback_summary() {
|
||||
local exit_code="$?"
|
||||
if [[ -f "$REPORT_DIR/summary.json" ]]; then
|
||||
return "$exit_code"
|
||||
fi
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
QA_REPORT_DIR="$REPORT_DIR" \
|
||||
QA_ISSUE_ID="OAFRONT3-1" \
|
||||
QA_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
QA_MODE="pc-readonly-visual-evidence" \
|
||||
QA_STATUS="FAIL" \
|
||||
QA_SUMMARY_NOTES="Playwright 未生成 summary.json;查看脚本日志和 playwright-report。" \
|
||||
node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
run_privacy_smoke() {
|
||||
local sensitive_pattern='(["'\''"]?(WECOM_[A-Z0-9_]*(SECRET|TOKEN)|[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|ENCODING_AES_KEY|ENCODINGAESKEY|CORP_SECRET|ACCESS_TOKEN)[A-Z0-9_]*)["'\''"]?[[:space:]]*[:=][[:space:]]*["'\''"]?[A-Za-z0-9._:/+=-]{8,}["'\''"]?)|Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._-]{8,}|access_token=[A-Za-z0-9._-]{8,}|1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}|-----BEGIN|BEGIN [A-Z ]*KEY|"((token|secret|password|EncodingAESKey|encodingAESKey|sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId))"[[:space:]]*:[[:space:]]*"[^"*]{8,}"|\b20[0-9]{12,}\b|"(raw|rawPayload|raw_payload|payload|xml|ciphertext|Encrypt|formValue|formValues|attachments|comments)"[[:space:]]*:'
|
||||
local matches
|
||||
matches="$(grep -RInE "$sensitive_pattern" "$REPORT_DIR" || true)"
|
||||
if [[ -n "$matches" ]]; then
|
||||
printf '%s\n' "$matches" >&2
|
||||
echo "[oafront3][FAIL] report privacy smoke matched sensitive pattern" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[oafront3][PASS] report privacy smoke"
|
||||
}
|
||||
|
||||
trap write_fallback_summary EXIT
|
||||
|
||||
load_default_env
|
||||
mkdir -p "$REPORT_DIR" "$OUTPUT_DIR"
|
||||
|
||||
echo ">>> [oafront3] PC readonly visual evidence base=$PUBLIC_BASE_URL"
|
||||
(
|
||||
cd "$CLIENT_DIR"
|
||||
E2E_MODE=live \
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
OAFRONT3_PC_REPORT_DIR="$REPORT_DIR" \
|
||||
OAFRONT3_PC_OUTPUT_DIR="$OUTPUT_DIR" \
|
||||
npx playwright test tests/e2e/oafront3-pc-readonly-visual.spec.ts --workers=1 --reporter=list
|
||||
)
|
||||
|
||||
run_privacy_smoke
|
||||
echo "[oafront3] summary=$REPORT_DIR/summary.json"
|
||||
在新工单中引用
屏蔽一个用户