Explorar o código

feat(oa): generate mobile OA approval PDF via html2canvas and jspdf

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 2 meses
pai
achega
bf22b29d80

+ 235 - 0
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",

+ 2 - 0
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"

+ 154 - 46
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 `<span class="oa-print-wm__item" style="top:${top}px;left:${left}px">${escapeHtml(model.watermarkName)} ${escapeHtml(model.watermarkName)}</span>`
-  }).join('')
-
-  let rowsHtml = ''
-  for (const row of model.rows) {
-    rowsHtml += `<tr><th>${escapeHtml(row.label)}</th><td>${cellInnerHtml(row)}</td></tr>`
-  }
-
-  let flowHtml = ''
-  if (model.flowItems.length) {
-    model.flowItems.forEach((f, fi) => {
-      flowHtml += '<tr class="oa-print-flow">'
-      if (fi === 0) {
-        flowHtml += `<th class="oa-print-flow__label" rowspan="${model.flowItems.length}">审批流程</th>`
-      }
-      flowHtml += `<td class="oa-print-flow__cell">${flowCellHtml(f)}</td></tr>`
-    })
-  } else {
-    flowHtml =
-      '<tr class="oa-print-flow"><th class="oa-print-flow__label">审批流程</th><td class="oa-print-flow__cell"><span class="oa-print-text">-</span></td></tr>'
-  }
-
-  return `<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="utf-8"/>
-<meta name="viewport" content="width=device-width,initial-scale=1"/>
-<title>${escapeHtml(model.title)}</title>
-<style>
+function buildPrintStylesContent() {
+  return `
   * { box-sizing: border-box; }
   body { margin: 0; background: #fff; color: #1f2329;
     font-family: "PingFang SC","Microsoft YaHei","Helvetica Neue",Arial,sans-serif; }
@@ -702,15 +671,39 @@ function buildPrintHtml(model) {
     line-height: 1; color: currentColor; }
   .oa-stamp .main.is-chars3 { font-size: 26px; letter-spacing: 1px; }
   .oa-print-footer { display: flex; flex-wrap: wrap; justify-content: space-between;
-    gap: 8px; margin-top: 16px; font-size: 12px; color: #646464; }
-  @media print {
-    body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
-    .oa-print-page { max-width: none; padding: 12mm 14mm; }
+    gap: 8px; margin-top: 16px; font-size: 12px; color: #646464; }`
+}
+
+function buildPrintPageContent(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 `<span class="oa-print-wm__item" style="top:${top}px;left:${left}px">${escapeHtml(model.watermarkName)} ${escapeHtml(model.watermarkName)}</span>`
+  }).join('')
+
+  let rowsHtml = ''
+  for (const row of model.rows) {
+    rowsHtml += `<tr><th>${escapeHtml(row.label)}</th><td>${cellInnerHtml(row)}</td></tr>`
   }
-</style>
-</head>
-<body>
-  <div class="oa-print-page">
+
+  let flowHtml = ''
+  if (model.flowItems.length) {
+    model.flowItems.forEach((f, fi) => {
+      flowHtml += '<tr class="oa-print-flow">'
+      if (fi === 0) {
+        flowHtml += `<th class="oa-print-flow__label" rowspan="${model.flowItems.length}">审批流程</th>`
+      }
+      flowHtml += `<td class="oa-print-flow__cell">${flowCellHtml(f)}</td></tr>`
+    })
+  } else {
+    flowHtml =
+      '<tr class="oa-print-flow"><th class="oa-print-flow__label">审批流程</th><td class="oa-print-flow__cell"><span class="oa-print-text">-</span></td></tr>'
+  }
+
+  return `<div class="oa-print-page">
     <div class="oa-print-wm" aria-hidden="true">${wmItems}</div>
     <div class="oa-print-inner">
       <h1 class="oa-print-title">${escapeHtml(model.title)}</h1>
@@ -736,11 +729,126 @@ function buildPrintHtml(model) {
         <span>打印人:${escapeHtml(model.printerName)}</span>
       </div>
     </div>
-  </div>
+  </div>`
+}
+
+export function buildPrintBodyHtml(model) {
+  return `<style>${buildPrintStylesContent()}</style>${buildPrintPageContent(model)}`
+}
+
+function buildPrintHtml(model) {
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8"/>
+<meta name="viewport" content="width=device-width,initial-scale=1"/>
+<title>${escapeHtml(model.title)}</title>
+<style>${buildPrintStylesContent()}</style>
+</head>
+<body>
+${buildPrintPageContent(model)}
 </body>
 </html>`
 }
 
+/** 用 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') {