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,18 +1,33 @@
'use client';
import { FormEvent, KeyboardEvent, useMemo, useState } from 'react';
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
import {
BotMessageSquare,
Image as ImageIcon,
Maximize2,
MessageCircleQuestionMark,
Mic,
Search,
SendHorizontal,
Sparkles,
SquarePen,
Trash2,
X,
} from 'lucide-react';
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
import { getFallbackHelpSuggestions, searchHelpArticles, type HelpSearchResult } from '@/lib/wenfan-help-search';
import {
createWenfanConversationRecord,
deleteWenfanConversationRecord,
updateWenfanConversationRecord,
type WenfanConversationRecord,
} from '@/lib/wenfan-conversation-history';
import {
getFallbackHelpMessage,
getFallbackHelpSuggestions,
searchHelpArticles,
shouldShowGenericHelpSuggestions,
type HelpSearchResult,
} from '@/lib/wenfan-help-search';
type ChatMessage =
| {
@@ -53,24 +68,64 @@ const INITIAL_MESSAGES: ChatMessage[] = [
const STARTER_QUESTIONS = getFallbackHelpSuggestions();
type WenfanConversation = WenfanConversationRecord<ChatMessage>;
const INITIAL_CONVERSATIONS: WenfanConversation[] = [
{ id: 'history-product-create', title: '怎么新建产品?', messages: INITIAL_MESSAGES },
{ id: 'history-version-requirements', title: '怎么把需求纳入版本?', messages: INITIAL_MESSAGES },
{ id: 'history-dev-task-submit', title: '开发任务怎么提测?', messages: INITIAL_MESSAGES },
{ id: 'history-test-bug', title: '测试用例失败后怎么提 Bug', messages: INITIAL_MESSAGES },
{ id: 'history-activity-log', title: '日志记录会记录哪些行为?', messages: INITIAL_MESSAGES },
];
export default function WenfanXiaobaoPage() {
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [history, setHistory] = useState<string[]>([
'怎么新建产品?',
'怎么把需求纳入版本?',
'开发任务怎么提测?',
'测试用例失败后怎么提 Bug',
'日志记录会记录哪些行为?',
]);
const [conversations, setConversations] = useState<WenfanConversation[]>(INITIAL_CONVERSATIONS);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const visibleHistory = useMemo(() => history.slice(0, 12), [history]);
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
const userQuestionCount = useMemo(
() => messages.filter((message) => message.role === 'user').length,
[messages],
);
const showGenericSuggestions = shouldShowGenericHelpSuggestions(userQuestionCount);
const showStarterQuestions = showGenericSuggestions && userQuestionCount === 0;
function handleNewConversation() {
const id = `conversation-${Date.now()}`;
const record = createWenfanConversationRecord<ChatMessage>(id, INITIAL_MESSAGES);
setConversations((current) => [record, ...current]);
setActiveConversationId(id);
setMessages(INITIAL_MESSAGES);
setInput('');
}
function openConversation(conversation: WenfanConversation) {
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setInput('');
}
function deleteConversation(event: MouseEvent<HTMLButtonElement>, id: string) {
event.stopPropagation();
setConversations((current) => deleteWenfanConversationRecord(current, id));
if (activeConversationId === id) {
setActiveConversationId(null);
setMessages(INITIAL_MESSAGES);
setInput('');
}
}
function askQuestion(rawQuestion: string) {
const question = rawQuestion.trim();
if (!question) return;
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
const nextUserQuestionCount = userQuestionCount + 1;
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
@@ -90,12 +145,14 @@ export default function WenfanXiaobaoPage() {
id: `fallback-${Date.now()}`,
role: 'assistant',
type: 'fallback',
content: '暂时没有找到对应的内置帮助内容。你可以换个关键词,或者从下面这些常见问题开始。',
suggestions: STARTER_QUESTIONS,
content: getFallbackHelpMessage(showFallbackSuggestions),
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
};
setMessages((current) => [...current, userMessage, assistantMessage]);
setHistory((current) => [question, ...current.filter((item) => item !== question)]);
const nextMessages = [...messages, userMessage, assistantMessage];
setMessages(nextMessages);
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
setInput('');
}
@@ -118,10 +175,7 @@ export default function WenfanXiaobaoPage() {
<div className="space-y-3 p-3">
<button
type="button"
onClick={() => {
setMessages(INITIAL_MESSAGES);
setInput('');
}}
onClick={handleNewConversation}
className="flex h-10 w-full items-center gap-2 rounded-lg px-3 text-left text-[14px] font-medium text-[#171717] hover:bg-white"
>
<SquarePen className="h-4 w-4" strokeWidth={1.9} />
@@ -139,20 +193,33 @@ export default function WenfanXiaobaoPage() {
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<p className="px-3 py-2 text-[12px] font-medium text-[#6b6b6b]"></p>
<div className="space-y-1">
{visibleHistory.map((item, index) => (
<button
key={`${item}-${index}`}
type="button"
onClick={() => askQuestion(item)}
className={`w-full rounded-lg p-3 text-left transition-colors ${
index === 0
{visibleHistory.map((conversation, index) => (
<div
key={conversation.id}
className={`group flex items-start gap-1 rounded-lg p-1.5 transition-colors ${
conversation.id === activeConversationId
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
: 'bg-white hover:bg-[var(--bg-subtle)]'
}`}
>
<p className="truncate text-[13px] leading-5 text-[#202123]">{item}</p>
<button
type="button"
onClick={() => openConversation(conversation)}
className="min-w-0 flex-1 rounded-md px-1.5 py-1.5 text-left"
>
<p className="truncate text-[13px] leading-5 text-[#202123]">{conversation.title}</p>
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
</button>
<button
type="button"
onClick={(event) => deleteConversation(event, conversation.id)}
className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#8a8a8a] opacity-0 transition-opacity hover:bg-white hover:text-[#171717] focus:opacity-100 group-hover:opacity-100"
aria-label={`删除历史记录:${conversation.title}`}
title="删除历史记录"
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.9} />
</button>
</div>
))}
</div>
</div>
@@ -174,9 +241,15 @@ export default function WenfanXiaobaoPage() {
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-7 px-6 py-8">
{messages.map((message) => (
<MessageRow key={message.id} message={message} onAsk={askQuestion} />
<MessageRow
key={message.id}
message={message}
onAsk={askQuestion}
showGenericSuggestions={showGenericSuggestions}
/>
))}
{showStarterQuestions && (
<div className="flex flex-wrap gap-2 pt-2">
{STARTER_QUESTIONS.map((question) => (
<button
@@ -189,6 +262,7 @@ export default function WenfanXiaobaoPage() {
</button>
))}
</div>
)}
</div>
</div>
@@ -238,7 +312,15 @@ export default function WenfanXiaobaoPage() {
);
}
function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question: string) => void }) {
function MessageRow({
message,
onAsk,
showGenericSuggestions,
}: {
message: ChatMessage;
onAsk: (question: string) => void;
showGenericSuggestions: boolean;
}) {
if (message.role === 'user') {
return (
<div className="flex justify-end">
@@ -260,7 +342,10 @@ function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question
{message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
{message.type === 'fallback' && (
<div className="space-y-3">
<p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>
<p className="text-[14px] leading-7 text-[#202123]">
{getFallbackHelpMessage(showGenericSuggestions && message.suggestions.length > 0)}
</p>
{showGenericSuggestions && message.suggestions.length > 0 && (
<div className="flex flex-wrap gap-2">
{message.suggestions.map((suggestion) => (
<button
@@ -273,6 +358,7 @@ function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question
</button>
))}
</div>
)}
</div>
)}
</div>
@@ -282,6 +368,7 @@ function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question
function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (question: string) => void }) {
const { article, matchedKeywords, relatedArticles } = result;
const [previewImage, setPreviewImage] = useState<HelpArticle['images'][number] | null>(null);
return (
<div className="space-y-5 text-[14px] leading-7 text-[#202123]">
@@ -313,7 +400,18 @@ function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (quest
<div className="grid gap-3">
{article.images.map((image) => (
<figure key={image.src} className="overflow-hidden rounded-xl border border-[#ececec] bg-white">
<button
type="button"
onClick={() => setPreviewImage(image)}
className="group relative block w-full overflow-hidden text-left"
aria-label={`放大查看:${image.caption}`}
>
<img src={image.src} alt={image.alt} className="max-h-[320px] w-full object-cover object-top" />
<span className="absolute bottom-3 right-3 inline-flex items-center gap-1.5 rounded-full bg-[#171717]/85 px-2.5 py-1 text-[11px] font-medium text-white opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
<Maximize2 className="h-3.5 w-3.5" strokeWidth={1.9} />
</span>
</button>
<figcaption className="border-t border-[#ececec] px-3 py-2 text-[12px] text-[#6b6b6b]">
{image.caption}
</figcaption>
@@ -323,6 +421,43 @@ function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (quest
</div>
)}
{previewImage && (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 px-8 py-8"
role="dialog"
aria-modal="true"
aria-label={previewImage.caption}
onClick={() => setPreviewImage(null)}
>
<div
className="flex max-h-full w-full max-w-6xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[#ececec] px-4">
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-[#171717]">{previewImage.caption}</p>
<p className="truncate text-[11px] text-[#6b6b6b]">{previewImage.alt}</p>
</div>
<button
type="button"
onClick={() => setPreviewImage(null)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
aria-label="关闭图片预览"
>
<X className="h-4 w-4" strokeWidth={2} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto bg-[#111] p-4">
<img
src={previewImage.src}
alt={previewImage.alt}
className="mx-auto max-h-[calc(100vh-160px)] w-auto max-w-full rounded-lg object-contain"
/>
</div>
</div>
</div>
)}
{relatedArticles.length > 0 && (
<div className="space-y-2">
<p className="flex items-center gap-2 text-[13px] font-semibold text-[#171717]">

View File

@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWenfanConversationRecord,
deleteWenfanConversationRecord,
updateWenfanConversationRecord,
} from './wenfan-conversation-history';
type TestMessage = {
role: 'assistant' | 'user';
content?: string;
};
const introMessage: TestMessage = {
role: 'assistant',
content: '我是问翻小宝',
};
test('createWenfanConversationRecord creates history only from the new conversation action', () => {
const record = createWenfanConversationRecord('conv-1', [introMessage]);
assert.equal(record.id, 'conv-1');
assert.equal(record.title, '新对话');
assert.deepEqual(record.messages, [introMessage]);
});
test('updateWenfanConversationRecord updates an existing conversation without adding a question record', () => {
const record = createWenfanConversationRecord('conv-1', [introMessage]);
const messages: TestMessage[] = [
introMessage,
{ role: 'user', content: '怎么新建产品?' },
{ role: 'assistant', content: '产品创建帮助' },
{ role: 'user', content: '怎么新建项目?' },
];
const next = updateWenfanConversationRecord([record], 'conv-1', messages);
assert.equal(next.length, 1);
assert.equal(next[0]?.title, '怎么新建产品?');
assert.deepEqual(next[0]?.messages, messages);
});
test('updateWenfanConversationRecord does not create history when there is no active conversation', () => {
const messages: TestMessage[] = [introMessage, { role: 'user', content: '怎么新建产品?' }];
assert.deepEqual(updateWenfanConversationRecord([], null, messages), []);
});
test('deleteWenfanConversationRecord removes a conversation history item', () => {
const first = createWenfanConversationRecord('conv-1', [introMessage]);
const second = createWenfanConversationRecord('conv-2', [introMessage]);
assert.deepEqual(deleteWenfanConversationRecord([first, second], 'conv-1'), [second]);
});

View File

@@ -0,0 +1,53 @@
export type WenfanHistoryMessage = {
role: string;
content?: string;
};
export type WenfanConversationRecord<TMessage extends WenfanHistoryMessage> = {
id: string;
title: string;
messages: TMessage[];
};
const DEFAULT_CONVERSATION_TITLE = '新对话';
function getConversationTitle<TMessage extends WenfanHistoryMessage>(messages: TMessage[]): string {
const firstQuestion = messages.find((message) => message.role === 'user' && message.content?.trim());
return firstQuestion?.content?.trim() || DEFAULT_CONVERSATION_TITLE;
}
export function createWenfanConversationRecord<TMessage extends WenfanHistoryMessage>(
id: string,
messages: TMessage[],
): WenfanConversationRecord<TMessage> {
return {
id,
title: getConversationTitle(messages),
messages,
};
}
export function updateWenfanConversationRecord<TMessage extends WenfanHistoryMessage>(
records: WenfanConversationRecord<TMessage>[],
activeId: string | null | undefined,
messages: TMessage[],
): WenfanConversationRecord<TMessage>[] {
if (!activeId) return records;
return records.map((record) =>
record.id === activeId
? {
...record,
title: getConversationTitle(messages),
messages,
}
: record,
);
}
export function deleteWenfanConversationRecord<TMessage extends WenfanHistoryMessage>(
records: WenfanConversationRecord<TMessage>[],
id: string,
): WenfanConversationRecord<TMessage>[] {
return records.filter((record) => record.id !== id);
}

View File

@@ -435,7 +435,7 @@ export const WENFAN_HELP_ARTICLES: HelpArticle[] = [
},
{
id: 'bug-workflow',
title: 'Bug 如何建和流转',
title: 'Bug 如何从失败用例创建和流转',
category: 'Bug',
keywords: [
'Bug',
@@ -448,16 +448,25 @@ export const WENFAN_HELP_ARTICLES: HelpArticle[] = [
'提Bug',
'怎么提bug',
'测试失败提bug',
'失败后提Bug',
'失败后提 BUG',
'测试用例失败后提Bug',
'测试用例不通过提Bug',
'不通过后提Bug',
'从失败用例提Bug',
'失败用例提 BUG',
'修复Bug',
'验证Bug',
'关闭Bug',
'Bug状态',
],
scenario: '当测试用例失败或线上发现问题时,创建 Bug 并跟踪修复、验证和关闭。',
entry: '版本详情 -> Bug Tab -> 新建 Bug或测试用例失败后点击提 Bug',
scenario: '当测试用例执行失败后,从失败用例里提 Bug并跟踪修复、验证和关闭。',
entry: '版本详情 -> 测试用例 Tab -> 打开失败/不通过用例 -> 关联 Bug 区域点击提 BUG',
steps: [
'从 Bug Tab 点击新建 Bug或在失败测试用例里点击提 Bug。',
'填写 Bug 标题、严重程度、关联版本、关联测试用例、复现步骤。',
'打开版本详情并进入测试用例 Tab。',
'执行测试用例,并将问题用例标记为不通过/失败。',
'打开该失败测试用例详情,在“关联 Bug”区域点击提 BUG。',
'填写 Bug 标题、严重程度、复现步骤;版本和测试用例来源会自动带入。',
'分配修复负责人和计划修复时间。',
'负责人将 Bug 切换到修复中。',
'修复完成后切换为待验证。',
@@ -466,11 +475,12 @@ export const WENFAN_HELP_ARTICLES: HelpArticle[] = [
requiredFields: ['Bug 标题', '严重程度', '所属版本', '复现步骤', '负责人', '计划修复时间'],
statusFlow: ['待修复', '修复中', '待验证', '已关闭', '已拒绝'],
notes: [
'当前不能从 Bug Tab 直接新建 BugBug Tab 用于查看、筛选和跟踪已由失败用例提出的 Bug。',
'Bug 直接挂在版本上,测试用例只是来源追踪。',
'关键 Bug 会影响小宝预警风险等级。',
'关闭前应确认修复结果和验证记录。',
],
images: [helpImage('bug-workflow', 'Bug 页面截图', '在 Bug Tab 新建并跟踪 Bug。')],
images: [helpImage('bug-workflow', '失败用例提 Bug 截图', '在失败测试用例详情中点击提 BUG并在 Bug Tab 跟踪修复。')],
relatedIds: ['test-case-workflow', 'xiaobao-warning', 'activity-logs'],
},
{

View File

@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getFallbackHelpSuggestions, searchHelpArticles } from './wenfan-help-search';
import {
getFallbackHelpMessage,
getFallbackHelpSuggestions,
searchHelpArticles,
shouldShowGenericHelpSuggestions,
} from './wenfan-help-search';
import { WENFAN_HELP_ARTICLES } from './wenfan-help-articles';
test('searchHelpArticles matches common product creation phrasing', () => {
@@ -27,6 +32,17 @@ test('searchHelpArticles matches bug creation from failed test cases', () => {
assert.equal(result[0]?.article.id, 'bug-workflow');
});
test('bug help article explains bug creation only from failed test cases', () => {
const article = WENFAN_HELP_ARTICLES.find((item) => item.id === 'bug-workflow');
assert.ok(article);
assert.match(article.entry, /测试用例/);
assert.match(article.entry, /提 BUG|提 Bug/);
assert.doesNotMatch(article.entry, /Bug Tab -> 新建 Bug/);
assert.ok(article.steps.some((step) => /失败|不通过/.test(step) && /提 BUG|提 Bug/.test(step)));
assert.ok(article.notes.some((note) => /不支持|不能/.test(note) && /Bug Tab/.test(note)));
});
test('searchHelpArticles keeps related article ids from the matched article', () => {
const result = searchHelpArticles('AI拆解测试用例在哪里', WENFAN_HELP_ARTICLES);
@@ -46,3 +62,16 @@ test('getFallbackHelpSuggestions returns starter examples when no article matche
'开发任务怎么提测?',
]);
});
test('getFallbackHelpMessage removes generic prompt wording when generic suggestions are hidden', () => {
assert.match(getFallbackHelpMessage(true), /常见问题/);
assert.doesNotMatch(getFallbackHelpMessage(false), /常见问题/);
assert.match(getFallbackHelpMessage(false), /更具体的系统关键词/);
});
test('shouldShowGenericHelpSuggestions hides generic prompts after the first user follow-up', () => {
assert.equal(shouldShowGenericHelpSuggestions(0), true);
assert.equal(shouldShowGenericHelpSuggestions(1), true);
assert.equal(shouldShowGenericHelpSuggestions(2), false);
assert.equal(shouldShowGenericHelpSuggestions(5), false);
});

View File

@@ -103,3 +103,14 @@ export function searchHelpArticles(
export function getFallbackHelpSuggestions(): string[] {
return FALLBACK_SUGGESTIONS;
}
export function getFallbackHelpMessage(showGenericSuggestions: boolean): string {
if (showGenericSuggestions) {
return '暂时没有找到对应的内置帮助内容。你可以换个关键词,或者从下面这些常见问题开始。';
}
return '暂时没有找到对应的内置帮助内容。你可以换个更具体的系统关键词,或继续描述你要操作的模块。';
}
export function shouldShowGenericHelpSuggestions(userQuestionCount: number): boolean {
return userQuestionCount < 2;
}

View File

@@ -27,12 +27,42 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', ()
assert.match(page, /Mic/);
assert.match(page, /searchHelpArticles/);
assert.match(page, /HelpAnswer/);
assert.match(page, /shouldShowGenericHelpSuggestions/);
assert.match(page, /showGenericSuggestions/);
assert.match(page, /showStarterQuestions/);
assert.match(page, /showGenericSuggestions && message\.suggestions\.length > 0/);
assert.match(page, /createWenfanConversationRecord/);
assert.match(page, /updateWenfanConversationRecord/);
assert.match(page, /deleteWenfanConversationRecord/);
assert.match(page, /删除历史记录/);
assert.doesNotMatch(page, /setHistory\(\(current\) => \[question/);
assert.match(page, /入口路径/);
assert.match(page, /必填字段/);
assert.match(page, /状态流转/);
assert.match(page, /注意事项/);
assert.match(page, /previewImage/);
assert.match(page, /aria-modal="true"/);
assert.match(page, /点击放大/);
assert.match(page, /lg:grid-cols-\[300px_minmax\(0,1fr\)\]/);
assert.match(page, /rounded-\[28px\]/);
assert.doesNotMatch(page, /: 'border-\[var\(--line\)\] bg-\[var\(--bg\)\]/);
assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/);
});
test('wenfan help screenshot script targets version detail tabs and failed test case bug entry', () => {
const script = readFileSync(join(process.cwd(), 'scripts/capture-wenfan-help-screenshots.mjs'), 'utf8');
assert.match(script, /resolveScreenshotContext/);
assert.match(script, /loadDataKey\('test-cases'\)/);
assert.match(script, /loadDataKey\('version-plans'\)/);
assert.match(script, /findFailedTestCase/);
assert.match(script, /findAiReadyProductPlan/);
assert.match(script, /openProductPlanAiEntry/);
assert.match(script, /tabLabel: '关联需求'/);
assert.match(script, /tabLabel: '产品方案'/);
assert.match(script, /tabLabel: '开发任务'/);
assert.match(script, /tabLabel: '测试用例'/);
assert.match(script, /openFailedTestCaseBugEntry/);
assert.match(script, /提 BUG/);
assert.doesNotMatch(script, /\{ file: 'bug-workflow\.png', route: '\/versions' \}/);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 147 KiB

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 {