docs: add mobile OA print-to-PDF download implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-13 10:50:07 +08:00
1 parent 433eb5d113
commit 5897b35b4e
1 file changed
+611
@@ -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 `<a download>`.
**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
<div class="oa-stamp ${escapeHtml(model.stampClass)}" aria-label="${escapeHtml(model.stampText)}">
<div class="outer"></div>
<div class="arc-top"></div>
<div class="arc-bottom"></div>
<div class="label-top">OA审批</div>
<div class="main${stampChars3}">${escapeHtml(model.stampText)}</div>
<div class="label-bottom">approval</div>
</div>
```
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 `<style>...</style>` + `.oa-print-page` DOM (no full document). Have `buildPrintHtml` wrap it in `<!DOCTYPE html>...` 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
<template>
<view class="pdf-preview">
<view class="pdf-preview__tip">{{ tip }}</view>
</view>
</template>
<script>
export default {
data() {
return {
tip: '正在打开文档…',
_opened: false
}
},
onLoad(query) {
const url = decodeURIComponent(String((query && query.url) || '').trim())
const name = decodeURIComponent(
String((query && query.name) || 'approval.pdf').trim()
)
if (!url || !/^https?:\/\//i.test(url)) {
this.tip = '文件地址无效'
uni.showToast({ title: '文件地址无效', icon: 'none' })
return
}
this.openPdf(url, name)
},
methods: {
openPdf(url, name) {
if (this._opened) return
this._opened = true
uni.downloadFile({
url,
success: (res) => {
if (!res || res.statusCode !== 200 || !res.tempFilePath) {
this.tip = '下载失败'
uni.showToast({ title: '下载 PDF 失败', icon: 'none' })
this._opened = false
return
}
uni.openDocument({
filePath: res.tempFilePath,
fileType: 'pdf',
showMenu: true,
success: () => {
this.tip = '已打开预览'
setTimeout(() => {
uni.navigateBack({ fail: () => {} })
}, 300)
},
fail: () => {
this.tip = '无法打开文档'
uni.showToast({ title: '无法打开 PDF', icon: 'none' })
this._opened = false
}
})
},
fail: () => {
this.tip = '下载失败'
uni.showToast({ title: '下载 PDF 失败', icon: 'none' })
this._opened = false
}
})
}
}
}
</script>
<style scoped>
.pdf-preview {
min-height: 100vh;
padding: 48rpx 32rpx;
background: #f5f7fa;
}
.pdf-preview__tip {
text-align: center;
color: #64748b;
font-size: 28rpx;
}
</style>
```
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`