- ShareModal: 分享弹窗组件,支持内容控制和二维码 - MessageSelector: 消息选择器,支持按轮次选择分享内容 - ShareNavigator: 分享页面导航组件,支持目录和返回顶部
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
'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, // 新增:选择的消息ID,null 表示全部
|
||
}),
|
||
});
|
||
|
||
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>
|
||
);
|
||
}
|