diff --git a/backend/app/api/report.py b/backend/app/api/report.py index a1e7dbcf..fb200f72 100644 --- a/backend/app/api/report.py +++ b/backend/app/api/report.py @@ -3,9 +3,11 @@ Report API路由 提供模拟报告生成、获取、对话等接口 """ +import json import os import traceback import threading +from io import BytesIO from flask import request, jsonify, send_file from . import report_bp @@ -517,40 +519,61 @@ def list_reports(): @report_bp.route('//download', methods=['GET']) def download_report(report_id: str): """ - 下载报告(Markdown格式) - - 返回Markdown文件 + 下载报告(Markdown或JSON格式) + + Query参数: + format: 下载格式,支持 "markdown"(默认)或 "json" + + 返回对应格式的文件 """ try: report = ReportManager.get_report(report_id) - + if not report: return jsonify({ "success": False, "error": t('api.reportNotFound', id=report_id) }), 404 - - md_path = ReportManager._get_report_markdown_path(report_id) - - if not os.path.exists(md_path): - # 如果MD文件不存在,生成一个临时文件 - import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: - f.write(report.markdown_content) - temp_path = f.name - + + download_format = request.args.get('format', 'markdown').lower() + + if download_format not in {'markdown', 'json'}: + return jsonify({ + "success": False, + "error": "Unsupported report format. Use 'markdown' or 'json'." + }), 400 + + if download_format == 'json': + report_json = json.dumps( + report.to_dict(), + ensure_ascii=False, + indent=2 + ).encode('utf-8') + return send_file( - temp_path, + BytesIO(report_json), as_attachment=True, - download_name=f"{report_id}.md" + download_name=f"{report_id}.json", + mimetype='application/json' ) - + + # Default: markdown format + md_path = ReportManager._get_report_markdown_path(report_id) + + if not os.path.exists(md_path): + return send_file( + BytesIO(report.markdown_content.encode('utf-8')), + as_attachment=True, + download_name=f"{report_id}.md", + mimetype='text/markdown' + ) + return send_file( md_path, as_attachment=True, download_name=f"{report_id}.md" ) - + except Exception as e: logger.error(f"下载报告失败: {str(e)}") return jsonify({ diff --git a/backend/tests/test_report_download.py b/backend/tests/test_report_download.py new file mode 100644 index 00000000..b13941e0 --- /dev/null +++ b/backend/tests/test_report_download.py @@ -0,0 +1,76 @@ +import json +from types import SimpleNamespace + +from flask import Flask + +from app.api import report as report_api + + +def _download_response(monkeypatch, tmp_path, query_string=""): + report = SimpleNamespace( + markdown_content="# 预测报告\n\n完整内容", + to_dict=lambda: { + "report_id": "report-1", + "title": "预测报告", + }, + ) + monkeypatch.setattr( + report_api.ReportManager, + "get_report", + classmethod(lambda _cls, _report_id: report), + ) + monkeypatch.setattr( + report_api.ReportManager, + "_get_report_markdown_path", + classmethod(lambda _cls, _report_id: str(tmp_path / "missing.md")), + ) + + app = Flask(__name__) + with app.test_request_context( + f"/api/report/report-1/download{query_string}", + ): + return report_api.download_report("report-1") + + +def _response_text(response): + response.direct_passthrough = False + return response.get_data(as_text=True) + + +def test_download_report_returns_utf8_json_attachment(monkeypatch, tmp_path): + response = _download_response(monkeypatch, tmp_path, "?format=json") + + assert response.status_code == 200 + assert response.mimetype == "application/json" + assert "report-1.json" in response.headers["Content-Disposition"] + assert json.loads(_response_text(response)) == { + "report_id": "report-1", + "title": "预测报告", + } + + +def test_download_report_generates_markdown_attachment_in_memory( + monkeypatch, + tmp_path, +): + response = _download_response(monkeypatch, tmp_path) + + assert response.status_code == 200 + assert response.mimetype == "text/markdown" + assert "report-1.md" in response.headers["Content-Disposition"] + assert _response_text(response) == "# 预测报告\n\n完整内容" + assert list(tmp_path.iterdir()) == [] + + +def test_download_report_rejects_unknown_format(monkeypatch, tmp_path): + response, status = _download_response( + monkeypatch, + tmp_path, + "?format=pdf", + ) + + assert status == 400 + assert response.get_json() == { + "success": False, + "error": "Unsupported report format. Use 'markdown' or 'json'.", + } diff --git a/frontend/src/api/report.js b/frontend/src/api/report.js index 9b61ce72..7b676fc6 100644 --- a/frontend/src/api/report.js +++ b/frontend/src/api/report.js @@ -49,3 +49,14 @@ export const getReport = (reportId) => { export const chatWithReport = (data) => { return service.post('/api/report/chat', data) } + +/** + * Get the download URL for a report + * @param {string} reportId + * @param {string} format - 'markdown' or 'json' + * @returns {string} download URL + */ +export const getReportDownloadUrl = (reportId, format = 'markdown') => { + const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001' + return `${baseURL}/api/report/${reportId}/download?format=${format}` +} diff --git a/frontend/src/components/Step4Report.vue b/frontend/src/components/Step4Report.vue index 8e53ceb5..818b1158 100644 --- a/frontend/src/components/Step4Report.vue +++ b/frontend/src/components/Step4Report.vue @@ -127,14 +127,36 @@ - - + +
+
+ + +
+ +
@@ -393,7 +415,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { getAgentLog, getConsoleLog } from '../api/report' +import { getAgentLog, getConsoleLog, getReportDownloadUrl } from '../api/report' const router = useRouter() const { t } = useI18n() @@ -413,6 +435,12 @@ const goToInteraction = () => { } } +// Download menu state +const showDownloadMenu = ref(false) +const toggleDownloadMenu = () => { + showDownloadMenu.value = !showDownloadMenu.value +} + // State const agentLogs = ref([]) const consoleLogs = ref([]) @@ -3402,13 +3430,85 @@ watch(() => props.reportId, (newId) => { font-size: 14px; } +.report-actions { + display: flex; + gap: 8px; + width: calc(100% - 40px); + margin: 4px 20px 0 20px; +} + +.download-dropdown { + position: relative; +} + +.download-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 20px; + font-size: 14px; + font-weight: 600; + color: #1F2937; + background: #F3F4F6; + border: 1px solid #D1D5DB; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.download-btn:hover { + background: #E5E7EB; +} + +.download-menu { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + overflow: hidden; + z-index: 10; + min-width: 180px; +} + +.download-menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + font-size: 13px; + color: #374151; + text-decoration: none; + cursor: pointer; + transition: background 0.15s ease; +} + +.download-menu-item:hover { + background: #F3F4F6; +} + +.format-icon { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 700; + color: #6B7280; + background: #F3F4F6; + padding: 2px 6px; + border-radius: 4px; + min-width: 28px; + text-align: center; +} + .next-step-btn { display: flex; align-items: center; justify-content: center; gap: 8px; - width: calc(100% - 40px); - margin: 4px 20px 0 20px; + flex: 1; padding: 14px 20px; font-size: 14px; font-weight: 600; diff --git a/locales/en.json b/locales/en.json index 02679955..a34d337c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -210,6 +210,9 @@ "step4": { "generatingSection": "Generating {title}...", "goToInteraction": "Enter Deep Interaction", + "downloadReport": "Download Report", + "markdownFormat": "Markdown (.md)", + "jsonFormat": "JSON (.json)", "waitingForReportAgent": "Waiting for Report Agent...", "collapse": "Collapse ▲", "expandAll": "Show all {count} ▼", diff --git a/locales/zh.json b/locales/zh.json index c5937dcc..866a762c 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -210,6 +210,9 @@ "step4": { "generatingSection": "正在生成{title}...", "goToInteraction": "进入深度互动", + "downloadReport": "下载报告", + "markdownFormat": "Markdown (.md)", + "jsonFormat": "JSON (.json)", "waitingForReportAgent": "Waiting for Report Agent...", "collapse": "收起 ▲", "expandAll": "展开全部 {count} 条 ▼",