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) {