Просмотр исходного кода

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>
zlwu 3 месяцев назад
Родитель
Сommit
ea4ee3e573

+ 62 - 17
web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue

@@ -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
-                  v-for="(f, fi) in model.flowItems"
-                  :key="`f-${fi}`"
-                  class="oa-print-flow__item"
+            <!-- 审批流程:左列 rowspan,每个节点独立一行(含表格线,对齐钉钉导出) -->
+            <template v-if="model.flowItems.length">
+              <tr
+                v-for="(f, fi) in model.flowItems"
+                :key="`f-${fi}`"
+                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>
+                  <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
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<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
 
+  // 略超一页(原 min-height / 浮点)时不产生空白尾页;余量 < 1.5mm 仍算一页
+  const EPS_MM = 1.5
+  const totalPages = Math.min(40, Math.max(1, Math.ceil((imgHeight - EPS_MM) / pageHeight)))
+
   const pageData = canvas.toDataURL('image/jpeg', 0.95)
-  let heightLeft = imgHeight
-  let position = 0
-  let pageIndex = 0
+  const labelMmH = 4.5
 
-  while (heightLeft > 0) {
+  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)