This commit is contained in:
parent
7d5155052b
commit
47f04a6ff7
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue