feat(组件): 添加对话分享功能组件

- ShareModal: 分享弹窗组件,支持内容控制和二维码
- MessageSelector: 消息选择器,支持按轮次选择分享内容
- ShareNavigator: 分享页面导航组件,支持目录和返回顶部
This commit is contained in:
gaoziman 2025-12-24 15:58:37 +08:00
parent abc6cdbcfd
commit b0ecf51700
3 changed files with 987 additions and 0 deletions

View File

@ -0,0 +1,344 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Search, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
// 消息接口
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
}
// 问答轮次接口
interface Round {
index: number;
userMessage: Message;
assistantMessage?: Message;
preview: string;
messageIds: string[];
}
// 选择模式
type SelectionMode = 'all' | 'recent_1' | 'recent_3' | 'recent_5' | 'custom';
interface MessageSelectorProps {
messages: Message[];
selectedMessageIds: string[] | null;
onSelectionChange: (messageIds: string[] | null) => void;
}
export function MessageSelector({
messages,
selectedMessageIds,
onSelectionChange,
}: MessageSelectorProps) {
const [selectionMode, setSelectionMode] = useState<SelectionMode>('all');
const [searchQuery, setSearchQuery] = useState('');
// 将消息按问答轮次分组
const rounds = useMemo(() => {
const result: Round[] = [];
let currentRound: Round | null = null;
messages.forEach((msg) => {
if (msg.role === 'user') {
if (currentRound) {
result.push(currentRound);
}
currentRound = {
index: result.length + 1,
userMessage: msg,
preview: msg.content.slice(0, 50) + (msg.content.length > 50 ? '...' : ''),
messageIds: [msg.id],
};
} else if (msg.role === 'assistant' && currentRound) {
currentRound.assistantMessage = msg;
currentRound.messageIds.push(msg.id);
}
});
if (currentRound) {
result.push(currentRound);
}
return result;
}, [messages]);
// 根据消息数量生成选项
const modeOptions = useMemo(() => {
const options: { value: SelectionMode; label: string }[] = [
{ value: 'all', label: '全部对话' },
];
if (rounds.length > 1) {
options.push({ value: 'recent_1', label: '最近 1 轮' });
}
if (rounds.length > 3) {
options.push({ value: 'recent_3', label: '最近 3 轮' });
}
if (rounds.length > 5) {
options.push({ value: 'recent_5', label: '最近 5 轮' });
}
if (rounds.length > 1) {
options.push({ value: 'custom', label: '自定义选择' });
}
return options;
}, [rounds.length]);
// 根据选择模式获取选中的消息ID
const getMessageIdsByMode = useCallback(
(mode: SelectionMode): string[] | null => {
if (mode === 'all') return null;
if (mode === 'custom') return selectedMessageIds;
const recentCount = parseInt(mode.replace('recent_', ''));
const recentRounds = rounds.slice(-recentCount);
return recentRounds.flatMap((r) => r.messageIds);
},
[rounds, selectedMessageIds]
);
// 处理模式变化
const handleModeChange = (mode: SelectionMode) => {
setSelectionMode(mode);
if (mode === 'custom') {
if (selectedMessageIds === null) {
onSelectionChange(messages.map((m) => m.id));
}
} else {
onSelectionChange(getMessageIdsByMode(mode));
}
};
// 当前选中的轮次
const selectedRoundIndices = useMemo(() => {
if (selectedMessageIds === null) return new Set(rounds.map((r) => r.index));
return new Set(
rounds
.filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id)))
.map((r) => r.index)
);
}, [rounds, selectedMessageIds]);
// 切换轮次选择
const toggleRound = (round: Round) => {
if (selectionMode !== 'custom') return;
if (selectedMessageIds === null) {
const allIds = messages.map((m) => m.id);
const newIds = allIds.filter((id) => !round.messageIds.includes(id));
onSelectionChange(newIds);
} else {
const isSelected = round.messageIds.some((id) => selectedMessageIds.includes(id));
if (isSelected) {
const newIds = selectedMessageIds.filter((id) => !round.messageIds.includes(id));
onSelectionChange(newIds.length > 0 ? newIds : []);
} else {
onSelectionChange([...selectedMessageIds, ...round.messageIds]);
}
}
};
// 快捷操作
const handleSelectAll = () => {
onSelectionChange(messages.map((m) => m.id));
};
const handleSelectNone = () => {
onSelectionChange([]);
};
const handleSelectInverse = () => {
if (selectedMessageIds === null) {
onSelectionChange([]);
} else {
const allIds = messages.map((m) => m.id);
const inverseIds = allIds.filter((id) => !selectedMessageIds.includes(id));
onSelectionChange(inverseIds);
}
};
// 搜索过滤
const filteredRounds = useMemo(() => {
if (!searchQuery.trim()) return rounds;
const query = searchQuery.toLowerCase();
return rounds.filter(
(round) =>
round.userMessage.content.toLowerCase().includes(query) ||
round.assistantMessage?.content.toLowerCase().includes(query)
);
}, [rounds, searchQuery]);
// 计算选中数量
const selectedCount = useMemo(() => {
if (selectedMessageIds === null) return rounds.length;
return rounds.filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id))).length;
}, [rounds, selectedMessageIds]);
// 如果只有一轮对话,不显示选择器
if (rounds.length <= 1) {
return null;
}
const isCustomMode = selectionMode === 'custom';
return (
<div className="space-y-3">
{/* 标题行 */}
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
</label>
<span className="text-xs text-[var(--color-text-tertiary)]">
{rounds.length}
</span>
</div>
{/* 左右分栏布局 */}
<div className="flex gap-3 border border-[var(--color-border)] rounded-lg overflow-hidden">
{/* 左侧:选择模式 */}
<div className="w-[140px] flex-shrink-0 bg-[var(--color-bg-secondary)] border-r border-[var(--color-border)] p-2 space-y-1">
{modeOptions.map((option) => (
<label
key={option.value}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-all text-sm',
selectionMode === option.value
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
)}
>
<input
type="radio"
name="selectionMode"
value={option.value}
checked={selectionMode === option.value}
onChange={() => handleModeChange(option.value)}
className="sr-only"
/>
<span className="truncate">{option.label}</span>
</label>
))}
</div>
{/* 右侧:消息列表 */}
<div className="flex-1 flex flex-col min-w-0">
{/* 搜索框和快捷操作 */}
<div className="flex items-center gap-2 p-2 border-b border-[var(--color-border)]">
<div className="relative flex-1">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
/>
<input
type="text"
placeholder="搜索消息..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-xs bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-md text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
/>
</div>
{isCustomMode && (
<div className="flex items-center gap-1 text-xs flex-shrink-0">
<button
onClick={handleSelectAll}
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
>
</button>
<span className="text-[var(--color-border)]">|</span>
<button
onClick={handleSelectNone}
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
>
</button>
<span className="text-[var(--color-border)]">|</span>
<button
onClick={handleSelectInverse}
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
>
</button>
</div>
)}
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto max-h-[180px]">
{filteredRounds.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
</div>
) : (
filteredRounds.map((round) => {
const isSelected = selectedRoundIndices.has(round.index);
const canToggle = isCustomMode;
return (
<div
key={round.index}
onClick={() => canToggle && toggleRound(round)}
className={cn(
'flex items-center gap-2 px-3 py-2.5 border-b border-[var(--color-border)] last:border-b-0 transition-colors',
canToggle ? 'cursor-pointer hover:bg-[var(--color-bg-hover)]' : '',
isSelected && 'bg-[var(--color-primary)]/5'
)}
>
{/* 复选框 */}
<div
className={cn(
'flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors',
isSelected
? 'bg-[var(--color-primary)] border-[var(--color-primary)]'
: 'border-[var(--color-border)]',
!canToggle && 'opacity-60'
)}
>
{isSelected && <Check size={10} className="text-white" />}
</div>
{/* 序号 */}
<span
className={cn(
'flex-shrink-0 w-5 h-5 rounded-full text-xs flex items-center justify-center',
isSelected
? 'bg-[var(--color-primary)]/20 text-[var(--color-primary)]'
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]'
)}
>
{round.index}
</span>
{/* 内容预览 */}
<span
className={cn(
'flex-1 text-sm truncate',
isSelected
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)]'
)}
>
{round.preview}
</span>
</div>
);
})
)}
</div>
{/* 底部统计 */}
<div className="flex items-center justify-between px-3 py-2 bg-[var(--color-bg-secondary)] border-t border-[var(--color-border)] text-xs text-[var(--color-text-tertiary)]">
<span> {selectedCount}/{rounds.length} </span>
{isCustomMode && selectedCount === 0 && (
<span className="text-amber-600"></span>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,467 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import {
X,
Link2,
Copy,
Check,
Loader2,
Eye,
MessageSquare,
Wrench,
ImageIcon,
QrCode,
ExternalLink,
Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { QRCodeSVG } from 'qrcode.react';
import { MessageSelector } from './MessageSelector';
// 消息接口
interface MessageForShare {
id: string;
role: 'user' | 'assistant';
content: string;
}
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
conversationId: string;
conversationTitle: string;
messages?: MessageForShare[];
onPreview?: (url: string) => void;
}
interface ShareInfo {
shareId: string;
shareCode: string;
shareUrl: string;
title: string;
viewCount?: number;
createdAt?: string;
}
export function ShareModal({
isOpen,
onClose,
conversationId,
conversationTitle,
messages = [],
onPreview,
}: ShareModalProps) {
// 表单状态
const [title, setTitle] = useState(conversationTitle);
const [includeThinking, setIncludeThinking] = useState(true);
const [includeToolCalls, setIncludeToolCalls] = useState(false);
const [includeImages, setIncludeImages] = useState(true);
const [selectedMessageIds, setSelectedMessageIds] = useState<string[] | null>(null); // null 表示全部
// UI 状态
const [isCreating, setIsCreating] = useState(false);
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showQrCode, setShowQrCode] = useState(false);
// 已有分享列表
const [existingShares, setExistingShares] = useState<ShareInfo[]>([]);
const [loadingShares, setLoadingShares] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
// 重置状态
useEffect(() => {
if (isOpen) {
setTitle(conversationTitle);
setShareInfo(null);
setError(null);
setCopied(false);
setShowQrCode(false);
setSelectedMessageIds(null); // 重置为全部
loadExistingShares();
}
}, [isOpen, conversationTitle]);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
// ESC 关闭
useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
}
return () => {
document.removeEventListener('keydown', handleEsc);
};
}, [isOpen, onClose]);
// 加载已有分享
const loadExistingShares = async () => {
try {
setLoadingShares(true);
const response = await fetch(`/api/conversations/${conversationId}/share`);
if (response.ok) {
const data = await response.json();
setExistingShares(data.shares || []);
}
} catch (err) {
console.error('Load shares error:', err);
} finally {
setLoadingShares(false);
}
};
// 创建分享
const handleCreateShare = async () => {
// 验证:自定义选择时必须至少选择一条消息
if (selectedMessageIds !== null && selectedMessageIds.length === 0) {
setError('请至少选择一轮对话进行分享');
return;
}
try {
setIsCreating(true);
setError(null);
const response = await fetch(`/api/conversations/${conversationId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
includeThinking,
includeToolCalls,
includeImages,
selectedMessageIds, // 新增选择的消息IDnull 表示全部
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '创建分享失败');
}
const data = await response.json();
setShareInfo(data);
loadExistingShares();
} catch (err) {
setError(err instanceof Error ? err.message : '创建分享失败');
} finally {
setIsCreating(false);
}
};
// 复制链接
const handleCopy = async () => {
if (!shareInfo?.shareUrl) return;
try {
await navigator.clipboard.writeText(shareInfo.shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Copy error:', err);
}
};
// 删除分享
const handleDeleteShare = async (shareId: string) => {
if (!confirm('确定要删除此分享吗?')) return;
try {
const response = await fetch(
`/api/conversations/${conversationId}/share?shareId=${shareId}`,
{ method: 'DELETE' }
);
if (response.ok) {
setExistingShares((prev) => prev.filter((s) => s.shareId !== shareId));
if (shareInfo?.shareId === shareId) {
setShareInfo(null);
}
}
} catch (err) {
console.error('Delete share error:', err);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
ref={modalRef}
className="relative w-full max-w-2xl bg-[var(--color-bg-primary)] rounded-[4px] shadow-xl"
>
{/* 头部 */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2">
<Link2 size={20} className="text-[var(--color-primary)]" />
<h2 className="text-base font-medium text-[var(--color-text-primary)]">
</h2>
</div>
<button
onClick={onClose}
className="p-1 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
>
<X size={18} />
</button>
</div>
{/* 内容 */}
<div className="p-5">
{shareInfo ? (
// 分享成功后显示链接
<div className="space-y-4">
<div className="flex items-center gap-2 text-green-600">
<Check size={20} />
<span className="font-medium"></span>
</div>
{/* 链接复制区域 */}
<div className="flex items-center gap-2 p-3 bg-[var(--color-bg-secondary)] rounded-lg">
<input
type="text"
value={shareInfo.shareUrl}
readOnly
className="flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none"
/>
<button
onClick={handleCopy}
className={cn(
'p-2 rounded transition-colors',
copied
? 'bg-green-100 text-green-600'
: 'hover:bg-[var(--color-bg-hover)] text-[var(--color-text-secondary)]'
)}
title="复制链接"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
{/* 二维码 */}
<div className="space-y-2">
<button
onClick={() => setShowQrCode(!showQrCode)}
className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<QrCode size={16} />
<span>{showQrCode ? '隐藏二维码' : '显示二维码'}</span>
</button>
{showQrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg">
<QRCodeSVG
value={shareInfo.shareUrl}
size={160}
level="M"
includeMargin
/>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex gap-2">
<button
onClick={() => {
if (onPreview && shareInfo.shareUrl) {
onClose();
onPreview(shareInfo.shareUrl);
} else {
window.open(shareInfo.shareUrl, '_blank');
}
}}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-hover)] transition-colors"
>
<ExternalLink size={16} />
<span></span>
</button>
<button
onClick={() => setShareInfo(null)}
className="px-4 py-2.5 text-[var(--color-text-secondary)] bg-[var(--color-bg-secondary)] rounded-lg hover:bg-[var(--color-bg-hover)] transition-colors"
>
</button>
</div>
</div>
) : (
// 创建分享表单
<div className="space-y-4">
{/* 标题 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1.5">
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="输入分享标题"
className="w-full px-3 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
/>
</div>
{/* 消息选择器 */}
{messages.length > 0 && (
<MessageSelector
messages={messages}
selectedMessageIds={selectedMessageIds}
onSelectionChange={setSelectedMessageIds}
/>
)}
{/* 内容选项 - 横向布局 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
</label>
<div className="grid grid-cols-3 gap-2">
<label className="flex items-center gap-2 px-3 py-2.5 bg-[var(--color-bg-secondary)] rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors">
<input
type="checkbox"
checked={includeThinking}
onChange={(e) => setIncludeThinking(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<MessageSquare size={14} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
<span className="text-xs text-[var(--color-text-primary)]"></span>
</label>
<label className="flex items-center gap-2 px-3 py-2.5 bg-[var(--color-bg-secondary)] rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors">
<input
type="checkbox"
checked={includeToolCalls}
onChange={(e) => setIncludeToolCalls(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<Wrench size={14} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
<span className="text-xs text-[var(--color-text-primary)]"></span>
</label>
<label className="flex items-center gap-2 px-3 py-2.5 bg-[var(--color-bg-secondary)] rounded-lg cursor-pointer hover:bg-[var(--color-bg-hover)] transition-colors">
<input
type="checkbox"
checked={includeImages}
onChange={(e) => setIncludeImages(e.target.checked)}
className="w-4 h-4 rounded border-[var(--color-border)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<ImageIcon size={14} className="text-[var(--color-text-tertiary)] flex-shrink-0" />
<span className="text-xs text-[var(--color-text-primary)]"></span>
</label>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">
{error}
</div>
)}
{/* 创建按钮 */}
<button
onClick={handleCreateShare}
disabled={isCreating || !title.trim()}
className={cn(
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-colors',
isCreating || !title.trim()
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] cursor-not-allowed'
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)]'
)}
>
{isCreating ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>...</span>
</>
) : (
<>
<Link2 size={16} />
<span></span>
</>
)}
</button>
</div>
)}
{/* 已有分享列表 */}
{existingShares.length > 0 && !shareInfo && (
<div className="mt-6 pt-4 border-t border-[var(--color-border)]">
<h3 className="text-sm font-medium text-[var(--color-text-secondary)] mb-3">
({existingShares.length})
</h3>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{existingShares.map((share) => (
<div
key={share.shareId}
className="flex items-center justify-between p-3 bg-[var(--color-bg-secondary)] rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{share.title}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1">
<Eye size={12} />
{share.viewCount || 0}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
navigator.clipboard.writeText(share.shareUrl);
}}
className="p-1.5 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
title="复制链接"
>
<Copy size={14} />
</button>
<button
onClick={() => {
if (onPreview) {
onClose();
onPreview(share.shareUrl);
} else {
window.open(share.shareUrl, '_blank');
}
}}
className="p-1.5 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
title="打开"
>
<ExternalLink size={14} />
</button>
<button
onClick={() => handleDeleteShare(share.shareId)}
className="p-1.5 text-red-500 hover:bg-red-50 rounded transition-colors"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,176 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { List, ChevronUp, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface MessageAnchor {
id: string;
index: number;
preview: string;
}
interface ShareNavigatorProps {
messages: Array<{
id: string;
role: string;
content: string;
}>;
}
export function ShareNavigator({ messages }: ShareNavigatorProps) {
const [isOpen, setIsOpen] = useState(false);
const [showBackTop, setShowBackTop] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
// 提取用户消息作为目录项
const userMessages: MessageAnchor[] = messages
.filter((msg) => msg.role === 'user')
.map((msg, idx) => ({
id: msg.id,
index: idx + 1,
preview: msg.content.slice(0, 30) + (msg.content.length > 30 ? '...' : ''),
}));
// 监听滚动,显示/隐藏回到顶部按钮
useEffect(() => {
const handleScroll = () => {
setShowBackTop(window.scrollY > 500);
// 更新当前可视消息
const messageElements = document.querySelectorAll('[data-msg-index]');
let currentIndex: number | null = null;
messageElements.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.top <= 200 && rect.bottom > 0) {
currentIndex = parseInt(el.getAttribute('data-msg-index') || '0');
}
});
setActiveIndex(currentIndex);
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // 初始化
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 跳转到指定消息
const scrollToMessage = useCallback((id: string, index: number) => {
const element = document.getElementById(`msg-${id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 更新 URL hash
window.history.replaceState(null, '', `#msg-${index}`);
}
setIsOpen(false);
}, []);
// 回到顶部
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
window.history.replaceState(null, '', window.location.pathname);
}, []);
// 如果用户消息少于 2 条,不显示导航
if (userMessages.length < 2) {
return null;
}
return (
<>
{/* 浮动导航按钮组 */}
<div className="fixed right-6 bottom-6 z-50 flex flex-col items-end gap-3">
{/* 回到顶部按钮 */}
{showBackTop && (
<button
onClick={scrollToTop}
className="w-10 h-10 bg-white border border-gray-200 rounded-full shadow-lg flex items-center justify-center text-gray-600 hover:bg-gray-50 hover:text-[#DB6639] transition-all"
title="回到顶部"
>
<ChevronUp size={20} />
</button>
)}
{/* 目录按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
'w-10 h-10 rounded-full shadow-lg flex items-center justify-center transition-all',
isOpen
? 'bg-[#DB6639] text-white'
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-[#DB6639]'
)}
title="消息目录"
>
{isOpen ? <X size={20} /> : <List size={20} />}
</button>
</div>
{/* 目录面板 */}
{isOpen && (
<>
{/* 背景遮罩 */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* 目录内容 */}
<div className="fixed right-6 bottom-20 z-50 w-72 max-h-[60vh] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
{/* 头部 */}
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
</span>
<span className="text-xs text-gray-500">
{userMessages.length}
</span>
</div>
</div>
{/* 目录列表 */}
<div className="overflow-y-auto max-h-[calc(60vh-48px)]">
{userMessages.map((msg) => (
<button
key={msg.id}
onClick={() => scrollToMessage(msg.id, msg.index)}
className={cn(
'w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-b-0',
activeIndex === msg.index && 'bg-orange-50 border-l-2 border-l-[#DB6639]'
)}
>
<div className="flex items-start gap-2">
<span
className={cn(
'flex-shrink-0 w-5 h-5 rounded-full text-xs flex items-center justify-center mt-0.5',
activeIndex === msg.index
? 'bg-[#DB6639] text-white'
: 'bg-gray-200 text-gray-600'
)}
>
{msg.index}
</span>
<span
className={cn(
'text-sm leading-relaxed',
activeIndex === msg.index
? 'text-[#DB6639] font-medium'
: 'text-gray-700'
)}
>
{msg.preview}
</span>
</div>
</button>
))}
</div>
</div>
</>
)}
</>
);
}