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:
1 parent
1e2b903cd7
commit
ea4ee3e573
2 files changed
+207
-62
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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user