From bf22b29d80a5fb08e6b403657d2248990ccd80ec Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 13 Aug 2026 10:56:37 +0800 Subject: [PATCH] feat(oa): generate mobile OA approval PDF via html2canvas and jspdf Co-authored-by: Cursor --- mobile-oa-h5/package-lock.json | 235 ++++++++++++++++++ mobile-oa-h5/package.json | 2 + .../src/oa/print/exportOaApprovalPdf.js | 200 +++++++++++---- 3 files changed, 391 insertions(+), 46 deletions(-) diff --git a/mobile-oa-h5/package-lock.json b/mobile-oa-h5/package-lock.json index 559f3b84..4334f9fc 100644 --- a/mobile-oa-h5/package-lock.json +++ b/mobile-oa-h5/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@form-create/vant": "^3.2.14", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "vant": "^4.9.21", "vue": "^3.5.13", "vue-router": "^4.5.0" @@ -51,6 +53,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.8", "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", @@ -928,6 +939,26 @@ "dev": true, "license": "MIT" }, + "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/@vant/popperjs": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/@vant/popperjs/-/popperjs-1.3.0.tgz", @@ -1061,6 +1092,59 @@ "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", "license": "MIT" }, + "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/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/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/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/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -1073,6 +1157,16 @@ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", @@ -1133,6 +1227,17 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -1151,6 +1256,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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -1166,6 +1277,42 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "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/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "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/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -1193,6 +1340,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "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/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", @@ -1241,6 +1411,33 @@ "node": "^10 || ^12 || >=14" } }, + "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/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/rollup": { "version": "4.62.4", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz", @@ -1296,6 +1493,35 @@ "node": ">=0.10.0" } }, + "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/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/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1313,6 +1539,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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/vant": { "version": "4.10.0", "resolved": "https://registry.npmmirror.com/vant/-/vant-4.10.0.tgz", diff --git a/mobile-oa-h5/package.json b/mobile-oa-h5/package.json index faac5c6e..4bb86aee 100644 --- a/mobile-oa-h5/package.json +++ b/mobile-oa-h5/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@form-create/vant": "^3.2.14", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "vant": "^4.9.21", "vue": "^3.5.13", "vue-router": "^4.5.0" diff --git a/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js b/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js index c4d9caf8..668920a7 100644 --- a/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js +++ b/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js @@ -1,8 +1,10 @@ /** * OA 审批单移动端打印(S6) - * 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;优先 window.print(),无 jspdf 依赖。 + * 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;支持 html2canvas + jsPDF 导出。 * Spec: docs/superpowers/specs/2026-08-12-mobile-oa-task-centers-design.md §6.5 */ +import html2canvas from 'html2canvas' +import { jsPDF } from 'jspdf' import { formatDeptDisplay } from '../kit/oa-dept-value.js' import { formatPersonDisplay } from '../kit/oa-person-value.js' import { formatIdNameDisplay } from '../../utils/id-name-value.js' @@ -13,6 +15,7 @@ import { import { toRmbUpper } from '../../utils/rmb-upper.js' import { OA_STATUS_TEXT } from '../status-label.js' import { resolveStamp } from '../oa-status-stamp.js' +import { buildOaPrintFilename } from './oa-print-filename.js' export { resolveStamp } @@ -597,42 +600,8 @@ function flowCellHtml(f) { return html } -function buildPrintHtml(model) { - const stampChars3 = model.stampText.length >= 3 ? ' is-chars3' : '' - const wmItems = Array.from({ length: 24 }, (_, i) => { - const row = Math.floor(i / 3) - const col = i % 3 - const top = 40 + row * 100 - const left = -20 + col * 240 + (row % 2) * 40 - return `${escapeHtml(model.watermarkName)} ${escapeHtml(model.watermarkName)}` - }).join('') - - let rowsHtml = '' - for (const row of model.rows) { - rowsHtml += `${escapeHtml(row.label)}${cellInnerHtml(row)}` - } - - let flowHtml = '' - if (model.flowItems.length) { - model.flowItems.forEach((f, fi) => { - flowHtml += '' - if (fi === 0) { - flowHtml += `审批流程` - } - flowHtml += `${flowCellHtml(f)}` - }) - } else { - flowHtml = - '审批流程-' - } - - return ` - - - - -${escapeHtml(model.title)} - - - -
+ + let flowHtml = '' + if (model.flowItems.length) { + model.flowItems.forEach((f, fi) => { + flowHtml += '' + if (fi === 0) { + flowHtml += `审批流程` + } + flowHtml += `${flowCellHtml(f)}` + }) + } else { + flowHtml = + '审批流程-' + } + + return `

${escapeHtml(model.title)}

@@ -736,11 +729,126 @@ function buildPrintHtml(model) { 打印人:${escapeHtml(model.printerName)}
-
+ ` +} + +export function buildPrintBodyHtml(model) { + return `${buildPrintPageContent(model)}` +} + +function buildPrintHtml(model) { + return ` + + + + +${escapeHtml(model.title)} + + + +${buildPrintPageContent(model)} ` } +/** 用 canvas 绘制中文页码(jsPDF 内置字体不支持汉字,会乱码) */ +function renderPageLabelCanvas(text) { + const font = '20px "PingFang SC", "Microsoft YaHei", "Heiti SC", sans-serif' + const measure = document.createElement('canvas') + const mctx = measure.getContext('2d') + if (!mctx) { + const empty = document.createElement('canvas') + empty.width = 1 + empty.height = 1 + return empty + } + mctx.font = font + const tw = Math.ceil(mctx.measureText(text).width) + 8 + const th = 28 + const scale = 2 + const c = document.createElement('canvas') + c.width = tw * scale + c.height = th * scale + const ctx = c.getContext('2d') + if (!ctx) return c + ctx.scale(scale, scale) + ctx.clearRect(0, 0, tw, th) + ctx.font = font + ctx.fillStyle = '#646464' + ctx.textBaseline = 'middle' + ctx.fillText(text, 4, th / 2) + return c +} + +async function canvasToPdfBlob(canvas) { + const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' }) + const pageWidth = pdf.internal.pageSize.getWidth() + const pageHeight = pdf.internal.pageSize.getHeight() + const imgWidth = pageWidth + const imgHeight = (canvas.height * imgWidth) / canvas.width + + const EPS_MM = 1.5 + const totalPages = Math.min(40, Math.max(1, Math.ceil((imgHeight - EPS_MM) / pageHeight))) + + const pageData = canvas.toDataURL('image/jpeg', 0.95) + const labelMmH = 4.5 + + for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) { + if (pageIndex > 0) pdf.addPage() + const position = -pageIndex * pageHeight + pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight) + + const label = `第${pageIndex + 1}页` + const labelCanvas = renderPageLabelCanvas(label) + if (labelCanvas.width > 1) { + const labelMmW = (labelCanvas.width / labelCanvas.height) * labelMmH + pdf.addImage( + labelCanvas.toDataURL('image/png'), + 'PNG', + (pageWidth - labelMmW) / 2, + pageHeight - 10, + labelMmW, + labelMmH + ) + } + } + + return pdf.output('blob') +} + +export async function generateOaApprovalPdfBlob(detail, opts = {}) { + if (!detail) throw new Error('暂无审批详情,无法导出') + const model = buildOaPrintModel(detail, { + printerName: opts.printerName || '' + }) + const filename = buildOaPrintFilename(detail) + const host = document.createElement('div') + host.setAttribute('data-oa-print-host', '1') + host.style.cssText = + 'position:fixed;left:-100000px;top:0;width:794px;z-index:-1;opacity:1;pointer-events:none;' + document.body.appendChild(host) + host.innerHTML = buildPrintBodyHtml(model) + try { + await new Promise((r) => requestAnimationFrame(() => r())) + await new Promise((r) => setTimeout(r, 50)) + const el = host.querySelector('.oa-print-page') + if (!el) throw new Error('print sheet missing') + const canvas = await html2canvas(el, { + scale: 2, + useCORS: true, + allowTaint: true, + backgroundColor: '#ffffff', + logging: false, + windowWidth: el.scrollWidth, + windowHeight: el.scrollHeight + }) + const blob = await canvasToPdfBlob(canvas) + return { blob, filename } + } finally { + host.remove() + } +} + function isWeChatMiniWebView() { try { if (typeof window !== 'undefined' && window.__wxjs_environment === 'miniprogram') {