feat(问翻小宝): 接入静态帮助中心
This commit is contained in:
@@ -1,48 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, KeyboardEvent, useMemo, useState } from 'react';
|
||||
import {
|
||||
BotMessageSquare,
|
||||
Image as ImageIcon,
|
||||
MessageCircleQuestionMark,
|
||||
Mic,
|
||||
Plus,
|
||||
Search,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
} 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 = [
|
||||
{ title: '如何从产品创建到版本发布?', time: '今天' },
|
||||
{ title: '需求池的状态分别代表什么?', time: '今天' },
|
||||
{ title: '成员离职后怎么转交任务?', time: '昨天' },
|
||||
{ title: 'AI 配置和模型在哪里维护?', time: '昨天' },
|
||||
{ title: '测试用例失败后怎么提 Bug?', time: '周一' },
|
||||
];
|
||||
type ChatMessage =
|
||||
| {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
type: 'intro';
|
||||
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 MESSAGES = [
|
||||
const INITIAL_MESSAGES: ChatMessage[] = [
|
||||
{
|
||||
id: 'intro',
|
||||
role: 'assistant',
|
||||
title: '问翻小宝',
|
||||
content: '我是 FTB 的系统帮助助手。你可以直接问产品、项目、版本、需求、任务、测试、Bug、权限和 AI 配置相关的使用问题。',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
title: '我',
|
||||
content: '怎么把需求放进某个版本里?',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
title: '问翻小宝',
|
||||
content: '进入版本详情后,在需求区域选择当前项目下已采纳的需求;保存后,版本会基于这些需求继续承载开发任务、测试用例和发布风险分析。',
|
||||
type: 'intro',
|
||||
content:
|
||||
'我是问翻小宝,第一阶段只从内置帮助中心回答系统怎么用,不调用 AI,也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。',
|
||||
},
|
||||
];
|
||||
|
||||
const STARTER_QUESTIONS = getFallbackHelpSuggestions();
|
||||
|
||||
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 (
|
||||
<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)]">
|
||||
@@ -50,6 +118,10 @@ export default function WenfanXiaobaoPage() {
|
||||
<div className="space-y-3 p-3">
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<p className="px-3 py-2 text-[12px] font-medium text-[#6b6b6b]">历史记录</p>
|
||||
<div className="space-y-1">
|
||||
{HISTORY_ITEMS.map((item, index) => (
|
||||
{visibleHistory.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
key={`${item}-${index}`}
|
||||
type="button"
|
||||
onClick={() => askQuestion(item)}
|
||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||
index === 0
|
||||
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
|
||||
: 'bg-white hover:bg-[var(--bg-subtle)]'
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-[13px] leading-5 text-[#202123]">{item.title}</p>
|
||||
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{item.time}</p>
|
||||
<p className="truncate text-[13px] leading-5 text-[#202123]">{item}</p>
|
||||
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -95,20 +168,21 @@ export default function WenfanXiaobaoPage() {
|
||||
<h1 className="truncate text-[15px] font-semibold text-[#171717]">问翻小宝</h1>
|
||||
</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>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-7 px-6 py-8">
|
||||
{MESSAGES.map((message) => (
|
||||
<MessageRow key={`${message.title}-${message.content}`} message={message} />
|
||||
{messages.map((message) => (
|
||||
<MessageRow key={message.id} message={message} onAsk={askQuestion} />
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{QUICK_QUESTIONS.map((question) => (
|
||||
{STARTER_QUESTIONS.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
onClick={() => askQuestion(question)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{question}
|
||||
@@ -119,12 +193,15 @@ export default function WenfanXiaobaoPage() {
|
||||
</div>
|
||||
|
||||
<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]">
|
||||
<textarea
|
||||
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]"
|
||||
placeholder="输入系统使用问题,或点击语音按钮"
|
||||
placeholder="输入系统使用问题,例如:需求怎么纳入版本?"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3 pb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -138,21 +215,22 @@ export default function WenfanXiaobaoPage() {
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#171717] text-white transition-colors hover:bg-[#303030]"
|
||||
type="submit"
|
||||
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="发送"
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
@@ -160,10 +238,8 @@ export default function WenfanXiaobaoPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ message }: { message: { role: string; title: string; content: string } }) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
if (isUser) {
|
||||
function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question: string) => void }) {
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<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]">
|
||||
@@ -179,9 +255,109 @@ function MessageRow({ message }: { message: { role: string; title: string; conte
|
||||
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
||||
</div>
|
||||
<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="text-[14px] leading-7 text-[#202123]">{message.content}</p>
|
||||
<p className="mb-1 text-[13px] font-semibold text-[#171717]">问翻小宝</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user