fix(desktop): 报告 PDF 导出在壳内改走原生桥 —— WKWebView 拦死 window.open

实机验证抓到的:桌面壳内点 PDF 报「打印窗口被拦截」——Wails v3 的 WKWebView
把 window.open 拦成 null,前端弹打印窗那条路在壳内根本走不通(浏览器预览没事)。

- app.go 加 PrintReportPage(filename, html):打印视图 HTML 落临时文件(文件名
  过滤路径字符),openInSystem 交系统默认浏览器打开,页面 onload 自动唤起打印框,
  用户直接「存储为 PDF」。CJK 零字体依赖的原有优势不变。
- desktop.ts printReportHtml 改 async:inWails 走原生桥,浏览器维持 window.open;
  RunsView 调用点随之 async + 错误透 toast。
- 绑定重新生成(注意要 `wails3 generate bindings -ts`,裸跑默认吐 JS 且要
  GOWORK=off,否则 go.work 干扰找不到 Service)。

验证:go test/tsc/68 例 vitest 全绿;壳内被拦是用户实机复现的。
⚠️ 原生桥新路径(临时文件→浏览器→打印框)用户尚未实机点验,重新打的包已就位,
下次跑报告顺手验。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-17 09:40:15 +08:00
parent 7c67256f87
commit b4012dbbba
4 changed files with 62 additions and 20 deletions
+22
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
goruntime "runtime" goruntime "runtime"
"strings"
"time" "time"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
@@ -61,6 +62,27 @@ func (a *App) OpenReport(url, filename string) error {
return openInSystem(dst) return openInSystem(dst)
} }
// PrintReportPage 把报告打印视图 HTML 落到临时文件,交系统默认浏览器打开(在那里 ⌘P →
// 存储为 PDF)。Wails 的 WKWebView 会把 window.open 拦成 null,前端弹打印窗那条路在壳内
// 走不通;转交系统浏览器后「前端打印出 PDF、CJK 零字体依赖」的原有优势不变。返回落盘路径。
func (a *App) PrintReportPage(filename, html string) (string, error) {
if filename == "" {
filename = "report"
}
// 文件名进过滤:主题可能含 / 之类的路径字符。
safe := strings.Map(func(r rune) rune {
if strings.ContainsRune(`/\:*?"<>|`, r) {
return '_'
}
return r
}, filename)
dst := filepath.Join(os.TempDir(), "sundynix-print-"+safe+".html")
if err := os.WriteFile(dst, []byte(html), 0o600); err != nil {
return "", err
}
return dst, openInSystem(dst)
}
// Notify 弹一条系统通知(best-effortmacOS 用 osascript,其它平台暂静默)。 // Notify 弹一条系统通知(best-effortmacOS 用 osascript,其它平台暂静默)。
func (a *App) Notify(title, body string) { func (a *App) Notify(title, body string) {
if goruntime.GOOS == "darwin" { if goruntime.GOOS == "darwin" {
@@ -33,6 +33,15 @@ export function Ping(): $CancellablePromise<string> {
return $Call.ByID(1003072145); return $Call.ByID(1003072145);
} }
/**
* PrintReportPage 把报告打印视图 HTML 落到临时文件,交系统默认浏览器打开(在那里 ⌘P →
* 存储为 PDF)。Wails 的 WKWebView 会把 window.open 拦成 null,前端弹打印窗那条路在壳内
* 走不通;转交系统浏览器后「前端打印出 PDF、CJK 零字体依赖」的原有优势不变。返回落盘路径。
*/
export function PrintReportPage(filename: string, html: string): $CancellablePromise<string> {
return $Call.ByID(3564528865, filename, html);
}
/** /**
* ReadLocalFile 读取本地文件内容(本地文件系统 I/O)。 * ReadLocalFile 读取本地文件内容(本地文件系统 I/O)。
*/ */
+13 -7
View File
@@ -41,12 +41,12 @@ export function notify(title: string, body: string): void {
if (inWails()) void App.Notify(title, body); if (inWails()) void App.Notify(title, body);
} }
// printReportHtml:把已渲染的报告 HTML 在打印视图里出 PDF(浏览器/Webview 的"打印→存为 PDF")。 // printReportHtml:把已渲染的报告 HTML 在打印视图里出 PDF"打印→存为 PDF")。
// 走前端打印是为了让中文(CJK)零字体依赖即可正确排版——后端 PDF 需内嵌 CJK 字体,较重。 // 走前端打印是为了让中文(CJK)零字体依赖即可正确排版——后端 PDF 需内嵌 CJK 字体,较重。
export function printReportHtml(title: string, bodyHtml: string): boolean { // 桌面壳内 WKWebView 会把 window.open 拦成 null(实机验过),改走原生桥:
const w = window.open("", "_blank", "width=840,height=1024"); // HTML 落临时文件 → 系统默认浏览器打开 → 那里 ⌘P 出 PDF(自动唤起打印框)。
if (!w) return false; // 被弹窗拦截 export async function printReportHtml(title: string, bodyHtml: string): Promise<boolean> {
w.document.write( const html =
`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>` + `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>` +
`<style>` + `<style>` +
`*{box-sizing:border-box}` + `*{box-sizing:border-box}` +
@@ -59,8 +59,14 @@ export function printReportHtml(title: string, bodyHtml: string): boolean {
`@media print{body{margin:0}}` + `@media print{body{margin:0}}` +
`</style></head><body>${bodyHtml}` + `</style></head><body>${bodyHtml}` +
`<script>window.onload=function(){window.focus();window.print();};<\/script>` + `<script>window.onload=function(){window.focus();window.print();};<\/script>` +
`</body></html>`, `</body></html>`;
); if (inWails()) {
await App.PrintReportPage(title, html);
return true;
}
const w = window.open("", "_blank", "width=840,height=1024");
if (!w) return false; // 被弹窗拦截
w.document.write(html);
w.document.close(); w.document.close();
return true; return true;
} }
@@ -90,15 +90,20 @@ export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: st
} }
}; };
// 导出 PDF:把预览到的正文(已渲染 HTML)送进打印视图(CJK 零字体依赖,故走前端打印而非后端渲染)。 // 导出 PDF:把预览到的正文(已渲染 HTML)送进打印视图(CJK 零字体依赖,故走前端打印而非后端渲染)。
const exportPdf = () => { // 桌面壳内由原生桥转交系统浏览器打开(webview 拦 window.open),浏览器预览则直接弹打印窗。
const exportPdf = async () => {
const html = previewRef.current?.innerHTML; const html = previewRef.current?.innerHTML;
if (!html) { if (!html) {
toast.push("error", "暂无报告正文可导出"); toast.push("error", "暂无报告正文可导出");
return; return;
} }
if (!printReportHtml(curTopic || curId, html)) { try {
if (!(await printReportHtml(curTopic || curId, html))) {
toast.push("error", "打印窗口被拦截,请允许弹出窗口后重试"); toast.push("error", "打印窗口被拦截,请允许弹出窗口后重试");
} }
} catch (e) {
toast.push("error", (e as Error).message);
}
}; };
const nodes = deriveNodes(cur.exec); const nodes = deriveNodes(cur.exec);
// 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。 // 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。