为减少仓库噪声和重复规则,整理当前开发入口并移除已废弃资料。 - 精简根 AGENTS,补充四个应用规则与 README,统一规范入口 - 归整部署文件、SOP 原稿和参考资源,修正迁移后的脚本路径 - 取消虚拟环境、工具状态、业务导出及备份的跟踪,本机所需原件保留 - 移除停用的 graphify 配置、脚本与产物 - 删除历史业务文档、执行计划、OpenSpec 业务变更和移动 UI 原型 - 清理失效引用,历史细胞产品导入改为必须显式指定 --file 验证:git diff --cached --check、OpenSpec 严格校验、导入参数隔离检查 3/3 通过; 迁移文件完整性和现行链接已核对。未执行部署、业务数据导入或服务启停。
535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
/**
|
||
* 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()
|
||
}
|
||
}
|