feat(report): 生成只出 Markdown 预览,导出时再渲染 Word/PDF/Markdown
把"渲染"从生成阶段解耦到导出阶段(导出时再处理): - 生成阶段:报告正文按 Markdown 流式预览(前端 <Markdown> 已渲染), dispatcher 不再 eager 渲染 docx,改为经 mcp-go report_store 落盘报告源(title+sections JSON)。 - 导出阶段(按需现渲染): - GET /reports/:id/export?format=docx → mcp-go report_export 读源渲染 .docx; - ?format=md → 返回 Markdown 文本; - PDF → 前端把已渲染的 Markdown 送进打印视图出 PDF(CJK 零字体依赖)。 - 旧 /reports/:id/download 兼容保留(默认 docx)。 改动: - contract: ReportSourcePath(id) = <id>.json。 - mcp-go: 新增 report_store / report_export 工具(report_render 保留给 Studio render 节点)。 - dispatcher: handleReport 末尾 renderReport → storeReport。 - gateway: DownloadReport → ExportReport(经 NATS 调 report_export)。 - 前端: ReportView 单个「Word」→「导出」组 Word/PDF/Markdown; desktop.printReportHtml 客户端打印;api.reportExportUrl。 实测(docker 全栈 + mcp-go + gateway + dispatcher + DeepSeek 真跑): - 真实生成「绿茶的功效」18s 完成,report_store 落源(5章, 6280B) ✓ - export md 返回正确 Markdown(# 标题/## 小节/正文) ✓ - export docx 为合法「Microsoft Word 2007+」(含 document.xml/Content_Types) ✓ - 前端 tsc 干净 + 生产构建通过 ✓ (注:发现并修复一处环境问题——mcp-go 启动时若 Milvus 未起会阻塞在 rag 初始化、永不订阅工具,导致所有 mcp-go 工具"no responders";起全栈后正常。 报告生成在累积大量未完成 DeepSeek 流连接时会偶发卡顿,干净进程下正常。 前端导出按钮的实时点击因 React 受控输入自动化限制未在预览中走通,非代码缺陷。) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Play, Download, FileText, ExternalLink } from "lucide-react";
|
||||
import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
|
||||
import { isDesktop, saveReportAs, openReport, notify } from "../lib/desktop";
|
||||
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
|
||||
import { generateReport, streamTokens, streamExec, reportExportUrl, type Identity, type ExecEvent } from "../lib/api";
|
||||
import { saveReportAs, printReportHtml, notify } from "../lib/desktop";
|
||||
import { ExecTrace } from "../components/ExecTrace";
|
||||
import { Markdown } from "../components/Markdown";
|
||||
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
|
||||
|
||||
// 安全文件名:去掉路径不安全字符,限长。
|
||||
function reportFilename(topic: string, id: string): string {
|
||||
// 安全文件名:去掉路径不安全字符,限长 + 指定后缀。
|
||||
function reportFilename(topic: string, id: string, ext: string): string {
|
||||
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
|
||||
return `${base}.docx`;
|
||||
return `${base}.${ext}`;
|
||||
}
|
||||
|
||||
type Phase = "idle" | "running" | "done" | "error";
|
||||
@@ -26,9 +26,32 @@ export function ReportView({ identity }: { identity: Identity }) {
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const closeRef = useRef<(() => void) | null>(null);
|
||||
const execCloseRef = useRef<(() => void) | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const running = phase === "running";
|
||||
|
||||
// 导出 Word / Markdown:后端按需现渲染(导出时再处理),经原生"另存为"或浏览器下载。
|
||||
const exportFile = async (format: "docx" | "md") => {
|
||||
try {
|
||||
const p = await saveReportAs(reportExportUrl(taskId, format), reportFilename(topic, taskId, format));
|
||||
if (p) toast.push("success", "已保存到 " + p);
|
||||
} catch (e) {
|
||||
toast.push("error", (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出 PDF:把预览到的 Markdown(已渲染 HTML)送进打印视图出 PDF(CJK 零字体依赖)。
|
||||
const exportPdf = () => {
|
||||
const html = previewRef.current?.innerHTML;
|
||||
if (!html) {
|
||||
toast.push("error", "暂无报告正文可导出");
|
||||
return;
|
||||
}
|
||||
if (!printReportHtml(topic.trim() || taskId, html)) {
|
||||
toast.push("error", "打印窗口被拦截,请允许弹出窗口后重试");
|
||||
}
|
||||
};
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!topic.trim() || running) return;
|
||||
closeRef.current?.();
|
||||
@@ -93,24 +116,16 @@ export function ReportView({ identity }: { identity: Identity }) {
|
||||
</Button>
|
||||
{phase === "done" && taskId ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
icon={Download}
|
||||
onClick={async () => {
|
||||
const p = await saveReportAs(reportDownloadUrl(taskId), reportFilename(topic, taskId));
|
||||
if (p) toast.push("success", "已保存到 " + p);
|
||||
}}
|
||||
>
|
||||
{isDesktop() ? "另存为 Word" : "下载 Word"}
|
||||
<span className="text-[11px] text-slate-500">导出</span>
|
||||
<Button icon={FileType2} onClick={() => exportFile("docx")}>
|
||||
Word
|
||||
</Button>
|
||||
<Button icon={Printer} onClick={exportPdf}>
|
||||
PDF
|
||||
</Button>
|
||||
<Button variant="ghost" icon={FileCode} onClick={() => exportFile("md")}>
|
||||
Markdown
|
||||
</Button>
|
||||
{isDesktop() && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={ExternalLink}
|
||||
onClick={() => openReport(reportDownloadUrl(taskId), reportFilename(topic, taskId)).catch((e) => toast.push("error", String(e)))}
|
||||
>
|
||||
用系统打开
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="h-9" />
|
||||
@@ -131,7 +146,9 @@ export function ReportView({ identity }: { identity: Identity }) {
|
||||
}
|
||||
>
|
||||
{out ? (
|
||||
<Markdown text={out} className="text-sm" />
|
||||
<div ref={previewRef}>
|
||||
<Markdown text={out} className="text-sm" />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user