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 import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -9,6 +9,11 @@ from app.models import Report, Setting
from app.services import analysis_service from app.services import analysis_service
import os import os
import markdown import markdown
from weasyprint import HTML
import io
import tempfile
from urllib.parse import quote
from bs4 import BeautifulSoup
router = APIRouter() router = APIRouter()
@ -134,6 +139,26 @@ async def get_report_html(report_id: int, db: AsyncSession = Depends(get_db)):
</head> </head>
<body> <body>
{financial_html} {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> </body>
</html> </html>
""" """
@ -167,3 +192,332 @@ async def update_config(request: ConfigUpdateRequest, db: AsyncSession = Depends
await db.commit() await db.commit()
return {"status": "updated", "key": request.key} 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 numpy
pytest pytest
httpx httpx
weasyprint

View File

@ -1,12 +1,12 @@
"use client" "use client"
import { useEffect, useState, use } from "react" import { useEffect, useState, use, useRef } from "react"
import { getReport } from "@/lib/api" import { getReport } from "@/lib/api"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownRenderer } from "@/components/markdown-renderer" 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" import { Button } from "@/components/ui/button"
export default function AnalysisPage({ params }: { params: Promise<{ id: string }> }) { 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 [report, setReport] = useState<any>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null)
const [iframeHeight, setIframeHeight] = useState<number>(800)
// Polling logic // Polling logic
useEffect(() => { useEffect(() => {
@ -49,6 +51,41 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
return () => clearTimeout(interval) return () => clearTimeout(interval)
}, [reportId]) }, [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 (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> if (!report) return <div className="p-8"></div>
@ -75,6 +112,12 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
</div> </div>
) : report.status === "completed" ? "已完成" : report.status === "failed" ? "失败" : report.status === "pending" ? "待处理" : report.status} ) : report.status === "completed" ? "已完成" : report.status === "failed" ? "失败" : report.status === "pending" ? "待处理" : report.status}
</Badge> </Badge>
{report.status === "completed" && (
<Button onClick={handleDownloadPDF} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
PDF
</Button>
)}
</div> </div>
</div> </div>
@ -99,8 +142,9 @@ export default function AnalysisPage({ params }: { params: Promise<{ id: string
</TabsList> </TabsList>
<TabsContent value="financial_data" className="min-h-[500px]"> <TabsContent value="financial_data" className="min-h-[500px]">
<Card className="h-[800px]"> <Card style={{ height: `${iframeHeight}px` }}>
<iframe <iframe
ref={iframeRef}
src={`http://localhost:8000/api/reports/${report.id}/html`} src={`http://localhost:8000/api/reports/${report.id}/html`}
className="w-full h-full border-0 rounded-md" className="w-full h-full border-0 rounded-md"
title="分析仪表板" title="分析仪表板"