From 51ec0f393d3831d5dda9c7ae44c09e976394600d Mon Sep 17 00:00:00 2001 From: yelan Date: Wed, 16 Sep 2026 17:24:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(signing):=20=E5=89=8D=E7=AB=AF=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=AD=BE=E7=BD=B2=E5=90=8E=20PDF=20=E5=8E=9F=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/package-lock.json | 226 ++++++++++++++++++ clinical-web/package.json | 2 + clinical-web/src/utils/signing-pdf.ts | 205 ++++++++++++++++ .../components/SigningDocumentInteractive.vue | 7 + .../signing/components/SigningTaskDetail.vue | 12 + .../src/views/workbench/signing/index.vue | 36 ++- 6 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 clinical-web/src/utils/signing-pdf.ts diff --git a/clinical-web/package-lock.json b/clinical-web/package-lock.json index ab4160c..9d0b2d2 100644 --- a/clinical-web/package-lock.json +++ b/clinical-web/package-lock.json @@ -12,6 +12,8 @@ "@wangeditor/editor-for-vue": "^5.1.12", "axios": "^1.20.0", "element-plus": "^2.14.5", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "mammoth": "^1.12.2", "pinia": "^4.0.3", "vue": "^3.5.41", @@ -783,6 +785,26 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -1647,6 +1669,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -1715,6 +1746,26 @@ "node": ">= 0.4" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", @@ -1754,6 +1805,21 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1775,6 +1841,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", @@ -1871,6 +1946,16 @@ "ssr-window": "^3.0.0-alpha.1" } }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/duck": { "version": "0.1.12", "resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz", @@ -2300,6 +2385,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -2317,6 +2429,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2557,6 +2675,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2621,6 +2752,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2723,6 +2860,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jszip": { "version": "3.10.2", "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.2.tgz", @@ -3462,6 +3616,13 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -3652,6 +3813,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", @@ -3680,6 +3851,23 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { "version": "1.2.6", "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.6.tgz", @@ -3830,6 +4018,16 @@ "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==", "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", @@ -3839,6 +4037,25 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -4036,6 +4253,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmmirror.com/vite/-/vite-8.2.2.tgz", diff --git a/clinical-web/package.json b/clinical-web/package.json index 76766f8..8d3adcd 100644 --- a/clinical-web/package.json +++ b/clinical-web/package.json @@ -17,6 +17,8 @@ "@wangeditor/editor-for-vue": "^5.1.12", "axios": "^1.20.0", "element-plus": "^2.14.5", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "mammoth": "^1.12.2", "pinia": "^4.0.3", "vue": "^3.5.41", diff --git a/clinical-web/src/utils/signing-pdf.ts b/clinical-web/src/utils/signing-pdf.ts new file mode 100644 index 0000000..65e1753 --- /dev/null +++ b/clinical-web/src/utils/signing-pdf.ts @@ -0,0 +1,205 @@ +/** + * 前端生成「签署后 PDF」(原型阶段)。 + * + * 真实实现里 PDF 由服务端生成;这里用 html2canvas 把已经渲染好的文书截图, + * 再按 A4 分页拼进 jsPDF,让前端能先看到成品效果: + * ① 截图用固定版心宽度:实时容器有固定高度、内边距,且宽度随工作台面板变化, + * 直接截图既截不全、版面也会跟着变; + * ② 固定页眉在每页重复:页眉是系统按 HIS 数据渲染的,服务端生成 PDF 时同样要按页重复; + * ③ 页码只写 "1 / 2" 这类 ASCII:jsPDF 内置字体不含中文, + * 画中文页码需要内嵌字体(体积会明显变大),这一步留给服务端用真字体实现。 + * + * 输出为位图 PDF:文本不可选中,仅供原型演示;正式文书以服务端生成的 PDF 为准。 + */ + +const PAGE_WIDTH_MM = 210 +const PAGE_HEIGHT_MM = 297 +const MARGIN_X_MM = 15 +const MARGIN_TOP_MM = 12 +const MARGIN_BOTTOM_MM = 14 +const CONTENT_WIDTH_MM = PAGE_WIDTH_MM - MARGIN_X_MM * 2 +const CONTENT_HEIGHT_MM = PAGE_HEIGHT_MM - MARGIN_TOP_MM - MARGIN_BOTTOM_MM + +/** 截图版心宽度(px):约等于 180mm @96dpi,让 PDF 版面接近实际纸张 */ +const CAPTURE_WIDTH_PX = 680 +/** 截图倍率:2 倍下正文与小字都还清晰 */ +const CAPTURE_SCALE = 2 +/** 量不到页眉时的兜底高度(px) */ +const FALLBACK_HEADER_PX = 46 +/** 页眉高到每页留不出正文时的下限(mm),低于它就不再重复页眉,避免切出上百页 */ +const MIN_BODY_HEIGHT_MM = 60 + +export interface SigningPdfInput { + /** 已渲染的文书容器(含固定页眉与签署态取值) */ + element: HTMLElement + /** 文件名,不带扩展名 */ + fileName: string +} + +/** 量固定页眉的高度(含下外边距)——它是每页要重复的那一条 */ +function measureHeaderHeight(element: HTMLElement) { + const header = element.querySelector('.sign-doc-header') + + if (!header) { + return FALLBACK_HEADER_PX + } + + const marginBottom = Number.parseFloat(window.getComputedStyle(header).marginBottom) || 0 + const height = header.getBoundingClientRect().height + marginBottom + + return height > 0 ? Math.round(height) : FALLBACK_HEADER_PX +} + +export async function createSigningPdf(input: SigningPdfInput): Promise { + // 按需加载:这两个库合计约 600KB,只在真正导出 PDF 时才拉取, + // 不让签署工作台的首屏为「偶尔用一次」的功能买单 + const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ + import('html2canvas'), + import('jspdf'), + ]) + + const { element } = input + // 克隆体在目标版心下重新排版,页眉高度以克隆体为准(实时容器可能更窄,页眉会换行) + let clonedHeaderPx = 0 + + const canvas = await html2canvas(element, { + scale: CAPTURE_SCALE, + backgroundColor: '#ffffff', + useCORS: true, + logging: false, + onclone: (clonedDocument, clonedElement) => { + clonedElement.style.width = `${CAPTURE_WIDTH_PX}px` + clonedElement.style.height = 'auto' + clonedElement.style.maxHeight = 'none' + clonedElement.style.overflow = 'visible' + clonedElement.style.padding = '0' + clonedElement.style.margin = '0' + clonedElement.style.background = '#ffffff' + + // 文书外壳(.sign-doc)在页面里是定高滚动卡片:overflow: hidden 会把超出一屏的内容裁掉, + // 纸张阴影与圆角也不该出现在 PDF 页面上,这里一并抹平 + const paper = clonedElement.closest('.sign-doc') + + if (paper) { + paper.style.overflow = 'visible' + paper.style.height = 'auto' + paper.style.minHeight = '0' + paper.style.boxShadow = 'none' + paper.style.borderRadius = '0' + } + + const header = clonedElement.querySelector('.sign-doc-header') + + if (!header) { + return + } + + const marginBottom = + Number.parseFloat( + clonedDocument.defaultView?.getComputedStyle(header).marginBottom ?? '0', + ) || 0 + + clonedHeaderPx = Math.round(header.getBoundingClientRect().height + marginBottom) + }, + }) + + const mmPerPx = CONTENT_WIDTH_MM / canvas.width + const headerPx = clonedHeaderPx > 0 ? clonedHeaderPx : measureHeaderHeight(element) + let headerCanvasPx = Math.min(Math.max(Math.round(headerPx * CAPTURE_SCALE), 0), canvas.height) + + if (CONTENT_HEIGHT_MM - headerCanvasPx * mmPerPx < MIN_BODY_HEIGHT_MM) { + headerCanvasPx = 0 + } + + const bodyTopPx = headerCanvasPx + const bodyTotalPx = Math.max(canvas.height - bodyTopPx, 1) + const bodyPagePx = Math.max( + Math.floor((CONTENT_HEIGHT_MM - headerCanvasPx * mmPerPx) / mmPerPx), + 1, + ) + const pageCount = Math.max(Math.ceil(bodyTotalPx / bodyPagePx), 1) + + const pageCanvas = document.createElement('canvas') + pageCanvas.width = canvas.width + pageCanvas.height = headerCanvasPx + bodyPagePx + + const context = pageCanvas.getContext('2d') + + if (!context) { + throw new Error('当前浏览器不支持画布导出,无法生成 PDF') + } + + const pdf = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' }) + const imageHeightMm = pageCanvas.height * mmPerPx + + for (let page = 0; page < pageCount; page += 1) { + context.fillStyle = '#ffffff' + context.fillRect(0, 0, pageCanvas.width, pageCanvas.height) + + // 页眉:每页重复文书顶部那一条 + if (headerCanvasPx > 0) { + context.drawImage( + canvas, + 0, + 0, + canvas.width, + headerCanvasPx, + 0, + 0, + canvas.width, + headerCanvasPx, + ) + } + + // 正文:按页连续切片,接在页眉下面 + const sourceY = bodyTopPx + page * bodyPagePx + const sliceHeight = Math.min(bodyPagePx, canvas.height - sourceY) + + if (sliceHeight > 0) { + context.drawImage( + canvas, + 0, + sourceY, + canvas.width, + sliceHeight, + 0, + headerCanvasPx, + canvas.width, + sliceHeight, + ) + } + + if (page > 0) { + pdf.addPage() + } + + pdf.addImage( + pageCanvas.toDataURL('image/png'), + 'PNG', + MARGIN_X_MM, + MARGIN_TOP_MM, + CONTENT_WIDTH_MM, + imageHeightMm, + ) + + pdf.setFontSize(9) + pdf.setTextColor(130, 130, 130) + pdf.text(`${page + 1} / ${pageCount}`, PAGE_WIDTH_MM / 2, PAGE_HEIGHT_MM - 7, { + align: 'center', + }) + } + + return pdf.output('blob') +} + +/** 生成并触发浏览器下载 */ +export async function downloadSigningPdf(input: SigningPdfInput): Promise { + const blob = await createSigningPdf(input) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = url + link.download = `${input.fileName}.pdf` + link.click() + window.setTimeout(() => URL.revokeObjectURL(url), 0) +} diff --git a/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue b/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue index 438d4c1..567f0a0 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue @@ -164,6 +164,13 @@ onMounted(() => { applyStoredValues() syncFromDom() }) + +/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */ +function getDocumentElement() { + return containerRef.value +} + +defineExpose({ getDocumentElement })