feat(问翻小宝): 增强帮助搜索与会话历史

关键改动:

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

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

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

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-07-01 09:20:10 +08:00
parent 8683b341e2
commit e5ce2d221e
20 changed files with 714 additions and 81 deletions

View File

@@ -1,9 +1,10 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
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');
@@ -16,21 +17,23 @@ const AUTH_USER = {
email: 'admin@company.com',
};
const versionDetailRoute = (context) => (context.versionId ? `/versions/${context.versionId}` : '/versions');
const SCREENSHOTS = [
{ file: 'product-create.png', route: '/products' },
{ file: 'project-create.png', route: '/projects' },
{ file: 'version-create.png', route: '/versions' },
{ file: 'requirement-pool.png', route: '/requirements' },
{ file: 'version-requirement-include.png', route: '/versions' },
{ file: 'research-plan.png', route: '/versions' },
{ file: 'product-plan.png', route: '/versions' },
{ file: 'ai-decompose.png', route: '/versions' },
{ file: 'ui-design-plan.png', route: '/versions' },
{ file: 'dev-task-workflow.png', route: '/versions' },
{ file: 'test-case-workflow.png', route: '/versions' },
{ file: 'bug-workflow.png', route: '/versions' },
{ file: 'activity-logs.png', route: '/workspace' },
{ file: 'xiaobao-warning.png', route: '/xiaobao-warning' },
{ 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() {
@@ -54,6 +57,97 @@ 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);
@@ -189,6 +283,193 @@ async function navigateAndWait(client, url) {
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, "\\'");
@@ -200,7 +481,35 @@ async function authenticate(client) {
});
}
async function capture(client, route, outputPath) {
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,
@@ -208,10 +517,11 @@ async function capture(client, route, outputPath) {
deviceScaleFactor: 1,
mobile: false,
});
await prepareScreenshot(client, item, context);
const screenshot = await client.send('Page.captureScreenshot', {
format: 'png',
fromSurface: true,
captureBeyondViewport: true,
captureBeyondViewport: false,
});
writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
}
@@ -233,6 +543,7 @@ async function main() {
}
await assertServerAvailable();
const context = await resolveScreenshotContext();
mkdirSync(OUTPUT_DIR, { recursive: true });
const userDataDir = join(tmpdir(), `wenfan-help-screenshots-${Date.now()}`);
@@ -250,7 +561,7 @@ async function main() {
for (const item of SCREENSHOTS) {
const outputPath = join(OUTPUT_DIR, item.file);
await capture(client, item.route, outputPath);
await capture(client, item, context, outputPath);
console.log(`saved ${outputPath}`);
}
} finally {