|
|
@@ -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`
|