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 @@
-
- | 审批流程 |
-
- -
-
+
+
+ |
+ 审批流程
+ |
+
{{ f.left }}
{{ f.right }}
-
-
+
+
+ |
+
+
+
+ | 审批流程 |
+
+ -
|
@@ -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)
|