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:
parent
c329ef2885
commit
17a4ed2513
|
|
@ -567,6 +567,134 @@ async def get_report(ticker: str, date: str):
|
||||||
return content
|
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 ==============
|
# ============== Portfolio ==============
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Table, Input, Modal, Skeleton, Button } from 'antd'
|
import { Table, Input, Modal, Skeleton, Button, Space, message } from 'antd'
|
||||||
import { FileTextOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
|
import { FileTextOutlined, SearchOutlined, CloseOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
|
||||||
const { Search } = Input
|
const { Search } = Input
|
||||||
|
|
@ -24,17 +24,41 @@ export default function ReportsViewer() {
|
||||||
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
|
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setReports(data)
|
setReports(data)
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Failed to fetch reports:', err)
|
setReports([])
|
||||||
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' },
|
|
||||||
])
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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) => {
|
const handleViewReport = async (record) => {
|
||||||
setSelectedReport(record)
|
setSelectedReport(record)
|
||||||
setLoadingContent(true)
|
setLoadingContent(true)
|
||||||
|
|
@ -95,17 +119,22 @@ export default function ReportsViewer() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Search */}
|
{/* Search + Export */}
|
||||||
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
||||||
<Search
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
placeholder="搜索股票代码或日期..."
|
<Search
|
||||||
allowClear
|
placeholder="搜索股票代码或日期..."
|
||||||
value={searchText}
|
allowClear
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
value={searchText}
|
||||||
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.48)' }} />}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
size="large"
|
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.48)' }} />}
|
||||||
style={{ width: '100%' }}
|
size="large"
|
||||||
/>
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={handleExportCsv} disabled={reports.length === 0}>
|
||||||
|
导出CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reports Table */}
|
{/* Reports Table */}
|
||||||
|
|
@ -153,7 +182,21 @@ export default function ReportsViewer() {
|
||||||
setSelectedReport(null)
|
setSelectedReport(null)
|
||||||
setReportContent(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}
|
width={800}
|
||||||
closeIcon={<CloseOutlined style={{ fontSize: 16 }} />}
|
closeIcon={<CloseOutlined style={{ fontSize: 16 }} />}
|
||||||
styles={{
|
styles={{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue