feat: 添加报告PDF下载功能并优化分析页iframe高度自适应

This commit is contained in:
xucheng 2026-01-06 00:10:52 +08:00
parent a1ed75c405
commit 880df10484
3 changed files with 403 additions and 4 deletions

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@ -9,6 +9,11 @@ from app.models import Report, Setting
from app.services import analysis_service
import os
import markdown
from weasyprint import HTML
import io
import tempfile
from urllib.parse import quote
from bs4 import BeautifulSoup
router = APIRouter()
@ -134,6 +139,26 @@ async def get_report_html(report_id: int, db: AsyncSession = Depends(get_db)):
</head>
<body>
{financial_html}
<script>
// Send height to parent window
function sendHeight() {{
const height = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
window.parent.postMessage({{ type: 'resize', height: height }}, '*');
}}
// Send height on load and when window resizes
window.addEventListener('load', sendHeight);
window.addEventListener('resize', sendHeight);
// Also send immediately in case load already fired
sendHeight();
</script>
</body>
</html>
"""
@ -167,3 +192,332 @@ async def update_config(request: ConfigUpdateRequest, db: AsyncSession = Depends
await db.commit()
return {"status": "updated", "key": request.key}
@router.get("/reports/{report_id}/pdf")
async def download_report_pdf(report_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Report).options(selectinload(Report.sections)).where(Report.id == report_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
# Get Financial HTML (Charts)
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
base_dir = os.path.join(root_dir, "data", report.market)
symbol_dir = os.path.join(base_dir, report.symbol)
# Fuzzy match logic
financial_html = ""
try:
if not os.path.exists(symbol_dir) and os.path.exists(base_dir):
candidates = [d for d in os.listdir(base_dir) if d.startswith(report.symbol) and os.path.isdir(os.path.join(base_dir, d))]
if candidates:
symbol_dir = os.path.join(base_dir, candidates[0])
start_html_path = os.path.join(symbol_dir, "report.html")
if os.path.exists(start_html_path):
with open(start_html_path, 'r', encoding='utf-8') as f:
financial_html = f.read()
# Parse and clean HTML - keep table styles but remove containers and backgrounds
soup = BeautifulSoup(financial_html, 'html.parser')
# Modify style tags to remove background colors
for style_tag in soup.find_all('style'):
style_content = style_tag.string
if style_content:
# Remove CSS variables for backgrounds
style_content = '\n'.join([line for line in style_content.split('\n')
if not any(bg in line.lower() for bg in ['--bg:', '--card-bg:', '--header-bg:', '--section-bg:'])])
# Remove background properties from body
style_content = style_content.replace('background: var(--bg);', 'background: white;')
style_content = style_content.replace('background: var(--card-bg);', '')
style_content = style_content.replace('background: var(--header-bg);', '')
style_tag.string = style_content
# Remove container divs but keep content
for div in soup.find_all('div', class_='report-container'):
div.unwrap()
# Remove inline styles from container divs only, preserve td/th styles (including background colors)
for div in soup.find_all('div'):
if div.get('style'):
del div['style']
# Limit table columns to first 6
for table in soup.find_all('table'):
for row in table.find_all('tr'):
cells = row.find_all(['th', 'td'])
if len(cells) > 6:
for cell in cells[6:]:
cell.decompose()
financial_html = str(soup)
else:
financial_html = "<p>财务图表尚未生成,数据获取可能仍在进行中。</p>"
except Exception as e:
financial_html = f"<p>加载财务图表时出错: {str(e)}</p>"
# Build analysis sections HTML
sections_html = ""
section_names = {
'company_profile': '公司简介',
'fundamental_analysis': '基本面分析',
'insider_analysis': '内部人士分析',
'bullish_analysis': '看涨分析',
'bearish_analysis': '看跌分析'
}
import re
for section in sorted(report.sections, key=lambda s: list(section_names.keys()).index(s.section_name) if s.section_name in section_names else 999):
# Pre-process markdown to fix list formatting
content = section.content
# 1. First, find cases where numbered lists are clumped in one line like "1. xxx 2. yyy"
# and split them into multiple lines.
content = re.sub(r'(\s)(\d+\.\s)', r'\n\2', content)
# 2. Ensure list items have proper spacing
lines = content.split('\n')
fixed_lines = []
in_list = False
for i, line in enumerate(lines):
stripped = line.strip()
# Check if this line is a list item (bullet or numbered)
# Regex \d+\.\s matches "1. ", "2. ", etc.
is_list_item = stripped.startswith('* ') or stripped.startswith('- ') or re.match(r'^\d+\.\s', stripped)
if is_list_item:
# Add blank line before first list item
if not in_list and fixed_lines and fixed_lines[-1].strip():
fixed_lines.append('')
in_list = True
fixed_lines.append(line)
else:
# Add blank line after last list item
if in_list and stripped:
fixed_lines.append('')
in_list = False
fixed_lines.append(line)
content = '\n'.join(fixed_lines)
section_content = markdown.markdown(content, extensions=['tables', 'fenced_code'])
sections_html += f"""
<div class="section">
<div class="section-content">
{section_content}
</div>
</div>
"""
# Complete PDF HTML
complete_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{report.company_name} - 分析报告</title>
<style>
@page {{
size: A4;
margin: 2.5cm 1cm;
}}
body {{
font-family: "PingFang SC", "Microsoft YaHei", "SimHei", sans-serif;
line-height: 1.6;
color: #333;
font-size: 11pt;
background-color: white;
margin: 0;
padding: 0;
}}
.cover {{
page-break-after: always;
text-align: center;
padding-top: 30%;
}}
.cover h1 {{
font-size: 32pt;
margin-bottom: 20px;
color: #1a1a1a;
}}
.cover .meta {{
font-size: 14pt;
color: #666;
margin: 10px 0;
}}
h1 {{
font-size: 20pt;
color: #1a1a1a;
margin-top: 20px;
margin-bottom: 12px;
border-bottom: 2px solid #4a90e2;
padding-bottom: 8px;
}}
h2 {{
font-size: 16pt;
color: #2c3e50;
margin-top: 16px;
margin-bottom: 8px;
}}
h3 {{
font-size: 13pt;
color: #34495e;
margin-top: 12px;
margin-bottom: 6px;
}}
p {{
margin: 4px 0;
text-align: justify;
font-size: 10pt;
}}
/* Prevent double spacing in lists */
li p {{
margin: 0;
}}
table {{
border-collapse: collapse;
width: 100%;
max-width: 100%;
margin: 10px 0;
font-size: 6pt;
table-layout: fixed;
line-height: 1.2;
}}
th, td {{
border: 1px solid #ddd;
padding: 2px 2px;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
}}
/* First column for tables starting from the 2nd table (财务指标表) - narrower */
table:nth-of-type(n+2) th:first-child,
table:nth-of-type(n+2) td:first-child {{
width: 150px;
min-width: 150px;
max-width: 150px;
}}
/* PE and PB columns in the first table (Basic Info) - narrower */
table:first-of-type th:nth-child(4),
table:first-of-type td:nth-child(4),
table:first-of-type th:nth-child(5),
table:first-of-type td:nth-child(5) {{
width: 90px;
}}
th {{
background-color: #f3f4f6;
color: #374151;
font-weight: bold;
font-size: 6pt;
padding: 5px 2px;
}}
tr:nth-child(even) {{
background-color: #f9f9f9;
}}
/* Handle tables with many columns */
.table-wrapper {{
overflow: hidden;
max-width: 100%;
}}
img {{
max-width: 100%;
height: auto;
display: block;
margin: 10px auto;
}}
.section {{
page-break-before: always;
}}
.section:first-of-type {{
page-break-before: auto;
}}
.section-title {{
font-size: 22pt;
color: #1a1a1a;
margin-bottom: 15px;
border-bottom: 3px solid #4a90e2;
padding-bottom: 8px;
}}
.section-content {{
margin-top: 10px;
}}
ul, ol {{
margin: 0 0 6px 0;
padding-left: 30px;
line-height: 1.8;
}}
li {{
margin: 2px 0;
font-size: 10pt;
list-style-position: outside;
}}
code {{
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 10pt;
}}
pre {{
background-color: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
font-size: 9pt;
}}
blockquote {{
border-left: 4px solid #4a90e2;
padding-left: 15px;
margin: 15px 0;
color: #555;
font-style: italic;
}}
</style>
</head>
<body>
<div class="cover">
<h1>{report.company_name}</h1>
<div class="meta">{report.market} {report.symbol}</div>
<div class="meta">分析日期: {report.created_at.strftime('%Y年%m月%d')}</div>
{f'<div class="meta">AI模型: {report.ai_model}</div>' if report.ai_model else ''}
</div>
<div class="section">
<div class="section-content">
{financial_html}
</div>
</div>
{sections_html}
</body>
</html>
"""
# Generate PDF
try:
# Create a temporary file for the PDF
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file:
pdf_path = tmp_file.name
HTML(string=complete_html).write_pdf(pdf_path)
# Return the PDF file
filename = f"{report.company_name}_{report.symbol}_分析报告.pdf"
# Use RFC 5987 encoding for non-ASCII filenames
filename_encoded = quote(filename)
return FileResponse(
path=pdf_path,
media_type='application/pdf',
headers={
'Content-Disposition': f"attachment; filename*=UTF-8''{filename_encoded}"
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成PDF失败: {str(e)}")

View File

@ -13,3 +13,4 @@ pandas
numpy
pytest
httpx
weasyprint

View File

@ -1,12 +1,12 @@
"use client"
import { useEffect, useState, use } from "react"
import { useEffect, useState, use, useRef } from "react"
import { getReport } from "@/lib/api"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownRenderer } from "@/components/markdown-renderer"
import { Loader2, RefreshCcw, ExternalLink } from "lucide-react"
import { Loader2, RefreshCcw, ExternalLink, Download } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function AnalysisPage({ params }: { params: Promise<{ id: string }> }) {
@ -22,6 +22,8 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
const [report, setReport] = useState<any>(null)
const [loading, setLoading] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null)
const [iframeHeight, setIframeHeight] = useState<number>(800)
// Polling logic
useEffect(() => {
@ -49,6 +51,41 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
return () => clearTimeout(interval)
}, [reportId])
// Listen for height messages from iframe
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Accept messages from localhost (for development)
if (event.data.type === 'resize' && typeof event.data.height === 'number') {
setIframeHeight(event.data.height + 20) // Add some padding
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
// Handle PDF download
const handleDownloadPDF = async () => {
try {
const response = await fetch(`http://localhost:8000/api/reports/${report.id}/pdf`)
if (!response.ok) {
throw new Error('下载失败')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${report.company_name}_${report.symbol}_分析报告.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('PDF下载失败:', error)
alert('PDF下载失败请稍后重试')
}
}
if (loading && !report) return <div className="flex h-screen items-center justify-center"><Loader2 className="animate-spin h-8 w-8 text-primary" /></div>
if (!report) return <div className="p-8"></div>
@ -75,6 +112,12 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
</div>
) : report.status === "completed" ? "已完成" : report.status === "failed" ? "失败" : report.status === "pending" ? "待处理" : report.status}
</Badge>
{report.status === "completed" && (
<Button onClick={handleDownloadPDF} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
PDF
</Button>
)}
</div>
</div>
@ -99,8 +142,9 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
</TabsList>
<TabsContent value="financial_data" className="min-h-[500px]">
<Card className="h-[800px]">
<Card style={{ height: `${iframeHeight}px` }}>
<iframe
ref={iframeRef}
src={`http://localhost:8000/api/reports/${report.id}/html`}
className="w-full h-full border-0 rounded-md"
title="分析仪表板"