feat: 添加报告PDF下载功能并优化分析页iframe高度自适应
This commit is contained in:
parent
a1ed75c405
commit
880df10484
@ -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)}")
|
||||
|
||||
@ -13,3 +13,4 @@ pandas
|
||||
numpy
|
||||
pytest
|
||||
httpx
|
||||
weasyprint
|
||||
|
||||
@ -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="分析仪表板"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user