diff --git a/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue b/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue index f940881c..ad2be84d 100644 --- a/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue +++ b/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue @@ -50,21 +50,44 @@ - - 审批流程 - -
-
-
+ + + 审批流程 + + - @@ -102,6 +125,7 @@ .oa-print-page { position: relative; width: 794px; + /* 至少一页 A4 高,水印铺满;分页用 EPS 容差,避免略超 297mm 多出空白第二页 */ min-height: 1123px; padding: 48px 56px 56px; box-sizing: border-box; @@ -159,16 +183,25 @@ position: relative; } + /* + * 边框用 separate + 只画右/下边,避免 collapse 下相邻 1px 叠成粗细不一 + *(html2canvas 导出时尤其明显);外框由 table 上/左 + 末行下/末列右拼成。 + */ .oa-print-table { width: 100%; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; table-layout: fixed; font-size: 13px; + border-top: 1px solid #1f2329; + border-left: 1px solid #1f2329; } .oa-print-table th, .oa-print-table td { - border: 1px solid #1f2329; + border: none; + border-right: 1px solid #1f2329; + border-bottom: 1px solid #1f2329; padding: 9px 12px; vertical-align: middle; line-height: 1.5; @@ -191,13 +224,13 @@ white-space: pre-wrap; } - .oa-print-flow th, - .oa-print-flow td { - vertical-align: top; + .oa-print-flow__label { + vertical-align: middle; } - .oa-print-flow__item { - padding: 3px 0; + .oa-print-flow__cell { + vertical-align: middle; + padding: 8px 12px; } .oa-print-flow__row { @@ -224,6 +257,18 @@ color: #646a73; } + .oa-print-flow__person-lines { + margin-top: 6px; + display: grid; + gap: 3px; + } + + .oa-print-flow__person-line { + line-height: 1.55; + word-break: break-word; + white-space: pre-wrap; + } + .oa-print-footer { display: flex; gap: 28px; diff --git a/web-admin/src/utils/oa-approval-print.ts b/web-admin/src/utils/oa-approval-print.ts index b0204da6..5bc578dd 100644 --- a/web-admin/src/utils/oa-approval-print.ts +++ b/web-admin/src/utils/oa-approval-print.ts @@ -11,6 +11,7 @@ import { normalizeRelatedIdsFromFormValue, type RelatedPrintBlock } from '@/utils/oa-approval-print-related' +import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines' export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司' @@ -31,7 +32,10 @@ export type OaPrintFieldRow = { export type OaPrintFlowItem = { left: string right: string + /** @deprecated 多人节点改用 personLines;单人/发起人可保留 */ comment?: string + /** 或签/会签按人纯文本行 */ + personLines?: string[] } export type OaPrintModel = { @@ -254,57 +258,70 @@ async function buildFormRows(detail: any): Promise { return out } -function buildFlowItems(detail: any): OaPrintFlowItem[] { +/** 钉钉风格:人名不加状态括号,状态放在整行末尾 */ +function peopleDisplayNames(p: any): string[] { + const people = Array.isArray(p?.people) ? p.people : [] + const fromPeople = people + .map((x: any) => String(x?.nickName || x?.id || '').trim()) + .filter(Boolean) + if (fromPeople.length) return fromPeople + const name = String(p?.assigneeUserName || p?.assigneeUserId || '').trim() + return name ? [name] : [] +} + +function progressPersonLinesFromItem(p: any): string[] { + const people = Array.isArray(p?.people) ? p.people : [] + if (!people.length) return [] + return formatProgressPersonLines(people).map((x) => x.text) +} + +function isCcProgressItem(p: any): boolean { + const taskType = String(p?.taskType || p?.task_type || '').trim() + const signMode = String(p?.signMode || p?.sign_mode || '').trim() + const title = String(p?.title || p?.nodeName || '').trim() + // 主判据:taskType / signMode;标题兜底仅匹配默认「抄送人」或「抄送…」节点名 + return ( + taskType === 'cc' || + signMode === 'cc' || + title === '抄送人' || + /^抄送/.test(title) + ) +} + +/** + * 从 progress 组装审批流程行;抄送节点聚合为「抄送A, B, C」对齐钉钉导出。 + * 若 progress 漏抄送,则用 tasks 兜底补一行。 + */ +export function buildFlowItems(detail: any): OaPrintFlowItem[] { const list = Array.isArray(detail?.progress) ? detail.progress : [] const items: OaPrintFlowItem[] = [] - // 聚合连续抄送任务,接近钉钉「抄送A,B,C」 + // 聚合连续抄送任务,对齐钉钉「抄送A, B, C」 let ccBuf: { names: string[]; ts: string } | null = null const flushCc = () => { if (!ccBuf) return + const uniq = Array.from(new Set(ccBuf.names.map((n) => n.trim()).filter(Boolean))) items.push({ - left: `抄送${ccBuf.names.join(',')}`, + left: uniq.length ? `抄送: ${uniq.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 names = peopleDisplayNames(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 signMode = String(p.signMode || p.sign_mode || '') const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : '' - if (taskType === 'cc') { + if (isCcProgressItem(p)) { 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 (names.length) ccBuf.names.push(...names) if (ts) ccBuf.ts = ts continue } @@ -319,6 +336,10 @@ function buildFlowItems(detail: any): OaPrintFlowItem[] { continue } if (kind === 'terminal') { + // 打回结果已落在审批人节点,不单独输出终态「已打回」行 + if (String(p.status || '') === 'returned' || title === '已打回') { + continue + } items.push({ left: title || st, right: ts @@ -326,14 +347,30 @@ function buildFlowItems(detail: any): OaPrintFlowItem[] { continue } if (kind === 'pending_node') { + const personLines = progressPersonLinesFromItem(p) + const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】` items.push({ - left: `【${title}】 ${st}`, - right: ts + left: personLines.length + ? `${head} ${st}` + : name + ? `${head} ${name} ${st}` + : `${head} ${st}`, + right: personLines.length ? '' : ts, + personLines: personLines.length ? personLines : undefined }) continue } // task(或签/会签同节点多人已合并) const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】` + const personLines = progressPersonLinesFromItem(p) + if (personLines.length) { + items.push({ + left: `${head} ${st}`, + right: '', + personLines + }) + continue + } items.push({ left: name ? `${head} ${name} ${st}` : `${head} ${st}`, right: ts, @@ -341,6 +378,34 @@ function buildFlowItems(detail: any): OaPrintFlowItem[] { }) } flushCc() + + // progress 未带抄送时,用 tasks 兜底(对齐钉钉:流程末展示抄送人) + const hasCcLine = items.some((it) => it.left.startsWith('抄送')) + if (!hasCcLine) { + const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [] + const ccTasks = tasks.filter((t: any) => { + const tt = String(t?.taskType || t?.task_type || '').trim() + const sm = String(t?.signMode || t?.sign_mode || '').trim() + return tt === 'cc' || sm === 'cc' + }) + if (ccTasks.length) { + const names = ccTasks + .map((t: any) => + String(t?.assigneeUserName || t?.assignee_user_name || t?.assigneeUserId || '').trim() + ) + .filter(Boolean) + const uniq = Array.from(new Set(names)) + let ts = '' + for (const t of ccTasks) { + const tTs = String(t?.updateTime || t?.createTime || '').trim() + if (tTs && (!ts || tTs > ts)) ts = tTs + } + if (uniq.length) { + items.push({ left: `抄送: ${uniq.join(', ')}`, right: ts }) + } + } + } + return items } @@ -397,6 +462,35 @@ export function buildOaPrintFilename(detail: any): string { 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() @@ -404,25 +498,31 @@ async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) { 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 + // 略超一页(原 min-height / 浮点)时不产生空白尾页;余量 < 1.5mm 仍算一页 + const EPS_MM = 1.5 + const totalPages = Math.min(40, Math.max(1, Math.ceil((imgHeight - EPS_MM) / pageHeight))) - while (heightLeft > 0) { + 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 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 + + 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)