diff --git a/src/components/features/ExportDropdown.tsx b/src/components/features/ExportDropdown.tsx new file mode 100644 index 0000000..bdb7fdf --- /dev/null +++ b/src/components/features/ExportDropdown.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { + MoreHorizontal, + FileText, + FileJson, + FileCode, + FileType, + Loader2, + Check, + AlertCircle, + Download, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + generateExportFilename, + generatePdfInBrowser, + type ExportFormat, + type ExportData, +} from '@/lib/export'; + +interface ExportDropdownProps { + conversationId: string; + conversationTitle: string; + disabled?: boolean; +} + +// 导出格式配置 +const EXPORT_FORMATS: { + format: ExportFormat; + label: string; + icon: typeof FileText; + description: string; +}[] = [ + { + format: 'markdown', + label: 'Markdown', + icon: FileText, + description: '纯文本格式,易于编辑', + }, + { + format: 'json', + label: 'JSON', + icon: FileJson, + description: '完整数据,便于备份', + }, + { + format: 'html', + label: 'HTML', + icon: FileCode, + description: '网页格式,保留样式', + }, + { + format: 'pdf', + label: 'PDF', + icon: FileType, + description: '文档格式,适合打印', + }, +]; + +export function ExportDropdown({ + conversationId, + conversationTitle, + disabled, +}: ExportDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [exporting, setExporting] = useState(null); + const [exportSuccess, setExportSuccess] = useState(null); + const [exportError, setExportError] = useState(null); + const menuRef = useRef(null); + + // 点击外部关闭菜单 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // 清除成功/错误状态 + useEffect(() => { + if (exportSuccess) { + const timer = setTimeout(() => setExportSuccess(null), 2000); + return () => clearTimeout(timer); + } + }, [exportSuccess]); + + useEffect(() => { + if (exportError) { + const timer = setTimeout(() => setExportError(null), 3000); + return () => clearTimeout(timer); + } + }, [exportError]); + + /** + * 触发文件下载 + */ + const downloadFile = (content: string | Blob, filename: string, mimeType: string) => { + const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + /** + * 处理导出 + */ + const handleExport = async (format: ExportFormat) => { + if (exporting) return; + + try { + setExporting(format); + setExportError(null); + + if (format === 'pdf') { + // PDF 需要先获取数据,然后在客户端生成 + const response = await fetch( + `/api/conversations/${conversationId}/export?format=pdf` + ); + + if (!response.ok) { + throw new Error('获取导出数据失败'); + } + + const data = await response.json() as ExportData; + + // 在客户端生成 PDF + const pdfBlob = await generatePdfInBrowser(data, { + format: 'pdf', + includeThinking: true, + includeToolCalls: true, + includeImages: true, + }); + + const filename = generateExportFilename(conversationTitle, 'pdf'); + downloadFile(pdfBlob, filename, 'application/pdf'); + } else { + // 其他格式直接从 API 获取 + const response = await fetch( + `/api/conversations/${conversationId}/export?format=${format}` + ); + + if (!response.ok) { + throw new Error('导出失败'); + } + + // 从 Content-Disposition 获取文件名,或生成默认文件名 + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = generateExportFilename(conversationTitle, format); + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename\*=UTF-8''(.+)/); + if (filenameMatch) { + filename = decodeURIComponent(filenameMatch[1]); + } + } + + const content = await response.text(); + const mimeType = response.headers.get('Content-Type') || 'text/plain'; + downloadFile(content, filename, mimeType); + } + + setExportSuccess(format); + setIsOpen(false); + } catch (error) { + console.error('Export error:', error); + setExportError(error instanceof Error ? error.message : '导出失败'); + } finally { + setExporting(null); + } + }; + + return ( +
+ {/* 触发按钮 */} + + + {/* 下拉菜单 */} + {isOpen && ( +
+ {/* 导出选项标题 */} +
+ + 导出对话 +
+ + {/* 导出格式列表 */} + {EXPORT_FORMATS.map(({ format, label, icon: Icon, description }) => ( + + ))} +
+ )} + + {/* 错误提示 */} + {exportError && ( +
+ + {exportError} +
+ )} +
+ ); +}