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

468 lines
17 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 { 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>
);
}