diff --git a/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js b/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js index 9166fec1..9164dc85 100644 --- a/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js +++ b/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js @@ -1,3 +1,4 @@ +import { attachmentFullUrl } from '../../utils/attachment.js' /** * OA 审批单移动端打印(S6) * 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;支持 html2canvas + jsPDF 导出。 @@ -66,7 +67,6 @@ function escapeHtml(s) { .replace(/"/g, '"') } - function formatDateOnly(createTime) { const raw = String(createTime || '').trim() if (!raw) return '-' @@ -99,10 +99,12 @@ function formatScalar(value, sep = '、') { return String(value) } if (Array.isArray(value)) { - return value - .map((x) => formatScalar(x, sep)) - .filter((x) => x && x !== '-') - .join(sep) || '-' + return ( + value + .map((x) => formatScalar(x, sep)) + .filter((x) => x && x !== '-') + .join(sep) || '-' + ) } if (typeof value === 'object') { if (value.name != null) return String(value.name) @@ -397,7 +399,13 @@ export function buildOaPrintModel(detail, opts = {}) { stampClass, stampText, rows: [...headerRows, ...buildFormRows(detail)], - flowItems: buildFlowItems(detail), + flowItems: buildFlowItems(detail, (url) => { + try { + return new URL(attachmentFullUrl(url), window.location.origin).href + } catch { + return '' + } + }), printTime, printerName, watermarkName: printerName === '-' ? '审批' : printerName @@ -435,6 +443,9 @@ function flowCellHtml(f) { } else if (f.comment) { html += `
意见:${escapeHtml(f.comment)}
` } + for (const url of f.attachmentLinks || []) { + html += `
${escapeHtml(url)}
` + } return html } @@ -472,9 +483,10 @@ function buildPrintStylesContent() { line-height: 1.5; word-break: break-all; } .oa-print-table th { width: 118px; font-weight: 400; text-align: left; background: #fff; } .oa-print-text { white-space: pre-wrap; } + .oa-print-url a { color: inherit; } .oa-print-url { color: #3370ff; word-break: break-all; } .oa-print-flow__row { display: flex; justify-content: space-between; gap: 12px; } - .oa-print-flow__left { flex: 1; min-width: 0; } + .oa-print-flow__left { white-space: pre-wrap; overflow-wrap: anywhere; flex: 1; min-width: 0; } .oa-print-flow__right { flex: 0 0 auto; color: #646464; white-space: nowrap; } .oa-print-flow__person-lines { margin-top: 4px; padding-left: 8px; } .oa-print-flow__person-line { margin-top: 2px; font-size: 12px; } @@ -602,7 +614,7 @@ function renderPageLabelCanvas(text) { return c } -async function canvasToPdfBlob(canvas) { +async function canvasToPdfBlob(canvas, sheet) { const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' }) const pageWidth = pdf.internal.pageSize.getWidth() const pageHeight = pdf.internal.pageSize.getHeight() @@ -620,6 +632,23 @@ async function canvasToPdfBlob(canvas) { const position = -pageIndex * pageHeight pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight) + // html2canvas 只保留像素,单独把附件链接写为 PDF 注解;跨页部分按页裁切。 + const sheetRect = sheet.getBoundingClientRect() + const ratio = pageWidth / sheetRect.width + for (const link of sheet.querySelectorAll('a[data-oa-print-link]')) { + if (!/^https?:\/\//i.test(link.href)) continue + for (const rect of link.getClientRects()) { + const top = (rect.top - sheetRect.top) * ratio - pageIndex * pageHeight + const bottom = Math.min(pageHeight, top + rect.height * ratio) + const y = Math.max(0, top) + if (bottom > y) { + pdf.link((rect.left - sheetRect.left) * ratio, y, rect.width * ratio, bottom - y, { + url: link.href + }) + } + } + } + const label = `第${pageIndex + 1}页` const labelCanvas = renderPageLabelCanvas(label) if (labelCanvas.width > 1) { @@ -664,7 +693,7 @@ export async function generateOaApprovalPdfBlob(detail, opts = {}) { windowWidth: el.scrollWidth, windowHeight: el.scrollHeight }) - const blob = await canvasToPdfBlob(canvas) + const blob = await canvasToPdfBlob(canvas, el) return { blob, filename } } finally { host.remove() diff --git a/mobile-oa-h5/src/oa/print/oa-print-comments.test.js b/mobile-oa-h5/src/oa/print/oa-print-comments.test.js new file mode 100644 index 00000000..a40a5170 --- /dev/null +++ b/mobile-oa-h5/src/oa/print/oa-print-comments.test.js @@ -0,0 +1,95 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildFlowItems } from './oa-print-flow.js' + +describe('打印讨论评论', () => { + it('按时间展示正文、提及和附件,并排在待审节点前', () => { + const items = buildFlowItems({ + progress: [ + { + kind: 'submit', + title: '发起人', + status: 'submitted', + timestamp: '2026-11-01 10:00:00' + }, + { + kind: 'pending_node', + title: '审批人', + status: 'pending', + timestamp: '2026-11-01 10:00:00' + } + ], + comments: [ + { + id: '2', + createByName: '李四', + createTime: '2026-11-01 12:00:00', + content: '', + attachmentRefs: [{ url: 'https://example.com/file.pdf', fileName: '附件.pdf' }] + }, + { + id: '1', + createByName: '张三', + createTime: '2026-11-01 11:00:00', + content: '@李四 请处理\n第二行', + mentions: [{ userId: 'u2', userName: '李四' }] + } + ] + }) + assert.equal(items.length, 4) + assert.equal(items[1].left, '【评论】 张三') + assert.equal(items[1].right, '2026-11-01 11:00:00') + assert.deepEqual(items[1].personLines, [ + { left: '@李四 请处理\n第二行', right: '' }, + { left: '提到:@李四', right: '' } + ]) + assert.deepEqual(items[2].attachmentLinks, ['https://example.com/file.pdf']) + assert.match(items[3].left, /待处理/) + }) + it('撤回评论不泄露正文、提及和附件', () => { + const [item] = buildFlowItems({ + comments: [ + { + id: '1', + createBy: 'u1', + status: 'revoked', + content: 'secret', + mentions: [{ userName: 'secret' }], + attachmentRefs: [{ url: 'https://example.com/secret' }] + } + ] + }) + assert.equal(item.left, '【评论】 u1 已撤回') + assert.equal(JSON.stringify(item).includes('secret'), false) + }) + it('安全过滤附件协议,保留纯附件与纯提及评论', () => { + const items = buildFlowItems({ + comments: [ + { + id: '1', + mentions: [{ userId: 'u1' }], + attachmentRefs: [{ url: 'javascript:alert(1)' }, { url: 'https://example.com/a?x=1&y=2' }] + } + ] + }) + assert.deepEqual(items[0].attachmentLinks, ['https://example.com/a?x=1&y=2']) + assert.deepEqual(items[0].personLines, [{ left: '提到:@u1', right: '' }]) + }) +}) + +it('同秒节点使用详情页的稳定排序键', () => { + const progress = Array.from({ length: 12 }, (_, index) => ({ + kind: 'task', + nodeKey: `node-${String(11 - index).padStart(2, '0')}`, + title: `节点${11 - index}`, + status: 'approved', + timestamp: '2026-11-01 10:00:00' + })) + const items = buildFlowItems({ + progress, + comments: [{ id: 'comment', content: '讨论', createTime: '2026-11-01 10:00:00' }] + }) + assert.equal(items[0].left, '【节点0】 已同意') + assert.equal(items[11].left, '【节点11】 已同意') + assert.equal(items[12].left, '【评论】 评论') +}) diff --git a/mobile-oa-h5/src/oa/print/oa-print-flow.js b/mobile-oa-h5/src/oa/print/oa-print-flow.js index 0ec39aad..9607f2fe 100644 --- a/mobile-oa-h5/src/oa/print/oa-print-flow.js +++ b/mobile-oa-h5/src/oa/print/oa-print-flow.js @@ -1,3 +1,4 @@ +import { mergeProgressWithComments } from '../oa-progress-merge-comments.js' /** * 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测) */ @@ -37,16 +38,22 @@ function isCcProgressItem(p) { 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() - return ( - taskType === 'cc' || - signMode === 'cc' || - title === '抄送人' || - /^抄送/.test(title) - ) + return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title) } -export function buildFlowItems(detail) { - const list = Array.isArray(detail?.progress) ? detail.progress : [] +export function buildFlowItems(detail, resolveUrl = (url) => url) { + const progress = Array.isArray(detail?.progress) ? detail.progress : [] + // 沿用详情页的时间顺序;无讨论时保持原有审批节点顺序。 + const list = + Array.isArray(detail?.comments) && detail.comments.length + ? mergeProgressWithComments( + progress.map((p, index) => ({ + ...p, + key: `${p.kind}-${p.nodeKey || p.taskId || index}-${p.roundNo || 1}` + })), + detail.comments + ) + : progress const items = [] let ccBuf = null const flushCc = () => { @@ -70,6 +77,37 @@ export function buildFlowItems(detail) { const signMode = String(p.signMode || p.sign_mode || '') const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : '' + if (kind === 'comment') { + flushCc() + const revoked = p.status === 'revoked' + const author = String(p.assignee || p.title || '评论').trim() + const item = { + left: `【评论】 ${author}${revoked ? ' 已撤回' : ''}`, + right: ts + } + if (!revoked) { + const lines = [] + if (comment) lines.push({ left: comment, right: '' }) + const mentions = (p.mentions || []) + .map((m) => String(m.userName || m.userId || '').trim()) + .filter(Boolean) + if (mentions.length) + lines.push({ + left: `提到:${mentions.map((name) => `@${name}`).join('、')}`, + right: '' + }) + if (lines.length) item.personLines = lines + const links = (p.attachmentRefs || []) + .map((file) => String(file.url || '').trim()) + .filter((url) => url && !/^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(url)) + .map(resolveUrl) + .filter((url) => /^https?:\/\//i.test(url)) + if (links.length) item.attachmentLinks = links + } + items.push(item) + continue + } + if (isCcProgressItem(p)) { if (!ccBuf) ccBuf = { names: [], ts } if (names.length) ccBuf.names.push(...names) diff --git a/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue b/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue index 11ad20b7..b271c92e 100644 --- a/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue +++ b/web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue @@ -86,6 +86,9 @@
意见:{{ f.comment }}
+
+ {{ url }} +
@@ -221,6 +224,10 @@ background: #fff; } + .oa-print-url a { + color: inherit; + } + .oa-print-url { color: #3370ff; word-break: break-all; @@ -248,6 +255,8 @@ } .oa-print-flow__left { + white-space: pre-wrap; + overflow-wrap: anywhere; flex: 1 1 auto; min-width: 0; } diff --git a/web-admin/src/utils/oa-approval-print-comments.test.ts b/web-admin/src/utils/oa-approval-print-comments.test.ts new file mode 100644 index 00000000..b8f8bab5 --- /dev/null +++ b/web-admin/src/utils/oa-approval-print-comments.test.ts @@ -0,0 +1,90 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildFlowItems } from './oa-approval-print-flow' + +describe('打印讨论评论', () => { + it('按时间展示正文、提及和附件,并排在待审节点前', () => { + const items = buildFlowItems({ + progress: [ + { kind: 'submit', title: '发起人', status: 'submitted', timestamp: '2026-11-01 10:00:00' }, + { + kind: 'pending_node', + title: '审批人', + status: 'pending', + timestamp: '2026-11-01 10:00:00' + } + ], + comments: [ + { + id: '2', + createByName: '李四', + createTime: '2026-11-01 12:00:00', + content: '', + attachmentRefs: [{ url: 'https://example.com/file.pdf', fileName: '附件.pdf' }] + }, + { + id: '1', + createByName: '张三', + createTime: '2026-11-01 11:00:00', + content: '@李四 请处理\n第二行', + mentions: [{ userId: 'u2', userName: '李四' }] + } + ] + }) + assert.equal(items.length, 4) + assert.equal(items[1].left, '【评论】 张三') + assert.equal(items[1].right, '2026-11-01 11:00:00') + assert.deepEqual(items[1].personLines, [ + { left: '@李四 请处理\n第二行', right: '' }, + { left: '提到:@李四', right: '' } + ]) + assert.deepEqual(items[2].attachmentLinks, ['https://example.com/file.pdf']) + assert.match(items[3].left, /待处理/) + }) + it('撤回评论不泄露正文、提及和附件', () => { + const [item] = buildFlowItems({ + comments: [ + { + id: '1', + createBy: 'u1', + status: 'revoked', + content: 'secret', + mentions: [{ userName: 'secret' }], + attachmentRefs: [{ url: 'https://example.com/secret' }] + } + ] + }) + assert.equal(item.left, '【评论】 u1 已撤回') + assert.equal(JSON.stringify(item).includes('secret'), false) + }) + it('安全过滤附件协议,保留纯附件与纯提及评论', () => { + const items = buildFlowItems({ + comments: [ + { + id: '1', + mentions: [{ userId: 'u1' }], + attachmentRefs: [{ url: 'javascript:alert(1)' }, { url: 'https://example.com/a?x=1&y=2' }] + } + ] + }) + assert.deepEqual(items[0].attachmentLinks, ['https://example.com/a?x=1&y=2']) + assert.deepEqual(items[0].personLines, [{ left: '提到:@u1', right: '' }]) + }) +}) + +it('同秒节点使用详情页的稳定排序键', () => { + const progress = Array.from({ length: 12 }, (_, index) => ({ + kind: 'task', + nodeKey: `node-${String(11 - index).padStart(2, '0')}`, + title: `节点${11 - index}`, + status: 'approved', + timestamp: '2026-11-01 10:00:00' + })) + const items = buildFlowItems({ + progress, + comments: [{ id: 'comment', content: '讨论', createTime: '2026-11-01 10:00:00' }] + }) + assert.equal(items[0].left, '【节点0】 已同意') + assert.equal(items[11].left, '【节点11】 已同意') + assert.equal(items[12].left, '【评论】 评论') +}) diff --git a/web-admin/src/utils/oa-approval-print-flow.ts b/web-admin/src/utils/oa-approval-print-flow.ts index da8c970a..1b5522eb 100644 --- a/web-admin/src/utils/oa-approval-print-flow.ts +++ b/web-admin/src/utils/oa-approval-print-flow.ts @@ -1,3 +1,4 @@ +import { mergeProgressWithComments } from './oa-progress-merge-comments' /** * OA 打印「审批流程」行组装(无 Vue/canvas 副作用,便于单测) */ @@ -11,6 +12,7 @@ export type OaPrintPersonLine = { export type OaPrintFlowItem = { left: string right: string + attachmentLinks?: string[] /** @deprecated 多人节点改用 personLines;单人/发起人可保留 */ comment?: string /** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */ @@ -66,20 +68,29 @@ 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() - return ( - taskType === 'cc' || - signMode === 'cc' || - title === '抄送人' || - /^抄送/.test(title) - ) + 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 : [] +export function buildFlowItems( + detail: any, + resolveUrl: (url: string) => string = (url) => url +): OaPrintFlowItem[] { + const progress = Array.isArray(detail?.progress) ? detail.progress : [] + // 沿用详情页的时间顺序;无讨论时保持原有审批节点顺序。 + const list = + Array.isArray(detail?.comments) && detail.comments.length + ? mergeProgressWithComments( + progress.map((p: any, index: number) => ({ + ...p, + key: `${p.kind}-${p.nodeKey || p.taskId || index}-${p.roundNo || 1}` + })), + detail.comments + ) + : progress const items: OaPrintFlowItem[] = [] let ccBuf: { names: string[]; ts: string } | null = null @@ -104,6 +115,37 @@ export function buildFlowItems(detail: any): OaPrintFlowItem[] { const signMode = String(p.signMode || p.sign_mode || '') const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : '' + if (kind === 'comment') { + flushCc() + const revoked = p.status === 'revoked' + const author = String(p.assignee || p.title || '评论').trim() + const item: OaPrintFlowItem = { + left: `【评论】 ${author}${revoked ? ' 已撤回' : ''}`, + right: ts + } + if (!revoked) { + const lines = [] + if (comment) lines.push({ left: comment, right: '' }) + const mentions = (p.mentions || []) + .map((m: any) => String(m.userName || m.userId || '').trim()) + .filter(Boolean) + if (mentions.length) + lines.push({ + left: `提到:${mentions.map((name: string) => `@${name}`).join('、')}`, + right: '' + }) + if (lines.length) item.personLines = lines + const links = (p.attachmentRefs || []) + .map((file: any) => String(file.url || '').trim()) + .filter((url: string) => url && !/^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(url)) + .map(resolveUrl) + .filter((url: string) => /^https?:\/\//i.test(url)) + if (links.length) item.attachmentLinks = links + } + items.push(item) + continue + } + if (isCcProgressItem(p)) { if (!ccBuf) ccBuf = { names: [], ts } if (names.length) ccBuf.names.push(...names) diff --git a/web-admin/src/utils/oa-approval-print.ts b/web-admin/src/utils/oa-approval-print.ts index 31285e6e..18ebbe34 100644 --- a/web-admin/src/utils/oa-approval-print.ts +++ b/web-admin/src/utils/oa-approval-print.ts @@ -1,3 +1,4 @@ +import { attachmentFullUrl } from '@/composables/useAttachmentActions' /** * OA 审批单钉钉风格 PDF 导出 */ @@ -398,7 +399,13 @@ export async function buildOaPrintModel( stampClass, stampText, rows: [...headerRows, ...formRows], - flowItems: buildFlowItems(detail), + flowItems: buildFlowItems(detail, (url) => { + try { + return new URL(attachmentFullUrl(url), window.location.origin).href + } catch { + return '' + } + }), printTime, printerName, watermarkName: printerName === '-' ? '审批' : printerName @@ -447,7 +454,7 @@ function renderPageLabelCanvas(text: string): HTMLCanvasElement { return c } -async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) { +async function canvasToPdf(canvas: HTMLCanvasElement, filename: string, sheet: HTMLElement) { const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' }) const pageWidth = pdf.internal.pageSize.getWidth() const pageHeight = pdf.internal.pageSize.getHeight() @@ -466,6 +473,23 @@ async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) { const position = -pageIndex * pageHeight pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight) + // html2canvas 只保留像素,单独把附件链接写为 PDF 注解;跨页部分按页裁切。 + const sheetRect = sheet.getBoundingClientRect() + const ratio = pageWidth / sheetRect.width + for (const link of sheet.querySelectorAll('a[data-oa-print-link]')) { + if (!/^https?:\/\//i.test(link.href)) continue + for (const rect of link.getClientRects()) { + const top = (rect.top - sheetRect.top) * ratio - pageIndex * pageHeight + const bottom = Math.min(pageHeight, top + rect.height * ratio) + const y = Math.max(0, top) + if (bottom > y) { + pdf.link((rect.left - sheetRect.left) * ratio, y, rect.width * ratio, bottom - y, { + url: link.href + }) + } + } + } + const label = `第${pageIndex + 1}页` const labelCanvas = renderPageLabelCanvas(label) if (labelCanvas.width > 1) { @@ -526,7 +550,7 @@ export async function exportOaApprovalPdf( windowWidth: el.scrollWidth, windowHeight: el.scrollHeight }) - await canvasToPdf(canvas, filename) + await canvasToPdf(canvas, filename, el) } finally { app.unmount() host.remove()