提交
This commit is contained in:
1 parent
f6efca8fdf
commit
93a83d260f
45 files changed
+1211
-393
No files matched your search
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
for (const p of list) {
|
||||
const kind = String(p.kind || '')
|
||||
const taskType = String(p.taskType || '')
|
||||
const title = String(p.title || '-').trim()
|
||||
const name = String(p.assigneeUserName || p.assigneeUserId || '').trim()
|
||||
const ts = String(p.timestamp || '').trim()
|
||||
const comment = String(p.commentText || '').trim()
|
||||
const st = statusText(String(p.status || ''))
|
||||
|
||||
if (taskType === 'cc') {
|
||||
if (!ccBuf) ccBuf = { names: [], ts }
|
||||
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
|
||||
items.push({
|
||||
left: name ? `【${title}】 ${name} ${st}` : `【${title}】 ${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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user