Files
zkxw/web-admin/src/utils/oa-approval-print.ts
T
zlwu b220298f89 chore: 清理历史资料并规范工程目录与开发规则
为减少仓库噪声和重复规则,整理当前开发入口并移除已废弃资料。

- 精简根 AGENTS,补充四个应用规则与 README,统一规范入口
- 归整部署文件、SOP 原稿和参考资源,修正迁移后的脚本路径
- 取消虚拟环境、工具状态、业务导出及备份的跟踪,本机所需原件保留
- 移除停用的 graphify 配置、脚本与产物
- 删除历史业务文档、执行计划、OpenSpec 业务变更和移动 UI 原型
- 清理失效引用,历史细胞产品导入改为必须显式指定 --file

验证:git diff --cached --check、OpenSpec 严格校验、导入参数隔离检查 3/3 通过;
迁移文件完整性和现行链接已核对。未执行部署、业务数据导入或服务启停。
2026-10-29 23:08:29 +08:00

535 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OA 审批单钉钉风格 PDF 导出
*/
import { createApp, h, nextTick } from 'vue'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import {
assembleRelatedPrintBlocks,
normalizeRelatedIdsFromFormValue,
type RelatedPrintBlock
} from '@/utils/oa-approval-print-related'
import { formatDeptDisplay } from '@/utils/oa-dept-value'
import { formatPersonDisplay } from '@/utils/oa-person-value'
import { formatPostDisplay } from '@/utils/oa-post-value'
import { formatMoneyDisplay } from '@/utils/money-format'
import { formatReceiveAccountDisplay } from '@/utils/oa-receive-account-value'
import {
formatFormValueDisplay,
formatRegionLikeDisplay
} from '@/utils/oa-form-value-display'
import { toRmbUpper } from '@/utils/rmb-upper'
import { formatOaUploadPathsForPrint } from '@/utils/oa-upload-file-type'
import {
buildFlowItems,
type OaPrintFlowItem,
type OaPrintPersonLine
} from '@/utils/oa-approval-print-flow'
export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
export type OaStampClass = 'ok' | 'run' | 'rej' | 'ret' | 'can'
export type { RelatedPrintBlock }
export type OaPrintFieldRow = {
label: string
/** 普通文本(可含换行) */
text?: string
/** 附件文件名 / 链接列表(上传字段仅文件名) */
urls?: string[]
/** 关联审批单多行块(优先于 text) */
blocks?: RelatedPrintBlock[]
}
export type { OaPrintPersonLine, OaPrintFlowItem }
export { buildFlowItems }
export type OaPrintModel = {
title: string
companyName: string
createDate: string
stampClass: OaStampClass
stampText: string
rows: OaPrintFieldRow[]
flowItems: OaPrintFlowItem[]
printTime: string
printerName: string
watermarkName: string
}
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'
])
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 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, sep = '、'): string {
return formatFormValueDisplay(value, sep)
}
/** 日期/时间区间(FormCreate:日期区间、时间区间等)打印用 ~ 连接起止 */
function isRangeFieldRule(rule: any): boolean {
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 formatFieldValue(value: unknown, rule: any): string {
if (Array.isArray(value) && isRangeFieldRule(rule)) {
return formatScalar(value, '~')
}
return formatScalar(value)
}
function formatTripLegForPrint(leg: Record<string, unknown>, index: number): string {
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 formatTripItineraryForPrint(value: unknown): string {
if (!Array.isArray(value) || !value.length) return '-'
const lines = value
.filter((leg) => leg && typeof leg === 'object')
.map((leg, idx) => formatTripLegForPrint(leg as Record<string, unknown>, idx))
return lines.length ? lines.join('\n') : '-'
}
async 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) {
await 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 = normalizeRelatedIdsFromFormValue(value)
if (!ids.length) {
out.push({ label, text: '-' })
continue
}
const blocks = await assembleRelatedPrintBlocks(ids)
out.push({
label,
blocks: blocks.length
? blocks
: [{ indexLabel: '1', title: '关联信息加载失败' }]
})
continue
}
if (type === 'dept' || type === 'ownerDept') {
const text = formatDeptDisplay(value)
out.push({ label, text: text || '-' })
continue
}
if (type === 'person' || type === 'ownerPerson') {
const text = formatPersonDisplay(value)
out.push({ label, text: text || '-' })
continue
}
if (type === 'post') {
const text = formatPostDisplay(value)
out.push({ label, text: text || '-' })
continue
}
if (type === 'money') {
const showUpper = rule.props?.showUpper !== false
const n =
value == null || value === ''
? null
: typeof value === 'number'
? value
: Number(String(value).replace(/,/g, ''))
if (n == null || !Number.isFinite(n)) {
out.push({ label, text: '-' })
continue
}
const numText = formatMoneyDisplay(n, 2)
const upper = showUpper ? toRmbUpper(n) : ''
out.push({
label,
text: upper ? `${numText}(${upper})` : numText
})
continue
}
if (type === 'receiveAccount') {
const text = formatReceiveAccountDisplay(value)
out.push({ label, text: text || '-' })
continue
}
if (type === 'region' || type === 'cascader') {
const text = formatRegionLikeDisplay(value)
out.push({ label, text: text || '-' })
continue
}
if (type === 'tripItinerary') {
out.push({ label, text: formatTripItineraryForPrint(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 = formatOaUploadPathsForPrint(value)
out.push({ label, urls: names.length ? names : [], text: names.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: formatFieldValue(value, rule) })
}
}
function ensurePlainObject(raw: unknown): Record<string, any> {
if (raw == null) return {}
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? (parsed as Record<string, any>) : {}
} catch {
return {}
}
}
if (typeof raw === 'object') return raw as Record<string, any>
return {}
}
async function buildFormRows(detail: any): Promise<OaPrintFieldRow[]> {
const schema = ensurePlainObject(detail?.formSchemaJson)
const formData = ensurePlainObject(detail?.formDataJson)
const rule = Array.isArray(schema?.rule)
? schema.rule
: Array.isArray(detail?.formSchemaJson)
? detail.formSchemaJson
: []
const out: OaPrintFieldRow[] = []
await walkRules(rule, formData, out)
if (out.length) return out
// schema 缺失:兜底平铺 formData(附件路径收成文件名)
for (const [k, v] of Object.entries(formData)) {
const names = formatOaUploadPathsForPrint(v)
if (names.length && extractUrls(v).every((u) => /\/minio\//i.test(u) || /\.[a-z0-9]{2,5}(\?|$)/i.test(u))) {
out.push({ label: k, urls: names })
continue
}
const urls = extractUrls(v).filter(isHttpUrl)
if (urls.length) out.push({ label: k, urls: formatOaUploadPathsForPrint(urls) })
else out.push({ label: k, text: formatScalar(v) })
}
return out
}
export async function buildOaPrintModel(
detail: any,
opts: { printerName: string; printTime?: string }
): Promise<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() || '-'
}
]
const formRows = await buildFormRows(detail)
return {
title,
companyName: OA_PRINT_COMPANY_NAME,
createDate: formatDateOnly(detail?.createTime),
stampClass,
stampText,
rows: [...headerRows, ...formRows],
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`
}
/** 用 canvas 绘制中文页码(jsPDF 内置字体不支持汉字,会乱码) */
function renderPageLabelCanvas(text: string): HTMLCanvasElement {
const font = '20px "PingFang SC", "Microsoft YaHei", "Heiti SC", sans-serif'
const measure = document.createElement('canvas')
const mctx = measure.getContext('2d')
if (!mctx) {
const empty = document.createElement('canvas')
empty.width = 1
empty.height = 1
return empty
}
mctx.font = font
const tw = Math.ceil(mctx.measureText(text).width) + 8
const th = 28
const scale = 2
const c = document.createElement('canvas')
c.width = tw * scale
c.height = th * scale
const ctx = c.getContext('2d')
if (!ctx) return c
ctx.scale(scale, scale)
ctx.clearRect(0, 0, tw, th)
ctx.font = font
ctx.fillStyle = '#646464'
ctx.textBaseline = 'middle'
ctx.fillText(text, 4, th / 2)
return c
}
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
// 略超一页(原 min-height / 浮点)时不产生空白尾页;余量 < 1.5mm 仍算一页
const EPS_MM = 1.5
const totalPages = Math.min(40, Math.max(1, Math.ceil((imgHeight - EPS_MM) / pageHeight)))
const pageData = canvas.toDataURL('image/jpeg', 0.95)
const labelMmH = 4.5
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
if (pageIndex > 0) pdf.addPage()
const position = -pageIndex * pageHeight
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
const label = `第${pageIndex + 1}页`
const labelCanvas = renderPageLabelCanvas(label)
if (labelCanvas.width > 1) {
const labelMmW = (labelCanvas.width / labelCanvas.height) * labelMmH
pdf.addImage(
labelCanvas.toDataURL('image/png'),
'PNG',
(pageWidth - labelMmW) / 2,
pageHeight - 10,
labelMmW,
labelMmH
)
}
}
pdf.save(filename)
}
/**
* 根据实例详情导出 PDF(不弹系统打印框)
*/
export async function exportOaApprovalPdf(
detail: any,
opts: { printerName: string }
): Promise<void> {
const model = await 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()
}
}