Ver código fonte

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

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 meses atrás
pai
commit
5897b35b4e

+ 611 - 0
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 `<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`