Files
ai/tools/frontend-visual-audit.mjs
2026-06-15 17:45:28 +08:00

357 lines
13 KiB
JavaScript

import { createRequire } from 'node:module';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const require = createRequire(import.meta.url);
const { chromium } = require('playwright');
const targets = [
{ name: 'user-pc', url: process.env.USER_APP_URL || 'http://127.0.0.1:5174/', width: 1440, height: 1000 },
{ name: 'user-h5', url: process.env.USER_APP_URL || 'http://127.0.0.1:5174/', width: 390, height: 844, isMobile: true },
{ name: 'admin-pc', url: process.env.ADMIN_URL || 'http://127.0.0.1:5175/', width: 1440, height: 1000 },
{ name: 'admin-h5', url: process.env.ADMIN_URL || 'http://127.0.0.1:5175/', width: 390, height: 844, isMobile: true }
];
const outputDir = resolve(process.env.FRONTEND_AUDIT_DIR || 'storage/private/frontend-visual-audit');
const apiBase = process.env.API_BASE_URL || 'http://127.0.0.1:3000/api';
const userEmail = process.env.FRONTEND_AUDIT_USER_EMAIL || 'visual-audit@example.com';
const userPassword = process.env.FRONTEND_AUDIT_USER_PASSWORD || 'Audit123!';
const adminEmail = process.env.FRONTEND_AUDIT_ADMIN_EMAIL || 'admin@example.com';
const adminPassword = process.env.FRONTEND_AUDIT_ADMIN_PASSWORD || 'Admin123!';
const browser = await chromium.launch({ headless: true });
const report = [];
try {
await mkdir(outputDir, { recursive: true });
const userAuth = await loginOrRegister(userEmail, userPassword, '视觉验收');
const adminAuth = await login(adminEmail, adminPassword);
const project = await ensureLiveActionProject(userAuth.access_token);
for (const target of targets) {
const context = await browser.newContext({
viewport: { width: target.width, height: target.height },
isMobile: Boolean(target.isMobile),
deviceScaleFactor: target.isMobile ? 2 : 1
});
if (target.name.startsWith('user')) {
await context.addInitScript(({ token, projectId }) => {
localStorage.setItem('ai_manga_user_token', token);
localStorage.setItem('ai_manga_selected_project_id', projectId);
}, { token: userAuth.access_token, projectId: project.id });
await auditInteractiveTarget(context, target, [
{ label: 'create', action: async (page) => clickButton(page, '新建') },
{ label: 'projects', action: async (page) => clickButton(page, '项目') },
{ label: 'studio', action: async (page) => clickButton(page, '制作') },
{ label: 'quota', action: async (page) => clickButton(page, '额度') },
{ label: 'review', action: async (page) => clickButton(page, '审核') },
{ label: 'progress', action: async (page) => clickButton(page, '进度') },
{ label: 'result', action: async (page) => clickButton(page, '成品') },
{ label: 'tutorial', action: async (page) => clickButton(page, '教程') },
{ label: 'profile', action: async (page) => clickButton(page, '我的') }
]);
} else {
await context.addInitScript(({ token, user, email }) => {
localStorage.setItem('admin_token', token);
localStorage.setItem('admin_user', JSON.stringify(user));
localStorage.setItem('admin_email', email);
}, { token: adminAuth.access_token, user: adminAuth.user, email: adminEmail });
await auditInteractiveTarget(context, target, [
{ label: 'dashboard' },
{ label: 'projects', action: async (page) => clickButton(page, '项目管理') },
{ label: 'tasks', action: async (page) => clickButton(page, '任务管理') },
{ label: 'routerAudit', action: async (page) => clickButton(page, 'Router 审计') },
{ label: 'hitAnalysis', action: async (page) => clickButton(page, '爆款诊断') },
{ label: 'aiPlatforms', action: async (page) => clickButton(page, 'AI 平台入口') },
{ label: 'providers', action: async (page) => clickButton(page, 'AI 接入') },
{ label: 'costs', action: async (page) => clickButton(page, '成本日志') },
{ label: 'audit', action: async (page) => clickButton(page, '审计日志') }
]);
}
await context.close();
}
const reportPath = `${outputDir}/report.json`;
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(reportPath);
for (const item of report) {
console.log([
item.target.name,
item.step,
`pageOverflow=${item.audit.horizontalPageOverflow}`,
`textOverflow=${item.audit.overflowElements.length}`,
`overlaps=${item.audit.overlaps.length}`,
`consoleErrors=${item.consoleErrors.length}`,
`screenshot=${item.screenshot}`
].join(' | '));
}
} finally {
await browser.close();
}
async function auditInteractiveTarget(context, target, steps) {
const page = await context.newPage();
const consoleErrors = [];
const pageErrors = [];
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.goto(target.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(900);
for (const step of steps) {
console.log(`audit ${target.name}:${step.label}`);
if (step.action) {
await step.action(page).catch((error) => {
consoleErrors.push(`step ${step.label}: ${error.message}`);
});
await page.waitForTimeout(900);
}
const screenshot = `${outputDir}/${target.name}-${step.label}.png`;
await page.screenshot({ path: screenshot, fullPage: false, timeout: 5000 }).catch((error) => {
consoleErrors.push(`screenshot ${step.label}: ${error.message}`);
});
const audit = await page.evaluate(() => {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const bodyWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth);
const interactiveSelector = [
'button',
'a[href]',
'input',
'select',
'textarea',
'[role="button"]',
'.primary-action',
'.ghost-button',
'.nav-item',
'.rail-nav button'
].join(',');
const textSelector = [
'button',
'a',
'span',
'strong',
'small',
'p',
'h1',
'h2',
'h3',
'label'
].join(',');
function visible(element) {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
Number(style.opacity) !== 0 &&
rect.bottom >= 0 &&
rect.top <= viewportHeight &&
rect.right >= 0 &&
rect.left <= viewportWidth &&
rect.width > 1 &&
rect.height > 1;
}
function pathOf(element) {
const parts = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 4) {
const id = current.id ? `#${current.id}` : '';
const cls = current.className && typeof current.className === 'string'
? `.${current.className.trim().split(/\s+/).slice(0, 2).join('.')}`
: '';
parts.unshift(`${current.tagName.toLowerCase()}${id}${cls}`);
current = current.parentElement;
}
return parts.join(' > ');
}
const overflowElements = [...document.querySelectorAll(textSelector)]
.filter((element) => visible(element))
.map((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const horizontalOverflow = element.scrollWidth - element.clientWidth;
const verticalOverflow = element.scrollHeight - element.clientHeight;
const viewportOverflow = Math.max(0, rect.right - viewportWidth, -rect.left);
const text = (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 120);
return {
selector: pathOf(element),
tag: element.tagName.toLowerCase(),
text,
className: typeof element.className === 'string' ? element.className : '',
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height)
},
overflow: {
horizontal: Math.round(horizontalOverflow),
vertical: Math.round(verticalOverflow),
viewport: Math.round(viewportOverflow)
},
whiteSpace: style.whiteSpace,
webkitLineClamp: style.webkitLineClamp,
textOverflow: style.textOverflow,
fontSize: style.fontSize
};
})
.filter((item) => {
const intentionallyClamped = item.webkitLineClamp && item.webkitLineClamp !== 'none';
const intentionallyEllipsized = item.textOverflow === 'ellipsis';
if ((intentionallyClamped || intentionallyEllipsized) && item.overflow.viewport <= 2) {
return false;
}
return item.overflow.horizontal > 2 || item.overflow.vertical > 4 || item.overflow.viewport > 2;
})
.slice(0, 80);
const interactive = [...document.querySelectorAll(interactiveSelector)]
.filter((element) => visible(element))
.slice(0, 220)
.map((element) => {
const rect = element.getBoundingClientRect();
return {
element,
selector: pathOf(element),
text: (element.textContent || element.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 80),
rect: {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height
}
};
});
const overlaps = [];
for (let i = 0; i < interactive.length; i += 1) {
for (let j = i + 1; j < interactive.length; j += 1) {
if (overlaps.length >= 80) break;
const a = interactive[i];
const b = interactive[j];
if (a.element.contains(b.element) || b.element.contains(a.element)) continue;
const xOverlap = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
const yOverlap = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
if (xOverlap > 2 && yOverlap > 2) {
overlaps.push({
a: { selector: a.selector, text: a.text },
b: { selector: b.selector, text: b.text },
overlap: { x: Math.round(xOverlap), y: Math.round(yOverlap) }
});
}
}
if (overlaps.length >= 80) break;
}
return {
title: document.title,
url: window.location.href,
viewport: { width: viewportWidth, height: viewportHeight },
bodyWidth,
horizontalPageOverflow: Math.max(0, bodyWidth - viewportWidth),
overflowElements,
overlaps: overlaps.slice(0, 80)
};
});
report.push({
target,
step: step.label,
screenshot,
consoleErrors: [...consoleErrors],
pageErrors: [...pageErrors],
audit
});
}
await page.close();
}
async function clickButton(page, name) {
const button = page.getByRole('button', { name, exact: true }).first();
await button.waitFor({ state: 'visible', timeout: 5000 });
await button.click({ timeout: 5000 });
}
async function loginOrRegister(email, password, nickname) {
try {
return await login(email, password);
} catch {
return api('/auth/register', {
method: 'POST',
body: { email, password, nickname }
});
}
}
async function login(email, password) {
return api('/auth/login', {
method: 'POST',
body: { email, password }
});
}
async function ensureLiveActionProject(token) {
const projects = await api('/projects', { token });
const existing = projects.find((item) =>
item.output_mode === 'live_action_ai' &&
String(item.title || '').includes('视觉验收')
);
if (existing) return existing;
return api('/projects', {
method: 'POST',
token,
body: {
title: '真人小样视觉验收项目-超长标题用于检测按钮标签重叠与二号字体换行',
input_mode: 'ai_original',
output_mode: 'live_action_ai',
genre: '都市逆袭爽剧-长标签-用于压力测试',
style_code: 'photorealistic_short_drama',
target_episode_count: 3,
episode_duration: 60,
quality_level: 'mvp'
}
});
}
async function api(path, options = {}) {
const response = await fetch(`${apiBase}${path}`, {
method: options.method || 'GET',
headers: {
'content-type': 'application/json',
...(options.token ? { authorization: `Bearer ${options.token}` } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.code !== 0) {
throw new Error(payload?.message || `API ${path} failed: HTTP ${response.status}`);
}
return payload.data;
}