diff --git a/apps/web/app/wenfan-xiaobao/page.tsx b/apps/web/app/wenfan-xiaobao/page.tsx index 04d2366..1843d20 100644 --- a/apps/web/app/wenfan-xiaobao/page.tsx +++ b/apps/web/app/wenfan-xiaobao/page.tsx @@ -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; + +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(INITIAL_MESSAGES); const [input, setInput] = useState(''); - const [history, setHistory] = useState([ - '怎么新建产品?', - '怎么把需求纳入版本?', - '开发任务怎么提测?', - '测试用例失败后怎么提 Bug?', - '日志记录会记录哪些行为?', - ]); + const [conversations, setConversations] = useState(INITIAL_CONVERSATIONS); + const [activeConversationId, setActiveConversationId] = useState(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(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, 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() {
+ + +
))} @@ -174,21 +241,28 @@ export default function WenfanXiaobaoPage() {
{messages.map((message) => ( - + ))} -
- {STARTER_QUESTIONS.map((question) => ( - - ))} -
+ {showStarterQuestions && ( +
+ {STARTER_QUESTIONS.map((question) => ( + + ))} +
+ )}
@@ -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 (
@@ -260,19 +342,23 @@ function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question {message.type === 'article' && } {message.type === 'fallback' && (
-

{message.content}

-
- {message.suggestions.map((suggestion) => ( - - ))} -
+

+ {getFallbackHelpMessage(showGenericSuggestions && message.suggestions.length > 0)} +

+ {showGenericSuggestions && message.suggestions.length > 0 && ( +
+ {message.suggestions.map((suggestion) => ( + + ))} +
+ )}
)}
@@ -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(null); return (
@@ -313,7 +400,18 @@ function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (quest
{article.images.map((image) => (
- {image.alt} +
{image.caption}
@@ -323,6 +421,43 @@ function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (quest
)} + {previewImage && ( +
setPreviewImage(null)} + > +
event.stopPropagation()} + > +
+
+

{previewImage.caption}

+

{previewImage.alt}

+
+ +
+
+ {previewImage.alt} +
+
+
+ )} + {relatedArticles.length > 0 && (

diff --git a/apps/web/lib/wenfan-conversation-history.test.ts b/apps/web/lib/wenfan-conversation-history.test.ts new file mode 100644 index 0000000..799edef --- /dev/null +++ b/apps/web/lib/wenfan-conversation-history.test.ts @@ -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]); +}); diff --git a/apps/web/lib/wenfan-conversation-history.ts b/apps/web/lib/wenfan-conversation-history.ts new file mode 100644 index 0000000..501759b --- /dev/null +++ b/apps/web/lib/wenfan-conversation-history.ts @@ -0,0 +1,53 @@ +export type WenfanHistoryMessage = { + role: string; + content?: string; +}; + +export type WenfanConversationRecord = { + id: string; + title: string; + messages: TMessage[]; +}; + +const DEFAULT_CONVERSATION_TITLE = '新对话'; + +function getConversationTitle(messages: TMessage[]): string { + const firstQuestion = messages.find((message) => message.role === 'user' && message.content?.trim()); + return firstQuestion?.content?.trim() || DEFAULT_CONVERSATION_TITLE; +} + +export function createWenfanConversationRecord( + id: string, + messages: TMessage[], +): WenfanConversationRecord { + return { + id, + title: getConversationTitle(messages), + messages, + }; +} + +export function updateWenfanConversationRecord( + records: WenfanConversationRecord[], + activeId: string | null | undefined, + messages: TMessage[], +): WenfanConversationRecord[] { + if (!activeId) return records; + + return records.map((record) => + record.id === activeId + ? { + ...record, + title: getConversationTitle(messages), + messages, + } + : record, + ); +} + +export function deleteWenfanConversationRecord( + records: WenfanConversationRecord[], + id: string, +): WenfanConversationRecord[] { + return records.filter((record) => record.id !== id); +} diff --git a/apps/web/lib/wenfan-help-articles.ts b/apps/web/lib/wenfan-help-articles.ts index 8654f57..aaac3b6 100644 --- a/apps/web/lib/wenfan-help-articles.ts +++ b/apps/web/lib/wenfan-help-articles.ts @@ -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'], }, { diff --git a/apps/web/lib/wenfan-help-search.test.ts b/apps/web/lib/wenfan-help-search.test.ts index 3bc538f..a81069e 100644 --- a/apps/web/lib/wenfan-help-search.test.ts +++ b/apps/web/lib/wenfan-help-search.test.ts @@ -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); +}); diff --git a/apps/web/lib/wenfan-help-search.ts b/apps/web/lib/wenfan-help-search.ts index c489d43..32cfe43 100644 --- a/apps/web/lib/wenfan-help-search.ts +++ b/apps/web/lib/wenfan-help-search.ts @@ -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; +} diff --git a/apps/web/lib/wenfan-xiaobao-ui.test.ts b/apps/web/lib/wenfan-xiaobao-ui.test.ts index 0cd5f5b..bf2db84 100644 --- a/apps/web/lib/wenfan-xiaobao-ui.test.ts +++ b/apps/web/lib/wenfan-xiaobao-ui.test.ts @@ -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' \}/); +}); diff --git a/apps/web/public/help/wenfan-xiaobao/activity-logs.png b/apps/web/public/help/wenfan-xiaobao/activity-logs.png index 4d01f21..f1df09f 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/activity-logs.png and b/apps/web/public/help/wenfan-xiaobao/activity-logs.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/ai-decompose.png b/apps/web/public/help/wenfan-xiaobao/ai-decompose.png index e0f3c08..044a3c2 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/ai-decompose.png and b/apps/web/public/help/wenfan-xiaobao/ai-decompose.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/bug-workflow.png b/apps/web/public/help/wenfan-xiaobao/bug-workflow.png index e0f3c08..e5a5170 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/bug-workflow.png and b/apps/web/public/help/wenfan-xiaobao/bug-workflow.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/dev-task-workflow.png b/apps/web/public/help/wenfan-xiaobao/dev-task-workflow.png index e0f3c08..355e734 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/dev-task-workflow.png and b/apps/web/public/help/wenfan-xiaobao/dev-task-workflow.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/product-plan.png b/apps/web/public/help/wenfan-xiaobao/product-plan.png index e0f3c08..f1df09f 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/product-plan.png and b/apps/web/public/help/wenfan-xiaobao/product-plan.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/requirement-pool.png b/apps/web/public/help/wenfan-xiaobao/requirement-pool.png index 9ef33b6..97eda6a 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/requirement-pool.png and b/apps/web/public/help/wenfan-xiaobao/requirement-pool.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/research-plan.png b/apps/web/public/help/wenfan-xiaobao/research-plan.png index e0f3c08..0a96015 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/research-plan.png and b/apps/web/public/help/wenfan-xiaobao/research-plan.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/test-case-workflow.png b/apps/web/public/help/wenfan-xiaobao/test-case-workflow.png index e0f3c08..ac33acc 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/test-case-workflow.png and b/apps/web/public/help/wenfan-xiaobao/test-case-workflow.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/ui-design-plan.png b/apps/web/public/help/wenfan-xiaobao/ui-design-plan.png index e0f3c08..530ca8f 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/ui-design-plan.png and b/apps/web/public/help/wenfan-xiaobao/ui-design-plan.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/version-create.png b/apps/web/public/help/wenfan-xiaobao/version-create.png index e0f3c08..e2639b4 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/version-create.png and b/apps/web/public/help/wenfan-xiaobao/version-create.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/version-requirement-include.png b/apps/web/public/help/wenfan-xiaobao/version-requirement-include.png index e0f3c08..28ed1b5 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/version-requirement-include.png and b/apps/web/public/help/wenfan-xiaobao/version-requirement-include.png differ diff --git a/apps/web/public/help/wenfan-xiaobao/xiaobao-warning.png b/apps/web/public/help/wenfan-xiaobao/xiaobao-warning.png index 778979c..136a7b8 100644 Binary files a/apps/web/public/help/wenfan-xiaobao/xiaobao-warning.png and b/apps/web/public/help/wenfan-xiaobao/xiaobao-warning.png differ diff --git a/apps/web/scripts/capture-wenfan-help-screenshots.mjs b/apps/web/scripts/capture-wenfan-help-screenshots.mjs index 0a0dbc1..1217347 100644 --- a/apps/web/scripts/capture-wenfan-help-screenshots.mjs +++ b/apps/web/scripts/capture-wenfan-help-screenshots.mjs @@ -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 {