feat: 合同导出新增附件图片配置功能
- 新增附件图片加载区域(从 businessAnnex/techAnnex 等字段提取 OSS 图片) - 支持图片勾选显隐、拖拽排序(上移/下移) - 导出 HTML 预览和 PDF 均支持图片附件页渲染 - @open 重构为 onDialogOpen,自动拉取附件列表 - 新增 loadImage 辅助方法,PDF 导出支持 JPEG 图片嵌入
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user