/** * History page - browse saved analysis reports */ "use client"; import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { format } from "date-fns"; import { zhTW } from "date-fns/locale"; import { useAnalysisContext } from "@/context/AnalysisContext"; import { useAuth } from "@/contexts/auth-context"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter, } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Trash2, Eye, RefreshCw, TrendingUp, Cloud, CloudOff, FileText, Download } from "lucide-react"; import { getReportsByMarketType, deleteReport, getReportCountByMarketType, type SavedReport, } from "@/lib/reports-db"; import { getCloudReports, deleteCloudReport, saveCloudReport, isCloudSyncEnabled } from "@/lib/user-api"; 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: "美國股市分析報告" }, twse: { label: "🇹🇼 上市", description: "台灣證券交易所上市股票" }, tpex: { label: "🇹🇼 上櫃/興櫃", description: "台灣櫃買中心上櫃/興櫃股票" }, }; // Helper function to extract decision from Risk Manager's final decision const extractDecisionFromReport = (report: SavedReport): { action: string; color: string } => { // First try the decision.action field (processed decision) if (report.result.decision?.action) { const action = report.result.decision.action; const actionLower = action.toLowerCase(); const color = actionLower.includes("buy") ? "text-green-600" : actionLower.includes("sell") ? "text-red-600" : "text-yellow-600"; return { action, color }; } // Priority: try to extract from Risk Manager's final_trade_decision (最終決策) const finalDecision = report.result.reports?.final_trade_decision; if (finalDecision) { const text = finalDecision.toLowerCase(); // Look for Chinese decision keywords first if (text.includes("最終決策:買入") || text.includes("最終建議:買入") || text.includes("建議:買入")) { return { action: "買入", color: "text-green-600" }; } if (text.includes("最終決策:賣出") || text.includes("最終建議:賣出") || text.includes("建議:賣出")) { return { action: "賣出", color: "text-red-600" }; } if (text.includes("最終決策:持有") || text.includes("最終建議:持有") || text.includes("建議:持有")) { return { action: "持有", color: "text-yellow-600" }; } // Look for English keywords if (text.includes("buy") || text.includes("買入")) { return { action: "買入", color: "text-green-600" }; } if (text.includes("sell") || text.includes("賣出")) { return { action: "賣出", color: "text-red-600" }; } if (text.includes("hold") || text.includes("持有")) { return { action: "持有", color: "text-yellow-600" }; } } // Fallback: try trader_investment_plan if final_trade_decision not available const traderReport = report.result.reports?.trader_investment_plan; if (traderReport) { const text = traderReport.toLowerCase(); // Look for Chinese decision keywords first if (text.includes("最終交易提案:買入") || text.includes("建議:買入")) { return { action: "買入", color: "text-green-600" }; } if (text.includes("最終交易提案:賣出") || text.includes("建議:賣出")) { return { action: "賣出", color: "text-red-600" }; } if (text.includes("最終交易提案:持有") || text.includes("建議:持有")) { return { action: "持有", color: "text-yellow-600" }; } // Look for English keywords if (text.includes("buy") && !text.includes("sell")) { return { action: "買入 (BUY)", color: "text-green-600" }; } if (text.includes("sell") && !text.includes("buy")) { return { action: "賣出 (SELL)", color: "text-red-600" }; } if (text.includes("hold")) { return { action: "持有 (HOLD)", color: "text-yellow-600" }; } } return { action: "N/A", color: "text-gray-500" }; }; export default function HistoryPage() { const router = useRouter(); const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext(); const { isAuthenticated } = useAuth(); const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us"); const [reports, setReports] = useState([]); const [loading, setLoading] = useState(true); const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 }); const [isCloudData, setIsCloudData] = useState(false); // Delete confirmation dialog const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [reportToDelete, setReportToDelete] = useState( null ); const [deleting, setDeleting] = useState(false); // Sync state const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState<{ success: number; failed: number } | null>(null); const hasAutoSyncedRef = useRef(false); // Load reports when tab changes or auth state changes useEffect(() => { loadReports(); }, [activeTab, isAuthenticated]); // Load counts on mount or auth change useEffect(() => { loadCounts(); }, [isAuthenticated]); // Auto-sync local reports to cloud when page loads (if authenticated) useEffect(() => { const autoSync = async () => { // Only sync once per session, and only if authenticated if (hasAutoSyncedRef.current || !isAuthenticated || !isCloudSyncEnabled()) { return; } hasAutoSyncedRef.current = true; try { // Get all local reports const [usLocal, twseLocal, tpexLocal] = await Promise.all([ getReportsByMarketType("us"), getReportsByMarketType("twse"), getReportsByMarketType("tpex"), ]); const allLocal = [...usLocal, ...twseLocal, ...tpexLocal]; if (allLocal.length === 0) return; // Get cloud reports to check for duplicates const cloudReports = await getCloudReports(); const cloudKeys = new Set( cloudReports.map(r => `${r.ticker}_${r.analysis_date}`) ); // Find local-only reports to upload const toUpload = allLocal.filter( r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`) ); if (toUpload.length === 0) { console.log("☁️ Auto-sync: All reports already in cloud"); return; } console.log(`☁️ Auto-sync: Uploading ${toUpload.length} local reports to cloud...`); // Upload each report silently let success = 0; for (const report of toUpload) { try { const cloudId = await saveCloudReport({ ticker: report.ticker, market_type: report.market_type, analysis_date: report.analysis_date, result: report.result, }); if (cloudId) success++; } catch (e) { // Silently continue on error } } if (success > 0) { console.log(`☁️ Auto-sync: Successfully uploaded ${success} reports`); // Reload to show updated data await loadReports(); await loadCounts(); } } catch (error) { console.error("☁️ Auto-sync failed:", error); } }; autoSync(); }, [isAuthenticated]); const loadReports = async () => { setLoading(true); try { // Always load local IndexedDB reports first const localData = await getReportsByMarketType(activeTab); // If authenticated, also load from cloud and merge if (isAuthenticated && isCloudSyncEnabled()) { const cloudReports = await getCloudReports(); // Convert cloud reports to SavedReport format and filter by market type const cloudFiltered = cloudReports .filter(r => r.market_type === activeTab) .map(r => ({ id: parseInt(r.id.replace(/-/g, '').slice(0, 8), 16), // Convert UUID to number cloudId: r.id, // Keep cloud ID for deletion ticker: r.ticker, market_type: r.market_type as "us" | "twse" | "tpex", analysis_date: r.analysis_date, saved_at: new Date(r.created_at), result: r.result, })) as (SavedReport & { cloudId?: string })[]; if (cloudFiltered.length > 0) { // Merge: prefer cloud data, but include local-only reports // Create a Set of cloud report keys (ticker + date) for deduplication const cloudKeys = new Set( cloudFiltered.map(r => `${r.ticker}_${r.analysis_date}`) ); // Find local reports that don't exist in cloud const localOnly = localData.filter( r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`) ); // Combine: cloud reports + local-only reports const merged = [...cloudFiltered, ...localOnly]; // Sort by saved_at descending merged.sort((a, b) => new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime() ); setReports(merged); setIsCloudData(true); return; } } // If no cloud data or not authenticated, use local only setReports(localData); setIsCloudData(false); } catch (error) { console.error("Failed to load reports:", error); // Fall back to local on error const data = await getReportsByMarketType(activeTab); setReports(data); setIsCloudData(false); } finally { setLoading(false); } }; const loadCounts = async () => { try { // Always get local counts first const localCounts = await getReportCountByMarketType(); if (isAuthenticated && isCloudSyncEnabled()) { const cloudReports = await getCloudReports(); if (cloudReports.length > 0) { // Get local reports to check for duplicates const [usLocal, twseLocal, tpexLocal] = await Promise.all([ getReportsByMarketType("us"), getReportsByMarketType("twse"), getReportsByMarketType("tpex"), ]); // Cloud report keys for deduplication const cloudKeys = new Set( cloudReports.map(r => `${r.ticker}_${r.analysis_date}_${r.market_type}`) ); // Count local-only reports (not in cloud) const usLocalOnly = usLocal.filter( r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_us`) ).length; const twseLocalOnly = twseLocal.filter( r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_twse`) ).length; const tpexLocalOnly = tpexLocal.filter( r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_tpex`) ).length; // Cloud counts const usCoud = cloudReports.filter(r => r.market_type === "us").length; const twseCloud = cloudReports.filter(r => r.market_type === "twse").length; const tpexCloud = cloudReports.filter(r => r.market_type === "tpex").length; // Merged counts: cloud + local-only setCounts({ us: usCoud + usLocalOnly, twse: twseCloud + twseLocalOnly, tpex: tpexCloud + tpexLocalOnly, }); return; } } setCounts(localCounts); } catch (error) { console.error("Failed to load counts:", error); } }; // Sync local reports to cloud const handleSyncToCloud = async () => { if (!isAuthenticated || !isCloudSyncEnabled()) { alert("請先登入以啟用雲端同步"); return; } setSyncing(true); setSyncResult(null); try { // Get all local reports const [usLocal, twseLocal, tpexLocal] = await Promise.all([ getReportsByMarketType("us"), getReportsByMarketType("twse"), getReportsByMarketType("tpex"), ]); const allLocal = [...usLocal, ...twseLocal, ...tpexLocal]; // Get cloud reports to check for duplicates const cloudReports = await getCloudReports(); const cloudKeys = new Set( cloudReports.map(r => `${r.ticker}_${r.analysis_date}`) ); // Find local-only reports to upload const toUpload = allLocal.filter( r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`) ); if (toUpload.length === 0) { setSyncResult({ success: 0, failed: 0 }); alert("所有報告已同步到雲端!"); return; } // Upload each report let success = 0; let failed = 0; for (const report of toUpload) { try { const cloudId = await saveCloudReport({ ticker: report.ticker, market_type: report.market_type, analysis_date: report.analysis_date, result: report.result, }); if (cloudId) { success++; } else { failed++; } } catch (e) { failed++; } } setSyncResult({ success, failed }); // Reload data after sync await loadReports(); await loadCounts(); if (failed === 0) { alert(`成功同步 ${success} 份報告到雲端!`); } else { alert(`同步完成:${success} 成功,${failed} 失敗`); } } catch (error) { console.error("Sync failed:", error); alert("同步失敗,請稍後再試"); } finally { setSyncing(false); } }; const handleViewReport = (report: SavedReport) => { // Set the context with the saved report data setAnalysisResult(report.result); setTaskId(report.task_id || null); setMarketType(report.market_type); // Navigate to results page router.push("/analysis/results"); }; const handleDeleteClick = (report: SavedReport) => { setReportToDelete(report); setDeleteDialogOpen(true); }; const handleConfirmDelete = async () => { if (!reportToDelete) return; setDeleting(true); try { // If this is cloud data, delete from cloud const cloudId = (reportToDelete as any).cloudId; if (isCloudData && cloudId) { await deleteCloudReport(cloudId); } else if (reportToDelete.id) { // Delete from local IndexedDB await deleteReport(reportToDelete.id); } // Refresh reports and counts await loadReports(); await loadCounts(); setDeleteDialogOpen(false); setReportToDelete(null); } catch (error) { console.error("Failed to delete report:", error); } finally { setDeleting(false); } }; // Download PDF handler const [downloadingId, setDownloadingId] = useState(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 (
{/* Header */}

歷史報告

瀏覽已儲存的分析報告

{/* Pending Task Recovery Notice */} {/* Market Type Tabs */} setActiveTab(v as typeof activeTab)} className="w-full animate-slide-up animate-delay-200" > {(Object.keys(MARKET_LABELS) as Array).map( (key) => ( {MARKET_LABELS[key].label} {counts[key]} ) )} {(Object.keys(MARKET_LABELS) as Array).map( (marketType) => (
{/* Action buttons */}
{/* Sync to Cloud button - only show when authenticated */} {isAuthenticated && ( )} {/* Refresh button */}
{/* Report List */} {loading ? (

載入中...

) : reports.length === 0 ? (

尚無{MARKET_LABELS[marketType].label}的分析報告

執行分析後,可在結果頁面儲存報告

) : (
{reports.map((report) => ( {report.ticker} {MARKET_LABELS[report.market_type].label} 分析日期:{report.analysis_date}

儲存時間: {format( new Date(report.saved_at), "yyyy/MM/dd HH:mm", { locale: zhTW } )}

{(() => { const decision = extractDecisionFromReport(report); return (

決策: {decision.action}

); })()}
))}
)}
) )}
{/* Delete Confirmation Dialog */} 確認刪除 確定要刪除 {reportToDelete?.ticker} 於{" "} {reportToDelete?.analysis_date} 的分析報告嗎?
此操作無法復原。
); }