feat(问翻小宝): 增强帮助搜索与会话历史
关键改动: - 增加问翻小宝会话历史记录规则与测试 - 优化帮助搜索、兜底建议和页面交互结构 - 更新帮助截图资产和截图采集脚本 Co-Authored-By: Codex GPT-5 <codex@openai.com>
@@ -1,18 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FormEvent, KeyboardEvent, useMemo, useState } from 'react';
|
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
BotMessageSquare,
|
BotMessageSquare,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Maximize2,
|
||||||
MessageCircleQuestionMark,
|
MessageCircleQuestionMark,
|
||||||
Mic,
|
Mic,
|
||||||
Search,
|
Search,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
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 =
|
type ChatMessage =
|
||||||
| {
|
| {
|
||||||
@@ -53,24 +68,64 @@ const INITIAL_MESSAGES: ChatMessage[] = [
|
|||||||
|
|
||||||
const STARTER_QUESTIONS = getFallbackHelpSuggestions();
|
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() {
|
export default function WenfanXiaobaoPage() {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [history, setHistory] = useState<string[]>([
|
const [conversations, setConversations] = useState<WenfanConversation[]>(INITIAL_CONVERSATIONS);
|
||||||
'怎么新建产品?',
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||||
'怎么把需求纳入版本?',
|
|
||||||
'开发任务怎么提测?',
|
|
||||||
'测试用例失败后怎么提 Bug?',
|
|
||||||
'日志记录会记录哪些行为?',
|
|
||||||
]);
|
|
||||||
|
|
||||||
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) {
|
function askQuestion(rawQuestion: string) {
|
||||||
const question = rawQuestion.trim();
|
const question = rawQuestion.trim();
|
||||||
if (!question) return;
|
if (!question) return;
|
||||||
|
|
||||||
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
||||||
|
const nextUserQuestionCount = userQuestionCount + 1;
|
||||||
|
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -90,12 +145,14 @@ export default function WenfanXiaobaoPage() {
|
|||||||
id: `fallback-${Date.now()}`,
|
id: `fallback-${Date.now()}`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
type: 'fallback',
|
type: 'fallback',
|
||||||
content: '暂时没有找到对应的内置帮助内容。你可以换个关键词,或者从下面这些常见问题开始。',
|
content: getFallbackHelpMessage(showFallbackSuggestions),
|
||||||
suggestions: STARTER_QUESTIONS,
|
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages((current) => [...current, userMessage, assistantMessage]);
|
const nextMessages = [...messages, userMessage, assistantMessage];
|
||||||
setHistory((current) => [question, ...current.filter((item) => item !== question)]);
|
|
||||||
|
setMessages(nextMessages);
|
||||||
|
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||||
setInput('');
|
setInput('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +175,7 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<div className="space-y-3 p-3">
|
<div className="space-y-3 p-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleNewConversation}
|
||||||
setMessages(INITIAL_MESSAGES);
|
|
||||||
setInput('');
|
|
||||||
}}
|
|
||||||
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"
|
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} />
|
<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">
|
<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>
|
<p className="px-3 py-2 text-[12px] font-medium text-[#6b6b6b]">历史记录</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{visibleHistory.map((item, index) => (
|
{visibleHistory.map((conversation, index) => (
|
||||||
<button
|
<div
|
||||||
key={`${item}-${index}`}
|
key={conversation.id}
|
||||||
type="button"
|
className={`group flex items-start gap-1 rounded-lg p-1.5 transition-colors ${
|
||||||
onClick={() => askQuestion(item)}
|
conversation.id === activeConversationId
|
||||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
|
||||||
index === 0
|
|
||||||
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
|
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
|
||||||
: 'bg-white hover:bg-[var(--bg-subtle)]'
|
: 'bg-white hover:bg-[var(--bg-subtle)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="truncate text-[13px] leading-5 text-[#202123]">{item}</p>
|
<button
|
||||||
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
|
type="button"
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,21 +241,28 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<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">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-7 px-6 py-8">
|
||||||
{messages.map((message) => (
|
{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">
|
{showStarterQuestions && (
|
||||||
{STARTER_QUESTIONS.map((question) => (
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
<button
|
{STARTER_QUESTIONS.map((question) => (
|
||||||
key={question}
|
<button
|
||||||
type="button"
|
key={question}
|
||||||
onClick={() => askQuestion(question)}
|
type="button"
|
||||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
onClick={() => askQuestion(question)}
|
||||||
>
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||||
{question}
|
>
|
||||||
</button>
|
{question}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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') {
|
if (message.role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<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 === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
|
||||||
{message.type === 'fallback' && (
|
{message.type === 'fallback' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>
|
<p className="text-[14px] leading-7 text-[#202123]">
|
||||||
<div className="flex flex-wrap gap-2">
|
{getFallbackHelpMessage(showGenericSuggestions && message.suggestions.length > 0)}
|
||||||
{message.suggestions.map((suggestion) => (
|
</p>
|
||||||
<button
|
{showGenericSuggestions && message.suggestions.length > 0 && (
|
||||||
key={suggestion}
|
<div className="flex flex-wrap gap-2">
|
||||||
type="button"
|
{message.suggestions.map((suggestion) => (
|
||||||
onClick={() => onAsk(suggestion)}
|
<button
|
||||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
key={suggestion}
|
||||||
>
|
type="button"
|
||||||
{suggestion}
|
onClick={() => onAsk(suggestion)}
|
||||||
</button>
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
||||||
))}
|
>
|
||||||
</div>
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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 }) {
|
function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (question: string) => void }) {
|
||||||
const { article, matchedKeywords, relatedArticles } = result;
|
const { article, matchedKeywords, relatedArticles } = result;
|
||||||
|
const [previewImage, setPreviewImage] = useState<HelpArticle['images'][number] | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 text-[14px] leading-7 text-[#202123]">
|
<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">
|
<div className="grid gap-3">
|
||||||
{article.images.map((image) => (
|
{article.images.map((image) => (
|
||||||
<figure key={image.src} className="overflow-hidden rounded-xl border border-[#ececec] bg-white">
|
<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]">
|
<figcaption className="border-t border-[#ececec] px-3 py-2 text-[12px] text-[#6b6b6b]">
|
||||||
{image.caption}
|
{image.caption}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@@ -323,6 +421,43 @@ function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (quest
|
|||||||
</div>
|
</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 && (
|
{relatedArticles.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="flex items-center gap-2 text-[13px] font-semibold text-[#171717]">
|
<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',
|
id: 'bug-workflow',
|
||||||
title: 'Bug 如何新建和流转',
|
title: 'Bug 如何从失败用例创建和流转',
|
||||||
category: 'Bug',
|
category: 'Bug',
|
||||||
keywords: [
|
keywords: [
|
||||||
'Bug',
|
'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',
|
'修复Bug',
|
||||||
'验证Bug',
|
'验证Bug',
|
||||||
'关闭Bug',
|
'关闭Bug',
|
||||||
'Bug状态',
|
'Bug状态',
|
||||||
],
|
],
|
||||||
scenario: '当测试用例失败或线上发现问题时,创建 Bug 并跟踪修复、验证和关闭。',
|
scenario: '当测试用例执行失败后,从失败用例里提 Bug,并跟踪修复、验证和关闭。',
|
||||||
entry: '版本详情 -> Bug Tab -> 新建 Bug,或测试用例失败后点击提 Bug',
|
entry: '版本详情 -> 测试用例 Tab -> 打开失败/不通过用例 -> 关联 Bug 区域点击提 BUG',
|
||||||
steps: [
|
steps: [
|
||||||
'从 Bug Tab 点击新建 Bug,或在失败测试用例里点击提 Bug。',
|
'打开版本详情并进入测试用例 Tab。',
|
||||||
'填写 Bug 标题、严重程度、关联版本、关联测试用例、复现步骤。',
|
'执行测试用例,并将问题用例标记为不通过/失败。',
|
||||||
|
'打开该失败测试用例详情,在“关联 Bug”区域点击提 BUG。',
|
||||||
|
'填写 Bug 标题、严重程度、复现步骤;版本和测试用例来源会自动带入。',
|
||||||
'分配修复负责人和计划修复时间。',
|
'分配修复负责人和计划修复时间。',
|
||||||
'负责人将 Bug 切换到修复中。',
|
'负责人将 Bug 切换到修复中。',
|
||||||
'修复完成后切换为待验证。',
|
'修复完成后切换为待验证。',
|
||||||
@@ -466,11 +475,12 @@ export const WENFAN_HELP_ARTICLES: HelpArticle[] = [
|
|||||||
requiredFields: ['Bug 标题', '严重程度', '所属版本', '复现步骤', '负责人', '计划修复时间'],
|
requiredFields: ['Bug 标题', '严重程度', '所属版本', '复现步骤', '负责人', '计划修复时间'],
|
||||||
statusFlow: ['待修复', '修复中', '待验证', '已关闭', '已拒绝'],
|
statusFlow: ['待修复', '修复中', '待验证', '已关闭', '已拒绝'],
|
||||||
notes: [
|
notes: [
|
||||||
|
'当前不能从 Bug Tab 直接新建 Bug;Bug Tab 用于查看、筛选和跟踪已由失败用例提出的 Bug。',
|
||||||
'Bug 直接挂在版本上,测试用例只是来源追踪。',
|
'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'],
|
relatedIds: ['test-case-workflow', 'xiaobao-warning', 'activity-logs'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import { WENFAN_HELP_ARTICLES } from './wenfan-help-articles';
|
||||||
|
|
||||||
test('searchHelpArticles matches common product creation phrasing', () => {
|
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');
|
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', () => {
|
test('searchHelpArticles keeps related article ids from the matched article', () => {
|
||||||
const result = searchHelpArticles('AI拆解测试用例在哪里', WENFAN_HELP_ARTICLES);
|
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[] {
|
export function getFallbackHelpSuggestions(): string[] {
|
||||||
return FALLBACK_SUGGESTIONS;
|
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, /Mic/);
|
||||||
assert.match(page, /searchHelpArticles/);
|
assert.match(page, /searchHelpArticles/);
|
||||||
assert.match(page, /HelpAnswer/);
|
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, /状态流转/);
|
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, /lg:grid-cols-\[300px_minmax\(0,1fr\)\]/);
|
||||||
assert.match(page, /rounded-\[28px\]/);
|
assert.match(page, /rounded-\[28px\]/);
|
||||||
assert.doesNotMatch(page, /: 'border-\[var\(--line\)\] bg-\[var\(--bg\)\]/);
|
assert.doesNotMatch(page, /: 'border-\[var\(--line\)\] bg-\[var\(--bg\)\]/);
|
||||||
assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/);
|
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 { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
const BASE_URL = process.env.WENFAN_HELP_BASE_URL || 'http://localhost:3000';
|
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 VIEWPORT = { width: 1440, height: 1000 };
|
||||||
const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao');
|
const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao');
|
||||||
|
|
||||||
@@ -16,21 +17,23 @@ const AUTH_USER = {
|
|||||||
email: 'admin@company.com',
|
email: 'admin@company.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const versionDetailRoute = (context) => (context.versionId ? `/versions/${context.versionId}` : '/versions');
|
||||||
|
|
||||||
const SCREENSHOTS = [
|
const SCREENSHOTS = [
|
||||||
{ file: 'product-create.png', route: '/products' },
|
{ file: 'product-create.png', route: '/products', waitForText: '产品' },
|
||||||
{ file: 'project-create.png', route: '/projects' },
|
{ file: 'project-create.png', route: '/projects', waitForText: '项目' },
|
||||||
{ file: 'version-create.png', route: '/versions' },
|
{ file: 'version-create.png', route: '/versions', waitForText: '新建版本' },
|
||||||
{ file: 'requirement-pool.png', route: '/requirements' },
|
{ file: 'requirement-pool.png', route: '/requirements', waitForText: '需求池' },
|
||||||
{ file: 'version-requirement-include.png', route: '/versions' },
|
{ file: 'version-requirement-include.png', route: versionDetailRoute, tabLabel: '关联需求', waitForText: '添加需求' },
|
||||||
{ file: 'research-plan.png', route: '/versions' },
|
{ file: 'research-plan.png', route: versionDetailRoute, tabLabel: '调研', waitForText: '调研' },
|
||||||
{ file: 'product-plan.png', route: '/versions' },
|
{ file: 'product-plan.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: '产品方案' },
|
||||||
{ file: 'ai-decompose.png', route: '/versions' },
|
{ file: 'ai-decompose.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: 'AI 拆解', action: openProductPlanAiEntry, scrollText: 'AI 拆解' },
|
||||||
{ file: 'ui-design-plan.png', route: '/versions' },
|
{ file: 'ui-design-plan.png', route: versionDetailRoute, tabLabel: 'UI设计', waitForText: 'UI设计' },
|
||||||
{ file: 'dev-task-workflow.png', route: '/versions' },
|
{ file: 'dev-task-workflow.png', route: versionDetailRoute, tabLabel: '开发任务', waitForText: '开发任务' },
|
||||||
{ file: 'test-case-workflow.png', route: '/versions' },
|
{ file: 'test-case-workflow.png', route: versionDetailRoute, tabLabel: '测试用例', waitForText: '测试用例' },
|
||||||
{ file: 'bug-workflow.png', route: '/versions' },
|
{ file: 'bug-workflow.png', route: versionDetailRoute, tabLabel: '测试用例', waitForText: '提 BUG', action: openFailedTestCaseBugEntry },
|
||||||
{ file: 'activity-logs.png', route: '/workspace' },
|
{ file: 'activity-logs.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: '日志', scrollText: '日志' },
|
||||||
{ file: 'xiaobao-warning.png', route: '/xiaobao-warning' },
|
{ file: 'xiaobao-warning.png', route: '/xiaobao-warning', waitForText: '小宝预警' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function findBrowser() {
|
function findBrowser() {
|
||||||
@@ -54,6 +57,97 @@ function delay(ms) {
|
|||||||
return new Promise((resolveDelay) => setTimeout(resolveDelay, 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() {
|
async function assertServerAvailable() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(BASE_URL);
|
const response = await fetch(BASE_URL);
|
||||||
@@ -189,6 +283,193 @@ async function navigateAndWait(client, url) {
|
|||||||
await delay(1200);
|
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) {
|
async function authenticate(client) {
|
||||||
await navigateAndWait(client, `${BASE_URL}/login`);
|
await navigateAndWait(client, `${BASE_URL}/login`);
|
||||||
const serialized = JSON.stringify(AUTH_USER).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
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 navigateAndWait(client, `${BASE_URL}${route}`);
|
||||||
await client.send('Emulation.setDeviceMetricsOverride', {
|
await client.send('Emulation.setDeviceMetricsOverride', {
|
||||||
width: VIEWPORT.width,
|
width: VIEWPORT.width,
|
||||||
@@ -208,10 +517,11 @@ async function capture(client, route, outputPath) {
|
|||||||
deviceScaleFactor: 1,
|
deviceScaleFactor: 1,
|
||||||
mobile: false,
|
mobile: false,
|
||||||
});
|
});
|
||||||
|
await prepareScreenshot(client, item, context);
|
||||||
const screenshot = await client.send('Page.captureScreenshot', {
|
const screenshot = await client.send('Page.captureScreenshot', {
|
||||||
format: 'png',
|
format: 'png',
|
||||||
fromSurface: true,
|
fromSurface: true,
|
||||||
captureBeyondViewport: true,
|
captureBeyondViewport: false,
|
||||||
});
|
});
|
||||||
writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
|
writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
|
||||||
}
|
}
|
||||||
@@ -233,6 +543,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await assertServerAvailable();
|
await assertServerAvailable();
|
||||||
|
const context = await resolveScreenshotContext();
|
||||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
const userDataDir = join(tmpdir(), `wenfan-help-screenshots-${Date.now()}`);
|
const userDataDir = join(tmpdir(), `wenfan-help-screenshots-${Date.now()}`);
|
||||||
@@ -250,7 +561,7 @@ async function main() {
|
|||||||
|
|
||||||
for (const item of SCREENSHOTS) {
|
for (const item of SCREENSHOTS) {
|
||||||
const outputPath = join(OUTPUT_DIR, item.file);
|
const outputPath = join(OUTPUT_DIR, item.file);
|
||||||
await capture(client, item.route, outputPath);
|
await capture(client, item, context, outputPath);
|
||||||
console.log(`saved ${outputPath}`);
|
console.log(`saved ${outputPath}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||