|
|
@@ -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, '>')
|
|
|
+ .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) => `<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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|