Initial AI manga platform
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user