feat(web-admin): print OA or/and-sign nodes as per-person text lines

Align PDF flow section with progress UI person-line format.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-23 14:16:18 +08:00
1 parent 1e2b903cd7
commit ea4ee3e573
2 files changed
+210 -65

No files matched your search

+148 -48
View File
@@ -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<OaPrintFieldRow[]> {
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)