From 8bbc716d17ff6bf6c49122c3762a99e9e613bbea Mon Sep 17 00:00:00 2001 From: zlwu Date: Sat, 5 Sep 2026 14:59:58 +0800 Subject: [PATCH] fix(web-admin): use html-to-docx browser build and inline images for Word export Vite was resolving the Node ESM entry which needs Buffer/global; switch to the browser bundle and pre-convert images to data URLs so seals and watermarks embed. Co-authored-by: Cursor --- web-admin/src/env.d.ts | 10 +++ .../utils/crm/contract-html-to-docx.test.ts | 9 ++ .../src/utils/crm/contract-html-to-docx.ts | 89 ++++++++++++++++--- 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/web-admin/src/env.d.ts b/web-admin/src/env.d.ts index efd879d8f..fb7c421b5 100755 --- a/web-admin/src/env.d.ts +++ b/web-admin/src/env.d.ts @@ -17,6 +17,16 @@ declare module 'vue-img-cutter' declare module 'file-saver' +declare module '@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js' { + const HTMLtoDOCX: ( + htmlString: string, + headerHTMLString?: string | null, + documentOptions?: Record, + footerHTMLString?: string | null + ) => Promise + export default HTMLtoDOCX +} + declare module 'qrcode.vue' { export type Level = 'L' | 'M' | 'Q' | 'H' export type RenderAs = 'canvas' | 'svg' diff --git a/web-admin/src/utils/crm/contract-html-to-docx.test.ts b/web-admin/src/utils/crm/contract-html-to-docx.test.ts index 03ee9091a..e4db5ae1a 100644 --- a/web-admin/src/utils/crm/contract-html-to-docx.test.ts +++ b/web-admin/src/utils/crm/contract-html-to-docx.test.ts @@ -3,6 +3,7 @@ import { describe, it } from 'node:test' import { buildContractDocxFileName, injectSxWatermarkHtml, + inlineRemoteImagesAsDataUrls, parseContractSupplierCode, prepareContractHtmlForDocx, stripContractLogoHtml, @@ -86,3 +87,11 @@ describe('buildContractDocxFileName', () => { assert.equal(buildContractDocxFileName('', 'SX/X-JT1'), 'SX-X-JT1合同.docx') }) }) + +describe('inlineRemoteImagesAsDataUrls', () => { + it('keeps data URLs and leaves html without remote images unchanged', async () => { + const html = '

无图

' + const out = await inlineRemoteImagesAsDataUrls(html) + assert.equal(out, html) + }) +}) diff --git a/web-admin/src/utils/crm/contract-html-to-docx.ts b/web-admin/src/utils/crm/contract-html-to-docx.ts index 7501e4f00..32b80c704 100644 --- a/web-admin/src/utils/crm/contract-html-to-docx.ts +++ b/web-admin/src/utils/crm/contract-html-to-docx.ts @@ -1,15 +1,80 @@ /** 合同打印弹窗:HTML → DOCX 预处理与生成 */ -import htmlToDocxModule from '@turbodocx/html-to-docx' - const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' -/** CJS/ESM 互操作:Vite/浏览器多为函数,tsx/Node 偶发 `{ default: fn }` */ -const HTMLtoDOCX = ( - typeof htmlToDocxModule === 'function' - ? htmlToDocxModule - : (htmlToDocxModule as { default?: unknown })?.default -) as (htmlString: string, headerHTMLString?: string | null, documentOptions?: Record, footerHTMLString?: string | null) => Promise +type HtmlToDocxFn = ( + htmlString: string, + headerHTMLString?: string | null, + documentOptions?: Record, + footerHTMLString?: string | null +) => Promise + +/** + * 懒加载 browser 构建。 + * Vite 默认会解析到 Node ESM(依赖 Buffer/global),浏览器会报错; + * 顶层静态 import browser 包又会让 Node 单测因 XMLHttpRequest 初始化失败。 + */ +async function loadHtmlToDocx(): Promise { + const mod = await import('@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js') + const candidate = typeof mod === 'function' ? mod : (mod as { default?: unknown }).default + if (typeof candidate !== 'function') { + throw new Error('html-to-docx 加载失败:未获得可调用的转换函数') + } + return candidate as HtmlToDocxFn +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} + +async function urlToDataUrl(url: string): Promise { + try { + const res = await fetch(url) + if (!res.ok) return null + const blob = await res.blob() + const buf = await blob.arrayBuffer() + const mime = blob.type || 'application/octet-stream' + return `data:${mime};base64,${arrayBufferToBase64(buf)}` + } catch { + return null + } +} + +/** + * 将 HTML 中非 data: 图片先转成 data URL,避免库内 Node Buffer 路径失败导致印章/水印丢失。 + */ +export async function inlineRemoteImagesAsDataUrls(html: string): Promise { + const srcRe = /\bsrc=(["'])([^"']+)\1/gi + const urls = new Set() + let match: RegExpExecArray | null + while ((match = srcRe.exec(html)) !== null) { + const url = match[2] + if (url && !url.startsWith('data:')) urls.add(url) + } + + if (!urls.size) return html + + const replacements = new Map() + await Promise.all( + [...urls].map(async (url) => { + const dataUrl = await urlToDataUrl(url) + if (dataUrl) replacements.set(url, dataUrl) + }) + ) + + if (!replacements.size) return html + + return html.replace(/\bsrc=(["'])([^"']+)\1/gi, (full, quote: string, url: string) => { + const dataUrl = replacements.get(url) + return dataUrl ? `src=${quote}${dataUrl}${quote}` : full + }) +} /** * 将已预处理的合同 HTML 转为 DOCX Blob(浏览器下载用)。 @@ -19,11 +84,9 @@ export async function generateContractDocxBlob( preparedHtml: string, title?: string ): Promise { - if (typeof HTMLtoDOCX !== 'function') { - throw new Error('html-to-docx 加载失败:未获得可调用的转换函数') - } - - const fullHtml = `${preparedHtml}` + const HTMLtoDOCX = await loadHtmlToDocx() + const htmlWithImages = await inlineRemoteImagesAsDataUrls(preparedHtml) + const fullHtml = `${htmlWithImages}` const result = await HTMLtoDOCX(fullHtml, null, { orientation: 'portrait', title: title || '合同',