feat(问翻小宝): 增强帮助搜索与会话历史
关键改动: - 增加问翻小宝会话历史记录规则与测试 - 优化帮助搜索、兜底建议和页面交互结构 - 更新帮助截图资产和截图采集脚本 Co-Authored-By: Codex GPT-5 <codex@openai.com>
@@ -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>
|
||||
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
|
||||
</button>
|
||||
<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,21 +241,28 @@ 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{STARTER_QUESTIONS.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
onClick={() => askQuestion(question)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showStarterQuestions && (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{STARTER_QUESTIONS.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
onClick={() => askQuestion(question)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{question}
|
||||
</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,19 +342,23 @@ 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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => onAsk(suggestion)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => onAsk(suggestion)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{suggestion}
|
||||
</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">
|
||||
<img src={image.src} alt={image.alt} className="max-h-[320px] w-full object-cover object-top" />
|
||||
<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]">
|
||||
|
||||
54
apps/web/lib/wenfan-conversation-history.test.ts
Normal 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]);
|
||||
});
|
||||
53
apps/web/lib/wenfan-conversation-history.ts
Normal 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);
|
||||
}
|
||||
@@ -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 直接新建 Bug;Bug 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'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' \}/);
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 147 KiB |
@@ -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 {
|
||||
|
||||