/** * 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 { useLanguage } from "@/contexts/LanguageContext"; 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, FileText, Download, MessageCircle, } from "lucide-react"; import { getReportsByMarketType, deleteReport, deleteReports, getAllReports, bulkSaveReports, 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 - dynamic function to support translations const getMarketLabels = (t: ReturnType["t"]) => ({ us: { label: `🇺🇸 ${t.form.usMarket}`, description: t.form.tickerDescUS }, twse: { label: `🇹🇼 ${t.form.twseMarket}`, description: t.form.tickerDescTWSE, }, tpex: { label: `🇹🇼 ${t.form.tpexMarket}`, description: t.form.tickerDescTPEX, }, }); // Helper function to extract decision from Risk Manager's final decision const extractDecisionFromReport = ( report: SavedReport, ): { action: string; color: string } => { // DEBUG: Log the actual data structure to diagnose issues console.log("📊 DEBUG extractDecisionFromReport for:", report.ticker); console.log(" - result type:", typeof report.result); console.log(" - result.reports exists:", !!report.result?.reports); console.log( " - trader_investment_plan exists:", !!report.result?.reports?.trader_investment_plan, ); console.log(" - decision.action exists:", !!report.result?.decision?.action); if (report.result?.reports?.trader_investment_plan) { const traderText = report.result.reports.trader_investment_plan; console.log(" - trader_investment_plan type:", typeof traderText); console.log(" - trader_investment_plan length:", traderText.length); // Show last 150 chars to see the final decision console.log(" - last 150 chars:", traderText.slice(-150)); // Check if it contains the key phrase const hasProposal = traderText.includes("最終交易提案"); console.log(" - contains '最終交易提案':", hasProposal); } else { console.log(" - trader_investment_plan is NULL or undefined"); } // Helper function to find "最終交易提案" or "Final Trading Proposal" const findFinalProposal = ( text: string, ): { action: string; color: string } | null => { if (!text || typeof text !== "string") return null; // === CHINESE PATTERN === // Match "最終交易提案:持有" - handle markdown ** bold markers // Pattern handles: 最終交易提案:持有, 最終交易提案:**持有**, **最終交易提案:持有** // Use global flag to find ALL matches, then take the LAST one (final decision) const zhRegex = /\*{0,2}最終交易提案[::]\s*\*{0,2}(買入|賣出|持有)\*{0,2}/g; const zhMatches = [...text.matchAll(zhRegex)]; if (zhMatches.length > 0) { // Take the LAST match (the final decision at the end of the report) const lastMatch = zhMatches[zhMatches.length - 1]; const decision = lastMatch[1]; console.log( ` ✅ Matched ZH pattern: "${lastMatch[0]}" -> decision: "${decision}"`, ); if (decision === "買入") return { action: "買入", color: "text-green-600" }; if (decision === "賣出") return { action: "賣出", color: "text-red-600" }; if (decision === "持有") return { action: "持有", color: "text-yellow-600" }; } // === ENGLISH PATTERN === // Match "Final Trading Proposal: BUY/SELL/HOLD" - handle markdown ** bold markers // Pattern handles: Final Trading Proposal: Buy, **Final Trading Proposal**: Hold, etc. const enRegex = /\*{0,2}Final Trading Proposal\*{0,2}[::]\s*\*{0,2}(BUY|SELL|HOLD|Buy|Sell|Hold)\*{0,2}/gi; const enMatches = [...text.matchAll(enRegex)]; if (enMatches.length > 0) { const lastMatch = enMatches[enMatches.length - 1]; const decision = lastMatch[1].toUpperCase(); console.log( ` ✅ Matched EN pattern: "${lastMatch[0]}" -> decision: "${decision}"`, ); if (decision === "BUY") return { action: "BUY", color: "text-green-600" }; if (decision === "SELL") return { action: "SELL", color: "text-red-600" }; if (decision === "HOLD") return { action: "HOLD", color: "text-yellow-600" }; } return null; }; // Helper function to find other decision patterns const findOtherDecision = ( text: string, ): { action: string; color: string } | null => { if (!text || typeof text !== "string") return null; const lowerText = text.toLowerCase(); // Look for "最終決策" or "最終建議" const finalDecisionMatch = text.match( /最終(?:決策|建議)[::]\s*(買入|賣出|持有)/, ); if (finalDecisionMatch) { const decision = finalDecisionMatch[1]; if (decision === "買入") return { action: "買入", color: "text-green-600" }; if (decision === "賣出") return { action: "賣出", color: "text-red-600" }; if (decision === "持有") return { action: "持有", color: "text-yellow-600" }; } // English patterns if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(buy|long)/i)) { return { action: "買入", color: "text-green-600" }; } if ( lowerText.match(/(?:final|recommendation|decision)[:\s]*(sell|short)/i) ) { return { action: "賣出", color: "text-red-600" }; } if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(hold)/i)) { return { action: "持有", color: "text-yellow-600" }; } return null; }; // ====== PRIORITY 1: Trader's "最終交易提案" - HIGHEST PRIORITY ====== const traderReport = report.result.reports?.trader_investment_plan; if (traderReport) { const decision = findFinalProposal(traderReport); if (decision) { console.log(`📊 Found trader decision: ${decision.action}`); return decision; } } // ====== PRIORITY 2: Check final_trade_decision ====== const finalTradeDecision = report.result.reports?.final_trade_decision; if (finalTradeDecision) { const decision = findFinalProposal(finalTradeDecision) || findOtherDecision(finalTradeDecision); if (decision) return decision; } // ====== PRIORITY 3: Check risk_debate_state judge decision ====== const riskJudge = report.result.reports?.risk_debate_state?.judge_decision; if (riskJudge) { const decision = findOtherDecision(riskJudge); if (decision) return decision; } // ====== PRIORITY 4: Fall back to decision.action field ====== 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 5: Search in other report fields ====== const allReports = report.result.reports; if (allReports) { const reportTexts = [ allReports.market_report, allReports.sentiment_report, allReports.news_report, allReports.fundamentals_report, ].filter((t) => t && typeof t === "string"); for (const text of reportTexts) { const decision = findFinalProposal(text); if (decision) return decision; } } return { action: "N/A", color: "text-gray-500" }; }; /** * Detect report language from content (for backward compatibility with old reports) * Checks trader_investment_plan for Chinese/English keywords */ const detectReportLanguage = (reports: any): "en" | "zh-TW" => { const traderPlan = reports?.trader_investment_plan; if (!traderPlan || typeof traderPlan !== "string") { // If no trader plan, check other reports for Chinese characters const allText = JSON.stringify(reports || {}); const chineseRegex = /[\u4e00-\u9fa5]/; return chineseRegex.test(allText) ? "zh-TW" : "en"; } // Check for Chinese decision keywords const chineseKeywords = ["買入", "賣出", "持有", "最終交易提案"]; for (const keyword of chineseKeywords) { if (traderPlan.includes(keyword)) { return "zh-TW"; } } // Check for English decision keywords const englishKeywords = ["buy", "sell", "hold", "final trading proposal"]; const lowerPlan = traderPlan.toLowerCase(); for (const keyword of englishKeywords) { if (lowerPlan.includes(keyword.toLowerCase())) { return "en"; } } // Fallback: check for Chinese characters in the content const chineseRegex = /[\u4e00-\u9fa5]/; return chineseRegex.test(traderPlan) ? "zh-TW" : "en"; }; /** * Parse a date string from the backend as UTC. * Backend stores created_at in UTC but may not always include timezone info. * This ensures the date is correctly interpreted as UTC so the browser * converts it to the user's local timezone for display. */ const parseUTCDate = (dateStr: string): Date => { // If the string already has timezone info (Z, +, or - offset), parse directly if (dateStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(dateStr)) { return new Date(dateStr); } // Otherwise, append 'Z' to treat as UTC return new Date(dateStr + 'Z'); }; /** * Helper to generate a unique signature for deduplication. * This ensures reports for the same ticker on the same day are not squashed together. */ const getReportSignature = (report: any): string => { const baseKey = `${report.ticker}_${report.analysis_date}_${report.market_type || 'us'}`; let contentHash = ""; if (report.result) { if (report.result.reports?.trader_investment_plan) { const plan = report.result.reports.trader_investment_plan; contentHash = `${plan.length}_${plan.slice(-30).replace(/[\s\n\r]+/g, '')}`; } else { contentHash = JSON.stringify(report.result).length.toString(); } } const langKey = report.language || "unknown_lang"; return `${baseKey}_${langKey}_${contentHash}`; }; export default function HistoryPage() { const router = useRouter(); const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext(); const { isAuthenticated } = useAuth(); const { t, locale } = useLanguage(); // Dynamic market labels based on language const MARKET_LABELS = getMarketLabels(t); 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); // Auto-sync tracking ref const hasAutoSyncedRef = useRef(false); const cloudReportsPromiseRef = useRef | null>(null); const fetchCloudReportsCached = async (forceRefresh = false) => { if (forceRefresh || !cloudReportsPromiseRef.current) { cloudReportsPromiseRef.current = getCloudReports().catch(() => { cloudReportsPromiseRef.current = null; return []; }); } return cloudReportsPromiseRef.current; }; // Load reports when tab changes, auth state changes, or language changes useEffect(() => { loadReports(); }, [activeTab, isAuthenticated, locale]); // Load counts on mount, auth change, or language change useEffect(() => { loadCounts(); }, [isAuthenticated, locale]); // Bidirectional sync: upload local to cloud AND download cloud to local const performBidirectionalSync = async (isInitialSync = false) => { if (!isAuthenticated || !isCloudSyncEnabled()) { return; } // For initial sync, only run once per session if (isInitialSync && hasAutoSyncedRef.current) { return; } if (isInitialSync) { hasAutoSyncedRef.current = true; } try { // First auto-clean local duplicates that might exist from older flawed versions try { const allLocal = await getAllReports(); const seenSignatures = new Set(); const idsToDelete: number[] = []; for (const report of allLocal) { const signature = getReportSignature(report as any); if (seenSignatures.has(signature)) { if (report.id) idsToDelete.push(report.id); } else { seenSignatures.add(signature); } } if (idsToDelete.length > 0) { console.log(`🧹 Found ${idsToDelete.length} duplicate local reports, cleaning...`); await deleteReports(idsToDelete); } } catch (err) { console.error("Failed to cleanup local duplicates:", err); } // Get all local reports (re-fetch after cleanup) const [usLocal, twseLocal, tpexLocal] = await Promise.all([ getReportsByMarketType("us"), getReportsByMarketType("twse"), getReportsByMarketType("tpex"), ]); const allLocal = [...usLocal, ...twseLocal, ...tpexLocal]; // Get cloud reports const cloudReports = await fetchCloudReportsCached(true); // Force refresh const cloudKeys = new Set(cloudReports.map((r) => getReportSignature(r))); const localKeys = new Set(allLocal.map((r) => getReportSignature(r))); // === UPLOAD: Local -> Cloud === const toUpload = allLocal.filter( (r) => !cloudKeys.has(getReportSignature(r)), ); if (toUpload.length > 0) { console.log(`☁️ Sync: Uploading ${toUpload.length} local reports to cloud...`); let uploadSuccess = 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, language: report.language || detectReportLanguage(report.result?.reports), }); if (cloudId) uploadSuccess++; } catch (e) { // Silently continue on error } } if (uploadSuccess > 0) { console.log(`☁️ Sync: Uploaded ${uploadSuccess} reports to cloud`); } } // === DOWNLOAD: Cloud -> Local === const toDownload = cloudReports.filter( (r) => !localKeys.has(getReportSignature(r)), ); if (toDownload.length > 0) { console.log(`☁️ Sync: Downloading ${toDownload.length} cloud reports to local...`); const reportsToSave = toDownload.map((r) => ({ ticker: r.ticker, market_type: r.market_type as "us" | "twse" | "tpex", analysis_date: r.analysis_date, saved_at: parseUTCDate(r.created_at), result: r.result, language: (r.language || detectReportLanguage(r.result?.reports)) as "en" | "zh-TW" | undefined, })); const savedCount = await bulkSaveReports(reportsToSave); if (savedCount > 0) { console.log(`☁️ Sync: Downloaded ${savedCount} reports to local`); } } // === DELETE SYNC: Remove local reports that were deleted from cloud === // Only delete reports that are old enough (> 2 minutes) to avoid deleting newly saved reports const TWO_MINUTES_AGO = Date.now() - 2 * 60 * 1000; const toDeleteLocal = allLocal.filter((localReport) => { // Don't delete recently saved reports (might not be uploaded yet) const savedTime = new Date(localReport.saved_at).getTime(); if (savedTime > TWO_MINUTES_AGO) { return false; } // If local report is not in cloud, it was likely deleted on another device return !cloudKeys.has(getReportSignature(localReport)); }); if (toDeleteLocal.length > 0) { console.log(`☁️ Sync: Removing ${toDeleteLocal.length} locally cached reports that were deleted from cloud...`); const idsToDelete = toDeleteLocal .map((r) => r.id) .filter((id): id is number => id !== undefined); if (idsToDelete.length > 0) { await deleteReports(idsToDelete); console.log(`☁️ Sync: Removed ${idsToDelete.length} local reports`); } } // Reload UI if any changes if (toUpload.length > 0 || toDownload.length > 0 || toDeleteLocal.length > 0) { await loadReports(); await loadCounts(); } else { console.log("☁️ Sync: Already in sync"); } } catch (error) { console.error("☁️ Sync failed:", error); } }; // Initial sync when page loads (if authenticated) useEffect(() => { performBidirectionalSync(true); }, [isAuthenticated]); // Re-sync when page becomes visible (handles cross-device changes) useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === "visible" && isAuthenticated && isCloudSyncEnabled()) { console.log("☁️ Page visible, checking for updates..."); performBidirectionalSync(false); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [isAuthenticated]); const loadReports = async () => { setLoading(true); try { // Helper to filter reports by current UI language const filterByLang = (reports: SavedReport[]) => { return reports.filter((report) => { const reportLang = report.language || detectReportLanguage(report.result?.reports); return reportLang === locale; }); }; // 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 fetchCloudReportsCached(); // 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: parseUTCDate(r.created_at), result: r.result, language: r.language, // Include language from cloud })) 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 + content snippet) for deduplication const cloudKeys = new Set( cloudFiltered.map((r) => getReportSignature(r)), ); // Find local reports that don't exist in cloud const localOnly = localData.filter( (r) => !cloudKeys.has(getReportSignature(r)), ); // 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(), ); // Filter by current UI language before display setReports(filterByLang(merged)); setIsCloudData(true); return; } } // Filter local data by language before display setReports(filterByLang(localData)); setIsCloudData(false); } catch (error) { console.error("Failed to load reports:", error); const data = await getReportsByMarketType(activeTab); // Filter by language on error fallback too const filtered = data.filter((report) => { const reportLang = report.language || detectReportLanguage(report.result?.reports); return reportLang === locale; }); setReports(filtered as SavedReport[]); setIsCloudData(false); } finally { setLoading(false); } }; const loadCounts = async () => { try { // Helper to filter reports by language (matches current UI locale) const filterByLanguage = (reports: SavedReport[]) => { return reports.filter((report) => { // Get report language - use stored value or detect from content const reportLang = report.language || detectReportLanguage(report.result?.reports); // Match against current locale return reportLang === locale; }); }; if (isAuthenticated && isCloudSyncEnabled()) { const cloudReports = await fetchCloudReportsCached(); 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 => getReportSignature(r)) ); // Convert cloud reports to SavedReport format for language filtering const cloudAsSaved = cloudReports.map(r => ({ id: 0, ticker: r.ticker, market_type: r.market_type as "us" | "twse" | "tpex", analysis_date: r.analysis_date, saved_at: parseUTCDate(r.created_at), result: r.result, language: r.language, })) as SavedReport[]; // Filter cloud reports by language const cloudFiltered = filterByLanguage(cloudAsSaved); // Count local-only reports (not in cloud) and filter by language const usLocalOnly = filterByLanguage(usLocal.filter( r => !cloudKeys.has(getReportSignature(r)) )).length; const twseLocalOnly = filterByLanguage(twseLocal.filter( r => !cloudKeys.has(getReportSignature(r)) )).length; const tpexLocalOnly = filterByLanguage(tpexLocal.filter( r => !cloudKeys.has(getReportSignature(r)) )).length; // Cloud counts (already filtered by language) const usCloud = cloudFiltered.filter(r => r.market_type === "us").length; const twseCloud = cloudFiltered.filter(r => r.market_type === "twse").length; const tpexCloud = cloudFiltered.filter(r => r.market_type === "tpex").length; // Merged counts: cloud + local-only (both filtered by language) setCounts({ us: usCloud + usLocalOnly, twse: twseCloud + twseLocalOnly, tpex: tpexCloud + tpexLocalOnly, }); return; } } // If no cloud data or not authenticated, use local only with language filter const [usLocal, twseLocal, tpexLocal] = await Promise.all([ getReportsByMarketType("us"), getReportsByMarketType("twse"), getReportsByMarketType("tpex"), ]); setCounts({ us: filterByLanguage(usLocal).length, twse: filterByLanguage(twseLocal).length, tpex: filterByLanguage(tpexLocal).length, }); } catch (error) { console.error("Failed to load counts:", error); } }; // Handle refresh button - performs full sync if authenticated const handleRefresh = async () => { if (isAuthenticated && isCloudSyncEnabled()) { setSyncing(true); try { await performBidirectionalSync(false); } finally { setSyncing(false); } } else { await loadReports(); } }; 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 { const cloudId = (reportToDelete as any).cloudId; // IMPORTANT: Delete from BOTH cloud AND local to prevent re-sync issues // 1. If cloud ID exists, delete from cloud if (cloudId) { console.log("🗑️ Deleting from cloud:", cloudId); await deleteCloudReport(cloudId); } // 2. Always try to delete from local IndexedDB as well // Use strict matching: ticker + date + market_type + language try { const localReports = await getReportsByMarketType( reportToDelete.market_type, ); // Get language of report to delete (use stored or detect) const targetLang = reportToDelete.language || detectReportLanguage(reportToDelete.result?.reports); // Find matching report with same ticker, date, market, AND language const matchingLocal = localReports.find((r) => { if (r.ticker !== reportToDelete.ticker) return false; if (r.analysis_date !== reportToDelete.analysis_date) return false; if (r.market_type !== reportToDelete.market_type) return false; // Match language (detect if not stored) const localLang = r.language || detectReportLanguage(r.result?.reports); return localLang === targetLang; }); if (matchingLocal && matchingLocal.id) { console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id, "language:", targetLang); await deleteReport(matchingLocal.id); } } catch (localError) { console.warn("Could not delete local copy:", localError); } // Refresh reports and counts await fetchCloudReportsCached(true); 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; } // Detect report language based on trader's final decision keywords // This ensures PDF language matches the report content, not UI language const detectReportLanguage = (reports: any): "zh-TW" | "en" => { const traderPlan = reports?.trader_investment_plan; if (!traderPlan || typeof traderPlan !== "string") { return "zh-TW"; // Default to Chinese } // Chinese decision keywords: 買入, 賣出, 持有 const chineseKeywords = ["買入", "賣出", "持有", "最終交易提案"]; // English decision keywords: buy, sell, hold (case insensitive) const englishKeywords = ["buy", "sell", "hold", "final trading proposal"]; const lowerPlan = traderPlan.toLowerCase(); // Check for Chinese keywords first for (const keyword of chineseKeywords) { if (traderPlan.includes(keyword)) { return "zh-TW"; } } // Check for English keywords for (const keyword of englishKeywords) { if (lowerPlan.includes(keyword)) { return "en"; } } // Fallback: check for any Chinese characters const chineseRegex = /[\u4e00-\u9fa5]/; if (chineseRegex.test(traderPlan)) { return "zh-TW"; } return "en"; }; const reportLanguage = detectReportLanguage(report.result.reports); // 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, language: reportLanguage, // Use detected language based on trader decision }; 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 */}

{t.history.title}

{t.history.noHistory.replace( "尚無分析紀錄", "瀏覽已儲存的分析報告", )}

{/* 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) => (
{/* Refresh/Sync button */}
{/* Report List */} {loading ? (

{t.history.loading}

) : reports.length === 0 ? (

{t.history.noReportsFor}{" "} {MARKET_LABELS[marketType].label}

{t.history.afterAnalysisSave}

) : (
{reports.map((report) => ( {report.ticker} {MARKET_LABELS[report.market_type].label} {t.history.analysisDate}:{report.analysis_date}

{t.history.savedAt}: {format( new Date(report.saved_at), "yyyy/MM/dd HH:mm", { locale: zhTW }, )}

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

{t.history.decision}: {decision.action}

); })()}
))}
)}
))}
{/* Delete Confirmation Dialog */} {t.history.confirmDeleteTitle} {t.history.confirmDeleteDesc}{" "} {reportToDelete?.ticker} {t.history.on}{" "} {reportToDelete?.analysis_date}?
{t.history.cannotUndo}
); }