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 += `
`
}
+ for (const url of f.attachmentLinks || []) {
+ html += ``
+ }
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 @@
+
@@ -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()