From cbef68199b4bb607254164ab8860caa378dfec24 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 12 Aug 2026 16:40:08 +0800 Subject: [PATCH] feat(oa): add mobile approval PDF print for mine and cc Co-authored-by: Cursor --- .../2026-08-12-mobile-oa-task-centers.md | 9 + mobile-h5/pages/oa/cc/index.vue | 10 +- mobile-h5/pages/oa/mine/index.vue | 15 +- mobile-h5/pages/oa/webview/index.vue | 3 +- mobile-h5/utils/oa-h5.js | 5 +- .../src/oa/print/exportOaApprovalPdf.js | 801 ++++++++++++++++++ mobile-oa-h5/src/views/OaDetail.vue | 55 ++ 7 files changed, 889 insertions(+), 9 deletions(-) create mode 100644 mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js diff --git a/docs/superpowers/plans/2026-08-12-mobile-oa-task-centers.md b/docs/superpowers/plans/2026-08-12-mobile-oa-task-centers.md index 4f68f044..ba5f8d81 100644 --- a/docs/superpowers/plans/2026-08-12-mobile-oa-task-centers.md +++ b/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 详情应能调起打印对话框;微信内应看到明确失败提示。 diff --git a/mobile-h5/pages/oa/cc/index.vue b/mobile-h5/pages/oa/cc/index.vue index a501be3d..977cf44b 100644 --- a/mobile-h5/pages/oa/cc/index.vue +++ b/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 = '' diff --git a/mobile-h5/pages/oa/mine/index.vue b/mobile-h5/pages/oa/mine/index.vue index 1fcd2820..1dba0a98 100644 --- a/mobile-h5/pages/oa/mine/index.vue +++ b/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) { diff --git a/mobile-h5/pages/oa/webview/index.vue b/mobile-h5/pages/oa/webview/index.vue index d2b658a2..c38554c8 100644 --- a/mobile-h5/pages/oa/webview/index.vue +++ b/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) { diff --git a/mobile-h5/utils/oa-h5.js b/mobile-h5/utils/oa-h5.js index d01f0256..11d1a451 100644 --- a/mobile-h5/utils/oa-h5.js +++ b/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) } diff --git a/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js b/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js new file mode 100644 index 00000000..479a20c8 --- /dev/null +++ b/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, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +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) => `
${escapeHtml(u)}
`).join('') + } + const raw = String(row.text != null ? row.text : '-') + const html = raw + .split('\n') + .map((line) => escapeHtml(line)) + .join('
') + return `${html}` +} + +function flowCellHtml(f) { + let html = `
${escapeHtml(f.left)}
` + if (f.right) { + html += `
${escapeHtml(f.right)}
` + } + html += '
' + if (Array.isArray(f.personLines) && f.personLines.length) { + html += '
' + for (const line of f.personLines) { + html += `
${escapeHtml(line.left)}
` + if (line.right) { + html += `
${escapeHtml(line.right)}
` + } + html += '
' + } + html += '
' + } else if (f.comment) { + html += `
意见:${escapeHtml(f.comment)}
` + } + 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 `${escapeHtml(model.watermarkName)} ${escapeHtml(model.watermarkName)}` + }).join('') + + let rowsHtml = '' + for (const row of model.rows) { + rowsHtml += `${escapeHtml(row.label)}${cellInnerHtml(row)}` + } + + let flowHtml = '' + if (model.flowItems.length) { + model.flowItems.forEach((f, fi) => { + flowHtml += '' + if (fi === 0) { + flowHtml += `审批流程` + } + flowHtml += `${flowCellHtml(f)}` + }) + } else { + flowHtml = + '审批流程-' + } + + return ` + + + + +${escapeHtml(model.title)} + + + +
+ +
+

${escapeHtml(model.title)}

+
+ ${escapeHtml(model.companyName)} + 创建时间:${escapeHtml(model.createDate)} +
+
+
${escapeHtml(model.stampText)}
+ + ${rowsHtml}${flowHtml} +
+
+ +
+
+ +` +} + +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) + } + } +} diff --git a/mobile-oa-h5/src/views/OaDetail.vue b/mobile-oa-h5/src/views/OaDetail.vue index d098a9c3..38617762 100644 --- a/mobile-oa-h5/src/views/OaDetail.vue +++ b/mobile-oa-h5/src/views/OaDetail.vue @@ -34,6 +34,14 @@ 关闭 + + 打印 +