Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a511004812 | ||
|
|
7ac2ff282c |
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import { attachmentFullUrl } from '../../utils/attachment.js'
|
||||||
/**
|
/**
|
||||||
* OA 审批单移动端打印(S6)
|
* OA 审批单移动端打印(S6)
|
||||||
* 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;支持 html2canvas + jsPDF 导出。
|
* 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;支持 html2canvas + jsPDF 导出。
|
||||||
@@ -66,7 +67,6 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatDateOnly(createTime) {
|
function formatDateOnly(createTime) {
|
||||||
const raw = String(createTime || '').trim()
|
const raw = String(createTime || '').trim()
|
||||||
if (!raw) return '-'
|
if (!raw) return '-'
|
||||||
@@ -99,10 +99,12 @@ function formatScalar(value, sep = '、') {
|
|||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return (
|
||||||
.map((x) => formatScalar(x, sep))
|
value
|
||||||
.filter((x) => x && x !== '-')
|
.map((x) => formatScalar(x, sep))
|
||||||
.join(sep) || '-'
|
.filter((x) => x && x !== '-')
|
||||||
|
.join(sep) || '-'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
if (value.name != null) return String(value.name)
|
if (value.name != null) return String(value.name)
|
||||||
@@ -397,7 +399,13 @@ export function buildOaPrintModel(detail, opts = {}) {
|
|||||||
stampClass,
|
stampClass,
|
||||||
stampText,
|
stampText,
|
||||||
rows: [...headerRows, ...buildFormRows(detail)],
|
rows: [...headerRows, ...buildFormRows(detail)],
|
||||||
flowItems: buildFlowItems(detail),
|
flowItems: buildFlowItems(detail, (url) => {
|
||||||
|
try {
|
||||||
|
return new URL(attachmentFullUrl(url), window.location.origin).href
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
printTime,
|
printTime,
|
||||||
printerName,
|
printerName,
|
||||||
watermarkName: printerName === '-' ? '审批' : printerName
|
watermarkName: printerName === '-' ? '审批' : printerName
|
||||||
@@ -435,6 +443,9 @@ function flowCellHtml(f) {
|
|||||||
} else if (f.comment) {
|
} else if (f.comment) {
|
||||||
html += `<div class="oa-print-flow__comment">意见:${escapeHtml(f.comment)}</div>`
|
html += `<div class="oa-print-flow__comment">意见:${escapeHtml(f.comment)}</div>`
|
||||||
}
|
}
|
||||||
|
for (const url of f.attachmentLinks || []) {
|
||||||
|
html += `<div class="oa-print-url"><a data-oa-print-link href="${escapeHtml(url)}">${escapeHtml(url)}</a></div>`
|
||||||
|
}
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,9 +483,10 @@ function buildPrintStylesContent() {
|
|||||||
line-height: 1.5; word-break: break-all; }
|
line-height: 1.5; word-break: break-all; }
|
||||||
.oa-print-table th { width: 118px; font-weight: 400; text-align: left; background: #fff; }
|
.oa-print-table th { width: 118px; font-weight: 400; text-align: left; background: #fff; }
|
||||||
.oa-print-text { white-space: pre-wrap; }
|
.oa-print-text { white-space: pre-wrap; }
|
||||||
|
.oa-print-url a { color: inherit; }
|
||||||
.oa-print-url { color: #3370ff; word-break: break-all; }
|
.oa-print-url { color: #3370ff; word-break: break-all; }
|
||||||
.oa-print-flow__row { display: flex; justify-content: space-between; gap: 12px; }
|
.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__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-lines { margin-top: 4px; padding-left: 8px; }
|
||||||
.oa-print-flow__person-line { margin-top: 2px; font-size: 12px; }
|
.oa-print-flow__person-line { margin-top: 2px; font-size: 12px; }
|
||||||
@@ -602,7 +614,7 @@ function renderPageLabelCanvas(text) {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
async function canvasToPdfBlob(canvas) {
|
async function canvasToPdfBlob(canvas, sheet) {
|
||||||
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
|
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
|
||||||
const pageWidth = pdf.internal.pageSize.getWidth()
|
const pageWidth = pdf.internal.pageSize.getWidth()
|
||||||
const pageHeight = pdf.internal.pageSize.getHeight()
|
const pageHeight = pdf.internal.pageSize.getHeight()
|
||||||
@@ -620,6 +632,23 @@ async function canvasToPdfBlob(canvas) {
|
|||||||
const position = -pageIndex * pageHeight
|
const position = -pageIndex * pageHeight
|
||||||
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
|
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 label = `第${pageIndex + 1}页`
|
||||||
const labelCanvas = renderPageLabelCanvas(label)
|
const labelCanvas = renderPageLabelCanvas(label)
|
||||||
if (labelCanvas.width > 1) {
|
if (labelCanvas.width > 1) {
|
||||||
@@ -664,7 +693,7 @@ export async function generateOaApprovalPdfBlob(detail, opts = {}) {
|
|||||||
windowWidth: el.scrollWidth,
|
windowWidth: el.scrollWidth,
|
||||||
windowHeight: el.scrollHeight
|
windowHeight: el.scrollHeight
|
||||||
})
|
})
|
||||||
const blob = await canvasToPdfBlob(canvas)
|
const blob = await canvasToPdfBlob(canvas, el)
|
||||||
return { blob, filename }
|
return { blob, filename }
|
||||||
} finally {
|
} finally {
|
||||||
host.remove()
|
host.remove()
|
||||||
|
|||||||
@@ -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, '【评论】 评论')
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { mergeProgressWithComments } from '../oa-progress-merge-comments.js'
|
||||||
/**
|
/**
|
||||||
* 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测)
|
* 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测)
|
||||||
*/
|
*/
|
||||||
@@ -37,16 +38,22 @@ function isCcProgressItem(p) {
|
|||||||
const taskType = String(p?.taskType || p?.task_type || '').trim()
|
const taskType = String(p?.taskType || p?.task_type || '').trim()
|
||||||
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
|
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
|
||||||
const title = String(p?.title || p?.nodeName || '').trim()
|
const title = String(p?.title || p?.nodeName || '').trim()
|
||||||
return (
|
return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title)
|
||||||
taskType === 'cc' ||
|
|
||||||
signMode === 'cc' ||
|
|
||||||
title === '抄送人' ||
|
|
||||||
/^抄送/.test(title)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFlowItems(detail) {
|
export function buildFlowItems(detail, resolveUrl = (url) => url) {
|
||||||
const list = Array.isArray(detail?.progress) ? detail.progress : []
|
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 = []
|
const items = []
|
||||||
let ccBuf = null
|
let ccBuf = null
|
||||||
const flushCc = () => {
|
const flushCc = () => {
|
||||||
@@ -70,6 +77,37 @@ export function buildFlowItems(detail) {
|
|||||||
const signMode = String(p.signMode || p.sign_mode || '')
|
const signMode = String(p.signMode || p.sign_mode || '')
|
||||||
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
|
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 (isCcProgressItem(p)) {
|
||||||
if (!ccBuf) ccBuf = { names: [], ts }
|
if (!ccBuf) ccBuf = { names: [], ts }
|
||||||
if (names.length) ccBuf.names.push(...names)
|
if (names.length) ccBuf.names.push(...names)
|
||||||
|
|||||||
@@ -86,6 +86,9 @@
|
|||||||
<div v-else-if="f.comment" class="oa-print-flow__comment">
|
<div v-else-if="f.comment" class="oa-print-flow__comment">
|
||||||
意见:{{ f.comment }}
|
意见:{{ f.comment }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-for="url in f.attachmentLinks" :key="url" class="oa-print-url">
|
||||||
|
<a :href="url" data-oa-print-link>{{ url }}</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -221,6 +224,10 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.oa-print-url a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.oa-print-url {
|
.oa-print-url {
|
||||||
color: #3370ff;
|
color: #3370ff;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@@ -248,6 +255,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.oa-print-flow__left {
|
.oa-print-flow__left {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '【评论】 评论')
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { mergeProgressWithComments } from './oa-progress-merge-comments'
|
||||||
/**
|
/**
|
||||||
* OA 打印「审批流程」行组装(无 Vue/canvas 副作用,便于单测)
|
* OA 打印「审批流程」行组装(无 Vue/canvas 副作用,便于单测)
|
||||||
*/
|
*/
|
||||||
@@ -11,6 +12,7 @@ export type OaPrintPersonLine = {
|
|||||||
export type OaPrintFlowItem = {
|
export type OaPrintFlowItem = {
|
||||||
left: string
|
left: string
|
||||||
right: string
|
right: string
|
||||||
|
attachmentLinks?: string[]
|
||||||
/** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
|
/** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
|
||||||
comment?: string
|
comment?: string
|
||||||
/** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */
|
/** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */
|
||||||
@@ -66,20 +68,29 @@ function isCcProgressItem(p: any): boolean {
|
|||||||
const taskType = String(p?.taskType || p?.task_type || '').trim()
|
const taskType = String(p?.taskType || p?.task_type || '').trim()
|
||||||
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
|
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
|
||||||
const title = String(p?.title || p?.nodeName || '').trim()
|
const title = String(p?.title || p?.nodeName || '').trim()
|
||||||
return (
|
return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title)
|
||||||
taskType === 'cc' ||
|
|
||||||
signMode === 'cc' ||
|
|
||||||
title === '抄送人' ||
|
|
||||||
/^抄送/.test(title)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 progress 组装审批流程行;抄送节点聚合为「抄送A, B, C」对齐钉钉导出。
|
* 从 progress 组装审批流程行;抄送节点聚合为「抄送A, B, C」对齐钉钉导出。
|
||||||
* 若 progress 漏抄送,则用 tasks 兜底补一行。
|
* 若 progress 漏抄送,则用 tasks 兜底补一行。
|
||||||
*/
|
*/
|
||||||
export function buildFlowItems(detail: any): OaPrintFlowItem[] {
|
export function buildFlowItems(
|
||||||
const list = Array.isArray(detail?.progress) ? detail.progress : []
|
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[] = []
|
const items: OaPrintFlowItem[] = []
|
||||||
|
|
||||||
let ccBuf: { names: string[]; ts: string } | null = null
|
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 signMode = String(p.signMode || p.sign_mode || '')
|
||||||
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
|
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 (isCcProgressItem(p)) {
|
||||||
if (!ccBuf) ccBuf = { names: [], ts }
|
if (!ccBuf) ccBuf = { names: [], ts }
|
||||||
if (names.length) ccBuf.names.push(...names)
|
if (names.length) ccBuf.names.push(...names)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
||||||
/**
|
/**
|
||||||
* OA 审批单钉钉风格 PDF 导出
|
* OA 审批单钉钉风格 PDF 导出
|
||||||
*/
|
*/
|
||||||
@@ -398,7 +399,13 @@ export async function buildOaPrintModel(
|
|||||||
stampClass,
|
stampClass,
|
||||||
stampText,
|
stampText,
|
||||||
rows: [...headerRows, ...formRows],
|
rows: [...headerRows, ...formRows],
|
||||||
flowItems: buildFlowItems(detail),
|
flowItems: buildFlowItems(detail, (url) => {
|
||||||
|
try {
|
||||||
|
return new URL(attachmentFullUrl(url), window.location.origin).href
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
printTime,
|
printTime,
|
||||||
printerName,
|
printerName,
|
||||||
watermarkName: printerName === '-' ? '审批' : printerName
|
watermarkName: printerName === '-' ? '审批' : printerName
|
||||||
@@ -447,7 +454,7 @@ function renderPageLabelCanvas(text: string): HTMLCanvasElement {
|
|||||||
return c
|
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 pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
|
||||||
const pageWidth = pdf.internal.pageSize.getWidth()
|
const pageWidth = pdf.internal.pageSize.getWidth()
|
||||||
const pageHeight = pdf.internal.pageSize.getHeight()
|
const pageHeight = pdf.internal.pageSize.getHeight()
|
||||||
@@ -466,6 +473,23 @@ async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) {
|
|||||||
const position = -pageIndex * pageHeight
|
const position = -pageIndex * pageHeight
|
||||||
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
|
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<HTMLAnchorElement>('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 label = `第${pageIndex + 1}页`
|
||||||
const labelCanvas = renderPageLabelCanvas(label)
|
const labelCanvas = renderPageLabelCanvas(label)
|
||||||
if (labelCanvas.width > 1) {
|
if (labelCanvas.width > 1) {
|
||||||
@@ -526,7 +550,7 @@ export async function exportOaApprovalPdf(
|
|||||||
windowWidth: el.scrollWidth,
|
windowWidth: el.scrollWidth,
|
||||||
windowHeight: el.scrollHeight
|
windowHeight: el.scrollHeight
|
||||||
})
|
})
|
||||||
await canvasToPdf(canvas, filename)
|
await canvasToPdf(canvas, filename, el)
|
||||||
} finally {
|
} finally {
|
||||||
app.unmount()
|
app.unmount()
|
||||||
host.remove()
|
host.remove()
|
||||||
|
|||||||
Reference in new issue
Block a user