This commit is contained in:
zlwu committed 2026-07-16 11:45:34 +08:00
1 parent f6efca8fdf
commit 93a83d260f
45 files changed
+1211 -393

No files matched your search

+420
View File
@@ -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()
}
}