446 lines
13 KiB
TypeScript
446 lines
13 KiB
TypeScript
/**
|
|
* OA 审批单钉钉风格 PDF 导出
|
|
* Spec: docs/superpowers/specs/2026-07-15-oa-approval-print-pdf-design.md
|
|
*/
|
|
import { createApp, h, nextTick } from 'vue'
|
|
import html2canvas from 'html2canvas'
|
|
import { jsPDF } from 'jspdf'
|
|
|
|
export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
|
|
|
|
export type OaStampClass = 'ok' | 'run' | 'rej' | 'ret' | 'can'
|
|
|
|
export type OaPrintFieldRow = {
|
|
label: string
|
|
/** 普通文本(可含换行) */
|
|
text?: string
|
|
/** 附件 / 链接列表 */
|
|
urls?: string[]
|
|
}
|
|
|
|
export type OaPrintFlowItem = {
|
|
left: string
|
|
right: string
|
|
comment?: string
|
|
}
|
|
|
|
export type OaPrintModel = {
|
|
title: string
|
|
companyName: string
|
|
createDate: string
|
|
stampClass: OaStampClass
|
|
stampText: string
|
|
rows: OaPrintFieldRow[]
|
|
flowItems: OaPrintFlowItem[]
|
|
printTime: string
|
|
printerName: string
|
|
watermarkName: string
|
|
}
|
|
|
|
const STATUS_TEXT: Record<string, string> = {
|
|
pending: '待处理',
|
|
running: '进行中',
|
|
approved: '已同意',
|
|
rejected: '已拒绝',
|
|
completed: '已完成',
|
|
cancelled: '已撤销',
|
|
done: '已结束',
|
|
submitted: '已提交',
|
|
not_reached: '未到达',
|
|
returned: '已打回'
|
|
}
|
|
|
|
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'
|
|
])
|
|
|
|
export function resolveStamp(status: string | undefined | null): {
|
|
stampClass: OaStampClass
|
|
stampText: string
|
|
} {
|
|
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 statusText(s: string) {
|
|
return STATUS_TEXT[s] || s || '-'
|
|
}
|
|
|
|
function formatDateOnly(createTime: string | undefined | null): string {
|
|
const raw = String(createTime || '').trim()
|
|
if (!raw) return '-'
|
|
return raw.slice(0, 10)
|
|
}
|
|
|
|
function formatNow(): string {
|
|
const d = new Date()
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
}
|
|
|
|
function isHttpUrl(s: string) {
|
|
return /^https?:\/\//i.test(s)
|
|
}
|
|
|
|
function extractUrls(value: unknown): string[] {
|
|
if (value == null || value === '') return []
|
|
if (Array.isArray(value)) {
|
|
const out: string[] = []
|
|
for (const item of value) {
|
|
if (typeof item === 'string') {
|
|
const parts = item
|
|
.split(',')
|
|
.map((x) => x.trim())
|
|
.filter(Boolean)
|
|
out.push(...parts)
|
|
} else if (item && typeof item === 'object') {
|
|
const u = (item as any).url || (item as any).fileUrl || (item as any).path
|
|
if (u) out.push(String(u))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value
|
|
.split(',')
|
|
.map((x) => x.trim())
|
|
.filter(Boolean)
|
|
}
|
|
if (typeof value === 'object') {
|
|
const u = (value as any).url || (value as any).fileUrl
|
|
return u ? [String(u)] : []
|
|
}
|
|
return []
|
|
}
|
|
|
|
function formatScalar(value: unknown): string {
|
|
if (value == null || value === '') return '-'
|
|
if (typeof value === 'boolean') return value ? '是' : '否'
|
|
if (typeof value === 'number') return String(value)
|
|
if (typeof value === 'string') return value.trim() || '-'
|
|
if (Array.isArray(value)) {
|
|
if (!value.length) return '-'
|
|
return value
|
|
.map((v) => formatScalar(v))
|
|
.filter((s) => s && s !== '-')
|
|
.join('、')
|
|
}
|
|
if (typeof value === 'object') {
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
return String(value)
|
|
}
|
|
|
|
function walkRules(rules: any[], formData: Record<string, any>, out: OaPrintFieldRow[]) {
|
|
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 (UPLOAD_TYPES.has(type) || String(rule.name || '').toLowerCase().includes('upload')) {
|
|
const urls = extractUrls(value)
|
|
out.push({ label, urls: urls.length ? urls : [], text: urls.length ? undefined : '-' })
|
|
continue
|
|
}
|
|
// 字符串里像 URL 列表的也拆开展示
|
|
if (typeof value === 'string' && value.includes('http') && value.includes(',')) {
|
|
const urls = extractUrls(value).filter(isHttpUrl)
|
|
if (urls.length >= 2) {
|
|
out.push({ label, urls })
|
|
continue
|
|
}
|
|
}
|
|
const urls = extractUrls(value).filter(isHttpUrl)
|
|
if (urls.length && (Array.isArray(value) || UPLOAD_TYPES.has(type))) {
|
|
out.push({ label, urls })
|
|
continue
|
|
}
|
|
out.push({ label, text: formatScalar(value) })
|
|
}
|
|
}
|
|
|
|
function buildFormRows(detail: any): OaPrintFieldRow[] {
|
|
const schema = detail?.formSchemaJson
|
|
const formData = (detail?.formDataJson || {}) as Record<string, any>
|
|
const rule = Array.isArray(schema?.rule)
|
|
? schema.rule
|
|
: Array.isArray(schema)
|
|
? schema
|
|
: []
|
|
const out: OaPrintFieldRow[] = []
|
|
walkRules(rule, formData, out)
|
|
if (out.length) return out
|
|
// schema 缺失:兜底平铺 formData
|
|
for (const [k, v] of Object.entries(formData)) {
|
|
const urls = extractUrls(v).filter(isHttpUrl)
|
|
if (urls.length) out.push({ label: k, urls })
|
|
else out.push({ label: k, text: formatScalar(v) })
|
|
}
|
|
return out
|
|
}
|
|
|
|
function buildFlowItems(detail: any): OaPrintFlowItem[] {
|
|
const list = Array.isArray(detail?.progress) ? detail.progress : []
|
|
const items: OaPrintFlowItem[] = []
|
|
|
|
// 聚合连续抄送任务,接近钉钉「抄送A,B,C」
|
|
let ccBuf: { names: string[]; ts: string } | null = null
|
|
const flushCc = () => {
|
|
if (!ccBuf) return
|
|
items.push({
|
|
left: `抄送${ccBuf.names.join(',')}`,
|
|
right: ccBuf.ts || ''
|
|
})
|
|
ccBuf = null
|
|
}
|
|
|
|
const peopleNames = (p: any): string[] => {
|
|
const people = Array.isArray(p?.people) ? p.people : []
|
|
const fromPeople = people
|
|
.map((x: any) => {
|
|
const n = String(x?.nickName || x?.id || '').trim()
|
|
if (!n) return ''
|
|
const st = String(x?.status || '')
|
|
if (st === 'approved') return `${n}(已同意)`
|
|
if (st === 'rejected') return `${n}(已拒绝)`
|
|
if (st === 'returned') return `${n}(已打回)`
|
|
if (st === 'pending' || st === 'running') return `${n}(待处理)`
|
|
if (st === 'done') return `${n}(无需处理)`
|
|
return n
|
|
})
|
|
.filter(Boolean)
|
|
if (fromPeople.length) return fromPeople
|
|
const name = String(p.assigneeUserName || p.assigneeUserId || '').trim()
|
|
return name ? [name] : []
|
|
}
|
|
|
|
for (const p of list) {
|
|
const kind = String(p.kind || '')
|
|
const taskType = String(p.taskType || '')
|
|
const title = String(p.title || '-').trim()
|
|
const names = peopleNames(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 || '')
|
|
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
|
|
|
|
if (taskType === 'cc') {
|
|
if (!ccBuf) ccBuf = { names: [], ts }
|
|
if (names.length) ccBuf.names.push(...names.map((n) => n.replace(/\(.*?\)\s*$/, '')))
|
|
else if (name) ccBuf.names.push(name)
|
|
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') {
|
|
items.push({
|
|
left: title || st,
|
|
right: ts
|
|
})
|
|
continue
|
|
}
|
|
if (kind === 'pending_node') {
|
|
items.push({
|
|
left: `【${title}】 ${st}`,
|
|
right: ts
|
|
})
|
|
continue
|
|
}
|
|
// task(或签/会签同节点多人已合并)
|
|
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
|
|
items.push({
|
|
left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
|
|
right: ts,
|
|
comment: comment || undefined
|
|
})
|
|
}
|
|
flushCc()
|
|
return items
|
|
}
|
|
|
|
export function buildOaPrintModel(
|
|
detail: any,
|
|
opts: { printerName: string; printTime?: string }
|
|
): OaPrintModel {
|
|
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: OaPrintFieldRow[] = [
|
|
{ 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
|
|
}
|
|
}
|
|
|
|
export function buildOaPrintFilename(detail: any): string {
|
|
const initiator =
|
|
String(detail?.initiatorUserName || '').trim() ||
|
|
String(detail?.initiatorUserId || '').trim() ||
|
|
'用户'
|
|
const appName =
|
|
String(detail?.appName || '').trim() ||
|
|
String(detail?.title || '').trim() ||
|
|
'审批单'
|
|
const safe = (s: string) => s.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80)
|
|
return `${safe(initiator)}提交的${safe(appName)}.pdf`
|
|
}
|
|
|
|
async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) {
|
|
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
|
|
const pageWidth = pdf.internal.pageSize.getWidth()
|
|
const pageHeight = pdf.internal.pageSize.getHeight()
|
|
const imgWidth = pageWidth
|
|
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
|
|
|
const pageData = canvas.toDataURL('image/jpeg', 0.95)
|
|
let heightLeft = imgHeight
|
|
let position = 0
|
|
let pageIndex = 0
|
|
|
|
while (heightLeft > 0) {
|
|
if (pageIndex > 0) pdf.addPage()
|
|
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
|
|
// 页码
|
|
const totalHint = Math.max(1, Math.ceil(imgHeight / pageHeight))
|
|
pdf.setFontSize(10)
|
|
pdf.setTextColor(100)
|
|
pdf.text(`第 ${pageIndex + 1} 页`, pageWidth / 2, pageHeight - 8, { align: 'center' })
|
|
void totalHint
|
|
heightLeft -= pageHeight
|
|
position -= pageHeight
|
|
pageIndex += 1
|
|
// 安全上限
|
|
if (pageIndex > 40) break
|
|
}
|
|
|
|
pdf.save(filename)
|
|
}
|
|
|
|
/**
|
|
* 根据实例详情导出 PDF(不弹系统打印框)
|
|
*/
|
|
export async function exportOaApprovalPdf(
|
|
detail: any,
|
|
opts: { printerName: string }
|
|
): Promise<void> {
|
|
const model = buildOaPrintModel(detail, { printerName: opts.printerName })
|
|
const filename = buildOaPrintFilename(detail)
|
|
const { default: OaApprovalPrintSheet } = await import(
|
|
'@/components/oa-approval/OaApprovalPrintSheet.vue'
|
|
)
|
|
|
|
const host = document.createElement('div')
|
|
host.setAttribute('data-oa-print-host', '1')
|
|
host.style.cssText =
|
|
'position:fixed;left:-100000px;top:0;width:794px;z-index:-1;opacity:1;pointer-events:none;'
|
|
document.body.appendChild(host)
|
|
|
|
const app = createApp({
|
|
render: () => h(OaApprovalPrintSheet, { model })
|
|
})
|
|
app.mount(host)
|
|
|
|
try {
|
|
await nextTick()
|
|
// 给浏览器一帧完成布局 / 字体
|
|
await new Promise((r) => requestAnimationFrame(() => r(undefined)))
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
|
|
const el = host.querySelector('.oa-print-page') as HTMLElement | null
|
|
if (!el) throw new Error('print sheet missing')
|
|
|
|
const canvas = await html2canvas(el, {
|
|
scale: 2,
|
|
useCORS: true,
|
|
allowTaint: true,
|
|
backgroundColor: '#ffffff',
|
|
logging: false,
|
|
windowWidth: el.scrollWidth,
|
|
windowHeight: el.scrollHeight
|
|
})
|
|
await canvasToPdf(canvas, filename)
|
|
} finally {
|
|
app.unmount()
|
|
host.remove()
|
|
}
|
|
}
|