feat: 合同导出新增附件图片配置功能
- 新增附件图片加载区域(从 businessAnnex/techAnnex 等字段提取 OSS 图片) - 支持图片勾选显隐、拖拽排序(上移/下移) - 导出 HTML 预览和 PDF 均支持图片附件页渲染 - @open 重构为 onDialogOpen,自动拉取附件列表 - 新增 loadImage 辅助方法,PDF 导出支持 JPEG 图片嵌入
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog title="导出预览" :visible.sync="dialogVisible" width="95%" top="20px" append-to-body
|
<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="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="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>
|
<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>
|
<div style="font-weight: bold; margin-bottom: 10px; font-size: 14px;">附加行配置</div>
|
||||||
<el-checkbox v-model="selectAllRows" :indeterminate="rowIndeterminate" @change="handleSelectAllRows"
|
<el-checkbox v-model="selectAllRows" :indeterminate="rowIndeterminate" @change="handleSelectAllRows"
|
||||||
style="margin-bottom: 8px;">全选</el-checkbox>
|
style="margin-bottom: 8px;">全选</el-checkbox>
|
||||||
<div v-for="rowCfg in rowConfigs" :key="rowCfg.key" style="margin-bottom: 6px;">
|
<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>
|
<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>
|
</div>
|
||||||
<div style="flex: 1; border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; background: #f0f0f0;">
|
<div style="flex: 1; border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; background: #f0f0f0;">
|
||||||
@@ -36,6 +55,7 @@ import {
|
|||||||
} from '@/utils/productContent';
|
} from '@/utils/productContent';
|
||||||
import html2canvas from 'html2canvas';
|
import html2canvas from 'html2canvas';
|
||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
|
import { listByIds } from '@/api/system/oss';
|
||||||
|
|
||||||
const CHINESE_HEADING_REGEX = /^[零一二三四五六七八九十百千]+、/;
|
const CHINESE_HEADING_REGEX = /^[零一二三四五六七八九十百千]+、/;
|
||||||
|
|
||||||
@@ -75,6 +95,10 @@ export default {
|
|||||||
{ key: 'amountWordsRow', label: '大写金额行', checked: true },
|
{ key: 'amountWordsRow', label: '大写金额行', checked: true },
|
||||||
{ key: 'remarkRow', label: '备注行', checked: true },
|
{ key: 'remarkRow', label: '备注行', checked: true },
|
||||||
],
|
],
|
||||||
|
attachmentConfigs: [],
|
||||||
|
attachmentLoading: false,
|
||||||
|
selectAllAttachments: true,
|
||||||
|
attachmentIndeterminate: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -254,6 +278,15 @@ export default {
|
|||||||
const totalTax = products.reduce((a, p) => a + (parseFloat(p.taxTotal) || 0), 0);
|
const totalTax = products.reduce((a, p) => a + (parseFloat(p.taxTotal) || 0), 0);
|
||||||
const amountWords = productData.totalAmountInWords || convertToChinese(totalTax) || '零元整';
|
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 = `
|
const fullHtml = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -283,8 +316,8 @@ export default {
|
|||||||
<div style="position:relative;margin:12px 0 8px 0;text-align:center;">
|
<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="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 style="position:absolute;right:0;top:50%;transform:translateY(-50%);font-size:11px;">合同编号:${row.contractCode || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<div style="width:55%;">
|
<div style="width:55%;">
|
||||||
<div>供方(甲方):${row.supplier || ''}</div>
|
<div>供方(甲方):${row.supplier || ''}</div>
|
||||||
<div>需方(乙方):${row.customer || ''}</div>
|
<div>需方(乙方):${row.customer || ''}</div>
|
||||||
@@ -293,11 +326,11 @@ export default {
|
|||||||
<div>签订时间:${row.signTime ? this.parseTime(row.signTime, '{y}年{m}月{d}日') : ''}</div>
|
<div>签订时间:${row.signTime ? this.parseTime(row.signTime, '{y}年{m}月{d}日') : ''}</div>
|
||||||
<div>签订地点:${row.signLocation || ''}</div>
|
<div>签订地点:${row.signLocation || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">一、产品内容</div>
|
<div class="section-title">一、产品内容</div>
|
||||||
${productTableHtml}
|
${productTableHtml}
|
||||||
${contractContentHtml}
|
${contractContentHtml}
|
||||||
<div class="sign-section" data-clause style="page-break-inside:avoid;">
|
<div class="sign-section" data-clause style="page-break-inside:avoid;">
|
||||||
<div class="sign-row">
|
<div class="sign-row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div>供方(甲方):${row.supplier || ''}</div>
|
<div>供方(甲方):${row.supplier || ''}</div>
|
||||||
@@ -316,8 +349,9 @@ export default {
|
|||||||
<div>税号:${row.customerTaxNo || ''}</div>
|
<div>税号:${row.customerTaxNo || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
${imagePagesHtml}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</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';
|
const filename = '合同_' + (this.row.contractCode || 'export') + '_' + new Date().getTime() + '.pdf';
|
||||||
pdf.save(filename);
|
pdf.save(filename);
|
||||||
this.$emit('update:visible', false);
|
this.$emit('update:visible', false);
|
||||||
@@ -428,6 +490,84 @@ export default {
|
|||||||
this.exporting = false;
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user