From cf4aaa09b19b992d20e66b2d6b379a5d33d56acf Mon Sep 17 00:00:00 2001 From: MarkLo Date: Sun, 14 Dec 2025 03:21:59 +0800 Subject: [PATCH] feat: auto-save reports + fix PDF download from history 1. Auto-save reports when page closes: - Added beforeunload handler to auto-save analysis to history - Saves to local IndexedDB and cloud (if authenticated) - Prevents data loss if user accidentally closes the page 2. Fix PDF download from history page: - Made taskId optional in DownloadReports component - Added direct mode: can pass reports data directly - Updated backend to support both task-based and direct-data modes - History reports now have download PDF option available --- backend/app/api/routes.py | 50 +++++++----- backend/app/models/schemas.py | 9 ++- frontend/app/analysis/page.tsx | 81 ++++++++++++++++++- frontend/app/analysis/results/page.tsx | 4 +- .../components/analysis/DownloadReports.tsx | 30 +++++-- 5 files changed, 143 insertions(+), 31 deletions(-) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 92bac701..85dc0767 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -177,8 +177,12 @@ async def download_reports(request: DownloadRequest): """ Download analyst reports as PDF or ZIP + Supports two modes: + 1. Task-based: If task_id is provided, lookup reports from task manager + 2. Direct mode: If no task_id, use the directly provided reports data (for saved history) + Args: - request: Download request with ticker, date, task_id, and analyst list + request: Download request with ticker, date, analysts, and either task_id or direct reports Returns: PDF file (single analyst) or ZIP file (multiple analysts) @@ -186,20 +190,32 @@ async def download_reports(request: DownloadRequest): from fastapi.responses import Response from backend.app.services.download_service import download_service - # Get task result - task = task_manager.get_task_status(request.task_id) - - if not task: - raise HTTPException(status_code=404, detail=f"Task {request.task_id} not found") - - if task.get("status") != "completed": - raise HTTPException(status_code=400, detail="Task is not completed yet") - - result = task.get("result") - if not result: - raise HTTPException(status_code=404, detail="No analysis result found") - - reports_data = result.get("reports", {}) + # Determine data source: task-based or direct mode + if request.task_id: + # Task-based mode: lookup from task manager + task = task_manager.get_task_status(request.task_id) + + if not task: + raise HTTPException(status_code=404, detail=f"Task {request.task_id} not found") + + if task.get("status") != "completed": + raise HTTPException(status_code=400, detail="Task is not completed yet") + + result = task.get("result") + if not result: + raise HTTPException(status_code=404, detail="No analysis result found") + + reports_data = result.get("reports", {}) + price_data = result.get("price_data") + price_stats = result.get("price_stats") + else: + # Direct mode: use provided reports data + if not request.reports: + raise HTTPException(status_code=400, detail="Either task_id or reports data is required") + + reports_data = request.reports + price_data = request.price_data + price_stats = request.price_stats # Analyst name mapping ANALYST_MAPPING = { @@ -245,10 +261,6 @@ async def download_reports(request: DownloadRequest): if not reports_to_download: raise HTTPException(status_code=404, detail="No reports found for selected analysts") - # Extract price data for cover page - price_data = result.get("price_data") - price_stats = result.get("price_stats") - # Single report - return PDF if len(reports_to_download) == 1: pdf_bytes, filename = download_service.create_single_pdf( diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index c2e9059c..022d488a 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -153,9 +153,16 @@ class DownloadRequest(BaseModel): """Request model for downloading analyst reports""" ticker: str = Field(..., description="Stock ticker symbol") analysis_date: str = Field(..., description="Analysis date in YYYY-MM-DD format") - task_id: str = Field(..., description="Task ID of the completed analysis") analysts: List[str] = Field(..., description="List of analyst keys to download", min_length=1) + # Task-based mode: lookup reports from task + task_id: Optional[str] = Field(None, description="Task ID of the completed analysis (optional)") + + # Direct mode: reports data passed directly (for history/saved reports) + reports: Optional[Dict[str, Any]] = Field(None, description="Direct reports data (if no task_id)") + price_data: Optional[List[Dict[str, Any]]] = Field(None, description="Price data for PDF chart") + price_stats: Optional[Dict[str, Any]] = Field(None, description="Price stats for PDF cover page") + # 防呆:自動將股票代碼轉換為大寫 @field_validator('ticker') @classmethod diff --git a/frontend/app/analysis/page.tsx b/frontend/app/analysis/page.tsx index 34d9433e..a3b9e12b 100644 --- a/frontend/app/analysis/page.tsx +++ b/frontend/app/analysis/page.tsx @@ -3,30 +3,105 @@ */ "use client"; -import { useEffect } from "react"; +import { useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { AnalysisForm } from "@/components/analysis/AnalysisForm"; import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; import { ErrorAlert } from "@/components/shared/ErrorAlert"; import { useAnalysis } from "@/hooks/useAnalysis"; import { useAnalysisContext } from "@/context/AnalysisContext"; +import { useAuth } from "@/contexts/auth-context"; +import { saveReport, checkDuplicateReport } from "@/lib/reports-db"; +import { saveCloudReport, isCloudSyncEnabled } from "@/lib/user-api"; import type { AnalysisRequest } from "@/lib/types"; export default function AnalysisPage() { const router = useRouter(); - const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext(); + const { setAnalysisResult, setTaskId, setMarketType, marketType } = useAnalysisContext(); const { runAnalysis, loading, error, result, taskId } = useAnalysis(); + const { isAuthenticated } = useAuth(); + + // Ref to track if we've already saved (to prevent duplicate saves) + const hasSavedRef = useRef(false); + + // Auto-save function + const autoSaveReport = useCallback(async () => { + if (!result || hasSavedRef.current) return; + + try { + // Check for duplicate + const duplicate = await checkDuplicateReport(result.ticker, result.analysis_date); + if (duplicate) { + console.log("Report already saved, skipping auto-save"); + return; + } + + // Mark as saved to prevent duplicate saves + hasSavedRef.current = true; + + // Save to local IndexedDB + await saveReport( + result.ticker, + marketType, + result.analysis_date, + result, + taskId || undefined + ); + console.log("📁 Auto-saved report to local storage"); + + // If authenticated, also save to cloud + if (isAuthenticated && isCloudSyncEnabled()) { + const cloudId = await saveCloudReport({ + ticker: result.ticker, + market_type: marketType, + analysis_date: result.analysis_date, + result: result, + }); + if (cloudId) { + console.log("☁️ Auto-saved report to cloud"); + } + } + } catch (error) { + console.error("Auto-save failed:", error); + } + }, [result, marketType, taskId, isAuthenticated]); + + // Auto-save when page unloads (closing tab, navigating away, etc.) + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (result && !hasSavedRef.current) { + // Trigger auto-save synchronously (best effort) + autoSaveReport(); + + // Show browser's default "Leave site?" dialog only if there's unsaved analysis + e.preventDefault(); + e.returnValue = ''; + } + }; + + // Add listener when we have a result + if (result) { + window.addEventListener('beforeunload', handleBeforeUnload); + } + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [result, autoSaveReport]); // 當分析完成時自動跳轉到結果頁面 useEffect(() => { if (result && !loading && !error) { + // Auto-save before navigating to results + autoSaveReport(); + setAnalysisResult(result); if (taskId) { setTaskId(taskId); } router.push("/analysis/results"); } - }, [result, loading, error, router, setAnalysisResult, taskId, setTaskId]); + }, [result, loading, error, router, setAnalysisResult, taskId, setTaskId, autoSaveReport]); const handleSubmit = async (data: AnalysisRequest) => { try { diff --git a/frontend/app/analysis/results/page.tsx b/frontend/app/analysis/results/page.tsx index 75cd84e1..5a449d54 100644 --- a/frontend/app/analysis/results/page.tsx +++ b/frontend/app/analysis/results/page.tsx @@ -318,13 +318,15 @@ export default function AnalysisResultsPage() { {/* Download Reports Section - 放在分析報告下方 */} - {taskId && analysisResult.reports && ( + {analysisResult.reports && ( )} diff --git a/frontend/components/analysis/DownloadReports.tsx b/frontend/components/analysis/DownloadReports.tsx index 709b4f7a..ac668aa8 100644 --- a/frontend/components/analysis/DownloadReports.tsx +++ b/frontend/components/analysis/DownloadReports.tsx @@ -22,9 +22,11 @@ interface AnalystInfo { interface DownloadReportsProps { ticker: string; analysisDate: string; - taskId: string; + taskId?: string | null; // Now optional - if not provided, use direct data mode analysts: AnalystInfo[]; reports: any; + priceData?: any[]; // For direct download mode + priceStats?: any; // For direct download mode } export function DownloadReports({ @@ -33,6 +35,8 @@ export function DownloadReports({ taskId, analysts, reports, + priceData, + priceStats, }: DownloadReportsProps) { const [selectedAnalysts, setSelectedAnalysts] = useState([]); const [isDownloading, setIsDownloading] = useState(false); @@ -74,17 +78,29 @@ export function DownloadReports({ setIsDownloading(true); try { + // Build request body - use taskId if available, otherwise send direct data + const requestBody: any = { + ticker, + analysis_date: analysisDate, + analysts: selectedAnalysts, + }; + + if (taskId) { + // Task-based mode: API will look up reports from task + requestBody.task_id = taskId; + } else { + // Direct mode: send report data directly + requestBody.reports = reports; + requestBody.price_data = priceData; + requestBody.price_stats = priceStats; + } + const response = await fetch('/api/download/reports', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - ticker, - analysis_date: analysisDate, - task_id: taskId, - analysts: selectedAnalysts, - }), + body: JSON.stringify(requestBody), }); if (!response.ok) {