feat(dashboard): add CSV and PDF report export

- GET /api/reports/export: CSV with ticker,date,decision,summary
- GET /api/reports/{ticker}/{date}/pdf: PDF via fpdf2 with DejaVu fonts
- ReportsViewer: CSV export button + PDF export in modal footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
陈少杰 2026-04-07 16:24:38 +08:00
parent c329ef2885
commit 17a4ed2513
2 changed files with 190 additions and 19 deletions

View File

@ -567,6 +567,134 @@ async def get_report(ticker: str, date: str):
return content
# ============== Report Export ==============
import csv
import io
import re
from fpdf import FPDF
def _extract_decision(markdown_text: str) -> str:
"""Extract BUY/SELL/HOLD from markdown bold text."""
match = re.search(r'\*\*(BUY|SELL|HOLD)\*\*', markdown_text)
return match.group(1) if match else 'UNKNOWN'
def _extract_summary(markdown_text: str) -> str:
"""Extract first ~200 chars after '## 分析摘要'."""
match = re.search(r'## 分析摘要\s*\n+(.{0,300}?)(?=\n##|\Z)', markdown_text, re.DOTALL)
if match:
text = match.group(1).strip()
# Strip markdown formatting
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
text = re.sub(r'\*(.*?)\*', r'\1', text)
text = re.sub(r'[#\n]+', ' ', text)
return text[:200].strip()
return ''
@app.get("/api/reports/export")
async def export_reports_csv():
"""Export all reports as CSV: ticker,date,decision,summary."""
reports = get_reports_list()
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=["ticker", "date", "decision", "summary"])
writer.writeheader()
for r in reports:
content = get_report_content(r["ticker"], r["date"])
if content and content.get("report"):
writer.writerow({
"ticker": r["ticker"],
"date": r["date"],
"decision": _extract_decision(content["report"]),
"summary": _extract_summary(content["report"]),
})
else:
writer.writerow({
"ticker": r["ticker"],
"date": r["date"],
"decision": "UNKNOWN",
"summary": "",
})
return Response(
content=output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=tradingagents_reports.csv"},
)
@app.get("/api/reports/{ticker}/{date}/pdf")
async def export_report_pdf(ticker: str, date: str):
"""Export a single report as PDF."""
content = get_report_content(ticker, date)
if not content or not content.get("report"):
raise HTTPException(status_code=404, detail="Report not found")
markdown_text = content["report"]
decision = _extract_decision(markdown_text)
summary = _extract_summary(markdown_text)
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=20)
pdf.add_font("DejaVu", "", "/System/Library/Fonts/Supplemental/DejaVuSans.ttf", unicode=True)
pdf.add_font("DejaVu", "B", "/System/Library/Fonts/Supplemental/DejaVuSans-Bold.ttf", unicode=True)
pdf.add_page()
pdf.set_font("DejaVu", "B", 18)
pdf.cell(0, 12, f"TradingAgents 分析报告", ln=True, align="C")
pdf.ln(5)
pdf.set_font("DejaVu", "", 11)
pdf.cell(0, 8, f"股票: {ticker} 日期: {date}", ln=True)
pdf.ln(3)
# Decision badge
pdf.set_font("DejaVu", "B", 14)
if decision == "BUY":
pdf.set_text_color(34, 197, 94)
elif decision == "SELL":
pdf.set_text_color(220, 38, 38)
else:
pdf.set_text_color(245, 158, 11)
pdf.cell(0, 10, f"决策: {decision}", ln=True)
pdf.set_text_color(0, 0, 0)
pdf.ln(5)
# Summary
pdf.set_font("DejaVu", "B", 12)
pdf.cell(0, 8, "分析摘要", ln=True)
pdf.set_font("DejaVu", "", 10)
pdf.multi_cell(0, 6, summary or "")
pdf.ln(5)
# Full report text (stripped of heavy markdown)
pdf.set_font("DejaVu", "B", 12)
pdf.cell(0, 8, "完整报告", ln=True)
pdf.set_font("DejaVu", "", 9)
# Split into lines, filter out very long lines
for line in markdown_text.splitlines():
line = re.sub(r'\*\*(.*?)\*\*', r'\1', line)
line = re.sub(r'\*(.*?)\*', r'\1', line)
line = re.sub(r'#{1,6} ', '', line)
line = line.strip()
if not line:
pdf.ln(2)
continue
if len(line) > 120:
line = line[:120] + "..."
try:
pdf.multi_cell(0, 5, line)
except Exception:
pass
return Response(
content=pdf.output(),
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={ticker}_{date}_report.pdf"},
)
# ============== Portfolio ==============
import sys

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Table, Input, Modal, Skeleton, Button } from 'antd'
import { FileTextOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
import { Table, Input, Modal, Skeleton, Button, Space, message } from 'antd'
import { FileTextOutlined, SearchOutlined, CloseOutlined, DownloadOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
const { Search } = Input
@ -24,17 +24,41 @@ export default function ReportsViewer() {
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
const data = await res.json()
setReports(data)
} catch (err) {
console.error('Failed to fetch reports:', err)
setReports([
{ ticker: '300750.SZ', date: '2026-04-05', path: '/results/300750.SZ/2026-04-05' },
{ ticker: '600519.SS', date: '2026-03-20', path: '/results/600519.SS/2026-03-20' },
])
} catch {
setReports([])
} finally {
setLoading(false)
}
}
const handleExportCsv = async () => {
try {
const res = await fetch('/api/reports/export')
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'tradingagents_reports.csv'; a.click()
URL.revokeObjectURL(url)
} catch (e) {
message.error(e.message)
}
}
const handleExportPdf = async (ticker, date) => {
try {
const res = await fetch(`/api/reports/${ticker}/${date}/pdf`)
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `${ticker}_${date}_report.pdf`; a.click()
URL.revokeObjectURL(url)
} catch (e) {
message.error(e.message)
}
}
const handleViewReport = async (record) => {
setSelectedReport(record)
setLoadingContent(true)
@ -95,17 +119,22 @@ export default function ReportsViewer() {
return (
<div>
{/* Search */}
{/* Search + Export */}
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
<Search
placeholder="搜索股票代码或日期..."
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.48)' }} />}
size="large"
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Search
placeholder="搜索股票代码或日期..."
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.48)' }} />}
size="large"
style={{ flex: 1 }}
/>
<Button icon={<DownloadOutlined />} onClick={handleExportCsv} disabled={reports.length === 0}>
导出CSV
</Button>
</div>
</div>
{/* Reports Table */}
@ -153,7 +182,21 @@ export default function ReportsViewer() {
setSelectedReport(null)
setReportContent(null)
}}
footer={null}
footer={
selectedReport ? (
<Space>
<Button
icon={<DownloadOutlined />}
onClick={() => handleExportPdf(selectedReport.ticker, selectedReport.date)}
>
导出PDF
</Button>
<Button onClick={() => { setSelectedReport(null); setReportContent(null) }}>
关闭
</Button>
</Space>
) : null
}
width={800}
closeIcon={<CloseOutlined style={{ fontSize: 16 }} />}
styles={{