Files
ftb-project-management/apps/web/app/wenfan-xiaobao/page.tsx
Script Generator d2a07b0da2 feat(小宝): 优化助手入口与预警已读
关键改动:

- 更新 README 项目说明和小宝相关入口命名

- 优化问翻小宝页面与侧边栏测试

- 小宝预警已读状态抑制同签名重复 AI 请求

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 13:35:52 +08:00

498 lines
20 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, 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 {
createWenfanConversationRecord,
deleteWenfanConversationRecord,
updateWenfanConversationRecord,
type WenfanConversationRecord,
} from '@/lib/wenfan-conversation-history';
import {
getFallbackHelpMessage,
getFallbackHelpSuggestions,
searchHelpArticles,
shouldShowGenericHelpSuggestions,
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();
type WenfanConversation = WenfanConversationRecord<ChatMessage>;
const INITIAL_CONVERSATIONS: WenfanConversation[] = [
{ id: 'history-product-create', title: '怎么新建产品?', messages: INITIAL_MESSAGES },
{ id: 'history-version-requirements', title: '怎么把需求纳入版本?', messages: INITIAL_MESSAGES },
{ id: 'history-dev-task-submit', title: '开发任务怎么提测?', messages: INITIAL_MESSAGES },
{ id: 'history-test-bug', title: '测试用例失败后怎么提 Bug', messages: INITIAL_MESSAGES },
{ id: 'history-activity-log', title: '日志记录会记录哪些行为?', messages: INITIAL_MESSAGES },
];
export default function WenfanXiaobaoPage() {
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [conversations, setConversations] = useState<WenfanConversation[]>(INITIAL_CONVERSATIONS);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
const userQuestionCount = useMemo(
() => messages.filter((message) => message.role === 'user').length,
[messages],
);
const showGenericSuggestions = shouldShowGenericHelpSuggestions(userQuestionCount);
const showStarterQuestions = showGenericSuggestions && userQuestionCount === 0;
function handleNewConversation() {
const id = `conversation-${Date.now()}`;
const record = createWenfanConversationRecord<ChatMessage>(id, INITIAL_MESSAGES);
setConversations((current) => [record, ...current]);
setActiveConversationId(id);
setMessages(INITIAL_MESSAGES);
setInput('');
}
function openConversation(conversation: WenfanConversation) {
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setInput('');
}
function deleteConversation(event: MouseEvent<HTMLButtonElement>, id: string) {
event.stopPropagation();
setConversations((current) => deleteWenfanConversationRecord(current, id));
if (activeConversationId === id) {
setActiveConversationId(null);
setMessages(INITIAL_MESSAGES);
setInput('');
}
}
function askQuestion(rawQuestion: string) {
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',
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: getFallbackHelpMessage(showFallbackSuggestions),
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
};
const nextMessages = [...messages, userMessage, assistantMessage];
setMessages(nextMessages);
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
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={handleNewConversation}
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((conversation, index) => (
<div
key={conversation.id}
className={`group flex items-start gap-1 rounded-lg p-1.5 transition-colors ${
conversation.id === activeConversationId
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
: 'bg-white hover:bg-[var(--bg-subtle)]'
}`}
>
<button
type="button"
onClick={() => openConversation(conversation)}
className="min-w-0 flex-1 rounded-md px-1.5 py-1.5 text-left"
>
<p className="truncate text-[13px] leading-5 text-[#202123]">{conversation.title}</p>
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
</button>
<button
type="button"
onClick={(event) => deleteConversation(event, conversation.id)}
className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#8a8a8a] opacity-0 transition-opacity hover:bg-white hover:text-[#171717] focus:opacity-100 group-hover:opacity-100"
aria-label={`删除历史记录:${conversation.title}`}
title="删除历史记录"
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.9} />
</button>
</div>
))}
</div>
</div>
</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}
showGenericSuggestions={showGenericSuggestions}
/>
))}
{showStarterQuestions && (
<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,
showGenericSuggestions,
}: {
message: ChatMessage;
onAsk: (question: string) => void;
showGenericSuggestions: boolean;
}) {
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">
{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]">
{getFallbackHelpMessage(showGenericSuggestions && message.suggestions.length > 0)}
</p>
{showGenericSuggestions && message.suggestions.length > 0 && (
<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;
const [previewImage, setPreviewImage] = useState<HelpArticle['images'][number] | null>(null);
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">
<button
type="button"
onClick={() => setPreviewImage(image)}
className="group relative block w-full overflow-hidden text-left"
aria-label={`放大查看:${image.caption}`}
>
<img src={image.src} alt={image.alt} className="max-h-[320px] w-full object-cover object-top" />
<span className="absolute bottom-3 right-3 inline-flex items-center gap-1.5 rounded-full bg-[#171717]/85 px-2.5 py-1 text-[11px] font-medium text-white opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
<Maximize2 className="h-3.5 w-3.5" strokeWidth={1.9} />
</span>
</button>
<figcaption className="border-t border-[#ececec] px-3 py-2 text-[12px] text-[#6b6b6b]">
{image.caption}
</figcaption>
</figure>
))}
</div>
</div>
)}
{previewImage && (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 px-8 py-8"
role="dialog"
aria-modal="true"
aria-label={previewImage.caption}
onClick={() => setPreviewImage(null)}
>
<div
className="flex max-h-full w-full max-w-6xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[#ececec] px-4">
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-[#171717]">{previewImage.caption}</p>
<p className="truncate text-[11px] text-[#6b6b6b]">{previewImage.alt}</p>
</div>
<button
type="button"
onClick={() => setPreviewImage(null)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
aria-label="关闭图片预览"
>
<X className="h-4 w-4" strokeWidth={2} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto bg-[#111] p-4">
<img
src={previewImage.src}
alt={previewImage.alt}
className="mx-auto max-h-[calc(100vh-160px)] w-auto max-w-full rounded-lg object-contain"
/>
</div>
</div>
</div>
)}
{relatedArticles.length > 0 && (
<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>
);
}