Files
ftb-project-management/apps/web/scripts/capture-wenfan-help-screenshots.mjs
Script Generator e5ce2d221e feat(问翻小宝): 增强帮助搜索与会话历史
关键改动:

- 增加问翻小宝会话历史记录规则与测试

- 优化帮助搜索、兜底建议和页面交互结构

- 更新帮助截图资产和截图采集脚本

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 09:20:10 +08:00

582 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process';
const BASE_URL = process.env.WENFAN_HELP_BASE_URL || 'http://localhost:3000';
const API_BASE = process.env.WENFAN_HELP_API_BASE || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
const VIEWPORT = { width: 1440, height: 1000 };
const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao');
const AUTH_USER = {
id: 'm-8',
name: '超级管理员',
roleId: 'role-admin',
departmentId: 'dept-1',
phone: '13200132008',
email: 'admin@company.com',
};
const versionDetailRoute = (context) => (context.versionId ? `/versions/${context.versionId}` : '/versions');
const SCREENSHOTS = [
{ file: 'product-create.png', route: '/products', waitForText: '产品' },
{ file: 'project-create.png', route: '/projects', waitForText: '项目' },
{ file: 'version-create.png', route: '/versions', waitForText: '新建版本' },
{ file: 'requirement-pool.png', route: '/requirements', waitForText: '需求池' },
{ file: 'version-requirement-include.png', route: versionDetailRoute, tabLabel: '关联需求', waitForText: '添加需求' },
{ file: 'research-plan.png', route: versionDetailRoute, tabLabel: '调研', waitForText: '调研' },
{ file: 'product-plan.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: '产品方案' },
{ file: 'ai-decompose.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: 'AI 拆解', action: openProductPlanAiEntry, scrollText: 'AI 拆解' },
{ file: 'ui-design-plan.png', route: versionDetailRoute, tabLabel: 'UI设计', waitForText: 'UI设计' },
{ file: 'dev-task-workflow.png', route: versionDetailRoute, tabLabel: '开发任务', waitForText: '开发任务' },
{ file: 'test-case-workflow.png', route: versionDetailRoute, tabLabel: '测试用例', waitForText: '测试用例' },
{ file: 'bug-workflow.png', route: versionDetailRoute, tabLabel: '测试用例', waitForText: '提 BUG', action: openFailedTestCaseBugEntry },
{ file: 'activity-logs.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: '日志', scrollText: '日志' },
{ file: 'xiaobao-warning.png', route: '/xiaobao-warning', waitForText: '小宝预警' },
];
function findBrowser() {
const candidates = [
process.env.CHROME_PATH,
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean);
return candidates.find((candidate) => existsSync(candidate));
}
function delay(ms) {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}
async function loadDataKey(key) {
try {
const response = await fetch(`${API_BASE}/data/${key}`);
if (!response.ok) return null;
const payload = await response.json();
return payload.value ?? null;
} catch {
return null;
}
}
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function findPreferredVersionId(overview, testCases, versionPlans) {
const versions = Array.isArray(overview)
? overview.flatMap((product) => (Array.isArray(product.versions) ? product.versions : []))
: [];
const failedVersionIds = new Set(
toArray(testCases)
.filter((testCase) => testCase.status === 'failed' && testCase.versionId)
.map((testCase) => testCase.versionId),
);
const aiReadyVersionIds = new Set(
toArray(versionPlans)
.filter((plan) => plan.type === 'product' && plan.status === 'completed' && plan.resultUrl && plan.versionId)
.map((plan) => plan.versionId),
);
const scoreVersion = (version) => {
let score = 0;
if (!['released', 'closed'].includes(version.status)) score += 10;
if (version.status === 'developing') score += 4;
if (failedVersionIds.has(version.id)) score += 3;
if (aiReadyVersionIds.has(version.id)) score += 2;
return score;
};
return [...versions].sort((a, b) => scoreVersion(b) - scoreVersion(a))[0]?.id ?? null;
}
function findFailedTestCase(testCases, versionId) {
return toArray(testCases)
.filter((testCase) => testCase.versionId === versionId && testCase.status === 'failed')
.sort((a, b) => {
const roundDiff = (a.roundNo ?? 1) - (b.roundNo ?? 1);
if (roundDiff !== 0) return roundDiff;
return String(a.caseNo ?? a.title ?? '').localeCompare(String(b.caseNo ?? b.title ?? ''));
})[0] ?? null;
}
function findAiReadyProductPlan(versionPlans, versionId) {
return toArray(versionPlans)
.filter((plan) => (
plan.versionId === versionId
&& plan.type === 'product'
&& plan.status === 'completed'
&& plan.resultUrl
))
.sort((a, b) => {
const httpDiff = Number(String(b.resultUrl).startsWith('http')) - Number(String(a.resultUrl).startsWith('http'));
if (httpDiff !== 0) return httpDiff;
return String(b.completedAt ?? b.createdAt ?? '').localeCompare(String(a.completedAt ?? a.createdAt ?? ''));
})[0] ?? null;
}
async function resolveScreenshotContext() {
const [overview, testCases, versionPlans] = await Promise.all([
loadDataKey('products-overview'),
loadDataKey('test-cases'),
loadDataKey('version-plans'),
]);
const versionId = findPreferredVersionId(overview, testCases, versionPlans);
if (!versionId) {
console.warn('未找到可进入的版本详情,版本内截图将退回版本列表。');
}
const failedTestCase = findFailedTestCase(testCases, versionId);
if (!failedTestCase) {
console.warn('未找到失败/不通过测试用例Bug 配图将退回测试用例列表。');
}
const productPlan = findAiReadyProductPlan(versionPlans, versionId);
if (!productPlan) {
console.warn('未找到已完成且有成果链接的产品方案AI 拆解配图可能无法展示按钮。');
}
return { versionId, failedTestCase, productPlan };
}
async function assertServerAvailable() {
try {
const response = await fetch(BASE_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (error) {
throw new Error(`本地页面不可访问:${BASE_URL}。请先启动前端 dev server。`);
}
}
function launchBrowser(browserPath, userDataDir) {
const child = spawn(
browserPath,
[
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
'--remote-debugging-port=0',
`--user-data-dir=${userDataDir}`,
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
'about:blank',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
return child;
}
function waitForDevtools(child) {
return new Promise((resolveDevtools, rejectDevtools) => {
const timer = setTimeout(() => rejectDevtools(new Error('Chrome DevTools 启动超时。')), 15000);
function onData(data) {
const text = data.toString();
const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/);
if (!match) return;
clearTimeout(timer);
child.stdout.off('data', onData);
child.stderr.off('data', onData);
resolveDevtools(match[1]);
}
child.stdout.on('data', onData);
child.stderr.on('data', onData);
child.once('exit', (code) => {
clearTimeout(timer);
rejectDevtools(new Error(`浏览器提前退出,退出码:${code}`));
});
});
}
async function getPageWebSocketUrl(wsEndpoint) {
const url = new URL(wsEndpoint);
const listUrl = `http://${url.hostname}:${url.port}/json/list`;
for (let i = 0; i < 30; i += 1) {
const response = await fetch(listUrl);
const targets = await response.json();
const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl);
if (page) return page.webSocketDebuggerUrl;
await delay(200);
}
throw new Error('没有找到可截图的 Chrome 页面 target。');
}
class CdpClient {
constructor(webSocketUrl) {
this.webSocketUrl = webSocketUrl;
this.nextId = 1;
this.pending = new Map();
this.eventWaiters = new Map();
}
async connect() {
this.ws = new WebSocket(this.webSocketUrl);
this.ws.addEventListener('message', (event) => this.handleMessage(event.data));
await new Promise((resolveOpen, rejectOpen) => {
this.ws.addEventListener('open', resolveOpen, { once: true });
this.ws.addEventListener('error', rejectOpen, { once: true });
});
}
handleMessage(raw) {
const message = JSON.parse(raw);
if (message.id && this.pending.has(message.id)) {
const { resolve: resolvePending, reject } = this.pending.get(message.id);
this.pending.delete(message.id);
if (message.error) reject(new Error(message.error.message));
else resolvePending(message.result);
return;
}
if (message.method && this.eventWaiters.has(message.method)) {
const waiters = this.eventWaiters.get(message.method);
this.eventWaiters.delete(message.method);
for (const waiter of waiters) waiter(message.params);
}
}
send(method, params = {}) {
const id = this.nextId;
this.nextId += 1;
const payload = JSON.stringify({ id, method, params });
return new Promise((resolveSend, rejectSend) => {
this.pending.set(id, { resolve: resolveSend, reject: rejectSend });
this.ws.send(payload);
});
}
waitForEvent(method, timeoutMs = 15000) {
return new Promise((resolveEvent, rejectEvent) => {
const timer = setTimeout(() => rejectEvent(new Error(`等待 ${method} 超时。`)), timeoutMs);
const waiters = this.eventWaiters.get(method) || [];
waiters.push((params) => {
clearTimeout(timer);
resolveEvent(params);
});
this.eventWaiters.set(method, waiters);
});
}
close() {
this.ws?.close();
}
}
async function navigateAndWait(client, url) {
const loadPromise = client.waitForEvent('Page.loadEventFired').catch(() => undefined);
await client.send('Page.navigate', { url });
await loadPromise;
await delay(1200);
}
async function evaluateValue(client, expression) {
const result = await client.send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || '页面脚本执行失败。');
}
return result.result?.value;
}
async function waitForCondition(client, expression, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const matched = await evaluateValue(client, expression).catch(() => false);
if (matched) return true;
await delay(250);
}
return false;
}
async function waitForPageText(client, text, timeoutMs = 10000) {
return waitForCondition(
client,
`document.body && document.body.innerText.includes(${JSON.stringify(text)})`,
timeoutMs,
);
}
async function clickButtonByText(client, text) {
return evaluateValue(
client,
`(() => {
const target = ${JSON.stringify(text)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const buttons = Array.from(document.querySelectorAll('button,[role="button"]'));
const button = buttons.find((node) => {
const label = normalize(node.textContent);
return label === target || label.includes(target);
});
if (!button) return false;
button.scrollIntoView({ block: 'center', inline: 'center' });
button.click();
return true;
})()`,
);
}
async function clickNearestClickableByText(client, text) {
return evaluateValue(
client,
`(() => {
const target = ${JSON.stringify(text)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const clickables = Array.from(document.querySelectorAll('button,a,[role="button"],[class*="cursor-pointer"]'));
let node = clickables.find((element) => normalize(element.textContent).includes(target));
if (!node) {
const textNodes = Array.from(document.querySelectorAll('h1,h2,h3,p,span,div'))
.filter((element) => normalize(element.textContent).length < 240);
node = textNodes
.map((element) => element.closest('button,a,[role="button"],[class*="cursor-pointer"]'))
.find((element) => element && normalize(element.textContent).includes(target));
}
if (!node) return false;
node.scrollIntoView({ block: 'center', inline: 'center' });
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return true;
})()`,
);
}
async function scrollTextIntoView(client, text) {
return evaluateValue(
client,
`(() => {
const target = ${JSON.stringify(text)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const nodes = Array.from(document.querySelectorAll('button,a,h1,h2,h3,p,span,div'));
const node = nodes.find((element) => normalize(element.textContent).includes(target));
if (!node) return false;
node.scrollIntoView({ block: 'center', inline: 'center' });
return true;
})()`,
);
}
async function filterTestCaseList(client, keyword) {
if (!keyword) return false;
return evaluateValue(
client,
`(() => {
const keyword = ${JSON.stringify(keyword)};
const inputs = Array.from(document.querySelectorAll('input'));
const input = inputs.find((node) => {
const placeholder = node.getAttribute('placeholder') || '';
return placeholder.includes('搜索') || placeholder.includes('标题') || placeholder.includes('编号');
}) || inputs[0];
if (!input) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
input.focus();
if (setter) setter.call(input, keyword);
else input.value = keyword;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`,
);
}
async function scrollBugButtonIntoView(client) {
return evaluateValue(
client,
`(() => {
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const button = Array.from(document.querySelectorAll('button')).find((node) => {
const text = normalize(node.textContent);
return text.includes('提') && text.toUpperCase().includes('BUG');
});
if (!button) return false;
button.scrollIntoView({ block: 'center', inline: 'center' });
return true;
})()`,
);
}
async function openProductPlanAiEntry(client, context) {
if (!context.productPlan) return;
const opened = await clickNearestClickableByText(client, context.productPlan.title);
if (!opened) {
console.warn(`未打开产品方案:${context.productPlan.title}`);
return;
}
await delay(800);
const found = await waitForPageText(client, 'AI 拆解', 5000);
if (!found) {
console.warn(`已打开产品方案 ${context.productPlan.title},但未找到 AI 拆解入口。`);
}
await scrollTextIntoView(client, 'AI 拆解');
}
async function openFailedTestCaseBugEntry(client, context) {
const failedTestCase = context.failedTestCase;
if (failedTestCase) {
await filterTestCaseList(client, failedTestCase.caseNo || failedTestCase.title);
await delay(700);
}
const opened = await evaluateValue(
client,
`(() => {
const needles = ${JSON.stringify(
[context.failedTestCase?.caseNo, context.failedTestCase?.title].filter(Boolean),
)};
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const rows = Array.from(document.querySelectorAll('[class*="cursor-pointer"],[role="button"],button'));
const row = rows.find((node) => {
const text = normalize(node.textContent);
if (needles.length > 0) return needles.some((needle) => text.includes(needle));
return text.includes('TC-') && text.includes('不通过');
});
if (!row) return false;
row.scrollIntoView({ block: 'center', inline: 'center' });
row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return true;
})()`,
);
if (!opened) {
console.warn('未找到失败/不通过的测试用例行Bug 配图将停留在测试用例列表。');
await scrollTextIntoView(client, '不通过');
return;
}
await waitForCondition(
client,
`document.body && Array.from(document.querySelectorAll('button')).some((node) => {
const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
return text.includes('提') && text.toUpperCase().includes('BUG');
})`,
5000,
);
await scrollBugButtonIntoView(client);
}
async function authenticate(client) {
await navigateAndWait(client, `${BASE_URL}/login`);
const serialized = JSON.stringify(AUTH_USER).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
await client.send('Runtime.evaluate', {
expression: `
sessionStorage.setItem('ftb_auth_session', '${serialized}');
localStorage.setItem('ftb_auth_persist', '${serialized}');
`,
});
}
async function prepareScreenshot(client, item, context) {
if (item.tabLabel) {
const clicked = await clickButtonByText(client, item.tabLabel);
if (!clicked) {
console.warn(`未找到 Tab${item.tabLabel}`);
}
await delay(800);
}
if (item.action) {
await item.action(client, context);
await delay(600);
}
if (item.scrollText) {
await scrollTextIntoView(client, item.scrollText);
await delay(400);
}
if (item.waitForText) {
const found = await waitForPageText(client, item.waitForText, 8000);
if (!found) {
console.warn(`截图前未在页面找到文本:${item.waitForText}`);
}
}
}
async function capture(client, item, context, outputPath) {
const route = typeof item.route === 'function' ? item.route(context) : item.route;
await navigateAndWait(client, `${BASE_URL}${route}`);
await client.send('Emulation.setDeviceMetricsOverride', {
width: VIEWPORT.width,
height: VIEWPORT.height,
deviceScaleFactor: 1,
mobile: false,
});
await prepareScreenshot(client, item, context);
const screenshot = await client.send('Page.captureScreenshot', {
format: 'png',
fromSurface: true,
captureBeyondViewport: false,
});
writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
}
async function terminateBrowser(browser) {
if (browser.exitCode !== null) return;
const exited = new Promise((resolveExit) => {
browser.once('exit', resolveExit);
});
browser.kill();
await Promise.race([exited, delay(2000)]);
}
async function main() {
const browserPath = findBrowser();
if (!browserPath) {
throw new Error('未找到 Chrome 或 Edge请安装浏览器或通过 CHROME_PATH 指定浏览器路径。');
}
await assertServerAvailable();
const context = await resolveScreenshotContext();
mkdirSync(OUTPUT_DIR, { recursive: true });
const userDataDir = join(tmpdir(), `wenfan-help-screenshots-${Date.now()}`);
const browser = launchBrowser(browserPath, userDataDir);
let client;
try {
const browserWsEndpoint = await waitForDevtools(browser);
const pageWsEndpoint = await getPageWebSocketUrl(browserWsEndpoint);
client = new CdpClient(pageWsEndpoint);
await client.connect();
await client.send('Page.enable');
await client.send('Runtime.enable');
await authenticate(client);
for (const item of SCREENSHOTS) {
const outputPath = join(OUTPUT_DIR, item.file);
await capture(client, item, context, outputPath);
console.log(`saved ${outputPath}`);
}
} finally {
client?.close();
await terminateBrowser(browser);
try {
rmSync(userDataDir, { recursive: true, force: true });
} catch {
console.warn(`临时浏览器目录未能立即清理,可稍后手动删除:${userDataDir}`);
}
}
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});