From 17a4ed251379fc9bcc42d0c6ed834ebaebdad7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=B0=91=E6=9D=B0?= Date: Tue, 7 Apr 2026 16:24:38 +0800 Subject: [PATCH] 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 --- web_dashboard/backend/main.py | 128 ++++++++++++++++++ .../frontend/src/pages/ReportsViewer.jsx | 81 ++++++++--- 2 files changed, 190 insertions(+), 19 deletions(-) diff --git a/web_dashboard/backend/main.py b/web_dashboard/backend/main.py index 6025feca..d34736b4 100644 --- a/web_dashboard/backend/main.py +++ b/web_dashboard/backend/main.py @@ -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 diff --git a/web_dashboard/frontend/src/pages/ReportsViewer.jsx b/web_dashboard/frontend/src/pages/ReportsViewer.jsx index 7af919dd..098c55b7 100644 --- a/web_dashboard/frontend/src/pages/ReportsViewer.jsx +++ b/web_dashboard/frontend/src/pages/ReportsViewer.jsx @@ -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 (
- {/* Search */} + {/* Search + Export */}
- setSearchText(e.target.value)} - prefix={} - size="large" - style={{ width: '100%' }} - /> +
+ setSearchText(e.target.value)} + prefix={} + size="large" + style={{ flex: 1 }} + /> + +
{/* Reports Table */} @@ -153,7 +182,21 @@ export default function ReportsViewer() { setSelectedReport(null) setReportContent(null) }} - footer={null} + footer={ + selectedReport ? ( + + + + + ) : null + } width={800} closeIcon={} styles={{