Ver código fonte

feat(oa): add mobile approval PDF print for mine and cc

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

+ 9 - 0
docs/superpowers/plans/2026-08-12-mobile-oa-task-centers.md

@@ -786,3 +786,12 @@ EOF
 ## 执行说明
 
 实现时每完成一个 Task 再开下一个;打印(Task 12)可与角标并行,但不得阻塞 Task 5–10 合并。
+
+---
+
+## 打印验收笔记(S6 / Task 12)
+
+- **实现**:`mobile-oa-h5` 用可打印 HTML + `window.print()`(无 jspdf/html2canvas 依赖);字段/流程组装对齐 Web `oa-approval-print.ts`(关联审批嵌套块简化为「已关联 N 条」)。
+- **入口**:我发起的 / 抄送列表「打印」→ 详情 `autoPrint=1`;详情底栏次要「打印」按钮(紧挨关闭)。
+- **微信小程序 web-view**:系统打印能力通常不可用;检测后 `showToast`:「微信内暂不支持系统打印,请用手机浏览器打开或到电脑端导出」。不阻塞其它功能。
+- **建议验收**:手机浏览器 / 电脑打开 H5 详情应能调起打印对话框;微信内应看到明确失败提示。

+ 7 - 3
mobile-h5/pages/oa/cc/index.vue

@@ -238,7 +238,8 @@
             key: 'read',
             label: '已读',
             disabled: !!(row && row.isRead)
-          }
+          },
+          { key: 'print', label: '打印' }
         ]
       },
       isSelected(id) {
@@ -307,6 +308,7 @@
         if (!act || !row) return
         if (act.key === 'view') this.openView(row)
         else if (act.key === 'read') this.markOneRead(row)
+        else if (act.key === 'print') this.openView(row, { autoPrint: true })
       },
       async markOneRead(row) {
         if (!row || row.isRead || this.markingId) return
@@ -350,7 +352,7 @@
           this.batchMarking = false
         }
       },
-      async openView(row) {
+      async openView(row, opts) {
         if (!row || this.openingId) return
         const instanceId = String(row.instanceId || '').trim()
         const taskId = String(row.id || '').trim()
@@ -358,6 +360,7 @@
           uni.showToast({ title: '缺少实例 ID', icon: 'none' })
           return
         }
+        const autoPrint = !!(opts && opts.autoPrint)
         this.openingId = taskId || instanceId
         try {
           if (taskId && !row.isRead) {
@@ -376,7 +379,8 @@
             url:
               '/pages/oa/webview/index?instanceId=' +
               encodeURIComponent(instanceId) +
-              '&mode=view'
+              '&mode=view' +
+              (autoPrint ? '&autoPrint=1' : '')
           })
         } finally {
           this.openingId = ''

+ 12 - 3
mobile-h5/pages/oa/mine/index.vue

@@ -143,7 +143,10 @@
         ]
       },
       rowActions(row) {
-        const actions = [{ key: 'view', label: '查看', primary: true }]
+        const actions = [
+          { key: 'view', label: '查看', primary: true },
+          { key: 'print', label: '打印' }
+        ]
         const status = String((row && (row.instanceStatus || row.status)) || '')
         const appId = String((row && row.appId) || '').trim()
         if (COPY_RESTART_STATUSES.includes(status) && appId) {
@@ -181,9 +184,13 @@
           this.openCopyRestart(row)
           return
         }
+        if (act.key === 'print') {
+          this.openView(row, { autoPrint: true })
+          return
+        }
         this.openView(row)
       },
-      openView(row) {
+      openView(row, opts) {
         if (!row) return
         // mine 行是实例:Web 用 row.id;兼容 instanceId
         const instanceId = String(row.instanceId || row.id || '').trim()
@@ -191,12 +198,14 @@
           uni.showToast({ title: '缺少实例 ID', icon: 'none' })
           return
         }
+        const autoPrint = !!(opts && opts.autoPrint)
         stashOaDetailTitle(row.appName || row.title)
         uni.navigateTo({
           url:
             '/pages/oa/webview/index?instanceId=' +
             encodeURIComponent(instanceId) +
-            '&mode=view'
+            '&mode=view' +
+            (autoPrint ? '&autoPrint=1' : '')
         })
       },
       openCopyRestart(row) {

+ 2 - 1
mobile-h5/pages/oa/webview/index.vue

@@ -37,6 +37,7 @@ export default {
     const instanceId = String((query && query.instanceId) || '')
     const taskId = String((query && query.taskId) || '')
     const mode = String((query && query.mode) || '') || 'view'
+    const autoPrint = String((query && query.autoPrint) || '') === '1'
     const appId = String((query && query.appId) || '')
     const draftId = String((query && query.draftId) || '')
     const fromInstanceId = String((query && query.fromInstanceId) || '')
@@ -48,7 +49,7 @@ export default {
       if (cachedName) {
         uni.setNavigationBarTitle({ title: cachedName })
       }
-      src = buildOaDetailUrl({ instanceId, taskId, mode })
+      src = buildOaDetailUrl({ instanceId, taskId, mode, autoPrint })
     } else if (draftId || appId || fromInstanceId) {
       const cachedName = takeOaStartTitle()
       if (cachedName) {

+ 3 - 2
mobile-h5/utils/oa-h5.js

@@ -39,13 +39,14 @@ export function buildOaStartUrl({ appId, draftId, fromInstanceId } = {}) {
   return withQuery('/oa-start', params)
 }
 
-/** 审批详情页 URL(须带 token;mode 为 view | approve) */
-export function buildOaDetailUrl({ instanceId, taskId, mode } = {}) {
+/** 审批详情页 URL(须带 token;mode 为 view | approve;autoPrint=1 加载后自动调打印) */
+export function buildOaDetailUrl({ instanceId, taskId, mode, autoPrint } = {}) {
   const token = getToken()
   const params = {}
   if (instanceId) params.instanceId = instanceId
   if (taskId) params.taskId = taskId
   if (mode) params.mode = mode // view | approve
+  if (autoPrint) params.autoPrint = '1'
   if (token) params.token = token
   return withQuery('/oa-detail', params)
 }

+ 801 - 0
mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js

@@ -0,0 +1,801 @@
+/**
+ * OA 审批单移动端打印(S6)
+ * 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;优先 window.print(),无 jspdf 依赖。
+ * Spec: docs/superpowers/specs/2026-08-12-mobile-oa-task-centers-design.md §6.5
+ */
+import { formatDeptDisplay } from '../kit/oa-dept-value.js'
+import { formatPersonDisplay } from '../kit/oa-person-value.js'
+import { formatIdNameDisplay } from '../../utils/id-name-value.js'
+import {
+  formatReceiveAccountLine,
+  normalizeReceiveAccountModelValue
+} from '../../utils/receive-account-value.js'
+import { toRmbUpper } from '../../utils/rmb-upper.js'
+import { OA_STATUS_TEXT } from '../status-label.js'
+
+export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
+
+const UPLOAD_TYPES = new Set([
+  'upload',
+  'fcUpload',
+  'elUpload',
+  'el-upload',
+  'attachment',
+  'file',
+  'files'
+])
+
+const SKIP_RULE_TYPES = new Set([
+  'div',
+  'fcRow',
+  'fcCol',
+  'fcTable',
+  'fcTableGrid',
+  'col',
+  'row',
+  'group',
+  'tab',
+  'tabs',
+  'space',
+  'divider',
+  'html',
+  'vanCellGroup',
+  'leaveKit',
+  'confirmationKit',
+  'overtimeKit',
+  'resignationKit',
+  'tripKit'
+])
+
+function escapeHtml(s) {
+  return String(s ?? '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
+function statusText(s) {
+  return OA_STATUS_TEXT[s] || s || '-'
+}
+
+function formatDateOnly(createTime) {
+  const raw = String(createTime || '').trim()
+  if (!raw) return '-'
+  return raw.slice(0, 10)
+}
+
+function formatNow() {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+export function resolveStamp(status) {
+  const s = String(status || '').trim()
+  if (s === 'completed' || s === 'approved' || s === 'done') {
+    return { stampClass: 'ok', stampText: '已通过' }
+  }
+  if (s === 'rejected') return { stampClass: 'rej', stampText: '已拒绝' }
+  if (s === 'returned') return { stampClass: 'ret', stampText: '已打回' }
+  if (s === 'cancelled') return { stampClass: 'can', stampText: '已撤销' }
+  if (s === 'running' || s === 'pending') {
+    return { stampClass: 'run', stampText: '审批中' }
+  }
+  return { stampClass: 'run', stampText: '审批中' }
+}
+
+function ensurePlainObject(raw) {
+  if (raw == null) return {}
+  if (typeof raw === 'string') {
+    try {
+      const parsed = JSON.parse(raw)
+      return parsed && typeof parsed === 'object' ? parsed : {}
+    } catch {
+      return {}
+    }
+  }
+  if (typeof raw === 'object') return raw
+  return {}
+}
+
+function formatScalar(value, sep = '、') {
+  if (value == null || value === '') return '-'
+  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+    return String(value)
+  }
+  if (Array.isArray(value)) {
+    return value
+      .map((x) => formatScalar(x, sep))
+      .filter((x) => x && x !== '-')
+      .join(sep) || '-'
+  }
+  if (typeof value === 'object') {
+    if (value.name != null) return String(value.name)
+    if (value.label != null) return String(value.label)
+    try {
+      return JSON.stringify(value)
+    } catch {
+      return String(value)
+    }
+  }
+  return String(value)
+}
+
+function formatRegionLikeDisplay(value) {
+  if (value == null || value === '') return ''
+  if (Array.isArray(value)) {
+    return value
+      .map((x) => {
+        if (x && typeof x === 'object') return String(x.name || x.label || x.text || '').trim()
+        return String(x || '').trim()
+      })
+      .filter(Boolean)
+      .join(' / ')
+  }
+  if (typeof value === 'object') {
+    return String(value.name || value.label || value.text || '').trim()
+  }
+  return String(value).trim()
+}
+
+function fileNameFromUrl(url) {
+  const u = String(url || '').trim()
+  if (!u) return '未命名文件'
+  try {
+    const path = u.split('?')[0] || u
+    const last = path.split('/').pop() || ''
+    return decodeURIComponent(last) || '未命名文件'
+  } catch {
+    return '未命名文件'
+  }
+}
+
+function formatUploadNames(value) {
+  if (value == null || value === '') return []
+  if (Array.isArray(value)) {
+    const out = []
+    for (const item of value) {
+      if (typeof item === 'string') {
+        item
+          .split(',')
+          .map((x) => x.trim())
+          .filter(Boolean)
+          .forEach((p) => out.push(fileNameFromUrl(p)))
+      } else if (item && typeof item === 'object') {
+        const name = String(item.name || '').trim()
+        out.push(name || fileNameFromUrl(item.url || item.fileUrl || item.path))
+      }
+    }
+    return out
+  }
+  if (typeof value === 'string') {
+    return value
+      .split(',')
+      .map((x) => x.trim())
+      .filter(Boolean)
+      .map((p) => fileNameFromUrl(p))
+  }
+  if (typeof value === 'object') {
+    const name = String(value.name || '').trim()
+    return [name || fileNameFromUrl(value.url || value.fileUrl || value.path)]
+  }
+  return []
+}
+
+function formatMoney(value, showUpper) {
+  const n =
+    value == null || value === ''
+      ? null
+      : typeof value === 'number'
+        ? value
+        : Number(String(value).replace(/,/g, ''))
+  if (n == null || !Number.isFinite(n)) return '-'
+  const numText = n.toLocaleString('zh-CN', {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2
+  })
+  if (showUpper === false) return numText
+  const upper = toRmbUpper(n)
+  return upper ? `${numText}(${upper})` : numText
+}
+
+function formatReceiveAccounts(value) {
+  const list = normalizeReceiveAccountModelValue(value)
+  if (!list.length) return ''
+  return list.map(formatReceiveAccountLine).join('\n')
+}
+
+function formatTripLeg(leg, index) {
+  const transport = String(leg.transport || '').trim() || '-'
+  const tripType = String(leg.tripType || '').trim()
+  const fromCity = String(leg.fromCity || '').trim() || '-'
+  const toCity = String(leg.toCity || '').trim() || '-'
+  const startDate = String(leg.startDate || '').trim()
+  const startHalf = String(leg.startHalf || '').trim()
+  const endDate = String(leg.endDate || '').trim()
+  const endHalf = String(leg.endHalf || '').trim()
+  const duration = leg.duration
+  const durationText =
+    duration == null || duration === '' ? '-' : `${Number(duration)}天`
+  const route = `${fromCity} → ${toCity}`
+  const period =
+    startDate && endDate
+      ? `${startDate}${startHalf ? ` ${startHalf}` : ''} ~ ${endDate}${endHalf ? ` ${endHalf}` : ''}`
+      : '-'
+  const typePart = tripType ? ` · ${tripType}` : ''
+  return `行程${index + 1}:${transport}${typePart} · ${route} · ${period} · ${durationText}`
+}
+
+function formatTripItinerary(value) {
+  if (!Array.isArray(value) || !value.length) return '-'
+  const lines = value
+    .filter((leg) => leg && typeof leg === 'object')
+    .map((leg, idx) => formatTripLeg(leg, idx))
+  return lines.length ? lines.join('\n') : '-'
+}
+
+function isRangeFieldRule(rule) {
+  const type = String(rule?.type || '').trim()
+  const props = rule?.props && typeof rule.props === 'object' ? rule.props : {}
+  if (type === 'datePicker' || type === 'date' || type === 'dateRange') {
+    return /range$/i.test(String(props.type || type))
+  }
+  if (type === 'timePicker' || type === 'time' || type === 'timeRange') {
+    return props.isRange === true || type === 'timeRange'
+  }
+  return false
+}
+
+function normalizeRelatedIds(value) {
+  if (value == null || value === '') return []
+  if (Array.isArray(value)) {
+    return value.map((x) => String(x || '').trim()).filter(Boolean)
+  }
+  if (typeof value === 'string') {
+    const t = value.trim()
+    if (!t) return []
+    try {
+      const parsed = JSON.parse(t)
+      if (Array.isArray(parsed)) {
+        return parsed.map((x) => String(x || '').trim()).filter(Boolean)
+      }
+    } catch {
+      /* comma list */
+    }
+    return t
+      .split(',')
+      .map((x) => x.trim())
+      .filter(Boolean)
+  }
+  return []
+}
+
+function walkRules(rules, formData, out) {
+  if (!Array.isArray(rules)) return
+  for (const rule of rules) {
+    if (!rule || typeof rule !== 'object') continue
+    const type = String(rule.type || '').trim()
+    const children = rule.children || rule.props?.rule
+    if (Array.isArray(children) && children.length) {
+      walkRules(children, formData, out)
+    }
+    if (SKIP_RULE_TYPES.has(type)) continue
+    const field = String(rule.field || '').trim()
+    if (!field) continue
+    const label =
+      String(rule.title || rule.label || rule.props?.label || field).trim() || field
+    const value = formData?.[field]
+
+    if (type === 'relatedApproval') {
+      const ids = normalizeRelatedIds(value)
+      out.push({
+        label,
+        text: ids.length ? `已关联 ${ids.length} 条` : '-'
+      })
+      continue
+    }
+
+    if (type === 'dept' || type === 'ownerDept') {
+      out.push({ label, text: formatDeptDisplay(value) || '-' })
+      continue
+    }
+    if (type === 'person' || type === 'ownerPerson') {
+      out.push({ label, text: formatPersonDisplay(value) || '-' })
+      continue
+    }
+    if (type === 'post') {
+      out.push({ label, text: formatIdNameDisplay(value) || '-' })
+      continue
+    }
+    if (type === 'money') {
+      const showUpper = rule.props?.showUpper !== false
+      out.push({ label, text: formatMoney(value, showUpper) })
+      continue
+    }
+    if (type === 'receiveAccount') {
+      out.push({ label, text: formatReceiveAccounts(value) || '-' })
+      continue
+    }
+    if (type === 'region' || type === 'cascader') {
+      out.push({ label, text: formatRegionLikeDisplay(value) || '-' })
+      continue
+    }
+    if (type === 'tripItinerary') {
+      out.push({ label, text: formatTripItinerary(value) })
+      continue
+    }
+
+    const dragTag = String(rule._fc_drag_tag || '').trim()
+    if (
+      UPLOAD_TYPES.has(type) ||
+      UPLOAD_TYPES.has(dragTag) ||
+      String(rule.name || '')
+        .toLowerCase()
+        .includes('upload') ||
+      dragTag.toLowerCase().includes('upload')
+    ) {
+      const names = formatUploadNames(value)
+      out.push({
+        label,
+        urls: names.length ? names : undefined,
+        text: names.length ? undefined : '-'
+      })
+      continue
+    }
+
+    if (Array.isArray(value) && isRangeFieldRule(rule)) {
+      out.push({ label, text: formatScalar(value, '~') })
+      continue
+    }
+    out.push({ label, text: formatScalar(value) })
+  }
+}
+
+function buildFormRows(detail) {
+  const schema = ensurePlainObject(detail?.formSchemaJson)
+  const formData = ensurePlainObject(detail?.formDataJson || detail?.formData)
+  const rule = Array.isArray(schema?.rule)
+    ? schema.rule
+    : Array.isArray(detail?.formSchemaJson)
+      ? detail.formSchemaJson
+      : []
+  const out = []
+  walkRules(rule, formData, out)
+  if (out.length) return out
+  for (const [k, v] of Object.entries(formData)) {
+    const names = formatUploadNames(v)
+    if (names.length) {
+      out.push({ label: k, urls: names })
+      continue
+    }
+    out.push({ label: k, text: formatScalar(v) })
+  }
+  return out
+}
+
+function peopleDisplayNames(p) {
+  const people = Array.isArray(p?.people) ? p.people : []
+  const fromPeople = people
+    .map((x) => String(x?.nickName || x?.id || '').trim())
+    .filter(Boolean)
+  if (fromPeople.length) return fromPeople
+  const name = String(p?.assigneeUserName || p?.assigneeUserId || '').trim()
+  return name ? [name] : []
+}
+
+function formatProgressPersonLines(people) {
+  const list = Array.isArray(people) ? people : []
+  const out = []
+  const ACTED = new Set(['approved', 'rejected', 'returned', 'completed', 'transferred'])
+  for (const p of list) {
+    const name = String(p?.nickName || p?.id || '').trim()
+    if (!name) continue
+    const status = String(p.status || '').trim()
+    const comment = String(p.commentText || '').trim()
+    const ts = String(p.timestamp || '').trim()
+    const transferTo = String(p.transferToUserName || p.transferToUserId || '').trim()
+
+    if (status === 'done') {
+      out.push({ left: `${name} 无需处理`, right: '' })
+      continue
+    }
+    if (status === 'transferred') {
+      const mid = transferTo
+        ? comment
+          ? `转交给${transferTo} ${comment}`
+          : `转交给${transferTo}`
+        : comment || '已转交'
+      out.push({ left: `${name} ${mid}`, right: ts })
+      continue
+    }
+    if (status === 'pending' || status === 'running' || status === 'not_reached' || !status) {
+      if (!status && (comment || ts)) {
+        out.push({ left: `${name} ${comment || '已同意'}`, right: ts })
+        continue
+      }
+      out.push({ left: `${name} 待处理`, right: '' })
+      continue
+    }
+    if (ACTED.has(status) || comment || ts) {
+      const mid =
+        comment ||
+        (status === 'approved'
+          ? '已同意'
+          : status === 'rejected'
+            ? '已拒绝'
+            : status === 'returned'
+              ? '已打回'
+              : status === 'completed'
+                ? '已完成'
+                : statusText(status))
+      out.push({ left: `${name} ${mid}`, right: ts })
+      continue
+    }
+    out.push({ left: `${name} 待处理`, right: '' })
+  }
+  return out
+}
+
+function isCcProgressItem(p) {
+  const taskType = String(p?.taskType || p?.task_type || '').trim()
+  const signMode = String(p?.signMode || p?.sign_mode || '').trim()
+  const title = String(p?.title || p?.nodeName || '').trim()
+  return (
+    taskType === 'cc' ||
+    signMode === 'cc' ||
+    title === '抄送人' ||
+    /^抄送/.test(title)
+  )
+}
+
+export function buildFlowItems(detail) {
+  const list = Array.isArray(detail?.progress) ? detail.progress : []
+  const items = []
+  let ccBuf = null
+  const flushCc = () => {
+    if (!ccBuf) return
+    const uniq = Array.from(new Set(ccBuf.names.map((n) => n.trim()).filter(Boolean)))
+    items.push({
+      left: uniq.length ? `抄送: ${uniq.join(', ')}` : '抄送:',
+      right: ccBuf.ts || ''
+    })
+    ccBuf = null
+  }
+
+  for (const p of list) {
+    const kind = String(p.kind || '')
+    const title = String(p.title || '-').trim()
+    const names = peopleDisplayNames(p)
+    const name = names.join(', ')
+    const ts = String(p.timestamp || '').trim()
+    const comment = String(p.commentText || '').trim()
+    const st = statusText(String(p.status || ''))
+    const signMode = String(p.signMode || p.sign_mode || '')
+    const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
+
+    if (isCcProgressItem(p)) {
+      if (!ccBuf) ccBuf = { names: [], ts }
+      if (names.length) ccBuf.names.push(...names)
+      if (ts) ccBuf.ts = ts
+      continue
+    }
+    flushCc()
+
+    if (kind === 'submit') {
+      items.push({
+        left: name ? `【${title}】 ${name} ${st}` : `【${title}】 ${st}`,
+        right: ts,
+        comment: comment || undefined
+      })
+      continue
+    }
+    if (kind === 'terminal') {
+      if (String(p.status || '') === 'returned' || title === '已打回') continue
+      items.push({ left: title || st, right: ts })
+      continue
+    }
+
+    const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
+    const personLines = formatProgressPersonLines(Array.isArray(p?.people) ? p.people : [])
+    if (personLines.length) {
+      items.push({
+        left: `${head} ${st}`,
+        right: '',
+        personLines
+      })
+      continue
+    }
+    items.push({
+      left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
+      right: ts,
+      comment: comment || undefined
+    })
+  }
+  flushCc()
+
+  const hasCcLine = items.some((it) => it.left.startsWith('抄送'))
+  if (!hasCcLine) {
+    const tasks = Array.isArray(detail?.tasks) ? detail.tasks : []
+    const ccTasks = tasks.filter((t) => {
+      const tt = String(t?.taskType || t?.task_type || '').trim()
+      const sm = String(t?.signMode || t?.sign_mode || '').trim()
+      return tt === 'cc' || sm === 'cc'
+    })
+    if (ccTasks.length) {
+      const names = ccTasks
+        .map((t) =>
+          String(t?.assigneeUserName || t?.assignee_user_name || t?.assigneeUserId || '').trim()
+        )
+        .filter(Boolean)
+      const uniq = Array.from(new Set(names))
+      let ts = ''
+      for (const t of ccTasks) {
+        const tTs = String(t?.updateTime || t?.createTime || '').trim()
+        if (tTs && (!ts || tTs > ts)) ts = tTs
+      }
+      if (uniq.length) items.push({ left: `抄送: ${uniq.join(', ')}`, right: ts })
+    }
+  }
+  return items
+}
+
+export function buildOaPrintModel(detail, opts = {}) {
+  const { stampClass, stampText } = resolveStamp(detail?.status)
+  const appName = String(detail?.appName || '').trim()
+  const titleFallback = String(detail?.title || '').trim()
+  const title = appName || titleFallback || '审批单'
+  const printerName = String(opts.printerName || '').trim() || '-'
+  const printTime = opts.printTime || formatNow()
+  const headerRows = [
+    { label: '审批编号', text: String(detail?.id || '-').trim() || '-' },
+    {
+      label: '创建人',
+      text:
+        String(detail?.initiatorUserName || detail?.initiatorUserId || '-').trim() || '-'
+    },
+    {
+      label: '创建人部门',
+      text:
+        String(detail?.initiatorDeptName || detail?.initiatorDept || '-').trim() || '-'
+    }
+  ]
+  return {
+    title,
+    companyName: OA_PRINT_COMPANY_NAME,
+    createDate: formatDateOnly(detail?.createTime),
+    stampClass,
+    stampText,
+    rows: [...headerRows, ...buildFormRows(detail)],
+    flowItems: buildFlowItems(detail),
+    printTime,
+    printerName,
+    watermarkName: printerName === '-' ? '审批' : printerName
+  }
+}
+
+function cellInnerHtml(row) {
+  if (Array.isArray(row.urls) && row.urls.length) {
+    return row.urls.map((u) => `<div class="oa-print-url">${escapeHtml(u)}</div>`).join('')
+  }
+  const raw = String(row.text != null ? row.text : '-')
+  const html = raw
+    .split('\n')
+    .map((line) => escapeHtml(line))
+    .join('<br/>')
+  return `<span class="oa-print-text">${html}</span>`
+}
+
+function flowCellHtml(f) {
+  let html = `<div class="oa-print-flow__row"><div class="oa-print-flow__left">${escapeHtml(f.left)}</div>`
+  if (f.right) {
+    html += `<div class="oa-print-flow__right">${escapeHtml(f.right)}</div>`
+  }
+  html += '</div>'
+  if (Array.isArray(f.personLines) && f.personLines.length) {
+    html += '<div class="oa-print-flow__person-lines">'
+    for (const line of f.personLines) {
+      html += `<div class="oa-print-flow__row oa-print-flow__person-line"><div class="oa-print-flow__left">${escapeHtml(line.left)}</div>`
+      if (line.right) {
+        html += `<div class="oa-print-flow__right">${escapeHtml(line.right)}</div>`
+      }
+      html += '</div>'
+    }
+    html += '</div>'
+  } else if (f.comment) {
+    html += `<div class="oa-print-flow__comment">意见:${escapeHtml(f.comment)}</div>`
+  }
+  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>
+  * { box-sizing: border-box; }
+  body { margin: 0; background: #fff; color: #1f2329;
+    font-family: "PingFang SC","Microsoft YaHei","Helvetica Neue",Arial,sans-serif; }
+  .oa-print-page { position: relative; max-width: 794px; margin: 0 auto;
+    min-height: 100vh; padding: 32px 28px 40px; overflow: hidden; }
+  .oa-print-wm { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
+  .oa-print-wm__item { position: absolute; font-size: 14px; color: rgba(0,0,0,.045);
+    white-space: nowrap; transform: rotate(-30deg); letter-spacing: 2px; }
+  .oa-print-inner { position: relative; z-index: 1; }
+  .oa-print-title { margin: 0 0 12px; text-align: center; font-size: 22px;
+    font-weight: 700; letter-spacing: 2px; line-height: 1.3; }
+  .oa-print-meta { display: flex; flex-wrap: wrap; gap: 16px 32px; margin-bottom: 12px;
+    font-size: 12px; line-height: 1.5; }
+  .oa-print-table-wrap { position: relative; }
+  .oa-print-table { width: 100%; border-collapse: separate; border-spacing: 0;
+    table-layout: fixed; border-top: 1px solid #333; border-left: 1px solid #333; font-size: 13px; }
+  .oa-print-table th, .oa-print-table td { border-right: 1px solid #333; border-bottom: 1px solid #333;
+    padding: 8px 10px; vertical-align: top; word-break: break-word; }
+  .oa-print-table th { width: 28%; background: #fafafa; font-weight: 600; text-align: left; }
+  .oa-print-text { white-space: pre-wrap; }
+  .oa-print-url { line-height: 1.45; }
+  .oa-print-flow__row { display: flex; justify-content: space-between; gap: 12px; }
+  .oa-print-flow__left { flex: 1; min-width: 0; }
+  .oa-print-flow__right { flex: 0 0 auto; color: #646464; white-space: nowrap; }
+  .oa-print-flow__person-lines { margin-top: 4px; padding-left: 8px; }
+  .oa-print-flow__person-line { margin-top: 2px; font-size: 12px; }
+  .oa-print-flow__comment { margin-top: 4px; color: #646464; font-size: 12px; }
+  .oa-stamp { position: absolute; right: 12px; top: -8px; width: 88px; height: 88px;
+    border-radius: 50%; border: 3px solid; display: flex; align-items: center;
+    justify-content: center; font-weight: 700; font-size: 16px; transform: rotate(-12deg);
+    opacity: .85; z-index: 2; background: rgba(255,255,255,.55); }
+  .oa-stamp.ok { color: #059669; border-color: #059669; }
+  .oa-stamp.run { color: #d97706; border-color: #d97706; }
+  .oa-stamp.rej, .oa-stamp.ret, .oa-stamp.can { color: #dc2626; border-color: #dc2626; }
+  .oa-stamp.is-chars3 { font-size: 14px; }
+  .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; }
+  }
+</style>
+</head>
+<body>
+  <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>
+      <div class="oa-print-meta">
+        <span>${escapeHtml(model.companyName)}</span>
+        <span>创建时间:${escapeHtml(model.createDate)}</span>
+      </div>
+      <div class="oa-print-table-wrap">
+        <div class="oa-stamp ${escapeHtml(model.stampClass)}${stampChars3}">${escapeHtml(model.stampText)}</div>
+        <table class="oa-print-table">
+          <tbody>${rowsHtml}${flowHtml}</tbody>
+        </table>
+      </div>
+      <div class="oa-print-footer">
+        <span>打印时间:${escapeHtml(model.printTime)}</span>
+        <span>打印人:${escapeHtml(model.printerName)}</span>
+      </div>
+    </div>
+  </div>
+</body>
+</html>`
+}
+
+function isWeChatMiniWebView() {
+  try {
+    if (typeof window !== 'undefined' && window.__wxjs_environment === 'miniprogram') {
+      return true
+    }
+  } catch {
+    /* ignore */
+  }
+  try {
+    if (typeof wx !== 'undefined' && wx.miniProgram) return true
+  } catch {
+    /* ignore */
+  }
+  try {
+    const ua = String(navigator?.userAgent || '')
+    if (/miniProgram/i.test(ua)) return true
+  } catch {
+    /* ignore */
+  }
+  return false
+}
+
+/**
+ * 打开可打印 HTML 并调用 window.print()。
+ * @returns {Promise<{ ok: boolean, message?: string }>}
+ */
+export async function exportOaApprovalPdf(detail, opts = {}) {
+  if (!detail) {
+    return { ok: false, message: '暂无审批详情,无法打印' }
+  }
+
+  const model = buildOaPrintModel(detail, {
+    printerName: opts.printerName || ''
+  })
+  const html = buildPrintHtml(model)
+
+  // 微信小程序 web-view 内系统打印能力普遍不可用
+  if (isWeChatMiniWebView()) {
+    return {
+      ok: false,
+      message: '微信内暂不支持系统打印,请用手机浏览器打开或到电脑端导出'
+    }
+  }
+
+  let frame = null
+  try {
+    frame = document.createElement('iframe')
+    frame.setAttribute('title', 'oa-print')
+    frame.style.cssText =
+      'position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none;'
+    document.body.appendChild(frame)
+
+    const doc = frame.contentDocument || frame.contentWindow?.document
+    if (!doc || !frame.contentWindow) {
+      throw new Error('print frame unavailable')
+    }
+    doc.open()
+    doc.write(html)
+    doc.close()
+
+    await new Promise((r) => setTimeout(r, 80))
+    const win = frame.contentWindow
+    if (typeof win.print !== 'function') {
+      throw new Error('print not supported')
+    }
+    win.focus()
+    win.print()
+    return { ok: true }
+  } catch (e) {
+    return {
+      ok: false,
+      message:
+        (e && e.message) ||
+        '当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
+    }
+  } finally {
+    if (frame) {
+      setTimeout(() => {
+        try {
+          frame.remove()
+        } catch {
+          /* ignore */
+        }
+      }, 1500)
+    }
+  }
+}

+ 55 - 0
mobile-oa-h5/src/views/OaDetail.vue

@@ -34,6 +34,14 @@
         <van-button class="oa-detail__btn oa-detail__btn--close" @click="onClose">
           关闭
         </van-button>
+        <van-button
+          class="oa-detail__btn oa-detail__btn--ghost oa-detail__btn--print"
+          :loading="printing"
+          :disabled="acting || printing"
+          @click="onPrint"
+        >
+          打印
+        </van-button>
         <template v-if="canApprove">
           <van-button
             class="oa-detail__btn oa-detail__btn--ghost"
@@ -204,6 +212,7 @@ import {
 } from '../api/oa.js'
 import { prepareMobileFormSchema } from '../oa/schema-prep.js'
 import { statusText, statusTone } from '../oa/status-label.js'
+import { exportOaApprovalPdf } from '../oa/print/exportOaApprovalPdf.js'
 import OaProgressTimeline from '../oa/components/OaProgressTimeline.vue'
 import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
 import OaPersonSelect from '../oa/components/OaPersonSelect.vue'
@@ -212,11 +221,14 @@ const route = useRoute()
 const router = useRouter()
 const loading = ref(true)
 const acting = ref(false)
+const printing = ref(false)
 const detail = ref(null)
 const formData = ref({})
 const formRule = ref([])
 const formOption = ref({})
 const currentUserId = ref('')
+const currentUserName = ref('')
+const autoPrinted = ref(false)
 
 const commentVisible = ref(false)
 const commentKind = ref('approve') // approve | reject | return
@@ -232,6 +244,10 @@ const transferComment = ref('')
 
 const instanceId = computed(() => String(route.query.instanceId || '').trim())
 const queryTaskId = computed(() => String(route.query.taskId || '').trim())
+const wantAutoPrint = computed(() => {
+  const v = String(route.query.autoPrint || '').trim()
+  return v === '1' || v === 'true'
+})
 const mode = computed(() => {
   const m = String(route.query.mode || 'view').trim()
   return m || 'view'
@@ -357,6 +373,30 @@ function onClose() {
   closeToMini({ action: 'oa-detail-close' })
 }
 
+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 ||
+          '当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
+      )
+    }
+  } catch (e) {
+    showToast(
+      e?.message || '当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
+    )
+  } finally {
+    printing.value = false
+  }
+}
+
 function parseFormData(d) {
   if (d?.formData && typeof d.formData === 'object') return { ...d.formData }
   if (d?.formDataJson && typeof d.formDataJson === 'object') return { ...d.formDataJson }
@@ -545,6 +585,9 @@ async function bootstrap() {
       fetchOaInstanceDetail(id)
     ])
     currentUserId.value = String(me?.id || me?.userId || '').trim()
+    currentUserName.value = String(
+      me?.nickName || me?.userName || me?.name || ''
+    ).trim()
     detail.value = d
     try {
       document.title = d?.title || d?.appName || '审批详情'
@@ -568,6 +611,14 @@ async function bootstrap() {
   } finally {
     loading.value = false
   }
+
+  if (wantAutoPrint.value && detail.value && !autoPrinted.value) {
+    autoPrinted.value = true
+    // 等首屏渲染后再调打印,避免遮挡 loading
+    setTimeout(() => {
+      void onPrint()
+    }, 320)
+  }
 }
 
 onMounted(() => {
@@ -679,6 +730,10 @@ onMounted(() => {
 .oa-detail__btn--close:active {
   opacity: 0.85;
 }
+.oa-detail__btn--print {
+  flex: 0 0 auto;
+  min-width: 64px;
+}
 .oa-detail__btn--ghost {
   border: 1px solid #e2e8f0 !important;
   background: #fff !important;