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
This commit is contained in:
MarkLo 2025-12-14 03:21:59 +08:00
parent 3f13475485
commit cf4aaa09b1
5 changed files with 143 additions and 31 deletions

View File

@ -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(

View File

@ -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

View File

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

View File

@ -318,13 +318,15 @@ export default function AnalysisResultsPage() {
</Tabs>
{/* Download Reports Section - 放在分析報告下方 */}
{taskId && analysisResult.reports && (
{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>

View File

@ -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<string[]>([]);
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) {