Files
klp-oa/klp-ui/src/views/crm/contract/components/ContractExportDialog.vue
砂糖 cd3cc85c0a feat: 合同导出弹窗新增导出行配置(合计行/大写金额行/备注行可选)
- 新增 rowConfigs 配置,支持控制合计行、大写金额行、备注行显隐
- 新增全选/半选逻辑,与列配置交互风格一致
- 导出 HTML 模板根据行配置动态渲染
2026-06-06 17:15:31 +08:00

434 lines
20 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="generatePreviewHtml">
<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>
</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';
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 },
],
};
},
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:4px 4px;font-weight:bold;width:30px;">序号</th>';
if (hasCol('spec')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:80px;">规格(mm)</th>';
if (hasCol('material')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:60px;">材质</th>';
if (hasCol('quantity')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:55px;">数量(吨)</th>';
if (hasCol('taxPrice')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:70px;">含税单价(元/吨)</th>';
if (hasCol('taxDivisor')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:45px;">税率除数</th>';
if (hasCol('noTaxPrice')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:70px;">无税单价(元/吨)</th>';
if (hasCol('taxTotal')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:70px;">含税总额(元)</th>';
if (hasCol('noTaxTotal')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:70px;">无税总额(元)</th>';
if (hasCol('taxAmount')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;font-weight:bold;width:55px;">税额(元)</th>';
if (hasCol('remark')) headerCells += '<th style="border:1px solid #000;padding:4px 4px;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:3px 4px;text-align:center;">${index + 1}</td>`;
if (hasCol('spec')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${product.spec || ''}</td>`;
if (hasCol('material')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${product.material || ''}</td>`;
if (hasCol('quantity')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${product.quantity || ''}</td>`;
if (hasCol('taxPrice')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${product.taxPrice || ''}</td>`;
if (hasCol('taxDivisor')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${product.taxDivisor || '1.13'}</td>`;
if (hasCol('noTaxPrice')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${(product.noTaxPrice || 0).toFixed(3)}</td>`;
if (hasCol('taxTotal')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${(product.taxTotal || 0).toFixed(3)}</td>`;
if (hasCol('noTaxTotal')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${(product.noTaxTotal || 0).toFixed(3)}</td>`;
if (hasCol('taxAmount')) cells += `<td style="border:1px solid #000;padding:3px 4px;text-align:center;">${(product.taxAmount || 0).toFixed(3)}</td>`;
if (hasCol('remark')) cells += `<td style="border:1px solid #000;padding:3px 4px;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:4px 4px;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:3px 4px;text-align:center;">${val}</td>`;
}
cellIdx++;
});
const html = `
<table style="width:100%;border-collapse:collapse;font-size:11px;margin-bottom:6px;">
<tr>
<td colspan="4" style="border:1px solid #000;padding:3px 6px;font-weight:bold;text-align:left;">产品名称:${productData.productName || ''}</td>
<td colspan="${colCount - 4 > 0 ? colCount - 4 : 1}" style="border:1px solid #000;padding:3px 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:4px 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) || '零元整';
const fullHtml = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>合同预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'SimSun','宋体',serif; 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: 3px 4px; text-align: center; font-size: 11px; }
th { font-weight: bold; }
.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>
</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 pageCanvasHeight = printH * 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;
}
for (let i = 0; i < pages.length; i++) {
const { top, bottom } = pages[i];
const sliceH = bottom - top;
const sliceMm = sliceH / ratio;
const sc = document.createElement('canvas');
sc.width = canvas.width;
sc.height = Math.ceil(sliceH);
sc.getContext('2d').drawImage(canvas, 0, top, canvas.width, sliceH, 0, 0, canvas.width, sliceH);
pdf.addImage(sc.toDataURL('image/jpeg', 0.95), 'JPEG', mg, mg, printW, sliceMm);
if (i < pages.length - 1) {
pdf.addPage();
}
}
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;
}
},
}
};
</script>