feat: 合同导出新增附件图片配置功能

- 新增附件图片加载区域(从 businessAnnex/techAnnex 等字段提取 OSS 图片)
- 支持图片勾选显隐、拖拽排序(上移/下移)
- 导出 HTML 预览和 PDF 均支持图片附件页渲染
- @open 重构为 onDialogOpen,自动拉取附件列表
- 新增 loadImage 辅助方法,PDF 导出支持 JPEG 图片嵌入
This commit is contained in:
2026-06-08 11:21:25 +08:00
parent f6a74e58ea
commit fac59f4346

View File

@@ -1,6 +1,6 @@
<template>
<el-dialog title="导出预览" :visible.sync="dialogVisible" width="95%" top="20px" append-to-body
:close-on-click-modal="false" @open="generatePreviewHtml">
:close-on-click-modal="false" @open="onDialogOpen">
<div style="display: flex; gap: 16px; height: calc(100vh - 200px);">
<div style="width: 200px; flex-shrink: 0; border: 1px solid #e4e7ed; border-radius: 4px; padding: 12px; overflow-y: auto;">
<div style="font-weight: bold; margin-bottom: 10px; font-size: 14px;">产品表列配置</div>
@@ -13,8 +13,27 @@
<div style="font-weight: bold; margin-bottom: 10px; font-size: 14px;">附加行配置</div>
<el-checkbox v-model="selectAllRows" :indeterminate="rowIndeterminate" @change="handleSelectAllRows"
style="margin-bottom: 8px;">全选</el-checkbox>
<div v-for="rowCfg in rowConfigs" :key="rowCfg.key" style="margin-bottom: 6px;">
<el-checkbox v-model="rowCfg.checked" @change="onRowChange">{{ rowCfg.label }}</el-checkbox>
<div v-for="rowCfg in rowConfigs" :key="rowCfg.key" style="margin-bottom: 6px;">
<el-checkbox v-model="rowCfg.checked" @change="onRowChange">{{ rowCfg.label }}</el-checkbox>
</div>
<el-divider />
<div style="font-weight: bold; margin-bottom: 10px; font-size: 14px;">附件图片配置</div>
<div v-if="attachmentLoading" style="text-align:center;padding:10px;color:#909399;font-size:12px;">
<i class="el-icon-loading"></i> 加载中...
</div>
<div v-else-if="attachmentConfigs.length === 0" style="color:#909399;font-size:12px;text-align:center;padding:10px;">
无图片附件
</div>
<div v-else>
<el-checkbox v-model="selectAllAttachments" :indeterminate="attachmentIndeterminate" @change="handleSelectAllAttachments"
style="margin-bottom: 8px;">全选</el-checkbox>
<div v-for="(att, index) in attachmentConfigs" :key="att.ossId" style="display:flex;align-items:center;margin-bottom:6px;gap:2px;">
<el-checkbox v-model="att.checked" @change="onAttachmentChange" style="flex:1;min-width:0;">
<span style="font-size:11px;display:inline-block;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;" :title="att.originalName">{{ att.originalName }}</span>
</el-checkbox>
<el-button icon="el-icon-top" size="mini" circle :disabled="index === 0" @click="moveAttachmentUp(index)" style="padding:4px;"></el-button>
<el-button icon="el-icon-bottom" size="mini" circle :disabled="index === attachmentConfigs.length - 1" @click="moveAttachmentDown(index)" style="padding:4px;"></el-button>
</div>
</div>
</div>
<div style="flex: 1; border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; background: #f0f0f0;">
@@ -36,6 +55,7 @@ import {
} from '@/utils/productContent';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
import { listByIds } from '@/api/system/oss';
const CHINESE_HEADING_REGEX = /^[零一二三四五六七八九十百千]+、/;
@@ -75,6 +95,10 @@ export default {
{ key: 'amountWordsRow', label: '大写金额行', checked: true },
{ key: 'remarkRow', label: '备注行', checked: true },
],
attachmentConfigs: [],
attachmentLoading: false,
selectAllAttachments: true,
attachmentIndeterminate: false,
};
},
computed: {
@@ -254,6 +278,15 @@ export default {
const totalTax = products.reduce((a, p) => a + (parseFloat(p.taxTotal) || 0), 0);
const amountWords = productData.totalAmountInWords || convertToChinese(totalTax) || '零元整';
let imagePagesHtml = '';
this.attachmentConfigs.forEach(att => {
if (!att.checked) return;
imagePagesHtml += `
<div class="a4-page" style="display:flex;align-items:center;justify-content:center;background:#fff;">
<img src="${att.url}" style="max-width:90%;max-height:90%;object-fit:contain;" crossorigin="anonymous" />
</div>`;
});
const fullHtml = `
<!DOCTYPE html>
<html>
@@ -283,8 +316,8 @@ export default {
<div style="position:relative;margin:12px 0 8px 0;text-align:center;">
<div style="font-size:22px;font-weight:bold;letter-spacing:6px;">产 品 销 售 合 同</div>
<div style="position:absolute;right:0;top:50%;transform:translateY(-50%);font-size:11px;">合同编号:${row.contractCode || ''}</div>
</div>
<div class="info-row">
</div>
<div class="info-row">
<div style="width:55%;">
<div>供方(甲方):${row.supplier || ''}</div>
<div>需方(乙方):${row.customer || ''}</div>
@@ -293,11 +326,11 @@ export default {
<div>签订时间:${row.signTime ? this.parseTime(row.signTime, '{y}年{m}月{d}日') : ''}</div>
<div>签订地点:${row.signLocation || ''}</div>
</div>
</div>
<div class="section-title">一、产品内容</div>
${productTableHtml}
${contractContentHtml}
<div class="sign-section" data-clause style="page-break-inside:avoid;">
</div>
<div class="section-title">一、产品内容</div>
${productTableHtml}
${contractContentHtml}
<div class="sign-section" data-clause style="page-break-inside:avoid;">
<div class="sign-row">
<div class="col">
<div>供方(甲方):${row.supplier || ''}</div>
@@ -316,8 +349,9 @@ export default {
<div>税号:${row.customerTaxNo || ''}</div>
</div>
</div>
</div>
</div>
</div>
${imagePagesHtml}
</body>
</html>`;
@@ -418,6 +452,34 @@ export default {
}
}
for (let i = 0; i < this.attachmentConfigs.length; i++) {
const att = this.attachmentConfigs[i];
if (!att.checked) continue;
try {
const img = await this.loadImage(att.url);
pdf.addPage();
let drawW = img.width;
let drawH = img.height;
const scale = Math.min(printW / drawW, printH / drawH, 1);
drawW *= scale;
drawH *= scale;
const x = mg + (printW - drawW) / 2;
const y = mg + (printH - drawH) / 2;
const tmpCanvas = document.createElement('canvas');
tmpCanvas.width = img.width;
tmpCanvas.height = img.height;
tmpCanvas.getContext('2d').drawImage(img, 0, 0);
pdf.addImage(tmpCanvas.toDataURL('image/jpeg', 0.95), 'JPEG', x, y, drawW, drawH);
} catch (e) {
console.error('加载附件图片失败: ' + att.originalName, e);
}
}
const filename = '合同_' + (this.row.contractCode || 'export') + '_' + new Date().getTime() + '.pdf';
pdf.save(filename);
this.$emit('update:visible', false);
@@ -428,6 +490,84 @@ export default {
this.exporting = false;
}
},
async onDialogOpen() {
await this.fetchAttachments();
this.generatePreviewHtml();
},
async fetchAttachments() {
const row = this.row;
if (!row) return;
const fields = ['businessAnnex', 'techAnnex', 'productionSchedule', 'annexFiles'];
const allIds = [];
fields.forEach(field => {
if (row[field]) {
const ids = row[field].split(',').map(id => id.trim()).filter(id => id);
allIds.push(...ids);
}
});
if (allIds.length === 0) {
this.attachmentConfigs = [];
return;
}
this.attachmentLoading = true;
try {
const uniqueIds = [...new Set(allIds)];
const res = await listByIds(uniqueIds.join(','));
const files = res.data || [];
const imageSuffixes = ['jpg', 'jpeg', 'png', 'webp', 'bmp'];
this.attachmentConfigs = files
.filter(f => imageSuffixes.includes((f.fileSuffix || '').toLowerCase().replace(/^\./, '')))
.map(f => ({
ossId: f.ossId,
url: f.url,
originalName: f.originalName,
fileSuffix: f.fileSuffix,
checked: true
}));
this.selectAllAttachments = this.attachmentConfigs.length > 0;
this.attachmentIndeterminate = false;
} catch (e) {
console.error('获取附件列表失败', e);
this.attachmentConfigs = [];
} finally {
this.attachmentLoading = false;
}
},
moveAttachmentUp(index) {
if (index <= 0) return;
const arr = this.attachmentConfigs;
[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]];
this.generatePreviewHtml();
},
moveAttachmentDown(index) {
if (index >= this.attachmentConfigs.length - 1) return;
const arr = this.attachmentConfigs;
[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]];
this.generatePreviewHtml();
},
onAttachmentChange() {
const checkedCount = this.attachmentConfigs.filter(a => a.checked).length;
this.selectAllAttachments = checkedCount === this.attachmentConfigs.length;
this.attachmentIndeterminate = checkedCount > 0 && checkedCount < this.attachmentConfigs.length;
this.generatePreviewHtml();
},
handleSelectAllAttachments(val) {
this.attachmentConfigs.forEach(a => { a.checked = val; });
this.attachmentIndeterminate = false;
this.generatePreviewHtml();
},
loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
},
}
};
</script>