From d14a10479ecaecc96f912a1f1bf7cb7656190360 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Thu, 16 Jul 2026 16:28:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E8=A1=A5=E5=9B=9E=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E9=A1=B5=E5=89=A5=E7=A6=BB=E6=97=B6=E6=BC=8F=E6=90=AC?= =?UTF-8?q?=E7=9A=84=20PDF=20=E5=AF=BC=E5=87=BA=E4=B8=8E=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 759483b 把报告页剥成纯启动器时,Word/Markdown 导出搬去了运行页,但漏了两个: printReportHtml(前端打印出 PDF,CJK 零字体依赖)和 notify(完成弹系统通知) 外部调用点归零,成了被孤立的活功能。 - 运行页报告正文面板补 PDF 按钮:previewRef 抓已渲染 DOM 送打印视图, 与剥离前同一条路径。 - 完成通知挪进 attachRun 的 token 流 done 回调并加 label 参数:原先只有 报告会通知,但「跑几分钟、人早切走了」对编排任务一样成立,且 attachRun 是所有运行的唯一汇合点,放这儿不会再漂移。恢复的待审任务也带主题。 验证:tsc 干净、68 个 vitest 全过、两函数调用点恢复非零。 ⚠️ 未实机点验(需登录态):PDF 按钮实际点击出打印视图、通知实际弹出, 下次起服务后补验。 Co-Authored-By: Claude Opus 4.8 --- sundynix-desktop/frontend/src/App.tsx | 17 +++++++++---- .../frontend/src/views/RunsView.tsx | 25 ++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 02bb65a..114187d 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { Login } from "./views/Login"; import { submitTask, generateReport, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api"; import type { TaskDsl } from "./lib/dsl"; import { emptyRun, type RunState } from "./lib/run"; +import { notify } from "./lib/desktop"; import { ToastProvider } from "./ui"; // 会话标识:本地持久化生成一次(多轮历史用,与鉴权身份分离)。 @@ -178,7 +179,8 @@ export default function App() { // attachRun:给一个 task 挂上「状态轮询 + 执行轨迹流 + token 流」。新发起与恢复待审任务共用, // 故页面刷新/重开后能重新挂回在途任务(HITL 审批可能等数小时,必须可恢复,否则待审任务点不到批准)。 - const attachRun = useCallback((taskId: string, t0: number) => { + // label 用于完成时的系统通知文案(报告用主题,编排用泛称)。 + const attachRun = useCallback((taskId: string, t0: number, label: string) => { let first = true; // 轮询后端任务状态:可靠捕获 waiting(审批中断)—— exec 实时事件会抢跑,状态落 PG 不会丢。 const terminal = new Set(["done", "failed", "timeout", "rejected"]); @@ -217,7 +219,12 @@ export default function App() { first = false; return { ...r, output: r.output + tok, events: ev }; }), - () => setRun((r) => (r.taskId === taskId ? { ...r, phase: "done", events: [...r.events, { t: Date.now() - t0, label: "完成" }] } : r)), + () => { + // 系统通知:跑一次动辄几分钟,用户多半已经切走干别的了——不通知就只能自己回来刷。 + // 浏览器预览下 notify 是空操作,桌面壳里才弹。 + notify("运行完成", label); + setRun((r) => (r.taskId === taskId ? { ...r, phase: "done", events: [...r.events, { t: Date.now() - t0, label: "完成" }] } : r)); + }, () => setRun((r) => (r.taskId === taskId ? { ...r, phase: "error", error: "连接中断" } : r)), ); }, []); @@ -238,7 +245,7 @@ export default function App() { taskId, events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }], })); - attachRun(taskId, t0); + attachRun(taskId, t0, "编排任务已跑完"); } catch (e) { setRun((r) => ({ ...r, phase: "error", error: (e as Error).message })); } @@ -266,7 +273,7 @@ export default function App() { taskId, events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }], })); - attachRun(taskId, t0); + attachRun(taskId, t0, `报告已生成:${topic}`); } catch (e) { setRun((r) => ({ ...r, phase: "error", error: (e as Error).message })); } @@ -291,7 +298,7 @@ export default function App() { ? r // 已有 live run,不覆盖 : { phase: "streaming", taskId: waiting.task_id, output: "", events: [{ t: 0, label: "恢复待审任务" }], exec: [], lifecycle: "waiting" }, ); - attachRun(waiting.task_id, t0); + attachRun(waiting.task_id, t0, waiting.topic || "待审任务已跑完"); } catch { /* 列表拉取失败则跳过恢复,不影响正常使用 */ } diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx index 669a690..7549b03 100644 --- a/sundynix-desktop/frontend/src/views/RunsView.tsx +++ b/sundynix-desktop/frontend/src/views/RunsView.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; -import { Activity, FileText, FileType2, FileCode, History, Users, Wrench } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Activity, FileText, FileType2, FileCode, Printer, History, Users, Wrench } from "lucide-react"; import { ExecTrace } from "../components/ExecTrace"; import { TeamView } from "../components/TeamView"; import { OfficeView } from "../components/OfficeView"; @@ -9,7 +9,7 @@ import { extractChartBlocks, hasChart } from "../lib/chartspec"; import { deriveNodes, isMultiAgent, isReportRun, emptyRun, type RunState } from "../lib/run"; import { listRuns, taskEval, runReplay, reportExportUrl, reportFilename, type RunSummary, type EvalResult } from "../lib/api"; import { Tabs, Panel, Dot, Badge, Button, EmptyState, useToast, cn, type TabDef } from "../ui"; -import { saveReportAs } from "../lib/desktop"; +import { saveReportAs, printReportHtml } from "../lib/desktop"; type DetailTab = "trace" | "team" | "tools" | "eval"; @@ -37,6 +37,7 @@ export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: st const [evalRes, setEvalRes] = useState(null); const [tab, setTab] = useState("trace"); const [teamSkin, setTeamSkin] = useState<"office" | "board">("office"); // 团队视图皮肤:卡通办公室 / 卡片看板 + const previewRef = useRef(null); // 导出 PDF 要拿已渲染的正文 DOM useEffect(() => { let alive = true; @@ -88,6 +89,17 @@ export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: st toast.push("error", (e as Error).message); } }; + // 导出 PDF:把预览到的正文(已渲染 HTML)送进打印视图(CJK 零字体依赖,故走前端打印而非后端渲染)。 + const exportPdf = () => { + const html = previewRef.current?.innerHTML; + if (!html) { + toast.push("error", "暂无报告正文可导出"); + return; + } + if (!printReportHtml(curTopic || curId, html)) { + toast.push("error", "打印窗口被拦截,请允许弹出窗口后重试"); + } + }; const nodes = deriveNodes(cur.exec); // 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。 const calls = nodes.filter((n) => n.kind === "tool" || n.kind === "agent"); @@ -201,6 +213,9 @@ export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: st + @@ -209,7 +224,9 @@ export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: st } > {cur.output ? ( - +
+ +
) : ( )}