import React, { useState } from 'react'; import { createPortal } from 'react-dom'; import { Download, FileText, FileImage, File, Loader2, ChevronDown, Printer, Camera } from 'lucide-react'; import { ChineseButton } from './ChineseButton'; import { cn } from '../../lib/utils'; import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; export type DownloadFormat = 'markdown' | 'pdf' | 'png'; export type ExportMode = 'server' | 'frontend'; interface DownloadButtonProps { analysisData: any; analysisType: 'bazi' | 'ziwei' | 'yijing'; userName?: string; onDownload?: (format: DownloadFormat) => Promise; className?: string; disabled?: boolean; targetElementId?: string; // 用于前端导出的目标元素ID } const DownloadButton: React.FC = ({ analysisData, analysisType, userName, onDownload, className, disabled = false, targetElementId }) => { const [isDownloading, setIsDownloading] = useState(false); const [downloadingFormat, setDownloadingFormat] = useState(null); const [showDropdown, setShowDropdown] = useState(false); const allFormatOptions = [ { format: 'markdown' as DownloadFormat, label: 'Markdown文档', icon: FileText, description: '结构化文本格式,便于编辑', color: 'text-blue-600', bgColor: 'bg-blue-50 hover:bg-blue-100', mode: 'server' as ExportMode }, { format: 'pdf' as DownloadFormat, label: 'PDF文档(服务器生成)', icon: File, description: '服务器生成的PDF文档', color: 'text-red-600', bgColor: 'bg-red-50 hover:bg-red-100', mode: 'server' as ExportMode }, { format: 'pdf' as DownloadFormat, label: 'PDF文档(页面导出)', icon: Printer, description: '直接从页面生成PDF,分页格式', color: 'text-purple-600', bgColor: 'bg-purple-50 hover:bg-purple-100', mode: 'frontend' as ExportMode }, { format: 'png' as DownloadFormat, label: 'PNG长图(页面导出)', icon: Camera, description: '直接从页面生成PNG长图', color: 'text-teal-600', bgColor: 'bg-teal-50 hover:bg-teal-100', mode: 'frontend' as ExportMode } ]; // 根据是否有targetElementId来过滤选项 const formatOptions = allFormatOptions.filter(option => { // 如果是前端导出模式,需要有targetElementId才显示 if (option.mode === 'frontend') { return !!targetElementId; } // 服务器模式总是显示 return true; }); const handleDownload = async (format: DownloadFormat, mode: ExportMode = 'server') => { if (disabled || isDownloading) return; try { setIsDownloading(true); setDownloadingFormat(format); setShowDropdown(false); if (mode === 'frontend') { // 前端导出逻辑 await frontendExport(format); } else if (onDownload) { await onDownload(format); } else { // 默认服务器下载逻辑 await defaultDownload(format); } } catch (error) { // 显示错误提示 if (typeof window !== 'undefined' && (window as any).toast) { (window as any).toast.error(`下载失败: ${error instanceof Error ? error.message : '未知错误'}`); } } finally { setIsDownloading(false); setDownloadingFormat(null); } }; // 前端导出功能 const frontendExport = async (format: DownloadFormat) => { if (!targetElementId) { const error = '未指定导出目标元素ID,无法使用前端导出功能'; throw new Error(error); } const element = document.getElementById(targetElementId); if (!element) { const error = `未找到ID为"${targetElementId}"的元素,请确认页面已完全加载`; throw new Error(error); } if (format === 'png') { await exportToPNG(element); } else if (format === 'pdf') { await exportToPDF(element); } }; // 导出为PNG const exportToPNG = async (element: HTMLElement): Promise => { // 确保页面完全加载和渲染 await new Promise(resolve => setTimeout(resolve, 1000)); const canvas = await html2canvas(element, { scale: 2, useCORS: true, allowTaint: true, backgroundColor: '#ffffff', scrollX: 0, scrollY: 0, logging: true, // 启用日志以调试 // 移除固定尺寸限制,让html2canvas自动计算 // width: 640, // height: element.scrollHeight + 100, windowWidth: 640, // windowHeight: element.scrollHeight + 100, onclone: (clonedDoc) => { // 隐藏不需要导出的元素 const elementsToHide = clonedDoc.querySelectorAll( '.no-export, [data-no-export], .fixed, .sticky, .floating' ); elementsToHide.forEach(el => { (el as HTMLElement).style.display = 'none'; }); // 模拟移动端视口 const viewport = clonedDoc.createElement('meta'); viewport.name = 'viewport'; viewport.content = 'width=640, initial-scale=1'; clonedDoc.head.appendChild(viewport); // 添加移动端样式 const style = clonedDoc.createElement('style'); style.textContent = ` /* 模拟移动端视口 */ body { width: 640px !important; max-width: 640px !important; margin: 0 !important; padding: 16px !important; font-size: 16px !important; line-height: 1.6 !important; } /* 移除所有边框 */ * { border-left: none !important; border-right: none !important; } /* 确保移动端布局 */ .grid { grid-template-columns: 1fr !important; } .flex { flex-direction: column !important; } /* 移动端响应式类生效 */ .sm\\:grid-cols-1, .sm\\:grid-cols-2, .sm\\:grid-cols-3 { grid-template-columns: 1fr !important; } .sm\\:flex-col { flex-direction: column !important; } `; clonedDoc.head.appendChild(style); } }); const link = document.createElement('a'); const fileName = getFileName('png', 'frontend'); link.download = fileName; link.href = canvas.toDataURL('image/png', 1.0); link.click(); // 显示成功提示 if (typeof window !== 'undefined' && (window as any).toast) { (window as any).toast.success('PNG长图导出成功'); } }; // 导出为PDF const exportToPDF = async (element: HTMLElement): Promise => { // 确保页面完全加载和渲染 await new Promise(resolve => setTimeout(resolve, 1000)); const canvas = await html2canvas(element, { scale: 1.5, useCORS: true, allowTaint: true, backgroundColor: '#ffffff', scrollX: 0, scrollY: 0, logging: true, // 启用日志以调试 // 移除固定尺寸限制,让html2canvas自动计算 // width: 640, // height: element.scrollHeight + 100, windowWidth: 640, // windowHeight: element.scrollHeight + 100, onclone: (clonedDoc) => { // 隐藏不需要导出的元素 const elementsToHide = clonedDoc.querySelectorAll( '.no-export, [data-no-export], .fixed, .sticky, .floating' ); elementsToHide.forEach(el => { (el as HTMLElement).style.display = 'none'; }); // 模拟移动端视口 const viewport = clonedDoc.createElement('meta'); viewport.name = 'viewport'; viewport.content = 'width=640, initial-scale=1'; clonedDoc.head.appendChild(viewport); // 添加移动端样式 const style = clonedDoc.createElement('style'); style.textContent = ` /* 模拟移动端视口 */ body { width: 640px !important; max-width: 640px !important; margin: 0 !important; padding: 16px !important; font-size: 16px !important; line-height: 1.6 !important; } /* 移除所有边框 */ * { border-left: none !important; border-right: none !important; } /* 确保移动端布局 */ .grid { grid-template-columns: 1fr !important; } .flex { flex-direction: column !important; } /* 移动端响应式类生效 */ .sm\\:grid-cols-1, .sm\\:grid-cols-2, .sm\\:grid-cols-3 { grid-template-columns: 1fr !important; } .sm\\:flex-col { flex-direction: column !important; } `; clonedDoc.head.appendChild(style); } }); const imgData = canvas.toDataURL('image/png'); const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); const pdfWidth = 210; const pdfHeight = 297; const margin = 10; const contentWidth = pdfWidth - 2 * margin; const contentHeight = pdfHeight - 2 * margin; const imgWidth = canvas.width; const imgHeight = canvas.height; // 优先填满宽度,让内容宽度占满页面 const widthRatio = contentWidth / (imgWidth * 0.264583); const scaledWidth = contentWidth; // 直接使用全部可用宽度 const scaledHeight = imgHeight * 0.264583 * widthRatio; const pageHeight = contentHeight; const totalPages = Math.ceil(scaledHeight / pageHeight); for (let i = 0; i < totalPages; i++) { if (i > 0) { pdf.addPage(); } const yOffset = -i * pageHeight; pdf.addImage( imgData, 'PNG', margin, margin + yOffset, scaledWidth, scaledHeight ); } const fileName = getFileName('pdf', 'frontend'); pdf.save(fileName); // 显示成功提示 if (typeof window !== 'undefined' && (window as any).toast) { (window as any).toast.success('PDF文档导出成功'); } }; // 生成文件名 const getFileName = (format: string, mode: ExportMode = 'server') => { const typeLabel = getAnalysisTypeLabel(); const userPart = userName || 'user'; const exportMode = mode === 'frontend' ? '页面导出' : '服务器导出'; // 获取分析报告生成时间 let analysisDate; if (analysisData?.created_at) { analysisDate = new Date(analysisData.created_at); } else if (analysisData?.basic_info?.created_at) { analysisDate = new Date(analysisData.basic_info.created_at); } else if (analysisData?.metadata?.analysis_time) { analysisDate = new Date(analysisData.metadata.analysis_time); } else { // 如果没有分析时间,使用当前时间作为备用 analysisDate = new Date(); } const year = analysisDate.getFullYear(); const month = String(analysisDate.getMonth() + 1).padStart(2, '0'); const day = String(analysisDate.getDate()).padStart(2, '0'); const hour = String(analysisDate.getHours()).padStart(2, '0'); const minute = String(analysisDate.getMinutes()).padStart(2, '0'); const second = String(analysisDate.getSeconds()).padStart(2, '0'); const dateStr = `${year}${month}${day}`; const timeStr = `${hour}${minute}${second}`; return `${typeLabel}_${userPart}_${exportMode}_${dateStr}_${timeStr}.${format}`; }; const defaultDownload = async (format: DownloadFormat) => { try { // 获取认证token const token = localStorage.getItem('auth_token'); if (!token) { throw new Error('请先登录'); } // 获取正确的API基础URL const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? 'http://localhost:3001/api' : (window.location.hostname.includes('koyeb.app') ? `${window.location.origin}/api` : `${window.location.origin}/api`)); // 调用后端下载API const response = await fetch(`${API_BASE_URL}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ analysisData, analysisType, format, userName }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `下载失败 (${response.status})`); } // 获取文件名(从响应头或生成默认名称) const contentDisposition = response.headers.get('Content-Disposition'); // 生成与后端一致的文件名格式:分析类型_用户名_日期_时间(使用分析记录创建时间) // 优先使用分析记录的创建时间,如果没有则使用当前时间 let analysisDate; if (analysisData.created_at) { analysisDate = new Date(analysisData.created_at); } else if (analysisData.basic_info?.created_at) { analysisDate = new Date(analysisData.basic_info.created_at); } else { // 如果没有创建时间,使用当前时间作为备用 analysisDate = new Date(); } const year = analysisDate.getFullYear(); const month = String(analysisDate.getMonth() + 1).padStart(2, '0'); const day = String(analysisDate.getDate()).padStart(2, '0'); const hour = String(analysisDate.getHours()).padStart(2, '0'); const minute = String(analysisDate.getMinutes()).padStart(2, '0'); const second = String(analysisDate.getSeconds()).padStart(2, '0'); const dateStr = `${year}${month}${day}`; const timeStr = `${hour}${minute}${second}`; const exportMode = '服务器导出'; let filename = `${getAnalysisTypeLabel()}_${userName || 'user'}_${exportMode}_${dateStr}_${timeStr}.${format === 'markdown' ? 'md' : format}`; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=(['"]?)([^'"\n]*?)\1/); if (filenameMatch && filenameMatch[2]) { filename = decodeURIComponent(filenameMatch[2]); } } // 创建blob并下载 const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); // 清理 setTimeout(() => { window.URL.revokeObjectURL(url); document.body.removeChild(a); }, 100); // 显示成功提示 if (typeof window !== 'undefined' && (window as any).toast) { (window as any).toast.success(`${format.toUpperCase()}文件下载成功`); } } catch (error) { // 显示错误提示 if (typeof window !== 'undefined' && (window as any).toast) { (window as any).toast.error(error instanceof Error ? error.message : '下载失败,请重试'); } throw error; } }; const getAnalysisTypeLabel = () => { switch (analysisType) { case 'bazi': return '八字命理'; case 'ziwei': return '紫微斗数'; case 'yijing': return '易经占卜'; default: return '命理'; } }; const getFormatLabel = (format: DownloadFormat) => { switch (format) { case 'markdown': return 'Markdown'; case 'pdf': return 'PDF'; case 'png': return 'PNG'; default: return ''; } }; return (
{/* 主下载按钮 */}
setShowDropdown(!showDropdown)} disabled={disabled || isDownloading} variant="secondary" className="flex items-center space-x-2 bg-gradient-to-r from-yellow-500 to-yellow-600 hover:from-yellow-600 hover:to-yellow-700 text-white border-0 shadow-lg" > {isDownloading ? ( ) : ( )} {isDownloading ? `正在生成${getFormatLabel(downloadingFormat!)}...` : '下载'}
{/* 使用Portal渲染弹出层到body,脱离父容器限制 */} {showDropdown && createPortal( <> {/* 背景遮罩 */}
setShowDropdown(false)} /> {/* 弹出层 - 固定定位到屏幕中央 */}

选择下载格式

{getAnalysisTypeLabel()}分析结果

{formatOptions.map((option) => { const Icon = option.icon; const isCurrentlyDownloading = isDownloading && downloadingFormat === option.format; return ( ); })}

💡 提示:PDF和PNG格式包含完整的视觉设计,Markdown格式便于编辑

, document.body )}
); }; export default DownloadButton;