This commit is contained in:
MarkLo 2025-12-16 00:45:07 +08:00
parent 7d5155052b
commit 47f04a6ff7
3 changed files with 195 additions and 142 deletions

View File

@ -211,7 +211,7 @@ export default function AnalysisResultsPage() {
{analysisResult.analysis_date}
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-center flex-wrap">
{/* Save success/error feedback */}
{saveSuccess && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-sm animate-fade-in">
@ -226,6 +226,20 @@ export default function AnalysisResultsPage() {
</span>
)}
{/* Download PDF Button */}
{analysisResult.reports && (
<DownloadReports
ticker={analysisResult.ticker}
analysisDate={analysisResult.analysis_date}
taskId={taskId}
analysts={ANALYSTS}
reports={analysisResult.reports}
priceData={analysisResult.price_data}
priceStats={analysisResult.price_stats}
compact={true}
/>
)}
{/* Save Report Button */}
<Button
variant="default"
@ -316,19 +330,6 @@ export default function AnalysisResultsPage() {
</TabsContent>
))}
</Tabs>
{/* Download Reports Section - 放在分析報告下方 */}
{analysisResult.reports && (
<DownloadReports
ticker={analysisResult.ticker}
analysisDate={analysisResult.analysis_date}
taskId={taskId}
analysts={ANALYSTS}
reports={analysisResult.reports}
priceData={analysisResult.price_data}
priceStats={analysisResult.price_stats}
/>
)}
</div>
</div>
</div>

View File

@ -27,7 +27,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Trash2, Eye, RefreshCw, TrendingUp, Cloud, CloudOff } from "lucide-react";
import { Trash2, Eye, RefreshCw, TrendingUp, Cloud, CloudOff, FileText, Download } from "lucide-react";
import {
getReportsByMarketType,
deleteReport,
@ -38,6 +38,22 @@ import { getCloudReports, deleteCloudReport, isCloudSyncEnabled } from "@/lib/us
import { LoginPrompt } from "@/components/auth/login-button";
import { PendingTaskRecovery } from "@/components/PendingTaskRecovery";
// Analyst definitions for download
const ANALYSTS = [
{ key: "market", label: "市場分析師", reportKey: "market_report", description: "技術分析與市場趨勢評估" },
{ key: "social", label: "社群媒體分析師", reportKey: "sentiment_report", description: "社群情緒與市場氛圍分析" },
{ key: "news", label: "新聞分析師", reportKey: "news_report", description: "新聞事件與影響分析" },
{ key: "fundamentals", label: "基本面分析師", reportKey: "fundamentals_report", description: "財務數據與基本面分析" },
{ key: "bull", label: "看漲研究員", reportKey: "investment_debate_state.bull_history", description: "看漲觀點與投資論據" },
{ key: "bear", label: "看跌研究員", reportKey: "investment_debate_state.bear_history", description: "看跌觀點與風險警告" },
{ key: "research_manager", label: "研究經理", reportKey: "investment_debate_state.judge_decision", description: "研究團隊綜合決策" },
{ key: "trader", label: "交易員", reportKey: "trader_investment_plan", description: "交易執行計劃與策略" },
{ key: "risky", label: "激進分析師", reportKey: "risk_debate_state.risky_history", description: "高風險高回報策略分析" },
{ key: "safe", label: "保守分析師", reportKey: "risk_debate_state.safe_history", description: "穩健保守策略分析" },
{ key: "neutral", label: "中立分析師", reportKey: "risk_debate_state.neutral_history", description: "中立平衡策略分析" },
{ key: "risk_manager", label: "風險經理", reportKey: "risk_debate_state.judge_decision", description: "風險管理綜合決策" },
];
// Market type labels
const MARKET_LABELS = {
us: { label: "🇺🇸 美股", description: "美國股市分析報告" },
@ -248,6 +264,84 @@ export default function HistoryPage() {
}
};
// Download PDF handler
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const handleDownloadPdf = async (report: SavedReport) => {
setDownloadingId(report.id ?? null);
try {
// Get all available analyst keys
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const availableAnalystKeys = ANALYSTS
.filter(analyst => {
const reportContent = getNestedValue(report.result.reports, analyst.reportKey);
return reportContent && reportContent.trim().length > 0;
})
.map(a => a.key);
if (availableAnalystKeys.length === 0) {
alert('此報告沒有可下載的分析師報告');
return;
}
// Build request body
const requestBody = {
ticker: report.ticker,
analysis_date: report.analysis_date,
analysts: availableAnalystKeys,
reports: report.result.reports,
price_data: report.result.price_data,
price_stats: report.result.price_stats,
};
const response = await fetch('/api/download/reports', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `下載失敗 (${response.status})`);
}
// Get the blob
const blob = await response.blob();
// Get filename from header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `${report.ticker}_Combined_Report_${report.analysis_date}.pdf`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.+)/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error: any) {
console.error('Download error:', error);
alert(error.message || '下載失敗,請稍後再試');
} finally {
setDownloadingId(null);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 via-pink-50/20 to-purple-50/30 dark:from-gray-950 dark:via-purple-950/40 dark:to-gray-950">
<div className="container mx-auto px-4 py-12">
@ -375,7 +469,7 @@ export default function HistoryPage() {
);
})()}
</CardContent>
<CardFooter className="flex gap-2">
<CardFooter className="flex gap-2 flex-wrap">
<Button
variant="default"
size="sm"
@ -385,6 +479,25 @@ export default function HistoryPage() {
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => handleDownloadPdf(report)}
disabled={downloadingId === report.id}
>
{downloadingId === report.id ? (
<>
<Download className="h-4 w-4 animate-bounce" />
</>
) : (
<>
<FileText className="h-4 w-4" />
PDF
</>
)}
</Button>
<Button
variant="destructive"
size="sm"

View File

@ -1,16 +1,12 @@
/**
* Download Reports Component
* Allows users to select and download analyst reports
* Simple unified PDF download button
*/
"use client";
import { useState } from "react";
import { Download, FileDown, CheckIcon } from "lucide-react";
import { Download, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface AnalystInfo {
key: string;
@ -22,11 +18,13 @@ interface AnalystInfo {
interface DownloadReportsProps {
ticker: string;
analysisDate: string;
taskId?: string | null; // Now optional - if not provided, use direct data mode
taskId?: string | null;
analysts: AnalystInfo[];
reports: any;
priceData?: any[]; // For direct download mode
priceStats?: any; // For direct download mode
priceData?: any[];
priceStats?: any;
/** Compact mode - just the button, no card wrapper */
compact?: boolean;
}
export function DownloadReports({
@ -37,8 +35,8 @@ export function DownloadReports({
reports,
priceData,
priceStats,
compact = false,
}: DownloadReportsProps) {
const [selectedAnalysts, setSelectedAnalysts] = useState<string[]>([]);
const [isDownloading, setIsDownloading] = useState(false);
// Helper to get nested value from reports object
@ -46,43 +44,25 @@ export function DownloadReports({
return path.split('.').reduce((current, key) => current?.[key], obj);
};
// Filter analysts that have actual reports
const availableAnalysts = analysts.filter(analyst => {
const reportContent = getNestedValue(reports, analyst.reportKey);
return reportContent && reportContent.trim().length > 0;
});
// Get all available analyst keys (those with actual reports)
const availableAnalystKeys = analysts
.filter(analyst => {
const reportContent = getNestedValue(reports, analyst.reportKey);
return reportContent && reportContent.trim().length > 0;
})
.map(a => a.key);
// Handle select all
const handleSelectAll = () => {
if (selectedAnalysts.length === availableAnalysts.length) {
setSelectedAnalysts([]);
} else {
setSelectedAnalysts(availableAnalysts.map(a => a.key));
}
};
// Handle individual selection
const handleToggleAnalyst = (analystKey: string) => {
setSelectedAnalysts(prev => {
if (prev.includes(analystKey)) {
return prev.filter(key => key !== analystKey);
} else {
return [...prev, analystKey];
}
});
};
// Handle download
// Handle download - always download all available reports as combined PDF
const handleDownload = async () => {
if (selectedAnalysts.length === 0) return;
if (availableAnalystKeys.length === 0) return;
setIsDownloading(true);
try {
// Build request body - use taskId if available, otherwise send direct data
// Build request body with all available analysts
const requestBody: any = {
ticker,
analysis_date: analysisDate,
analysts: selectedAnalysts,
analysts: availableAnalystKeys, // Always include all available analysts
};
if (taskId) {
@ -114,15 +94,13 @@ export function DownloadReports({
// Get filename from Content-Disposition header if available
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `${ticker}_${analysisDate}.pdf`;
let filename = `${ticker}_Combined_Report_${analysisDate}.pdf`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.+)/);
if (filenameMatch) {
filename = filenameMatch[1];
}
} else if (selectedAnalysts.length > 1) {
filename = `${ticker}_${analysisDate}.zip`;
}
// Create download link
@ -144,94 +122,55 @@ export function DownloadReports({
}
};
if (availableAnalysts.length === 0) {
if (availableAnalystKeys.length === 0) {
return null;
}
const isAllSelected = selectedAnalysts.length === availableAnalysts.length && availableAnalysts.length > 0;
// Compact mode - just the button
if (compact) {
return (
<Button
onClick={handleDownload}
disabled={isDownloading}
variant="outline"
className="gap-2 hover-lift"
>
{isDownloading ? (
<>
<Download className="h-4 w-4 animate-bounce" />
...
</>
) : (
<>
<FileText className="h-4 w-4" />
PDF
</>
)}
</Button>
);
}
// Full mode - with description
return (
<Card className="hover-lift animate-scale-up">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileDown className="h-5 w-5" />
</CardTitle>
<CardDescription>
PDF或多個ZIP
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Select All */}
<div className="flex items-center space-x-2 pb-2 border-b">
<Checkbox
id="select-all"
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
<Label
htmlFor="select-all"
className="text-sm font-medium cursor-pointer"
>
({availableAnalysts.length} )
</Label>
</div>
{/* Analyst List */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{availableAnalysts.map(analyst => {
const isSelected = selectedAnalysts.includes(analyst.key);
return (
<div
key={analyst.key}
onClick={() => handleToggleAnalyst(analyst.key)}
className={cn(
"relative flex cursor-pointer flex-col gap-2 rounded-lg border-2 p-4 transition-all hover:bg-accent",
isSelected
? "border-primary bg-primary/5 text-primary"
: "border-muted-foreground/25 bg-card text-muted-foreground"
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border transition-colors",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground"
)}
>
{isSelected && <CheckIcon className="h-3.5 w-3.5" />}
</div>
<div className="flex-1">
<p className="text-sm font-medium select-none">
{analyst.label}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground pl-8">
{analyst.description}
</p>
</div>
);
})}
</div>
{/* Download Button */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-muted-foreground">
{selectedAnalysts.length}
</div>
<Button
onClick={handleDownload}
disabled={selectedAnalysts.length === 0 || isDownloading}
className="gap-2"
>
<Download className="h-4 w-4" />
{isDownloading ? '下載中...' : selectedAnalysts.length === 1 ? '下載 PDF' : '下載 ZIP'}
</Button>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-center p-4">
<Button
onClick={handleDownload}
disabled={isDownloading}
size="lg"
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600"
>
{isDownloading ? (
<>
<Download className="h-5 w-5 animate-bounce" />
...
</>
) : (
<>
<FileText className="h-5 w-5" />
PDF
</>
)}
</Button>
</div>
);
}