From 5897b35b4e5f3455aa933ae3bbd46e87ebdba8ed Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 13 Aug 2026 10:50:07 +0800 Subject: [PATCH] docs: add mobile OA print-to-PDF download implementation plan Co-authored-by: Cursor --- ...2026-08-13-mobile-oa-print-pdf-download.md | 611 ++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-mobile-oa-print-pdf-download.md diff --git a/docs/superpowers/plans/2026-08-13-mobile-oa-print-pdf-download.md b/docs/superpowers/plans/2026-08-13-mobile-oa-print-pdf-download.md new file mode 100644 index 00000000..b4fa26fd --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-mobile-oa-print-pdf-download.md @@ -0,0 +1,611 @@ +# Mobile OA Print → PDF Download Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make mobile OA「打印」generate a web-aligned PDF and open WeChat system document preview (download + `openDocument`), instead of `window.print()`. + +**Architecture:** `mobile-oa-h5` renders the existing print HTML off-screen, captures with `html2canvas`, builds PDF via `jspdf`, uploads via existing attachment API, then `wx.miniProgram.navigateTo` a new `mobile-h5` preview page that `downloadFile` + `openDocument({ showMenu: true })`. Outside WeChat, fall back to blob download via ``. + +**Tech Stack:** Vue 3, Vite (`mobile-oa-h5`), uni-app (`mobile-h5`), `html2canvas@^1.4.1`, `jspdf@^4.2.1`, Node assert tests + +**Spec:** `docs/superpowers/specs/2026-08-13-mobile-oa-print-pdf-download-design.md` + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| `mobile-oa-h5/src/oa/print/oa-print-filename.js` | Pure: build PDF filename from detail | +| `mobile-oa-h5/src/oa/print/oa-print-filename.test.js` | Unit tests for filename | +| `mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js` | Model + HTML + canvas→PDF + upload/navigate/save | +| `mobile-oa-h5/package.json` | Add `html2canvas`, `jspdf` | +| `mobile-oa-h5/src/views/OaDetail.vue` | Toast copy for export failures | +| `mobile-h5/pages/oa/pdf-preview/index.vue` | Host: download + openDocument + navigateBack | +| `mobile-h5/pages.json` | Register pdf-preview route | + +--- + +### Task 1: PDF filename helper (TDD) + +**Files:** +- Create: `mobile-oa-h5/src/oa/print/oa-print-filename.js` +- Create: `mobile-oa-h5/src/oa/print/oa-print-filename.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +import assert from 'node:assert/strict' +import { buildOaPrintFilename } from './oa-print-filename.js' + +assert.equal( + buildOaPrintFilename({ + initiatorUserName: '张三', + appName: '请假申请' + }), + '张三提交的请假申请.pdf' +) + +assert.equal( + buildOaPrintFilename({ + initiatorUserId: 'u1', + title: '报销' + }), + 'u1提交的报销.pdf' +) + +assert.equal(buildOaPrintFilename({}), '用户提交的审批单.pdf') + +assert.equal( + buildOaPrintFilename({ + initiatorUserName: 'a/b:c*', + appName: 'x?y' + }), + 'a_b_c_提交的x_y.pdf' +) + +console.log('oa-print-filename: ok') +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd mobile-oa-h5 && node src/oa/print/oa-print-filename.test.js` + +Expected: FAIL — module not found + +- [ ] **Step 3: Implement** + +```js +/** Align with web-admin `buildOaPrintFilename` in oa-approval-print.ts */ +export function buildOaPrintFilename(detail) { + const initiator = + String(detail?.initiatorUserName || '').trim() || + String(detail?.initiatorUserId || '').trim() || + '用户' + const appName = + String(detail?.appName || '').trim() || + String(detail?.title || '').trim() || + '审批单' + const safe = (s) => String(s).replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) + return `${safe(initiator)}提交的${safe(appName)}.pdf` +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd mobile-oa-h5 && node src/oa/print/oa-print-filename.test.js` + +Expected: `oa-print-filename: ok` + +- [ ] **Step 5: Commit** + +```bash +git add mobile-oa-h5/src/oa/print/oa-print-filename.js \ + mobile-oa-h5/src/oa/print/oa-print-filename.test.js +git commit -m "feat(oa): add mobile print PDF filename helper" +``` + +--- + +### Task 2: Align print HTML stamp + CSS with web v4 + +**Files:** +- Modify: `mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js` (`buildPrintHtml` styles + stamp DOM) + +Goal: PDF visual matches web `OaApprovalPrintSheet` (dingding stamp DOM, page width 794px, min-height ~1123px, table/meta/footer metrics). Keep field assembly as-is (related still 「已关联 N 条」). + +- [ ] **Step 1: Replace simplified `.oa-stamp` CSS and DOM in `buildPrintHtml`** + +Key CSS tokens (mirror web sheet): + +```css +.oa-print-page { + position: relative; + width: 794px; + max-width: 794px; + margin: 0 auto; + min-height: 1123px; + padding: 48px 56px 56px; + overflow: hidden; + background: #fff; + color: #1f2329; + font-family: "PingFang SC","Microsoft YaHei","Helvetica Neue",Arial,sans-serif; + box-sizing: border-box; +} +.oa-print-title { margin: 0 0 14px; text-align: center; font-size: 28px; + font-weight: 700; letter-spacing: 2px; line-height: 1.3; } +.oa-print-meta { display: flex; flex-wrap: wrap; gap: 16px 48px; margin-bottom: 12px; + font-size: 12px; line-height: 1.5; } +.oa-print-table { width: 100%; border-collapse: separate; border-spacing: 0; + table-layout: fixed; font-size: 13px; border-top: 1px solid #1f2329; border-left: 1px solid #1f2329; } +.oa-print-table th, .oa-print-table td { border: none; border-right: 1px solid #1f2329; + border-bottom: 1px solid #1f2329; padding: 9px 12px; vertical-align: middle; + line-height: 1.5; word-break: break-all; } +.oa-print-table th { width: 118px; font-weight: 400; text-align: left; background: #fff; } +.oa-print-url { color: #3370ff; word-break: break-all; } +.oa-stamp { --stamp: #3ec9a8; position: absolute; top: -10px; right: 18px; + width: 118px; height: 118px; z-index: 5; pointer-events: none; + transform: rotate(-18deg); opacity: 0.88; color: var(--stamp); } +.oa-stamp.ok { --stamp: #3ec9a8; } +.oa-stamp.run { --stamp: #f0a020; } +.oa-stamp.rej { --stamp: #e85d5d; } +.oa-stamp.ret { --stamp: #e07070; } +.oa-stamp.can { --stamp: #9aa0a6; } +.oa-stamp .outer { position: absolute; inset: 0; border-radius: 50%; + border: 5px solid currentColor; box-sizing: border-box; } +.oa-stamp .arc-top, .oa-stamp .arc-bottom { position: absolute; left: 14px; right: 14px; + border: 2px solid currentColor; opacity: 0.95; pointer-events: none; } +.oa-stamp .arc-top { top: 11px; height: 36px; border-bottom: none; + border-left-color: transparent; border-right-color: transparent; + border-radius: 50% 50% 0 0 / 100% 100% 0 0; } +.oa-stamp .arc-bottom { bottom: 11px; height: 36px; border-top: none; + border-left-color: transparent; border-right-color: transparent; + border-radius: 0 0 50% 50% / 0 0 100% 100%; } +.oa-stamp .label-top { position: absolute; top: 22px; left: 0; right: 0; + text-align: center; font-size: 10px; font-weight: 500; letter-spacing: 2px; + line-height: 1; color: currentColor; opacity: 0.95; } +.oa-stamp .label-bottom { position: absolute; bottom: 20px; left: 0; right: 0; + text-align: center; font-size: 9px; font-weight: 500; letter-spacing: 0.5px; + line-height: 1; color: currentColor; opacity: 0.9; + font-family: Arial, "Helvetica Neue", sans-serif; } +.oa-stamp .main { position: absolute; inset: 0; display: flex; align-items: center; + justify-content: center; font-size: 32px; font-weight: 700; letter-spacing: 2px; + line-height: 1; color: currentColor; } +.oa-stamp .main.is-chars3 { font-size: 26px; letter-spacing: 1px; } +``` + +Keep flow/footer/wm rules from the current file. Drop `@media print` after Task 3 if unused. + +Replace stamp DOM: + +```html +
+
+
+
+
OA审批
+
${escapeHtml(model.stampText)}
+
approval
+
+``` + +Where `stampChars3 = model.stampText.length >= 3 ? ' is-chars3' : ''` (class on `.main`, not on root). + +- [ ] **Step 2: Commit** + +```bash +git add mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js +git commit -m "fix(oa): align mobile print HTML stamp and layout with web PDF" +``` + +--- + +### Task 3: Dependencies + canvas→PDF generation + +**Files:** +- Modify: `mobile-oa-h5/package.json` +- Modify: `mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js` + +- [ ] **Step 1: Install deps (same major as web-admin)** + +```bash +cd mobile-oa-h5 && pnpm add html2canvas@^1.4.1 jspdf@^4.2.1 +``` + +If the package uses npm instead, run `npm install html2canvas@^1.4.1 jspdf@^4.2.1`. + +- [ ] **Step 2: Refactor HTML builder for off-screen mount** + +Add `buildPrintBodyHtml(model)` that returns `` + `.oa-print-page` DOM (no full document). Have `buildPrintHtml` wrap it in `...` only if still needed for tests; otherwise keep one function used by generation. + +- [ ] **Step 3: Add canvas helpers + `generateOaApprovalPdfBlob`** + +Imports: + +```js +import html2canvas from 'html2canvas' +import { jsPDF } from 'jspdf' +import { buildOaPrintFilename } from './oa-print-filename.js' +``` + +Port `renderPageLabelCanvas` and `canvasToPdfBlob` from web-admin `oa-approval-print.ts` (`EPS_MM = 1.5`, JPEG 0.95, Chinese page labels via canvas). + +```js +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() + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add mobile-oa-h5/package.json mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js +# also add lockfile if changed (pnpm-lock.yaml or package-lock.json) +git commit -m "feat(oa): generate mobile OA approval PDF via html2canvas and jspdf" +``` + +--- + +### Task 4: Export orchestration — WeChat upload + navigate / browser save + +**Files:** +- Modify: `mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js` + +- [ ] **Step 1: Replace `exportOaApprovalPdf` (remove `window.print` path)** + +Additional imports: + +```js +import { uploadAttachment, fetchAttachmentDownloadUrl } from '../../api/oa.js' +import { attachmentFullUrl } from '../../utils/attachment.js' +``` + +```js +function navigateToHostPdfPreview({ url, fileName }) { + const q = + 'url=' + + encodeURIComponent(url) + + '&name=' + + encodeURIComponent(fileName || 'approval.pdf') + const path = '/pages/oa/pdf-preview/index?' + q + try { + if (typeof wx !== 'undefined' && wx.miniProgram?.navigateTo) { + wx.miniProgram.navigateTo({ url: path }) + return true + } + } catch { + /* ignore */ + } + try { + if (typeof uni !== 'undefined' && uni.webView?.navigateTo) { + uni.webView.navigateTo({ url: path }) + return true + } + } catch { + /* ignore */ + } + return false +} + +/** + * @returns {Promise<{ ok: boolean, message?: string, mode?: 'preview' | 'download' }>} + */ +export async function exportOaApprovalPdf(detail, opts = {}) { + if (!detail) { + return { ok: false, message: '暂无审批详情,无法导出 PDF' } + } + + let blob + let filename + try { + ;({ blob, filename } = await generateOaApprovalPdfBlob(detail, opts)) + } catch (e) { + return { + ok: false, + message: (e && e.message) || '生成 PDF 失败' + } + } + + if (isWeChatMiniWebView()) { + try { + const file = new File([blob], filename, { type: 'application/pdf' }) + const res = await uploadAttachment(file) + const fileUrl = String(res?.fileUrl || res?.data?.fileUrl || '').trim() + if (!fileUrl) { + return { ok: false, message: '上传 PDF 失败:无文件地址' } + } + + let openUrl = '' + try { + const dl = await fetchAttachmentDownloadUrl({ + fileUrl, + fileName: filename + }) + openUrl = String(dl?.downloadUrl || dl?.data?.downloadUrl || '').trim() + } catch { + /* fall through */ + } + if (!openUrl) { + openUrl = attachmentFullUrl(fileUrl) || fileUrl + } + if (!/^https?:\/\//i.test(openUrl)) { + return { ok: false, message: '无法打开 PDF:下载地址无效' } + } + + const navigated = navigateToHostPdfPreview({ + url: openUrl, + fileName: filename + }) + if (!navigated) { + return { + ok: false, + message: '无法跳转预览页,请升级微信后重试' + } + } + return { ok: true, mode: 'preview' } + } catch (e) { + return { + ok: false, + message: (e && e.message) || '上传或打开 PDF 失败' + } + } + } + + try { + const href = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = href + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(href), 2000) + return { ok: true, mode: 'download' } + } catch (e) { + return { + ok: false, + message: (e && e.message) || '下载 PDF 失败' + } + } +} +``` + +Keep exporting `buildOaPrintModel`, `OA_PRINT_COMPANY_NAME`, and `resolveStamp` re-export for existing imports. + +- [ ] **Step 2: Commit** + +```bash +git add mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js +git commit -m "feat(oa): export mobile PDF via upload and WeChat preview navigate" +``` + +--- + +### Task 5: `mobile-h5` PDF preview page + +**Files:** +- Create: `mobile-h5/pages/oa/pdf-preview/index.vue` +- Modify: `mobile-h5/pages.json` (insert after `pages/oa/webview/index`) + +- [ ] **Step 1: Register route in `pages.json`** + +```json +{ + "path": "pages/oa/pdf-preview/index", + "style": { + "navigationBarTitleText": "文档预览", + "navigationBarBackgroundColor": "#ecfdf5", + "navigationBarTextStyle": "black" + } +} +``` + +- [ ] **Step 2: Create preview page** + +```vue + + + + + +``` + +Ops note: WeChat download合法域名 must include MinIO / download host. + +- [ ] **Step 3: Commit** + +```bash +git add mobile-h5/pages/oa/pdf-preview/index.vue mobile-h5/pages.json +git commit -m "feat(oa): add WeChat host page to preview exported OA PDF" +``` + +--- + +### Task 6: Wire `OaDetail` toast copy + +**Files:** +- Modify: `mobile-oa-h5/src/views/OaDetail.vue` (`onPrint`) + +- [ ] **Step 1: Update failure toasts** + +```js +async function onPrint() { + if (!detail.value || printing.value) return + printing.value = true + try { + const printerName = + currentUserName.value || + String(detail.value?.initiatorUserName || '').trim() || + '-' + const result = await exportOaApprovalPdf(detail.value, { printerName }) + if (!result?.ok) { + showToast(result?.message || '导出 PDF 失败') + } + } catch (e) { + showToast(e?.message || '导出 PDF 失败') + } finally { + printing.value = false + } +} +``` + +Do not toast on success when `mode === 'preview'`. + +- [ ] **Step 2: Commit** + +```bash +git add mobile-oa-h5/src/views/OaDetail.vue +git commit -m "fix(oa): update mobile print button toasts for PDF export" +``` + +--- + +### Task 7: Verification + +- [ ] **Step 1: Unit tests** + +```bash +cd mobile-oa-h5 && node src/oa/print/oa-print-filename.test.js +cd mobile-oa-h5 && node src/oa/oa-status-stamp.test.js +``` + +Expected: both print `ok` + +- [ ] **Step 2: Manual (WeChat)** + +1. Deploy `mobile-oa-h5` + `mobile-h5` used by the mini program web-view. +2. 详情点「打印」→ 系统 PDF 预览,可转发 / 用其他应用打开。 +3. 列表长按「打印」→ 详情自动导出并预览。 +4. 对比 web PDF:标题、公司名、印章、水印、页脚、页码大体一致。 +5. 失败场景:详情 toast,不关闭。 + +- [ ] **Step 3: Manual (browser)** + +Open `mobile-oa-h5` outside WeChat →「打印」直接下载 `.pdf`。 + +--- + +## Spec coverage checklist + +| Spec item | Task | +|-----------|------| +| html2canvas + jspdf generate PDF | Task 3 | +| Upload via existing attachment API | Task 4 | +| WeChat navigateTo + openDocument preview | Task 4–5 | +| Browser download fallback | Task 4 | +| Filename `{发起人}提交的{流程}.pdf` | Task 1 | +| Layout/stamp align web | Task 2 | +| autoPrint same path | Task 4 | +| Preview then navigateBack | Task 5 | +| Error toasts, stay on detail | Task 4 + 6 | +| Related nested out of scope | unchanged | + +## Consistency notes + +- Functions: `buildOaPrintFilename`, `generateOaApprovalPdfBlob`, `exportOaApprovalPdf`, `navigateToHostPdfPreview` +- Host path: `/pages/oa/pdf-preview/index` +- Upload fields: `fileUrl` (+ `data?.fileUrl` fallback like `OaUploadField`) +- Download fields: `downloadUrl` via `fetchAttachmentDownloadUrl`