feat(signing): 前端生成签署后 PDF 原型

This commit is contained in:
yelan
2026-09-16 17:24:02 +08:00
parent 2279f7a624
commit 51ec0f393d
6 changed files with 487 additions and 1 deletions
+226
View File
@@ -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",
+2
View File
@@ -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",
+205
View File
@@ -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<HTMLElement>('.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<Blob> {
// 按需加载:这两个库合计约 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<HTMLElement>('.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<HTMLElement>('.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<void> {
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)
}
@@ -164,6 +164,13 @@ onMounted(() => {
applyStoredValues()
syncFromDom()
})
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
function getDocumentElement() {
return containerRef.value
}
defineExpose({ getDocumentElement })
</script>
<template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import type {
SigningAnswers,
SigningArtifact,
@@ -33,6 +35,15 @@ const emit = defineEmits<{
selectionChange: [items: string[]]
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
}>()
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
/** 供父级「导出 PDF」使用:转发到实际渲染文书的那一层 */
function getDocumentElement() {
return documentRef.value?.getDocumentElement() ?? null
}
defineExpose({ getDocumentElement })
</script>
<template>
@@ -150,6 +161,7 @@ const emit = defineEmits<{
<!-- 任务创建时固化了文书 HTML 的走交互化渲染;老任务回退到静态纸样预览 -->
<SigningDocumentInteractive
v-if="task.documentContentHtml"
ref="documentRef"
:task="task"
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
@selection-change="emit('selectionChange', $event)"
@@ -34,6 +34,7 @@ import type {
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import type { SigningFieldProgress } from '@/utils/signing-document'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
@@ -106,6 +107,8 @@ const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false)
const voidDialogVisible = ref(false)
const taskDetailRef = ref<InstanceType<typeof SigningTaskDetail> | null>(null)
const pdfExporting = ref(false)
let listRequestId = 0
let detailRequestId = 0
@@ -467,7 +470,7 @@ async function handleTaskAction(action: SigningTaskAction) {
if (artifact) {
await downloadArtifact(artifact)
} else if (isSigningMockEnabled) {
ElMessage.info('原型环境不生成 PDF,可在下方「签署文件」中下载签名原图')
await exportSignedPdf()
} else {
ElMessage.info('当前任务暂无签署后 PDF')
}
@@ -530,6 +533,36 @@ async function handleTaskAction(action: SigningTaskAction) {
}
}
/**
* 原型阶段:后端没有签署后 PDF 时,前端按当前渲染结果现场生成一份。
* 真实实现里 PDF 由服务端生成(正式文书以服务端产物为准),
* 这里只是让前端能先看到成品效果,因此仅在演示模式下兜底。
*/
async function exportSignedPdf() {
const task = selectedTask.value
const element = taskDetailRef.value?.getDocumentElement()
if (!task || !element) {
ElMessage.info('该任务没有固化的文书正文,无法导出 PDF')
return
}
if (pdfExporting.value) {
return
}
pdfExporting.value = true
try {
await downloadSigningPdf({ element, fileName: `${task.documentName}-${task.id}` })
ElMessage.success('签署后 PDF 已生成')
} catch {
ElMessage.error('PDF 生成失败,请稍后重试')
} finally {
pdfExporting.value = false
}
}
async function downloadArtifact(artifact: SigningArtifact) {
try {
const blob = await downloadSigningArtifact(artifact.id)
@@ -691,6 +724,7 @@ onMounted(() => {
@page-change="handlePageChange"
/>
<SigningTaskDetail
ref="taskDetailRef"
:task="selectedTask"
:loading="detailLoading"
:audit-events="taskEvents"