feat(问翻小宝): 接入静态帮助中心
@@ -1,48 +1,116 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, KeyboardEvent, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
BotMessageSquare,
|
BotMessageSquare,
|
||||||
|
Image as ImageIcon,
|
||||||
MessageCircleQuestionMark,
|
MessageCircleQuestionMark,
|
||||||
Mic,
|
Mic,
|
||||||
Plus,
|
|
||||||
Search,
|
Search,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
|
Sparkles,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
||||||
|
import { getFallbackHelpSuggestions, searchHelpArticles, type HelpSearchResult } from '@/lib/wenfan-help-search';
|
||||||
|
|
||||||
const HISTORY_ITEMS = [
|
type ChatMessage =
|
||||||
{ title: '如何从产品创建到版本发布?', time: '今天' },
|
| {
|
||||||
{ title: '需求池的状态分别代表什么?', time: '今天' },
|
id: string;
|
||||||
{ title: '成员离职后怎么转交任务?', time: '昨天' },
|
role: 'assistant';
|
||||||
{ title: 'AI 配置和模型在哪里维护?', time: '昨天' },
|
type: 'intro';
|
||||||
{ title: '测试用例失败后怎么提 Bug?', time: '周一' },
|
content: string;
|
||||||
];
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
role: 'user';
|
||||||
|
type: 'text';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
type: 'article';
|
||||||
|
result: HelpSearchResult;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
type: 'fallback';
|
||||||
|
content: string;
|
||||||
|
suggestions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const QUICK_QUESTIONS = [
|
const INITIAL_MESSAGES: ChatMessage[] = [
|
||||||
'怎么创建一个版本?',
|
|
||||||
'怎么把需求关联到项目?',
|
|
||||||
'小宝预警的红点是什么意思?',
|
|
||||||
];
|
|
||||||
|
|
||||||
const MESSAGES = [
|
|
||||||
{
|
{
|
||||||
|
id: 'intro',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
title: '问翻小宝',
|
type: 'intro',
|
||||||
content: '我是 FTB 的系统帮助助手。你可以直接问产品、项目、版本、需求、任务、测试、Bug、权限和 AI 配置相关的使用问题。',
|
content:
|
||||||
},
|
'我是问翻小宝,第一阶段只从内置帮助中心回答系统怎么用,不调用 AI,也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。',
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
title: '我',
|
|
||||||
content: '怎么把需求放进某个版本里?',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
title: '问翻小宝',
|
|
||||||
content: '进入版本详情后,在需求区域选择当前项目下已采纳的需求;保存后,版本会基于这些需求继续承载开发任务、测试用例和发布风险分析。',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const STARTER_QUESTIONS = getFallbackHelpSuggestions();
|
||||||
|
|
||||||
export default function WenfanXiaobaoPage() {
|
export default function WenfanXiaobaoPage() {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [history, setHistory] = useState<string[]>([
|
||||||
|
'怎么新建产品?',
|
||||||
|
'怎么把需求纳入版本?',
|
||||||
|
'开发任务怎么提测?',
|
||||||
|
'测试用例失败后怎么提 Bug?',
|
||||||
|
'日志记录会记录哪些行为?',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const visibleHistory = useMemo(() => history.slice(0, 12), [history]);
|
||||||
|
|
||||||
|
function askQuestion(rawQuestion: string) {
|
||||||
|
const question = rawQuestion.trim();
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
type: 'text',
|
||||||
|
content: question,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assistantMessage: ChatMessage =
|
||||||
|
results.length > 0
|
||||||
|
? {
|
||||||
|
id: `article-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
type: 'article',
|
||||||
|
result: results[0],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: `fallback-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
type: 'fallback',
|
||||||
|
content: '暂时没有找到对应的内置帮助内容。你可以换个关键词,或者从下面这些常见问题开始。',
|
||||||
|
suggestions: STARTER_QUESTIONS,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((current) => [...current, userMessage, assistantMessage]);
|
||||||
|
setHistory((current) => [question, ...current.filter((item) => item !== question)]);
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
askQuestion(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
askQuestion(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-white text-[#171717]">
|
<div className="h-full bg-white text-[#171717]">
|
||||||
<main className="grid h-full min-h-0 grid-cols-1 overflow-hidden lg:grid-cols-[300px_minmax(0,1fr)]">
|
<main className="grid h-full min-h-0 grid-cols-1 overflow-hidden lg:grid-cols-[300px_minmax(0,1fr)]">
|
||||||
@@ -50,6 +118,10 @@ 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={() => {
|
||||||
|
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} />
|
||||||
@@ -67,18 +139,19 @@ 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">
|
||||||
{HISTORY_ITEMS.map((item, index) => (
|
{visibleHistory.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={item.title}
|
key={`${item}-${index}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => askQuestion(item)}
|
||||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||||
index === 0
|
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.title}</p>
|
<p className="truncate text-[13px] leading-5 text-[#202123]">{item}</p>
|
||||||
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{item.time}</p>
|
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,20 +168,21 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<h1 className="truncate text-[15px] font-semibold text-[#171717]">问翻小宝</h1>
|
<h1 className="truncate text-[15px] font-semibold text-[#171717]">问翻小宝</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">对话</span>
|
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">内置帮助</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<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.title}-${message.content}`} message={message} />
|
<MessageRow key={message.id} message={message} onAsk={askQuestion} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
{QUICK_QUESTIONS.map((question) => (
|
{STARTER_QUESTIONS.map((question) => (
|
||||||
<button
|
<button
|
||||||
key={question}
|
key={question}
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => askQuestion(question)}
|
||||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||||
>
|
>
|
||||||
{question}
|
{question}
|
||||||
@@ -119,12 +193,15 @@ export default function WenfanXiaobaoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="shrink-0 bg-white px-6 pb-6 pt-2">
|
<footer className="shrink-0 bg-white px-6 pb-6 pt-2">
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<form className="mx-auto w-full max-w-3xl" onSubmit={handleSubmit}>
|
||||||
<div className="rounded-[28px] border border-[#d9d9e3] bg-white shadow-[0_8px_28px_rgba(0,0,0,0.08)] focus-within:border-[#b9b9c6]">
|
<div className="rounded-[28px] border border-[#d9d9e3] bg-white shadow-[0_8px_28px_rgba(0,0,0,0.08)] focus-within:border-[#b9b9c6]">
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
onKeyDown={handleTextareaKeyDown}
|
||||||
className="max-h-32 min-h-14 w-full resize-none bg-transparent px-5 pt-4 text-[14px] leading-6 text-[#171717] outline-none placeholder:text-[#8a8a8a]"
|
className="max-h-32 min-h-14 w-full resize-none bg-transparent px-5 pt-4 text-[14px] leading-6 text-[#171717] outline-none placeholder:text-[#8a8a8a]"
|
||||||
placeholder="输入系统使用问题,或点击语音按钮"
|
placeholder="输入系统使用问题,例如:需求怎么纳入版本?"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between px-3 pb-3">
|
<div className="flex items-center justify-between px-3 pb-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -138,21 +215,22 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
|
||||||
title="帮助中心"
|
title="内置帮助"
|
||||||
>
|
>
|
||||||
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#171717] text-white transition-colors hover:bg-[#303030]"
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#171717] text-white transition-colors hover:bg-[#303030] disabled:bg-[#c8c8c8]"
|
||||||
title="发送"
|
title="发送"
|
||||||
|
disabled={!input.trim()}
|
||||||
>
|
>
|
||||||
<SendHorizontal className="h-4 w-4" strokeWidth={2} />
|
<SendHorizontal className="h-4 w-4" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -160,10 +238,8 @@ export default function WenfanXiaobaoPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageRow({ message }: { message: { role: string; title: string; content: string } }) {
|
function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question: string) => void }) {
|
||||||
const isUser = message.role === 'user';
|
if (message.role === 'user') {
|
||||||
|
|
||||||
if (isUser) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<div className="max-w-[76%] rounded-3xl bg-[#f4f4f4] px-4 py-2.5 text-[14px] leading-6 text-[#171717]">
|
<div className="max-w-[76%] rounded-3xl bg-[#f4f4f4] px-4 py-2.5 text-[14px] leading-6 text-[#171717]">
|
||||||
@@ -179,9 +255,109 @@ function MessageRow({ message }: { message: { role: string; title: string; conte
|
|||||||
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
<p className="mb-1 text-[13px] font-semibold text-[#171717]">{message.title}</p>
|
<p className="mb-1 text-[13px] font-semibold text-[#171717]">问翻小宝</p>
|
||||||
<p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>
|
{message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (question: string) => void }) {
|
||||||
|
const { article, matchedKeywords, relatedArticles } = result;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 text-[14px] leading-7 text-[#202123]">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full bg-[#f4f4f4] px-2.5 py-1 text-[12px] text-[#6b6b6b]">{article.category}</span>
|
||||||
|
{matchedKeywords.slice(0, 3).map((keyword) => (
|
||||||
|
<span key={keyword} className="rounded-full bg-[var(--accent-soft)] px-2.5 py-1 text-[12px] text-[var(--accent)]">
|
||||||
|
{keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[18px] font-semibold leading-7 text-[#171717]">{article.title}</h2>
|
||||||
|
<p className="mt-2 text-[#3f3f46]">{article.scenario}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HelpSection title="入口路径" items={[article.entry]} />
|
||||||
|
<HelpSection title="操作步骤" items={article.steps} ordered />
|
||||||
|
<HelpSection title="必填字段" items={article.requiredFields} />
|
||||||
|
<HelpSection title="状态流转" items={article.statusFlow} />
|
||||||
|
<HelpSection title="注意事项" items={article.notes} />
|
||||||
|
|
||||||
|
{article.images.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="flex items-center gap-2 text-[13px] font-semibold text-[#171717]">
|
||||||
|
<ImageIcon className="h-4 w-4" strokeWidth={1.9} />
|
||||||
|
配图
|
||||||
|
</p>
|
||||||
|
<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" />
|
||||||
|
<figcaption className="border-t border-[#ececec] px-3 py-2 text-[12px] text-[#6b6b6b]">
|
||||||
|
{image.caption}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{relatedArticles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="flex items-center gap-2 text-[13px] font-semibold text-[#171717]">
|
||||||
|
<Sparkles className="h-4 w-4" strokeWidth={1.9} />
|
||||||
|
相关帮助
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{relatedArticles.map((related) => (
|
||||||
|
<button
|
||||||
|
key={related.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAsk(related.title)}
|
||||||
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
||||||
|
>
|
||||||
|
{related.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpSection({ title, items, ordered = false }: { title: string; items: string[]; ordered?: boolean }) {
|
||||||
|
const ListTag = ordered ? 'ol' : 'ul';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-[13px] font-semibold text-[#171717]">{title}</h3>
|
||||||
|
<ListTag className={`space-y-1 ${ordered ? 'list-decimal' : 'list-disc'} pl-5 marker:text-[#9ca3af]`}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ListTag>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
541
apps/web/lib/wenfan-help-articles.ts
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
export type HelpImage = {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HelpArticle = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
keywords: string[];
|
||||||
|
scenario: string;
|
||||||
|
entry: string;
|
||||||
|
steps: string[];
|
||||||
|
requiredFields: string[];
|
||||||
|
statusFlow: string[];
|
||||||
|
notes: string[];
|
||||||
|
images: HelpImage[];
|
||||||
|
relatedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function helpImage(name: string, alt: string, caption: string): HelpImage {
|
||||||
|
return {
|
||||||
|
src: `/help/wenfan-xiaobao/${name}.png`,
|
||||||
|
alt,
|
||||||
|
caption,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WENFAN_HELP_ARTICLES: HelpArticle[] = [
|
||||||
|
{
|
||||||
|
id: 'product-create',
|
||||||
|
title: '产品如何新建',
|
||||||
|
category: '产品',
|
||||||
|
keywords: [
|
||||||
|
'产品',
|
||||||
|
'产品管理',
|
||||||
|
'新建产品',
|
||||||
|
'创建产品',
|
||||||
|
'新增产品',
|
||||||
|
'产品怎么建',
|
||||||
|
'怎么新建产品',
|
||||||
|
'产品要填什么',
|
||||||
|
'产品字段',
|
||||||
|
'产品名称',
|
||||||
|
'产品负责人',
|
||||||
|
'顶层容器',
|
||||||
|
],
|
||||||
|
scenario: '当你要把一个业务系统、客户项目或产品线作为顶层容器管理时,先创建产品。',
|
||||||
|
entry: '左侧导航栏 -> 产品 -> 新建产品',
|
||||||
|
steps: [
|
||||||
|
'进入“产品”页面。',
|
||||||
|
'点击页面右上角的新建产品按钮。',
|
||||||
|
'填写产品名称、负责人、说明等信息。',
|
||||||
|
'保存后,产品会出现在产品列表中,后续项目、版本和需求都围绕产品展开。',
|
||||||
|
],
|
||||||
|
requiredFields: ['产品名称', '负责人', '产品描述或备注(建议填写,方便团队识别)'],
|
||||||
|
statusFlow: ['产品本身没有复杂状态流转,主要作为项目、版本和需求的顶层归属。'],
|
||||||
|
notes: [
|
||||||
|
'产品不要和项目混用:产品是顶层容器,项目是产品下的执行范围。',
|
||||||
|
'产品名称建议稳定,后续项目和版本会引用它。',
|
||||||
|
],
|
||||||
|
images: [helpImage('product-create', '产品页面截图', '在产品页面创建产品。')],
|
||||||
|
relatedIds: ['project-create', 'requirement-pool', 'version-create'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project-create',
|
||||||
|
title: '项目如何新建',
|
||||||
|
category: '项目',
|
||||||
|
keywords: [
|
||||||
|
'项目',
|
||||||
|
'项目管理',
|
||||||
|
'新建项目',
|
||||||
|
'创建项目',
|
||||||
|
'新增项目',
|
||||||
|
'项目怎么建',
|
||||||
|
'项目归属产品',
|
||||||
|
'项目字段',
|
||||||
|
'项目负责人',
|
||||||
|
'项目成员',
|
||||||
|
'项目和产品',
|
||||||
|
],
|
||||||
|
scenario: '当一个产品下需要拆出具体交付范围、团队或客户实施范围时,创建项目。',
|
||||||
|
entry: '左侧导航栏 -> 项目 -> 新建项目',
|
||||||
|
steps: [
|
||||||
|
'进入“项目”页面。',
|
||||||
|
'点击新建项目按钮。',
|
||||||
|
'选择归属产品。',
|
||||||
|
'填写项目名称、负责人、项目类型、说明等信息。',
|
||||||
|
'保存后,可以继续在项目下创建版本和关联需求。',
|
||||||
|
],
|
||||||
|
requiredFields: ['归属产品', '项目名称', '项目负责人', '项目类型'],
|
||||||
|
statusFlow: ['项目作为执行容器,版本和任务状态会在项目下汇总。'],
|
||||||
|
notes: [
|
||||||
|
'一个产品可以有多个项目。',
|
||||||
|
'项目创建后,需求纳入版本时会按当前项目范围筛选候选需求。',
|
||||||
|
],
|
||||||
|
images: [helpImage('project-create', '项目页面截图', '在项目页面创建项目并选择归属产品。')],
|
||||||
|
relatedIds: ['product-create', 'version-create', 'version-requirement-include'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version-create',
|
||||||
|
title: '版本如何新建',
|
||||||
|
category: '版本',
|
||||||
|
keywords: [
|
||||||
|
'版本',
|
||||||
|
'版本号',
|
||||||
|
'新建版本',
|
||||||
|
'创建版本',
|
||||||
|
'新增版本',
|
||||||
|
'版本怎么建',
|
||||||
|
'版本字段',
|
||||||
|
'预期发布日期',
|
||||||
|
'发版日期',
|
||||||
|
'版本成员',
|
||||||
|
'项目开发类型',
|
||||||
|
'版本状态',
|
||||||
|
],
|
||||||
|
scenario: '当项目进入一次明确的交付或发版周期时,创建版本承载计划、开发、测试和发布风险。',
|
||||||
|
entry: '左侧导航栏 -> 版本 -> 新建版本',
|
||||||
|
steps: [
|
||||||
|
'进入“版本”页面。',
|
||||||
|
'点击新建版本按钮。',
|
||||||
|
'选择产品和项目。',
|
||||||
|
'填写版本号、版本名称、项目开发类型、预期发布日期。',
|
||||||
|
'添加版本成员,保存版本。',
|
||||||
|
],
|
||||||
|
requiredFields: ['产品', '项目', '版本号', '版本名称', '项目开发类型', '预期发布日期', '版本成员'],
|
||||||
|
statusFlow: ['计划中', '开发中', '已发布', '已关闭', '已暂停'],
|
||||||
|
notes: [
|
||||||
|
'版本是执行主线,需求、计划、开发任务、测试用例和 Bug 都围绕版本聚合。',
|
||||||
|
'预期发布日期会影响小宝预警的风险判断。',
|
||||||
|
],
|
||||||
|
images: [helpImage('version-create', '版本页面截图', '在版本页面创建版本并维护版本信息。')],
|
||||||
|
relatedIds: ['project-create', 'version-requirement-include', 'xiaobao-warning'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'requirement-pool',
|
||||||
|
title: '需求池如何新建和采纳需求',
|
||||||
|
category: '需求池',
|
||||||
|
keywords: [
|
||||||
|
'需求',
|
||||||
|
'需求池',
|
||||||
|
'新建需求',
|
||||||
|
'创建需求',
|
||||||
|
'新增需求',
|
||||||
|
'需求怎么建',
|
||||||
|
'需求字段',
|
||||||
|
'需求采纳',
|
||||||
|
'采纳需求',
|
||||||
|
'通过需求',
|
||||||
|
'需求状态',
|
||||||
|
'需求评审',
|
||||||
|
'需求进入版本',
|
||||||
|
],
|
||||||
|
scenario: '当产品或项目有新的业务诉求时,先放进需求池,再经过采纳后进入版本执行。',
|
||||||
|
entry: '左侧导航栏 -> 需求池 -> 新建需求',
|
||||||
|
steps: [
|
||||||
|
'进入“需求池”页面。',
|
||||||
|
'点击新建需求。',
|
||||||
|
'填写需求标题、归属产品/项目、需求描述、优先级等信息。',
|
||||||
|
'保存后,需求进入待评审或待采纳状态。',
|
||||||
|
'评审通过后点击采纳,需求才会成为版本可纳入的候选项。',
|
||||||
|
],
|
||||||
|
requiredFields: ['需求标题', '归属产品', '归属项目', '需求描述', '优先级', '提出人'],
|
||||||
|
statusFlow: ['待评审', '已采纳', '已规划', '开发中', '测试中', '已发布', '已关闭', '已拒绝'],
|
||||||
|
notes: [
|
||||||
|
'只有已采纳需求才应该进入版本。',
|
||||||
|
'需求解释为什么要做,版本和任务负责怎么执行。',
|
||||||
|
],
|
||||||
|
images: [helpImage('requirement-pool', '需求池页面截图', '在需求池中新建、评审和采纳需求。')],
|
||||||
|
relatedIds: ['version-requirement-include', 'version-create', 'dev-task-workflow'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version-requirement-include',
|
||||||
|
title: '版本里如何从需求池纳入需求',
|
||||||
|
category: '版本',
|
||||||
|
keywords: [
|
||||||
|
'版本需求',
|
||||||
|
'需求纳入版本',
|
||||||
|
'需求怎么纳入版本',
|
||||||
|
'需求怎么纳入版本号',
|
||||||
|
'纳入需求',
|
||||||
|
'纳入版本',
|
||||||
|
'纳入版本号',
|
||||||
|
'加入版本',
|
||||||
|
'关联需求',
|
||||||
|
'需求关联版本',
|
||||||
|
'需求进入版本',
|
||||||
|
'从需求池纳入',
|
||||||
|
'版本号里需求',
|
||||||
|
'版本号里面的需求',
|
||||||
|
'版本号需求',
|
||||||
|
'需求怎么放进版本',
|
||||||
|
'需求怎么加入版本号',
|
||||||
|
'已采纳需求',
|
||||||
|
],
|
||||||
|
scenario: '当需求池里的需求已采纳,并且本次版本要实现它时,把需求纳入版本。',
|
||||||
|
entry: '左侧导航栏 -> 版本 -> 打开版本详情 -> 需求区域',
|
||||||
|
steps: [
|
||||||
|
'打开目标版本详情。',
|
||||||
|
'进入需求区域或需求关联入口。',
|
||||||
|
'从当前项目下已采纳需求中选择要纳入本版本的需求。',
|
||||||
|
'保存关联关系。',
|
||||||
|
'后续产品方案、AI 拆解、开发任务和测试用例都围绕这些版本需求展开。',
|
||||||
|
],
|
||||||
|
requiredFields: ['目标版本', '至少一条已采纳需求'],
|
||||||
|
statusFlow: ['需求:已采纳 -> 已规划 -> 开发中 -> 测试中 -> 已发布'],
|
||||||
|
notes: [
|
||||||
|
'候选需求来自当前项目下已采纳需求,不从全量需求池随意选择。',
|
||||||
|
'如果找不到需求,先回需求池确认是否已采纳、是否属于当前项目。',
|
||||||
|
],
|
||||||
|
images: [helpImage('version-requirement-include', '版本需求区域截图', '在版本详情中把已采纳需求纳入版本。')],
|
||||||
|
relatedIds: ['requirement-pool', 'product-plan', 'ai-decompose'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'research-plan',
|
||||||
|
title: '调研计划如何创建和管理进度',
|
||||||
|
category: '调研',
|
||||||
|
keywords: [
|
||||||
|
'调研',
|
||||||
|
'调研计划',
|
||||||
|
'创建调研',
|
||||||
|
'新建调研',
|
||||||
|
'调研方向',
|
||||||
|
'调研进度',
|
||||||
|
'方向进度',
|
||||||
|
'调研完成',
|
||||||
|
'计划日志',
|
||||||
|
'VersionPlan',
|
||||||
|
],
|
||||||
|
scenario: '当版本前期需要先做业务、技术或方案调研时,创建调研计划并记录方向进展。',
|
||||||
|
entry: '版本详情 -> 计划区域 -> 新建调研计划',
|
||||||
|
steps: [
|
||||||
|
'打开版本详情。',
|
||||||
|
'在计划区域选择新建调研计划。',
|
||||||
|
'填写计划标题、负责人、计划起止时间。',
|
||||||
|
'维护调研方向和每个方向的完成情况。',
|
||||||
|
'所有调研方向完成并提交成果后,计划可完成。',
|
||||||
|
],
|
||||||
|
requiredFields: ['计划标题', '负责人', '计划开始时间', '计划结束时间', '调研方向'],
|
||||||
|
statusFlow: ['未开始', '进行中', '已完成'],
|
||||||
|
notes: [
|
||||||
|
'调研计划看的是方向完成度,不以需求覆盖作为完成条件。',
|
||||||
|
'方向进度更新会进入计划日志和工作活动记录。',
|
||||||
|
],
|
||||||
|
images: [helpImage('research-plan', '调研计划截图', '在版本计划中创建调研计划并更新方向进度。')],
|
||||||
|
relatedIds: ['product-plan', 'activity-logs', 'version-create'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product-plan',
|
||||||
|
title: '产品方案如何创建和提交成果',
|
||||||
|
category: '产品方案',
|
||||||
|
keywords: [
|
||||||
|
'产品方案',
|
||||||
|
'方案',
|
||||||
|
'原型',
|
||||||
|
'成果链接',
|
||||||
|
'创建产品方案',
|
||||||
|
'新建产品方案',
|
||||||
|
'需求覆盖',
|
||||||
|
'覆盖需求',
|
||||||
|
'提交成果',
|
||||||
|
'产品计划',
|
||||||
|
'方案进度',
|
||||||
|
],
|
||||||
|
scenario: '当版本需求需要先沉淀产品方案或原型成果时,创建产品方案计划。',
|
||||||
|
entry: '版本详情 -> 计划区域 -> 新建产品方案',
|
||||||
|
steps: [
|
||||||
|
'打开版本详情。',
|
||||||
|
'在计划区域创建产品方案计划。',
|
||||||
|
'选择或确认本方案覆盖的版本需求。',
|
||||||
|
'按需求记录未开始、部分完成、已完成的覆盖状态。',
|
||||||
|
'提交成果链接,通常是原型链接或方案文档。',
|
||||||
|
'覆盖需求全部完成并提交成果后,计划可完成。',
|
||||||
|
],
|
||||||
|
requiredFields: ['计划标题', '负责人', '计划起止时间', '覆盖需求', '成果标题', '成果链接'],
|
||||||
|
statusFlow: ['计划:未开始 -> 进行中 -> 已完成', '需求覆盖:未开始 -> 部分完成 -> 已完成'],
|
||||||
|
notes: [
|
||||||
|
'AI 拆解会优先使用已完成产品方案的成果链接作为原型输入。',
|
||||||
|
'部分完成只记录进度,不满足成果提交门槛。',
|
||||||
|
],
|
||||||
|
images: [helpImage('product-plan', '产品方案计划截图', '在产品方案计划中维护需求覆盖并提交成果。')],
|
||||||
|
relatedIds: ['version-requirement-include', 'ai-decompose', 'activity-logs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai-decompose',
|
||||||
|
title: 'AI 拆解开发任务和测试用例如何使用',
|
||||||
|
category: 'AI 拆解',
|
||||||
|
keywords: [
|
||||||
|
'AI拆解',
|
||||||
|
'ai拆解',
|
||||||
|
'AI 拆解',
|
||||||
|
'任务拆解',
|
||||||
|
'开发任务拆解',
|
||||||
|
'测试用例拆解',
|
||||||
|
'AI生成任务',
|
||||||
|
'AI生成用例',
|
||||||
|
'采纳草稿',
|
||||||
|
'对账报告',
|
||||||
|
'原型拆解',
|
||||||
|
],
|
||||||
|
scenario: '当版本已有已完成产品方案和已纳入需求时,可以让 AI 生成开发任务或测试用例草稿。',
|
||||||
|
entry: '版本详情 -> 产品方案计划 -> AI 拆解开发任务 / AI 拆解测试用例',
|
||||||
|
steps: [
|
||||||
|
'确认版本中已纳入至少一条需求。',
|
||||||
|
'确认至少一个产品方案计划已完成,并提交了成果链接。',
|
||||||
|
'在产品方案区域点击 AI 拆解开发任务或 AI 拆解测试用例。',
|
||||||
|
'查看对账报告,确认需求和原型是否匹配。',
|
||||||
|
'选择要采纳的草稿。',
|
||||||
|
'采纳后,草稿进入开发任务或测试用例列表,并带有 AI 草稿标记。',
|
||||||
|
],
|
||||||
|
requiredFields: ['已完成产品方案成果链接', '版本已纳入需求', '版本成员'],
|
||||||
|
statusFlow: ['AI 草稿 -> 用户采纳 -> 任务/用例列表 -> 用户编辑后转为普通记录'],
|
||||||
|
notes: [
|
||||||
|
'AI 只生成草稿,不代表负责人已确认排期。',
|
||||||
|
'AI 估时写入 aiEstimateHours,执行估时仍需人工确认。',
|
||||||
|
'第一阶段问翻小宝不会调用 AI;这里说明的是业务系统里的 AI 拆解功能。',
|
||||||
|
],
|
||||||
|
images: [helpImage('ai-decompose', 'AI 拆解入口截图', '在产品方案计划中触发 AI 拆解。')],
|
||||||
|
relatedIds: ['product-plan', 'dev-task-workflow', 'test-case-workflow'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ui-design-plan',
|
||||||
|
title: 'UI 设计计划如何创建和管理进度',
|
||||||
|
category: 'UI 设计',
|
||||||
|
keywords: [
|
||||||
|
'UI',
|
||||||
|
'ui',
|
||||||
|
'UI设计',
|
||||||
|
'ui设计',
|
||||||
|
'新建设计',
|
||||||
|
'创建UI设计',
|
||||||
|
'UI计划',
|
||||||
|
'设计进度',
|
||||||
|
'提交设计',
|
||||||
|
'设计成果',
|
||||||
|
],
|
||||||
|
scenario: '当版本需要设计稿、交互稿或视觉稿交付时,创建 UI 设计计划。',
|
||||||
|
entry: '版本详情 -> 计划区域 -> 新建 UI 设计',
|
||||||
|
steps: [
|
||||||
|
'打开版本详情。',
|
||||||
|
'在计划区域创建 UI 设计计划。',
|
||||||
|
'填写负责人和计划起止时间。',
|
||||||
|
'按需求维护设计覆盖进度。',
|
||||||
|
'提交设计稿链接或成果说明。',
|
||||||
|
'需求覆盖完成并提交成果后,计划可完成。',
|
||||||
|
],
|
||||||
|
requiredFields: ['计划标题', '负责人', '计划起止时间', '覆盖需求', '设计成果链接'],
|
||||||
|
statusFlow: ['计划:未开始 -> 进行中 -> 已完成', '需求覆盖:未开始 -> 部分完成 -> 已完成'],
|
||||||
|
notes: [
|
||||||
|
'UI 设计和产品方案一样,需要记录需求覆盖状态。',
|
||||||
|
'提交成果后,日志会记录计划完成和成果提交行为。',
|
||||||
|
],
|
||||||
|
images: [helpImage('ui-design-plan', 'UI 设计计划截图', '在版本计划中创建 UI 设计计划并提交成果。')],
|
||||||
|
relatedIds: ['product-plan', 'activity-logs', 'dev-task-workflow'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev-task-workflow',
|
||||||
|
title: '开发任务如何新建、开发中和提测',
|
||||||
|
category: '开发任务',
|
||||||
|
keywords: [
|
||||||
|
'开发任务',
|
||||||
|
'任务',
|
||||||
|
'DevTask',
|
||||||
|
'新建开发任务',
|
||||||
|
'创建开发任务',
|
||||||
|
'任务怎么建',
|
||||||
|
'领取任务',
|
||||||
|
'填写计划',
|
||||||
|
'开发中',
|
||||||
|
'自测',
|
||||||
|
'提测',
|
||||||
|
'提交测试',
|
||||||
|
'任务状态',
|
||||||
|
'阻塞',
|
||||||
|
],
|
||||||
|
scenario: '当版本需求已经明确,需要拆成具体研发工作项时,创建开发任务并推进状态。',
|
||||||
|
entry: '版本详情 -> 开发任务 Tab -> 新建开发任务',
|
||||||
|
steps: [
|
||||||
|
'打开版本详情并进入开发任务 Tab。',
|
||||||
|
'点击新建开发任务。',
|
||||||
|
'选择关联需求,填写任务标题、类型、负责人、计划起止时间、估时等信息。',
|
||||||
|
'负责人领取或确认计划后,将任务切换到开发中。',
|
||||||
|
'开发完成后进入自测。',
|
||||||
|
'自测通过且没有阻塞时,切换为已提测。',
|
||||||
|
],
|
||||||
|
requiredFields: ['关联需求或原型批注', '任务标题', '任务类型', '负责人', '计划开始时间', '计划结束时间', '执行估时'],
|
||||||
|
statusFlow: ['待开发', '开发中', '自测', '已提测'],
|
||||||
|
notes: [
|
||||||
|
'已提测是开发任务终态,后续测试失败不会回写开发任务状态。',
|
||||||
|
'任务阻塞时不能提测,必须先解除阻塞。',
|
||||||
|
'AI 草稿任务需要人工编辑或确认后再进入正式执行。',
|
||||||
|
],
|
||||||
|
images: [helpImage('dev-task-workflow', '开发任务页面截图', '在开发任务 Tab 新建任务并推进到提测。')],
|
||||||
|
relatedIds: ['ai-decompose', 'test-case-workflow', 'activity-logs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'test-case-workflow',
|
||||||
|
title: '测试用例如何新建和执行',
|
||||||
|
category: '测试用例',
|
||||||
|
keywords: [
|
||||||
|
'测试用例',
|
||||||
|
'用例',
|
||||||
|
'TestCase',
|
||||||
|
'新建测试用例',
|
||||||
|
'创建测试用例',
|
||||||
|
'用例怎么建',
|
||||||
|
'开始测试',
|
||||||
|
'测试通过',
|
||||||
|
'测试失败',
|
||||||
|
'测试阻塞',
|
||||||
|
'用例状态',
|
||||||
|
'测试轮次',
|
||||||
|
],
|
||||||
|
scenario: '当版本进入验收或测试阶段,需要按需求和测试点建立测试用例并执行。',
|
||||||
|
entry: '版本详情 -> 测试用例 Tab -> 新建测试用例',
|
||||||
|
steps: [
|
||||||
|
'打开版本详情并进入测试用例 Tab。',
|
||||||
|
'点击新建测试用例。',
|
||||||
|
'选择关联需求、测试类型、负责人和计划测试时间。',
|
||||||
|
'填写测试标题、步骤、预期结果。',
|
||||||
|
'开始测试后,将用例切换为测试中。',
|
||||||
|
'根据执行结果标记通过、失败或阻塞。',
|
||||||
|
],
|
||||||
|
requiredFields: ['用例标题', '测试类型', '负责人', '计划测试时间', '计划结束时间', '测试步骤', '预期结果'],
|
||||||
|
statusFlow: ['待测试', '测试中', '通过', '失败', '阻塞'],
|
||||||
|
notes: [
|
||||||
|
'测试用例主归属是版本,关联需求用于语义分组和覆盖说明。',
|
||||||
|
'失败用例可以直接创建 Bug。',
|
||||||
|
'只有最新轮次用例全部完成后,才能开启下一轮测试。',
|
||||||
|
],
|
||||||
|
images: [helpImage('test-case-workflow', '测试用例页面截图', '在测试用例 Tab 新建和执行用例。')],
|
||||||
|
relatedIds: ['bug-workflow', 'dev-task-workflow', 'ai-decompose'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bug-workflow',
|
||||||
|
title: 'Bug 如何新建和流转',
|
||||||
|
category: 'Bug',
|
||||||
|
keywords: [
|
||||||
|
'Bug',
|
||||||
|
'BUG',
|
||||||
|
'bug',
|
||||||
|
'缺陷',
|
||||||
|
'问题',
|
||||||
|
'新建Bug',
|
||||||
|
'创建Bug',
|
||||||
|
'提Bug',
|
||||||
|
'怎么提bug',
|
||||||
|
'测试失败提bug',
|
||||||
|
'修复Bug',
|
||||||
|
'验证Bug',
|
||||||
|
'关闭Bug',
|
||||||
|
'Bug状态',
|
||||||
|
],
|
||||||
|
scenario: '当测试用例失败或线上发现问题时,创建 Bug 并跟踪修复、验证和关闭。',
|
||||||
|
entry: '版本详情 -> Bug Tab -> 新建 Bug,或测试用例失败后点击提 Bug',
|
||||||
|
steps: [
|
||||||
|
'从 Bug Tab 点击新建 Bug,或在失败测试用例里点击提 Bug。',
|
||||||
|
'填写 Bug 标题、严重程度、关联版本、关联测试用例、复现步骤。',
|
||||||
|
'分配修复负责人和计划修复时间。',
|
||||||
|
'负责人将 Bug 切换到修复中。',
|
||||||
|
'修复完成后切换为待验证。',
|
||||||
|
'测试验证通过后关闭;不通过时可退回修复。',
|
||||||
|
],
|
||||||
|
requiredFields: ['Bug 标题', '严重程度', '所属版本', '复现步骤', '负责人', '计划修复时间'],
|
||||||
|
statusFlow: ['待修复', '修复中', '待验证', '已关闭', '已拒绝'],
|
||||||
|
notes: [
|
||||||
|
'Bug 直接挂在版本上,测试用例只是来源追踪。',
|
||||||
|
'关键 Bug 会影响小宝预警风险等级。',
|
||||||
|
'关闭前应确认修复结果和验证记录。',
|
||||||
|
],
|
||||||
|
images: [helpImage('bug-workflow', 'Bug 页面截图', '在 Bug Tab 新建并跟踪 Bug。')],
|
||||||
|
relatedIds: ['test-case-workflow', 'xiaobao-warning', 'activity-logs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activity-logs',
|
||||||
|
title: '日志记录会记录哪些行为',
|
||||||
|
category: '日志记录',
|
||||||
|
keywords: [
|
||||||
|
'日志',
|
||||||
|
'记录',
|
||||||
|
'行为记录',
|
||||||
|
'活动记录',
|
||||||
|
'工作活动',
|
||||||
|
'计划日志',
|
||||||
|
'日报',
|
||||||
|
'谁操作了',
|
||||||
|
'记录哪些行为',
|
||||||
|
'状态变更记录',
|
||||||
|
'work activity',
|
||||||
|
'activity',
|
||||||
|
],
|
||||||
|
scenario: '当你需要追踪计划、任务、测试、Bug 的关键操作时,查看日志和活动记录。',
|
||||||
|
entry: '版本详情计划抽屉、任务详情、工作台日报、小宝预警证据区域',
|
||||||
|
steps: [
|
||||||
|
'在计划详情里查看计划日志,了解需求覆盖、成果提交、AI 拆解等计划行为。',
|
||||||
|
'在开发任务、测试用例、Bug 详情里查看对应实体的状态变更记录。',
|
||||||
|
'在工作台日报里查看当前用户当天的交付、进展、创建和风险记录。',
|
||||||
|
'在小宝预警里,系统会把日志和活动作为风险证据之一。',
|
||||||
|
],
|
||||||
|
requiredFields: ['无需手工填写;系统在关键业务动作成功后自动记录。'],
|
||||||
|
statusFlow: ['日志不单独流转,跟随业务动作追加。'],
|
||||||
|
notes: [
|
||||||
|
'计划日志记录计划内部行为,例如需求覆盖更新和成果提交。',
|
||||||
|
'工作活动记录跨模块关键动作,例如任务开始、提测、Bug 修复、测试失败。',
|
||||||
|
'手动工作日志仍可补充说明多日进行中的工作。',
|
||||||
|
],
|
||||||
|
images: [helpImage('activity-logs', '日志记录页面截图', '在详情或工作台查看系统自动记录的行为。')],
|
||||||
|
relatedIds: ['research-plan', 'product-plan', 'dev-task-workflow'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'xiaobao-warning',
|
||||||
|
title: '小宝预警和问翻小宝有什么区别',
|
||||||
|
category: '小宝预警',
|
||||||
|
keywords: [
|
||||||
|
'小宝预警',
|
||||||
|
'预警',
|
||||||
|
'风险',
|
||||||
|
'红点',
|
||||||
|
'发布风险',
|
||||||
|
'能不能发版',
|
||||||
|
'问翻小宝和小宝预警',
|
||||||
|
],
|
||||||
|
scenario: '当你想判断版本能不能按期发布时,看小宝预警;当你不知道系统怎么用时,问问翻小宝。',
|
||||||
|
entry: '左侧导航栏 -> 小宝预警',
|
||||||
|
steps: [
|
||||||
|
'进入小宝预警页面。',
|
||||||
|
'查看有风险的未完成版本。',
|
||||||
|
'打开预警详情,查看风险分、预计发版日、风险原因和建议动作。',
|
||||||
|
],
|
||||||
|
requiredFields: ['无需手工填写;风险来自版本、任务、测试、Bug、日报和活动数据。'],
|
||||||
|
statusFlow: ['正常', '关注', '有风险', '大概率延期', '阻塞'],
|
||||||
|
notes: [
|
||||||
|
'小宝预警用于版本发布风险,不回答系统怎么使用。',
|
||||||
|
'问翻小宝第一阶段只回答帮助中心内容,不分析版本风险。',
|
||||||
|
],
|
||||||
|
images: [helpImage('xiaobao-warning', '小宝预警页面截图', '小宝预警用于查看版本发布风险。')],
|
||||||
|
relatedIds: ['version-create', 'activity-logs', 'bug-workflow'],
|
||||||
|
},
|
||||||
|
];
|
||||||
48
apps/web/lib/wenfan-help-search.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getFallbackHelpSuggestions, searchHelpArticles } from './wenfan-help-search';
|
||||||
|
import { WENFAN_HELP_ARTICLES } from './wenfan-help-articles';
|
||||||
|
|
||||||
|
test('searchHelpArticles matches common product creation phrasing', () => {
|
||||||
|
const result = searchHelpArticles('产品怎么新建,要填什么字段', WENFAN_HELP_ARTICLES);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.article.id, 'product-create');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles matches demand adoption and version inclusion synonyms', () => {
|
||||||
|
const result = searchHelpArticles('需求怎么纳入版本号里面', WENFAN_HELP_ARTICLES);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.article.id, 'version-requirement-include');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles matches dev task test submission phrasing', () => {
|
||||||
|
const result = searchHelpArticles('开发任务怎么提测', WENFAN_HELP_ARTICLES);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.article.id, 'dev-task-workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles matches bug creation from failed test cases', () => {
|
||||||
|
const result = searchHelpArticles('测试用例失败了怎么提bug', WENFAN_HELP_ARTICLES);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.article.id, 'bug-workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles keeps related article ids from the matched article', () => {
|
||||||
|
const result = searchHelpArticles('AI拆解测试用例在哪里', WENFAN_HELP_ARTICLES);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.article.id, 'ai-decompose');
|
||||||
|
assert.ok(result[0]?.relatedArticles.some((article) => article.id === 'product-plan'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles returns no article for short or unknown input', () => {
|
||||||
|
assert.deepEqual(searchHelpArticles('?', WENFAN_HELP_ARTICLES), []);
|
||||||
|
assert.deepEqual(searchHelpArticles('完全无关的问题', WENFAN_HELP_ARTICLES), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFallbackHelpSuggestions returns starter examples when no article matches', () => {
|
||||||
|
assert.deepEqual(getFallbackHelpSuggestions().slice(0, 3), [
|
||||||
|
'怎么新建产品?',
|
||||||
|
'怎么把需求纳入版本?',
|
||||||
|
'开发任务怎么提测?',
|
||||||
|
]);
|
||||||
|
});
|
||||||
105
apps/web/lib/wenfan-help-search.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { WENFAN_HELP_ARTICLES, type HelpArticle } from './wenfan-help-articles';
|
||||||
|
|
||||||
|
export type HelpSearchResult = {
|
||||||
|
article: HelpArticle;
|
||||||
|
score: number;
|
||||||
|
matchedKeywords: string[];
|
||||||
|
relatedArticles: HelpArticle[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_RESULT_SCORE = 15;
|
||||||
|
|
||||||
|
const FALLBACK_SUGGESTIONS = [
|
||||||
|
'怎么新建产品?',
|
||||||
|
'怎么把需求纳入版本?',
|
||||||
|
'开发任务怎么提测?',
|
||||||
|
'测试用例失败后怎么提 Bug?',
|
||||||
|
'AI 拆解在哪里使用?',
|
||||||
|
'日志记录会记录哪些行为?',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeHelpQuery(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[,。!?、,.!?;;::"'“”‘’()()【】[\]{}<>《》\s]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubsequence(needle: string, haystack: string): boolean {
|
||||||
|
if (needle.length < 4) return false;
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
for (const char of haystack) {
|
||||||
|
if (char === needle[index]) index += 1;
|
||||||
|
if (index === needle.length) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreText(query: string, text: string, exactScore: number, fuzzyScore: number): number {
|
||||||
|
const normalized = normalizeHelpQuery(text);
|
||||||
|
if (!normalized) return 0;
|
||||||
|
if (normalized.includes(query) || query.includes(normalized)) return exactScore;
|
||||||
|
if (isSubsequence(normalized, query) || isSubsequence(query, normalized)) return fuzzyScore;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRelatedArticles(article: HelpArticle, articles: HelpArticle[]): HelpArticle[] {
|
||||||
|
return article.relatedIds
|
||||||
|
.map((id) => articles.find((item) => item.id === id))
|
||||||
|
.filter((item): item is HelpArticle => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchHelpArticles(
|
||||||
|
query: string,
|
||||||
|
articles: HelpArticle[] = WENFAN_HELP_ARTICLES,
|
||||||
|
): HelpSearchResult[] {
|
||||||
|
const normalizedQuery = normalizeHelpQuery(query);
|
||||||
|
if (normalizedQuery.length < 2) return [];
|
||||||
|
|
||||||
|
return articles
|
||||||
|
.map((article) => {
|
||||||
|
let score = 0;
|
||||||
|
const matchedKeywords: string[] = [];
|
||||||
|
|
||||||
|
score += scoreText(normalizedQuery, article.title, 20, 10);
|
||||||
|
score += scoreText(normalizedQuery, article.category, 12, 0);
|
||||||
|
|
||||||
|
for (const keyword of article.keywords) {
|
||||||
|
const keywordScore = scoreText(normalizedQuery, keyword, 10, 6);
|
||||||
|
if (keywordScore > 0) {
|
||||||
|
score += keywordScore;
|
||||||
|
matchedKeywords.push(keyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryTexts = [
|
||||||
|
article.scenario,
|
||||||
|
article.entry,
|
||||||
|
...article.steps,
|
||||||
|
...article.requiredFields,
|
||||||
|
...article.statusFlow,
|
||||||
|
...article.notes,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const text of secondaryTexts) {
|
||||||
|
score += scoreText(normalizedQuery, text, 3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
article,
|
||||||
|
score,
|
||||||
|
matchedKeywords,
|
||||||
|
relatedArticles: resolveRelatedArticles(article, articles),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((result) => result.score >= MIN_RESULT_SCORE)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
return a.article.title.localeCompare(b.article.title, 'zh-CN');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFallbackHelpSuggestions(): string[] {
|
||||||
|
return FALLBACK_SUGGESTIONS;
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', ()
|
|||||||
assert.match(page, /对话/);
|
assert.match(page, /对话/);
|
||||||
assert.match(page, /textarea/);
|
assert.match(page, /textarea/);
|
||||||
assert.match(page, /Mic/);
|
assert.match(page, /Mic/);
|
||||||
|
assert.match(page, /searchHelpArticles/);
|
||||||
|
assert.match(page, /HelpAnswer/);
|
||||||
|
assert.match(page, /入口路径/);
|
||||||
|
assert.match(page, /必填字段/);
|
||||||
|
assert.match(page, /状态流转/);
|
||||||
|
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\)\]/);
|
||||||
|
|||||||
BIN
apps/web/public/help/wenfan-xiaobao/activity-logs.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/ai-decompose.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/bug-workflow.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/dev-task-workflow.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/product-create.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/product-plan.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/project-create.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/requirement-pool.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/research-plan.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/test-case-workflow.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/ui-design-plan.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/version-create.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/web/public/help/wenfan-xiaobao/xiaobao-warning.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
270
apps/web/scripts/capture-wenfan-help-screenshots.mjs
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.WENFAN_HELP_BASE_URL || 'http://localhost:3000';
|
||||||
|
const VIEWPORT = { width: 1440, height: 1000 };
|
||||||
|
const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao');
|
||||||
|
|
||||||
|
const AUTH_USER = {
|
||||||
|
id: 'm-8',
|
||||||
|
name: '超级管理员',
|
||||||
|
roleId: 'role-admin',
|
||||||
|
departmentId: 'dept-1',
|
||||||
|
phone: '13200132008',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function findBrowser() {
|
||||||
|
const candidates = [
|
||||||
|
process.env.CHROME_PATH,
|
||||||
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
|
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
|
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
|
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||||
|
'/usr/bin/google-chrome',
|
||||||
|
'/usr/bin/chromium',
|
||||||
|
'/usr/bin/chromium-browser',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return candidates.find((candidate) => existsSync(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertServerAvailable() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(BASE_URL);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`本地页面不可访问:${BASE_URL}。请先启动前端 dev server。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchBrowser(browserPath, userDataDir) {
|
||||||
|
const child = spawn(
|
||||||
|
browserPath,
|
||||||
|
[
|
||||||
|
'--headless=new',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--remote-debugging-port=0',
|
||||||
|
`--user-data-dir=${userDataDir}`,
|
||||||
|
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
||||||
|
'about:blank',
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForDevtools(child) {
|
||||||
|
return new Promise((resolveDevtools, rejectDevtools) => {
|
||||||
|
const timer = setTimeout(() => rejectDevtools(new Error('Chrome DevTools 启动超时。')), 15000);
|
||||||
|
|
||||||
|
function onData(data) {
|
||||||
|
const text = data.toString();
|
||||||
|
const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
||||||
|
if (!match) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.stdout.off('data', onData);
|
||||||
|
child.stderr.off('data', onData);
|
||||||
|
resolveDevtools(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on('data', onData);
|
||||||
|
child.stderr.on('data', onData);
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
rejectDevtools(new Error(`浏览器提前退出,退出码:${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageWebSocketUrl(wsEndpoint) {
|
||||||
|
const url = new URL(wsEndpoint);
|
||||||
|
const listUrl = `http://${url.hostname}:${url.port}/json/list`;
|
||||||
|
|
||||||
|
for (let i = 0; i < 30; i += 1) {
|
||||||
|
const response = await fetch(listUrl);
|
||||||
|
const targets = await response.json();
|
||||||
|
const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl);
|
||||||
|
if (page) return page.webSocketDebuggerUrl;
|
||||||
|
await delay(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('没有找到可截图的 Chrome 页面 target。');
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpClient {
|
||||||
|
constructor(webSocketUrl) {
|
||||||
|
this.webSocketUrl = webSocketUrl;
|
||||||
|
this.nextId = 1;
|
||||||
|
this.pending = new Map();
|
||||||
|
this.eventWaiters = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
this.ws = new WebSocket(this.webSocketUrl);
|
||||||
|
this.ws.addEventListener('message', (event) => this.handleMessage(event.data));
|
||||||
|
await new Promise((resolveOpen, rejectOpen) => {
|
||||||
|
this.ws.addEventListener('open', resolveOpen, { once: true });
|
||||||
|
this.ws.addEventListener('error', rejectOpen, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(raw) {
|
||||||
|
const message = JSON.parse(raw);
|
||||||
|
if (message.id && this.pending.has(message.id)) {
|
||||||
|
const { resolve: resolvePending, reject } = this.pending.get(message.id);
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
if (message.error) reject(new Error(message.error.message));
|
||||||
|
else resolvePending(message.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.method && this.eventWaiters.has(message.method)) {
|
||||||
|
const waiters = this.eventWaiters.get(message.method);
|
||||||
|
this.eventWaiters.delete(message.method);
|
||||||
|
for (const waiter of waiters) waiter(message.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(method, params = {}) {
|
||||||
|
const id = this.nextId;
|
||||||
|
this.nextId += 1;
|
||||||
|
const payload = JSON.stringify({ id, method, params });
|
||||||
|
|
||||||
|
return new Promise((resolveSend, rejectSend) => {
|
||||||
|
this.pending.set(id, { resolve: resolveSend, reject: rejectSend });
|
||||||
|
this.ws.send(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForEvent(method, timeoutMs = 15000) {
|
||||||
|
return new Promise((resolveEvent, rejectEvent) => {
|
||||||
|
const timer = setTimeout(() => rejectEvent(new Error(`等待 ${method} 超时。`)), timeoutMs);
|
||||||
|
const waiters = this.eventWaiters.get(method) || [];
|
||||||
|
waiters.push((params) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolveEvent(params);
|
||||||
|
});
|
||||||
|
this.eventWaiters.set(method, waiters);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateAndWait(client, url) {
|
||||||
|
const loadPromise = client.waitForEvent('Page.loadEventFired').catch(() => undefined);
|
||||||
|
await client.send('Page.navigate', { url });
|
||||||
|
await loadPromise;
|
||||||
|
await delay(1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(client) {
|
||||||
|
await navigateAndWait(client, `${BASE_URL}/login`);
|
||||||
|
const serialized = JSON.stringify(AUTH_USER).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
|
await client.send('Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
sessionStorage.setItem('ftb_auth_session', '${serialized}');
|
||||||
|
localStorage.setItem('ftb_auth_persist', '${serialized}');
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(client, route, outputPath) {
|
||||||
|
await navigateAndWait(client, `${BASE_URL}${route}`);
|
||||||
|
await client.send('Emulation.setDeviceMetricsOverride', {
|
||||||
|
width: VIEWPORT.width,
|
||||||
|
height: VIEWPORT.height,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
mobile: false,
|
||||||
|
});
|
||||||
|
const screenshot = await client.send('Page.captureScreenshot', {
|
||||||
|
format: 'png',
|
||||||
|
fromSurface: true,
|
||||||
|
captureBeyondViewport: true,
|
||||||
|
});
|
||||||
|
writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminateBrowser(browser) {
|
||||||
|
if (browser.exitCode !== null) return;
|
||||||
|
|
||||||
|
const exited = new Promise((resolveExit) => {
|
||||||
|
browser.once('exit', resolveExit);
|
||||||
|
});
|
||||||
|
browser.kill();
|
||||||
|
await Promise.race([exited, delay(2000)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browserPath = findBrowser();
|
||||||
|
if (!browserPath) {
|
||||||
|
throw new Error('未找到 Chrome 或 Edge,请安装浏览器,或通过 CHROME_PATH 指定浏览器路径。');
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertServerAvailable();
|
||||||
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const userDataDir = join(tmpdir(), `wenfan-help-screenshots-${Date.now()}`);
|
||||||
|
const browser = launchBrowser(browserPath, userDataDir);
|
||||||
|
let client;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const browserWsEndpoint = await waitForDevtools(browser);
|
||||||
|
const pageWsEndpoint = await getPageWebSocketUrl(browserWsEndpoint);
|
||||||
|
client = new CdpClient(pageWsEndpoint);
|
||||||
|
await client.connect();
|
||||||
|
await client.send('Page.enable');
|
||||||
|
await client.send('Runtime.enable');
|
||||||
|
await authenticate(client);
|
||||||
|
|
||||||
|
for (const item of SCREENSHOTS) {
|
||||||
|
const outputPath = join(OUTPUT_DIR, item.file);
|
||||||
|
await capture(client, item.route, outputPath);
|
||||||
|
console.log(`saved ${outputPath}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
await terminateBrowser(browser);
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
console.warn(`临时浏览器目录未能立即清理,可稍后手动删除:${userDataDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
229
docs/superpowers/plans/2026-06-30-wenfan-xiaobao-help-center.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# 问翻小宝静态帮助中心 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the first-stage Wenfan Xiaobao static help center with local keyword matching, article answers, related actions, and local page screenshots.
|
||||||
|
|
||||||
|
**Architecture:** Store help content in frontend static TypeScript data, search it with pure local scoring functions, and render matched articles inside the existing chat-style Wenfan page. Screenshots are generated by a Node script that opens local routes with Chrome/Edge headless and writes PNG files to `public/help/wenfan-xiaobao/`.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js App Router, React client components, TypeScript, Tailwind CSS, Node built-ins, Chrome/Edge headless screenshot command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Create `apps/web/lib/wenfan-help-articles.ts`: static article data and shared types.
|
||||||
|
- Create `apps/web/lib/wenfan-help-search.ts`: query normalization, scoring, search results, fallback suggestions.
|
||||||
|
- Create `apps/web/lib/wenfan-help-search.test.ts`: TDD tests for keywords, synonyms, fallback, related articles.
|
||||||
|
- Modify `apps/web/app/wenfan-xiaobao/page.tsx`: replace static mock chat with local search-driven chat UI.
|
||||||
|
- Modify `apps/web/lib/wenfan-xiaobao-ui.test.ts`: assert page imports help search and renders help answer surfaces.
|
||||||
|
- Create `apps/web/scripts/capture-wenfan-help-screenshots.mjs`: local screenshot script.
|
||||||
|
- Create `apps/web/public/help/wenfan-xiaobao/*.png`: generated help images.
|
||||||
|
|
||||||
|
## Task 1: Static Help Search Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/lib/wenfan-help-search.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getFallbackHelpSuggestions, searchHelpArticles } from './wenfan-help-search';
|
||||||
|
import { WENFAN_HELP_ARTICLES } from './wenfan-help-articles';
|
||||||
|
|
||||||
|
test('searchHelpArticles matches common product creation phrasing', () => {
|
||||||
|
const result = searchHelpArticles('产品怎么新建,要填什么字段', WENFAN_HELP_ARTICLES);
|
||||||
|
assert.equal(result[0]?.article.id, 'product-create');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles matches demand adoption and version inclusion synonyms', () => {
|
||||||
|
const result = searchHelpArticles('需求怎么纳入版本号里面', WENFAN_HELP_ARTICLES);
|
||||||
|
assert.equal(result[0]?.article.id, 'version-requirement-include');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles matches dev task test submission phrasing', () => {
|
||||||
|
const result = searchHelpArticles('开发任务怎么提测', WENFAN_HELP_ARTICLES);
|
||||||
|
assert.equal(result[0]?.article.id, 'dev-task-workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchHelpArticles keeps related article ids from the matched article', () => {
|
||||||
|
const result = searchHelpArticles('AI拆解测试用例在哪里', WENFAN_HELP_ARTICLES);
|
||||||
|
assert.equal(result[0]?.article.id, 'ai-decompose');
|
||||||
|
assert.ok(result[0]?.relatedArticles.some((article) => article.id === 'product-plan'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFallbackHelpSuggestions returns starter examples when no article matches', () => {
|
||||||
|
assert.deepEqual(getFallbackHelpSuggestions().slice(0, 3), [
|
||||||
|
'怎么新建产品?',
|
||||||
|
'怎么把需求纳入版本?',
|
||||||
|
'开发任务怎么提测?',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: TypeScript fails because `wenfan-help-search` and `wenfan-help-articles` do not exist.
|
||||||
|
|
||||||
|
## Task 2: Static Help Article Data
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/lib/wenfan-help-articles.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create types and 12 article records**
|
||||||
|
|
||||||
|
Create `HelpArticle`, `HelpImage`, `WENFAN_HELP_ARTICLES`, and 12 records:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type HelpImage = {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HelpArticle = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
keywords: string[];
|
||||||
|
scenario: string;
|
||||||
|
entry: string;
|
||||||
|
steps: string[];
|
||||||
|
requiredFields: string[];
|
||||||
|
statusFlow: string[];
|
||||||
|
notes: string[];
|
||||||
|
images: HelpImage[];
|
||||||
|
relatedIds: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Every article must include module words, action words, oral phrasing, synonyms, and common spelling variants in `keywords`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify remaining failure**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: TypeScript still fails because `searchHelpArticles` is not implemented.
|
||||||
|
|
||||||
|
## Task 3: Local Help Search Engine
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/lib/wenfan-help-search.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement local scoring**
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type HelpSearchResult = {
|
||||||
|
article: HelpArticle;
|
||||||
|
score: number;
|
||||||
|
matchedKeywords: string[];
|
||||||
|
relatedArticles: HelpArticle[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeHelpQuery(input: string): string;
|
||||||
|
export function searchHelpArticles(query: string, articles?: HelpArticle[]): HelpSearchResult[];
|
||||||
|
export function getFallbackHelpSuggestions(): string[];
|
||||||
|
```
|
||||||
|
|
||||||
|
Scoring rules:
|
||||||
|
|
||||||
|
- Exact title inclusion: +20
|
||||||
|
- Category inclusion: +12
|
||||||
|
- Keyword inclusion: +10 per keyword
|
||||||
|
- Step / required field / status flow / note inclusion: +3 per hit
|
||||||
|
- Query shorter than 2 normalized characters returns empty results.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify pass**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: all help search tests pass.
|
||||||
|
|
||||||
|
## Task 4: Chat Page Integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/app/wenfan-xiaobao/page.tsx`
|
||||||
|
- Modify: `apps/web/lib/wenfan-xiaobao-ui.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update UI static test first**
|
||||||
|
|
||||||
|
Add assertions that the page imports `searchHelpArticles`, renders `HelpAnswer`, and contains article sections for entry, required fields, status flow, notes, and images.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: `wenfan xiaobao page` static test fails because page has not been wired to help search.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement local chat behavior**
|
||||||
|
|
||||||
|
Use `useState` for input and messages. On send:
|
||||||
|
|
||||||
|
- Search local help articles.
|
||||||
|
- If matched, append a user message and an article answer message.
|
||||||
|
- If no match, append fallback suggestions.
|
||||||
|
- Quick question buttons call the same send function.
|
||||||
|
|
||||||
|
Render `HelpAnswer` with article title, scenario, entry, steps, required fields, status flow, notes, images, and related buttons.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify pass**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
## Task 5: Screenshot Script and Images
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/scripts/capture-wenfan-help-screenshots.mjs`
|
||||||
|
- Create directory: `apps/web/public/help/wenfan-xiaobao/`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement Chrome/Edge headless screenshot script**
|
||||||
|
|
||||||
|
The script should:
|
||||||
|
|
||||||
|
- Detect Chrome or Edge on Windows.
|
||||||
|
- Use `--headless=new --screenshot=<file> --window-size=1440,1000 <url>`.
|
||||||
|
- Capture the approved route list from `http://localhost:3000`.
|
||||||
|
- Write PNG files into `apps/web/public/help/wenfan-xiaobao/`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run screenshot script**
|
||||||
|
|
||||||
|
Run: `node apps/web/scripts/capture-wenfan-help-screenshots.mjs`
|
||||||
|
|
||||||
|
Expected: PNG files are created for the configured routes. If the dev server is not available, the script exits with a clear message.
|
||||||
|
|
||||||
|
## Task 6: Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- All files touched above.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full tests**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: 0 failures.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run type check**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web type-check`
|
||||||
|
|
||||||
|
Expected: exit code 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check route response**
|
||||||
|
|
||||||
|
Run: `curl.exe -I http://localhost:3000/wenfan-xiaobao`
|
||||||
|
|
||||||
|
Expected: HTTP 200.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Inspect git status**
|
||||||
|
|
||||||
|
Run: `git status --short`
|
||||||
|
|
||||||
|
Expected: only intended Wenfan help center files and earlier Wenfan UI files are changed.
|
||||||