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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-12 16:40:08 +08:00
1 parent 915253eefb
commit cbef68199b
7 files changed
+889 -9

No files matched your search

@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
@@ -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
View File
@@ -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;