claude-code-cchui/src/components/features/ShareNavigator.tsx
gaoziman b0ecf51700 feat(组件): 添加对话分享功能组件
- ShareModal: 分享弹窗组件,支持内容控制和二维码
- MessageSelector: 消息选择器,支持按轮次选择分享内容
- ShareNavigator: 分享页面导航组件,支持目录和返回顶部
2025-12-24 15:58:37 +08:00

177 lines
5.7 KiB
TypeScript

'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>
</>
)}
</>
);
}