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:
parent
3f13475485
commit
cf4aaa09b1
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue