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
|
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:
|
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:
|
Returns:
|
||||||
PDF file (single analyst) or ZIP file (multiple analysts)
|
PDF file (single analyst) or ZIP file (multiple analysts)
|
||||||
|
|
@ -186,7 +190,9 @@ async def download_reports(request: DownloadRequest):
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from backend.app.services.download_service import download_service
|
from backend.app.services.download_service import download_service
|
||||||
|
|
||||||
# Get task result
|
# 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)
|
task = task_manager.get_task_status(request.task_id)
|
||||||
|
|
||||||
if not task:
|
if not task:
|
||||||
|
|
@ -200,6 +206,16 @@ async def download_reports(request: DownloadRequest):
|
||||||
raise HTTPException(status_code=404, detail="No analysis result found")
|
raise HTTPException(status_code=404, detail="No analysis result found")
|
||||||
|
|
||||||
reports_data = result.get("reports", {})
|
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 name mapping
|
||||||
ANALYST_MAPPING = {
|
ANALYST_MAPPING = {
|
||||||
|
|
@ -245,10 +261,6 @@ async def download_reports(request: DownloadRequest):
|
||||||
if not reports_to_download:
|
if not reports_to_download:
|
||||||
raise HTTPException(status_code=404, detail="No reports found for selected analysts")
|
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
|
# Single report - return PDF
|
||||||
if len(reports_to_download) == 1:
|
if len(reports_to_download) == 1:
|
||||||
pdf_bytes, filename = download_service.create_single_pdf(
|
pdf_bytes, filename = download_service.create_single_pdf(
|
||||||
|
|
|
||||||
|
|
@ -153,9 +153,16 @@ class DownloadRequest(BaseModel):
|
||||||
"""Request model for downloading analyst reports"""
|
"""Request model for downloading analyst reports"""
|
||||||
ticker: str = Field(..., description="Stock ticker symbol")
|
ticker: str = Field(..., description="Stock ticker symbol")
|
||||||
analysis_date: str = Field(..., description="Analysis date in YYYY-MM-DD format")
|
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)
|
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')
|
@field_validator('ticker')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,105 @@
|
||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AnalysisForm } from "@/components/analysis/AnalysisForm";
|
import { AnalysisForm } from "@/components/analysis/AnalysisForm";
|
||||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { useAnalysis } from "@/hooks/useAnalysis";
|
import { useAnalysis } from "@/hooks/useAnalysis";
|
||||||
import { useAnalysisContext } from "@/context/AnalysisContext";
|
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";
|
import type { AnalysisRequest } from "@/lib/types";
|
||||||
|
|
||||||
export default function AnalysisPage() {
|
export default function AnalysisPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext();
|
const { setAnalysisResult, setTaskId, setMarketType, marketType } = useAnalysisContext();
|
||||||
const { runAnalysis, loading, error, result, taskId } = useAnalysis();
|
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(() => {
|
useEffect(() => {
|
||||||
if (result && !loading && !error) {
|
if (result && !loading && !error) {
|
||||||
|
// Auto-save before navigating to results
|
||||||
|
autoSaveReport();
|
||||||
|
|
||||||
setAnalysisResult(result);
|
setAnalysisResult(result);
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
setTaskId(taskId);
|
setTaskId(taskId);
|
||||||
}
|
}
|
||||||
router.push("/analysis/results");
|
router.push("/analysis/results");
|
||||||
}
|
}
|
||||||
}, [result, loading, error, router, setAnalysisResult, taskId, setTaskId]);
|
}, [result, loading, error, router, setAnalysisResult, taskId, setTaskId, autoSaveReport]);
|
||||||
|
|
||||||
const handleSubmit = async (data: AnalysisRequest) => {
|
const handleSubmit = async (data: AnalysisRequest) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -318,13 +318,15 @@ export default function AnalysisResultsPage() {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Download Reports Section - 放在分析報告下方 */}
|
{/* Download Reports Section - 放在分析報告下方 */}
|
||||||
{taskId && analysisResult.reports && (
|
{analysisResult.reports && (
|
||||||
<DownloadReports
|
<DownloadReports
|
||||||
ticker={analysisResult.ticker}
|
ticker={analysisResult.ticker}
|
||||||
analysisDate={analysisResult.analysis_date}
|
analysisDate={analysisResult.analysis_date}
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
analysts={ANALYSTS}
|
analysts={ANALYSTS}
|
||||||
reports={analysisResult.reports}
|
reports={analysisResult.reports}
|
||||||
|
priceData={analysisResult.price_data}
|
||||||
|
priceStats={analysisResult.price_stats}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,11 @@ interface AnalystInfo {
|
||||||
interface DownloadReportsProps {
|
interface DownloadReportsProps {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
analysisDate: string;
|
analysisDate: string;
|
||||||
taskId: string;
|
taskId?: string | null; // Now optional - if not provided, use direct data mode
|
||||||
analysts: AnalystInfo[];
|
analysts: AnalystInfo[];
|
||||||
reports: any;
|
reports: any;
|
||||||
|
priceData?: any[]; // For direct download mode
|
||||||
|
priceStats?: any; // For direct download mode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadReports({
|
export function DownloadReports({
|
||||||
|
|
@ -33,6 +35,8 @@ export function DownloadReports({
|
||||||
taskId,
|
taskId,
|
||||||
analysts,
|
analysts,
|
||||||
reports,
|
reports,
|
||||||
|
priceData,
|
||||||
|
priceStats,
|
||||||
}: DownloadReportsProps) {
|
}: DownloadReportsProps) {
|
||||||
const [selectedAnalysts, setSelectedAnalysts] = useState<string[]>([]);
|
const [selectedAnalysts, setSelectedAnalysts] = useState<string[]>([]);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
@ -74,17 +78,29 @@ export function DownloadReports({
|
||||||
|
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
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', {
|
const response = await fetch('/api/download/reports', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestBody),
|
||||||
ticker,
|
|
||||||
analysis_date: analysisDate,
|
|
||||||
task_id: taskId,
|
|
||||||
analysts: selectedAnalysts,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue