feat: 完成分析结果下载功能实现

- 新增DownloadButton组件,支持Markdown、PDF、PNG三种格式下载
- 实现后端下载API接口(/api/download)
- 添加Markdown、PDF、PNG三种格式生成器
- 集成下载按钮到所有分析结果页面
- 修复API路径配置问题,确保开发环境正确访问后端
- 添加下载历史记录功能和数据库表结构
- 完善错误处理和用户反馈机制
This commit is contained in:
patdelphi
2025-08-21 12:44:40 +08:00
parent 5801d6a9ee
commit 9231651ae1
12 changed files with 2666 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Responsi
import { Calendar, Star, BookOpen, Sparkles, User, BarChart3, Zap, TrendingUp, Loader2, Clock, Target, Heart, DollarSign, Activity } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
import { BackToTop } from './ui/BackToTop';
import DownloadButton from './ui/DownloadButton';
import { localApi } from '../lib/localApi';
interface CompleteBaziAnalysisProps {
@@ -277,6 +278,16 @@ const CompleteBaziAnalysis: React.FC<CompleteBaziAnalysisProps> = ({ birthDate,
<div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8">
{/* 下载按钮 */}
<div className="flex justify-end">
<DownloadButton
analysisData={analysisData}
analysisType="bazi"
userName={birthDate.name}
className="sticky top-4 z-10"
/>
</div>
{/* 标题和基本信息 */}
<Card className="chinese-card-decoration dragon-corner border-2 border-yellow-400">
<CardHeader className="text-center">

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Calendar, Star, BookOpen, Sparkles, User, BarChart3, Zap, TrendingUp, Loader2, Clock, Target, Heart, DollarSign, Activity, Crown, Compass, Moon, Sun, Hexagon, Layers, Eye, Shuffle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
import { BackToTop } from './ui/BackToTop';
import DownloadButton from './ui/DownloadButton';
import { localApi } from '../lib/localApi';
interface CompleteYijingAnalysisProps {
@@ -721,6 +722,16 @@ const CompleteYijingAnalysis: React.FC<CompleteYijingAnalysisProps> = ({
</CardContent>
</Card>
{/* 下载按钮 */}
<div className="flex justify-end mb-8">
<DownloadButton
analysisData={analysisData}
analysisType="yijing"
userName={question ? `占卜_${question.substring(0, 10)}` : 'user'}
className="sticky top-4 z-10"
/>
</div>
{/* 免责声明 */}
<Card className="chinese-card-decoration border-2 border-gray-300">
<CardContent className="text-center py-6">

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
import { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle } from './ui/ChineseCard';
import { ChineseLoading } from './ui/ChineseLoading';
import { BackToTop } from './ui/BackToTop';
import DownloadButton from './ui/DownloadButton';
import { localApi } from '../lib/localApi';
import { cn } from '../lib/utils';
@@ -580,6 +581,16 @@ const CompleteZiweiAnalysis: React.FC<CompleteZiweiAnalysisProps> = ({ birthDate
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8">
{/* 下载按钮 */}
<div className="flex justify-end">
<DownloadButton
analysisData={analysisData}
analysisType="ziwei"
userName={birthDate.name}
className="sticky top-4 z-10"
/>
</div>
{/* 标题和基本信息 */}
<Card className="chinese-card-decoration dragon-corner border-2 border-purple-400">
<CardHeader className="text-center">

View File

@@ -1,7 +1,7 @@
import React, { ReactNode, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Sparkles, User, History, LogOut, Home, Menu, X } from 'lucide-react';
import { Sparkles, User, History, LogOut, Home, Menu, X, Github } from 'lucide-react';
import { ChineseButton } from './ui/ChineseButton';
import { toast } from 'sonner';
import { cn } from '../lib/utils';
@@ -89,6 +89,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
);
})}
{/* GitHub链接 */}
<a
href="https://github.com/patdelphi/suanming"
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1.5 px-3 py-2 rounded-lg font-medium transition-all duration-300 text-sm border border-transparent hover:border-yellow-400 text-white hover:text-yellow-100 hover:bg-white/10"
title="查看GitHub源码"
>
<Github className="h-4 w-4" />
<span className="whitespace-nowrap">GitHub</span>
</a>
{user ? (
<ChineseButton
onClick={handleSignOut}
@@ -167,6 +179,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
);
})}
{/* 移动端GitHub链接 */}
<a
href="https://github.com/patdelphi/suanming"
target="_blank"
rel="noopener noreferrer"
onClick={closeMobileMenu}
className="flex items-center space-x-3 px-4 py-3 rounded-lg font-medium transition-all duration-200 border border-transparent text-white hover:text-yellow-100 hover:bg-white/10"
>
<Github className="h-5 w-5" />
<span>GitHub</span>
</a>
<div className="pt-4 border-t border-white/20">
{user ? (
<ChineseButton

View File

@@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { Download, FileText, FileImage, File, Loader2, ChevronDown } from 'lucide-react';
import { ChineseButton } from './ChineseButton';
import { cn } from '../../lib/utils';
export type DownloadFormat = 'markdown' | 'pdf' | 'png';
interface DownloadButtonProps {
analysisData: any;
analysisType: 'bazi' | 'ziwei' | 'yijing';
userName?: string;
onDownload?: (format: DownloadFormat) => Promise<void>;
className?: string;
disabled?: boolean;
}
const DownloadButton: React.FC<DownloadButtonProps> = ({
analysisData,
analysisType,
userName,
onDownload,
className,
disabled = false
}) => {
const [isDownloading, setIsDownloading] = useState(false);
const [downloadingFormat, setDownloadingFormat] = useState<DownloadFormat | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const formatOptions = [
{
format: 'markdown' as DownloadFormat,
label: 'Markdown文档',
icon: FileText,
description: '结构化文本格式,便于编辑',
color: 'text-blue-600',
bgColor: 'bg-blue-50 hover:bg-blue-100'
},
{
format: 'pdf' as DownloadFormat,
label: 'PDF文档',
icon: File,
description: '专业格式,便于打印和分享',
color: 'text-red-600',
bgColor: 'bg-red-50 hover:bg-red-100'
},
{
format: 'png' as DownloadFormat,
label: 'PNG图片',
icon: FileImage,
description: '高清图片格式,便于保存',
color: 'text-green-600',
bgColor: 'bg-green-50 hover:bg-green-100'
}
];
const handleDownload = async (format: DownloadFormat) => {
if (disabled || isDownloading) return;
try {
setIsDownloading(true);
setDownloadingFormat(format);
setShowDropdown(false);
if (onDownload) {
await onDownload(format);
} else {
// 默认下载逻辑
await defaultDownload(format);
}
} catch (error) {
console.error('下载失败:', error);
// 这里可以添加错误提示
} finally {
setIsDownloading(false);
setDownloadingFormat(null);
}
};
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 filename = `${getAnalysisTypeLabel()}_${userName || 'user'}_${new Date().toISOString().slice(0, 10)}.${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) {
console.error('下载失败:', 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 format.toUpperCase();
}
};
return (
<div className={cn('relative', className)}>
{/* 主下载按钮 */}
<div className="flex items-center space-x-2">
<ChineseButton
onClick={() => 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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
<span className="font-medium">
{isDownloading ? `正在生成${getFormatLabel(downloadingFormat!)}...` : '下载分析结果'}
</span>
<ChevronDown className={cn(
'h-4 w-4 transition-transform duration-200',
showDropdown ? 'rotate-180' : ''
)} />
</ChineseButton>
</div>
{/* 下拉菜单 */}
{showDropdown && (
<div className="absolute top-full left-0 mt-2 w-80 bg-white rounded-lg shadow-xl border border-gray-200 z-50">
<div className="p-3 border-b border-gray-100">
<h3 className="font-bold text-gray-800 text-sm"></h3>
<p className="text-xs text-gray-600 mt-1">{getAnalysisTypeLabel()}</p>
</div>
<div className="p-2">
{formatOptions.map((option) => {
const Icon = option.icon;
const isCurrentlyDownloading = isDownloading && downloadingFormat === option.format;
return (
<button
key={option.format}
onClick={() => handleDownload(option.format)}
disabled={disabled || isDownloading}
className={cn(
'w-full flex items-center space-x-3 p-3 rounded-lg transition-all duration-200',
option.bgColor,
'border border-transparent hover:border-gray-300',
disabled || isDownloading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
)}
>
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
option.bgColor.replace('hover:', '').replace('bg-', 'bg-').replace('-50', '-100')
)}>
{isCurrentlyDownloading ? (
<Loader2 className={cn('h-5 w-5 animate-spin', option.color)} />
) : (
<Icon className={cn('h-5 w-5', option.color)} />
)}
</div>
<div className="flex-1 text-left">
<div className={cn('font-medium text-sm', option.color)}>
{option.label}
</div>
<div className="text-xs text-gray-600 mt-1">
{option.description}
</div>
</div>
{isCurrentlyDownloading && (
<div className="text-xs text-gray-500">
...
</div>
)}
</button>
);
})}
</div>
<div className="p-3 border-t border-gray-100 bg-gray-50 rounded-b-lg">
<p className="text-xs text-gray-500 text-center">
💡 PDF和PNG格式包含完整的视觉设计Markdown格式便于编辑
</p>
</div>
</div>
)}
{/* 点击外部关闭下拉菜单 */}
{showDropdown && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowDropdown(false)}
/>
)}
</div>
);
};
export default DownloadButton;