Files
klp-oa/klp-ui/src/views/crm/contract/components/ContractExportDialog.vue
砂糖 9761faa2d2 feat(wms, crm): 新增导出模式选择并修复canvas图片缩放问题
1. 为Wms异常报表页面新增多行/单行导出切换功能
2. 拆分导出菜单为单行、多行两种导出选项
3. 修复ContractExportDialog中图片缩放比例的冗余限制
2026-06-11 14:49:31 +08:00

602 lines
27 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-dialog title="导出预览" :visible.sync="dialogVisible" width="95%" top="20px" append-to-body
: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>
<el-checkbox v-model="selectAllColumns" :indeterminate="columnIndeterminate" @change="handleSelectAllColumns"
style="margin-bottom: 8px;">全选</el-checkbox>
<div v-for="col in columnConfigs" :key="col.key" style="margin-bottom: 6px;">
<el-checkbox v-model="col.checked" @change="onColumnChange">{{ col.label }}</el-checkbox>
</div>
<el-divider />
<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>
<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;">
<iframe ref="previewFrame" style="width: 100%; height: 100%; border: none; background: #fff;"></iframe>
</div>
</div>
<span slot="footer">
<el-button @click="handleCancel"> </el-button>
<el-button type="primary" :loading="exporting" @click="confirmExport">确认导出</el-button>
</span>
</el-dialog>
</template>
<script>
import contractLogo from '@/assets/images/contractLogo.png';
import {
parseProductContent,
convertToChinese
} from '@/utils/productContent';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
import { listByIds } from '@/api/system/oss';
const CHINESE_HEADING_REGEX = /^[零一二三四五六七八九十百千]+、/;
export default {
name: "ContractExportDialog",
props: {
visible: {
type: Boolean,
default: false
},
row: {
type: Object,
default: null
}
},
data() {
return {
exporting: false,
selectAllColumns: true,
columnIndeterminate: false,
columnConfigs: [
{ key: 'spec', label: '规格mm', checked: true },
{ key: 'material', label: '材质', checked: true },
{ key: 'quantity', label: '数量(吨)', checked: true },
{ key: 'taxPrice', label: '含税单价(元/吨)', checked: true },
{ key: 'taxDivisor', label: '税率除数', checked: true },
{ key: 'noTaxPrice', label: '无税单价(元/吨)', checked: true },
{ key: 'taxTotal', label: '含税总额(元)', checked: true },
{ key: 'noTaxTotal', label: '无税总额(元)', checked: true },
{ key: 'taxAmount', label: '税额(元)', checked: true },
{ key: 'remark', label: '备注', checked: true },
],
selectAllRows: true,
rowIndeterminate: false,
rowConfigs: [
{ key: 'totalRow', label: '合计行', checked: true },
{ key: 'amountWordsRow', label: '大写金额行', checked: true },
{ key: 'remarkRow', label: '备注行', checked: true },
],
attachmentConfigs: [],
attachmentLoading: false,
selectAllAttachments: true,
attachmentIndeterminate: false,
};
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(val) {
this.$emit('update:visible', val);
}
}
},
methods: {
handleCancel() {
this.$emit('update:visible', false);
},
handleSelectAllColumns(val) {
this.columnConfigs.forEach(col => { col.checked = val; });
this.columnIndeterminate = false;
this.generatePreviewHtml();
},
onColumnChange() {
const checkedCount = this.columnConfigs.filter(c => c.checked).length;
this.selectAllColumns = checkedCount === this.columnConfigs.length;
this.columnIndeterminate = checkedCount > 0 && checkedCount < this.columnConfigs.length;
this.generatePreviewHtml();
},
handleSelectAllRows(val) {
this.rowConfigs.forEach(row => { row.checked = val; });
this.rowIndeterminate = false;
this.generatePreviewHtml();
},
onRowChange() {
const checkedCount = this.rowConfigs.filter(r => r.checked).length;
this.selectAllRows = checkedCount === this.rowConfigs.length;
this.rowIndeterminate = checkedCount > 0 && checkedCount < this.rowConfigs.length;
this.generatePreviewHtml();
},
buildProductTableHtml(productData, products) {
const activeCols = this.columnConfigs.filter(c => c.checked);
const activeRows = this.rowConfigs.filter(r => r.checked);
const hasCol = (key) => activeCols.some(c => c.key === key);
const hasRow = (key) => activeRows.some(r => r.key === key);
let headerCells = '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:30px;">序号</th>';
if (hasCol('spec')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:80px;">规格(mm)</th>';
if (hasCol('material')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:60px;">材质</th>';
if (hasCol('quantity')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:55px;">数量(吨)</th>';
if (hasCol('taxPrice')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">含税单价(元/吨)</th>';
if (hasCol('taxDivisor')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:45px;">税率除数</th>';
if (hasCol('noTaxPrice')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">无税单价(元/吨)</th>';
if (hasCol('taxTotal')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">含税总额(元)</th>';
if (hasCol('noTaxTotal')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:70px;">无税总额(元)</th>';
if (hasCol('taxAmount')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:55px;">税额(元)</th>';
if (hasCol('remark')) headerCells += '<th style="border:1px solid #000;padding:5px 6px;font-weight:bold;width:80px;">备注</th>';
const colCount = activeCols.length + 1;
let bodyRows = '';
products.forEach((product, index) => {
let cells = `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${index + 1}</td>`;
if (hasCol('spec')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.spec || ''}</td>`;
if (hasCol('material')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.material || ''}</td>`;
if (hasCol('quantity')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.quantity || ''}</td>`;
if (hasCol('taxPrice')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.taxPrice || ''}</td>`;
if (hasCol('taxDivisor')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${product.taxDivisor || '1.13'}</td>`;
if (hasCol('noTaxPrice')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.noTaxPrice || 0).toFixed(3)}</td>`;
if (hasCol('taxTotal')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.taxTotal || 0).toFixed(3)}</td>`;
if (hasCol('noTaxTotal')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.noTaxTotal || 0).toFixed(3)}</td>`;
if (hasCol('taxAmount')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${(product.taxAmount || 0).toFixed(3)}</td>`;
if (hasCol('remark')) cells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;max-width:100px;word-wrap:break-word;">${product.remark || ''}</td>`;
bodyRows += `<tr>${cells}</tr>`;
});
const totalQty = products.reduce((a, p) => a + (parseFloat(p.quantity) || 0), 0);
const totalTax = products.reduce((a, p) => a + (parseFloat(p.taxTotal) || 0), 0);
const totalNoTax = products.reduce((a, p) => a + (parseFloat(p.noTaxTotal) || 0), 0);
const totalTaxAmt = products.reduce((a, p) => a + (parseFloat(p.taxAmount) || 0), 0);
const totalAmountInWords = productData.totalAmountInWords || convertToChinese(totalTax) || '零元整';
let totalCells = '';
let cellIdx = 0;
activeCols.forEach((col) => {
if (cellIdx < 2) {
if (cellIdx === 0) {
totalCells += `<td colspan="3" style="border:1px solid #000;padding:5px 6px;font-weight:bold;text-align:center;">合&nbsp;&nbsp;计</td>`;
}
} else {
let val = '';
if (col.key === 'quantity') val = totalQty.toFixed(3);
else if (col.key === 'taxTotal') val = totalTax.toFixed(3);
else if (col.key === 'noTaxTotal') val = totalNoTax.toFixed(3);
else if (col.key === 'taxAmount') val = totalTaxAmt.toFixed(3);
totalCells += `<td style="border:1px solid #000;padding:4px 6px;text-align:center;">${val}</td>`;
}
cellIdx++;
});
const html = `
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:6px;border:2px solid #000;">
<tr>
<td colspan="4" style="border:1px solid #000;padding:4px 6px;font-weight:bold;text-align:left;">产品名称:${productData.productName || ''}</td>
<td colspan="${colCount - 4 > 0 ? colCount - 4 : 1}" style="border:1px solid #000;padding:4px 6px;font-weight:bold;text-align:left;">生产厂家:嘉祥科伦普重工有限公司</td>
</tr>
<tr>${headerCells}</tr>
${bodyRows}
${hasRow('totalRow') ? `<tr>${totalCells}</tr>` : ''}
${hasRow('amountWordsRow') ? `<tr><td colspan="${colCount}" style="border:1px solid #000;padding:5px 6px;font-weight:bold;text-align:left;">合计人民币(大写):${totalAmountInWords}</td></tr>` : ''}
${hasRow('remarkRow') && productData.remark ? `<tr><td colspan="${colCount}" style="border:1px solid #000;padding:4px 6px;text-align:left;">备注:${productData.remark}</td></tr>` : ''}
</table>`;
return html;
},
buildContractContentHtml(htmlContent) {
if (!htmlContent) return '';
const pTagRegex = /<p[^>]*>([\s\S]*?)<\/p>/g;
let match;
// Parse into clauses: each heading (Chinese number + 、) starts a new clause
const clauses = [];
let currentClause = null;
while ((match = pTagRegex.exec(htmlContent)) !== null) {
let content = match[1].replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
if (!content) continue;
const isHeading = CHINESE_HEADING_REGEX.test(content);
if (isHeading) {
if (currentClause) clauses.push(currentClause);
currentClause = { heading: content, bodies: [] };
} else if (currentClause) {
currentClause.bodies.push(content);
} else {
// Content before any heading: create a headless clause
clauses.push({ heading: null, bodies: [content] });
}
}
if (currentClause) clauses.push(currentClause);
if (clauses.length === 0) {
// Fallback: plain text content without <p> tags
const textContent = htmlContent.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();
if (textContent) {
clauses.push({ heading: null, bodies: [textContent] });
}
}
if (clauses.length === 0) return '';
let html = '<div style="margin-top:8px;font-size:12px;line-height:1.8;">';
clauses.forEach(clause => {
html += '<div data-clause style="page-break-inside:avoid;padding-top:2px;">';
if (clause.heading) {
html += `<div>${clause.heading}</div>`;
}
clause.bodies.forEach(body => {
html += `<div> ${body}</div>`;
});
html += '</div>';
});
html += '</div>';
return html;
},
generatePreviewHtml() {
const row = this.row;
if (!row) return;
let productData = parseProductContent(row.productContent);
if (!productData.productName && row.productName) {
productData.productName = row.productName;
}
const products = productData.products && productData.products.length > 0 ? productData.products : [];
const productTableHtml = this.buildProductTableHtml(productData, products);
const contractContentHtml = this.buildContractContentHtml(row.contractContent);
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>
<head><meta charset="utf-8"><title>合同预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'SimHei','黑体',sans-serif; font-weight: 600; color: #000; background: #e8e8e8; font-size: 12px; line-height: 1.6; display: flex; flex-direction: column; align-items: center; padding: 20px 0; }
.a4-page { width: 794px; min-height: 1123px; padding: 30px 40px; background: #fff; margin-bottom: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.15); }
.company-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 6px; }
.contract-title { text-align: center; font-size: 20px; font-weight: bold; letter-spacing: 6px; margin-bottom: 14px; }
.info-row { display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 13px; line-height: 2; }
.section-title { font-size: 13px; font-weight: bold; margin-bottom: 4px; margin-top: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #000; padding: 4px 6px; text-align: center; font-size: 13px; }
th { font-weight: 700; }
.sign-section { margin-top: 24px; font-size: 12px; line-height: 2.2; }
.sign-section .col { width: 48%; }
.sign-row { display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="a4-page" style="position:relative;">
<img src="${contractLogo}" style="position:absolute;left:40px;top:40px;height:80px;" crossorigin="anonymous" />
<div style="text-align:center;padding-top:10px;">
<div style="font-size:20px;font-weight:bold;letter-spacing:2px;">嘉祥科伦普重工有限公司</div>
</div>
<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 style="width:55%;">
<div>供方(甲方):${row.supplier || ''}</div>
<div>需方(乙方):${row.customer || ''}</div>
</div>
<div style="width:40%;">
<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 class="sign-row">
<div class="col">
<div>供方(甲方):${row.supplier || ''}</div>
<div>地址:${row.supplierAddress || ''}</div>
<div>电话:${row.supplierPhone || ''}</div>
<div>开户行:${row.supplierBank || ''}</div>
<div>账号:${row.supplierAccount || ''}</div>
<div>税号:${row.supplierTaxNo || ''}</div>
</div>
<div class="col">
<div>需方(乙方):${row.customer || ''}</div>
<div>地址:${row.customerAddress || ''}</div>
<div>电话:${row.customerPhone || ''}</div>
<div>开户行:${row.customerBank || ''}</div>
<div>账号:${row.customerAccount || ''}</div>
<div>税号:${row.customerTaxNo || ''}</div>
</div>
</div>
</div>
</div>
${imagePagesHtml}
</body>
</html>`;
this.$nextTick(() => {
const iframe = this.$refs.previewFrame;
if (iframe) {
iframe.srcdoc = fullHtml;
}
});
},
getClauseCanvasPositions(iframeDoc, a4Page, canvasWidth) {
const clauseEls = iframeDoc.querySelectorAll('[data-clause]');
const a4Rect = a4Page.getBoundingClientRect();
const scale = canvasWidth / a4Page.offsetWidth;
const positions = [];
clauseEls.forEach(el => {
const rect = el.getBoundingClientRect();
positions.push({
top: (rect.top - a4Rect.top) * scale,
bottom: (rect.bottom - a4Rect.top) * scale
});
});
positions.sort((a, b) => a.top - b.top);
return positions;
},
async confirmExport() {
const iframe = this.$refs.previewFrame;
if (!iframe || !iframe.contentWindow) return;
const iframeDoc = iframe.contentDocument;
const a4Page = iframeDoc.querySelector('.a4-page');
if (!a4Page) return;
this.exporting = true;
try {
const canvas = await html2canvas(a4Page, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false
});
const clausePositions = this.getClauseCanvasPositions(iframeDoc, a4Page, canvas.width);
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pW = pdf.internal.pageSize.getWidth();
const pH = pdf.internal.pageSize.getHeight();
const mg = 10;
const printW = pW - mg * 2;
const printH = pH - mg * 2;
const ratio = canvas.width / printW;
const footerMargin = 8;
const imagePrintH = printH - footerMargin;
const pageCanvasHeight = imagePrintH * ratio;
let currentY = 0;
const pages = [];
while (currentY < canvas.height) {
let pageBottom = currentY + pageCanvasHeight;
if (pageBottom >= canvas.height) {
pageBottom = canvas.height;
} else {
for (const clause of clausePositions) {
// If a clause starts before the page break and ends after it,
// and the clause fits on a single page, move the break before this clause
if (clause.top < pageBottom && clause.bottom > pageBottom) {
if (clause.bottom - clause.top <= pageCanvasHeight) {
pageBottom = clause.top;
}
break;
}
}
}
if (pageBottom <= currentY) {
pageBottom = currentY + pageCanvasHeight;
}
pages.push({ top: currentY, bottom: pageBottom });
currentY = pageBottom;
}
const checkedAttachmentCount = this.attachmentConfigs.filter(a => a.checked).length;
const totalPages = pages.length + checkedAttachmentCount;
let pageNum = 0;
const fullPageCanvasHeight = Math.round(printH * ratio);
for (let i = 0; i < pages.length; i++) {
pageNum++;
const { top, bottom } = pages[i];
const sliceH = bottom - top;
const sc = document.createElement('canvas');
sc.width = canvas.width;
sc.height = fullPageCanvasHeight;
const sctx = sc.getContext('2d');
sctx.fillStyle = '#ffffff';
sctx.fillRect(0, 0, sc.width, sc.height);
sctx.drawImage(canvas, 0, top, canvas.width, sliceH, 0, 0, canvas.width, sliceH);
sctx.fillStyle = '#000000';
sctx.font = 'bold 24px "SimHei","黑体",sans-serif';
sctx.textAlign = 'center';
sctx.textBaseline = 'bottom';
sctx.fillText(`${pageNum} 页,共 ${totalPages}`, sc.width / 2, sc.height - 4);
pdf.addImage(sc.toDataURL('image/jpeg', 0.95), 'JPEG', mg, mg, printW, printH);
if (i < pages.length - 1) {
pdf.addPage();
}
}
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();
pageNum++;
const canvasPageW = Math.round(printW * ratio);
const canvasImageH = Math.round(imagePrintH * ratio);
const canvasFooterH = Math.round(footerMargin * ratio);
const canvasTotalH = canvasImageH + canvasFooterH;
const attCanvas = document.createElement('canvas');
attCanvas.width = canvasPageW;
attCanvas.height = canvasTotalH;
const actx = attCanvas.getContext('2d');
actx.fillStyle = '#ffffff';
actx.fillRect(0, 0, canvasPageW, canvasTotalH);
const imgScale = Math.min(canvasPageW / img.width, canvasImageH / img.height);
const imgDrawW = img.width * imgScale;
const imgDrawH = img.height * imgScale;
const imgX = (canvasPageW - imgDrawW) / 2;
const imgY = (canvasImageH - imgDrawH) / 2;
actx.drawImage(img, imgX, imgY, imgDrawW, imgDrawH);
actx.fillStyle = '#000000';
actx.font = 'bold 24px "SimHei","黑体",sans-serif';
actx.textAlign = 'center';
actx.textBaseline = 'bottom';
actx.fillText(`${pageNum} 页,共 ${totalPages}`, canvasPageW / 2, canvasTotalH - 4);
pdf.addImage(attCanvas.toDataURL('image/jpeg', 0.95), 'JPEG', mg, mg, printW, printH);
} 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);
} catch (e) {
this.$message.error('PDF导出失败' + e.message);
console.error(e);
} finally {
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>