Files
ftb-project-management/apps/web/app/wenfan-xiaobao/page.tsx
2026-06-30 19:27:23 +08:00

364 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { FormEvent, KeyboardEvent, useMemo, useState } from 'react';
import {
BotMessageSquare,
Image as ImageIcon,
MessageCircleQuestionMark,
Mic,
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';
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 INITIAL_MESSAGES: ChatMessage[] = [
{
id: 'intro',
role: 'assistant',
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)]">
<aside className="hidden min-h-0 flex-col border-r border-[#e5e5e5] bg-white lg:flex">
<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} />
</button>
<label className="flex h-9 items-center gap-2 rounded-lg bg-white px-3 text-[#8a8a8a] ring-1 ring-[#e5e5e5]">
<Search className="h-4 w-4 shrink-0" strokeWidth={1.8} />
<input
className="min-w-0 flex-1 bg-transparent text-[13px] text-[#171717] outline-none placeholder:text-[#8a8a8a]"
placeholder="搜索历史记录"
/>
</label>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<p className="px-3 py-2 text-[12px] font-medium text-[#6b6b6b]"></p>
<div className="space-y-1">
{visibleHistory.map((item, index) => (
<button
key={`${item}-${index}`}
type="button"
onClick={() => askQuestion(item)}
className={`w-full rounded-lg p-3 text-left transition-colors ${
index === 0
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
: 'bg-white hover:bg-[var(--bg-subtle)]'
}`}
>
<p className="truncate text-[13px] leading-5 text-[#202123]">{item}</p>
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
</button>
))}
</div>
</div>
</aside>
<section className="flex min-h-0 flex-col bg-white">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[#ececec] px-5">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#f2f2f2] text-[#171717]">
<MessageCircleQuestionMark className="h-4 w-4" strokeWidth={2} />
</div>
<div className="min-w-0">
<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>
</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.id} message={message} onAsk={askQuestion} />
))}
<div className="flex flex-wrap gap-2 pt-2">
{STARTER_QUESTIONS.map((question) => (
<button
key={question}
type="button"
onClick={() => askQuestion(question)}
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
>
{question}
</button>
))}
</div>
</div>
</div>
<footer className="shrink-0 bg-white px-6 pb-6 pt-2">
<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="输入系统使用问题,例如:需求怎么纳入版本?"
/>
<div className="flex items-center justify-between px-3 pb-3">
<div className="flex items-center gap-1">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
title="语音输入"
>
<Mic className="h-4 w-4" strokeWidth={1.9} />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
title="内置帮助"
>
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
</button>
</div>
<button
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>
</form>
</footer>
</section>
</main>
</div>
);
}
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]">
{message.content}
</div>
</div>
);
}
return (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#171717] text-white">
<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]"></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>
);
}