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
+206 -61

No files matched your search

@@ -50,21 +50,44 @@
</template>
</td>
</tr>
<tr class="oa-print-flow">
<th>审批流程</th>
<td>
<div v-if="!model.flowItems.length" class="oa-print-text">-</div>
<div
<!-- 审批流程:左列 rowspan,每个节点独立一行(含表格线,对齐钉钉导出) -->
<template v-if="model.flowItems.length">
<tr
v-for="(f, fi) in model.flowItems"
:key="`f-${fi}`"
class="oa-print-flow__item"
class="oa-print-flow"
>
<th
v-if="fi === 0"
class="oa-print-flow__label"
:rowspan="model.flowItems.length"
>
审批流程
</th>
<td class="oa-print-flow__cell">
<div class="oa-print-flow__row">
<div class="oa-print-flow__left">{{ f.left }}</div>
<div class="oa-print-flow__right">{{ f.right }}</div>
</div>
<div v-if="f.comment" class="oa-print-flow__comment">意见:{{ f.comment }}</div>
<div v-if="f.personLines?.length" class="oa-print-flow__person-lines">
<div
v-for="(line, li) in f.personLines"
:key="li"
class="oa-print-flow__person-line"
>
{{ line }}
</div>
</div>
<div v-else-if="f.comment" class="oa-print-flow__comment">
意见:{{ f.comment }}
</div>
</td>
</tr>
</template>
<tr v-else class="oa-print-flow">
<th class="oa-print-flow__label">审批流程</th>
<td class="oa-print-flow__cell">
<span class="oa-print-text">-</span>
</td>
</tr>
</tbody>
@@ -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;
+147 -47
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 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)