refactor(crm/contract): 抽取合同导出预览组件到独立文件
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
<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-size: 12px; color: #909399;">勾选的列将显示在导出的产品表中</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 },
|
||||
],
|
||||
};
|
||||
},
|
||||
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();
|
||||
},
|
||||
buildProductTableHtml(productData, products) {
|
||||
const activeCols = this.columnConfigs.filter(c => c.checked);
|
||||
const hasCol = (key) => activeCols.some(c => c.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;">合 计</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}
|
||||
<tr>${totalCells}</tr>
|
||||
<tr>
|
||||
<td colspan="${colCount}" style="border:1px solid #000;padding:4px 6px;font-weight:bold;text-align:left;">合计人民币(大写):${totalAmountInWords}</td>
|
||||
</tr>
|
||||
${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(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/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(/ /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>
|
||||
@@ -120,7 +120,7 @@
|
||||
<div style="display: flex; gap: 10px; padding-top: 8px; border-top: 1px dashed #f0f0f0;">
|
||||
<el-button size="mini" type="text" :icon="row.isTop ? 'el-icon-top' : 'el-icon-s-operation'" @click.stop="handleToggleTop(row)">{{ row.isTop ? '取消置顶' : '置顶' }}</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-document" @click.stop="$emit('detailEdit', row)">详情编辑</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-download" @click.stop="handleExport(row)">导出</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-download" @click.stop="$emit('export', row)">导出</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click.stop="$emit('update', row)">修改</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click.stop="$emit('delete', row)">删除</el-button>
|
||||
</div>
|
||||
@@ -134,41 +134,11 @@
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
||||
@pagination="getList" style="padding: 10px; margin-bottom: 10px !important;" />
|
||||
|
||||
<!-- 导出预览对话框 -->
|
||||
<el-dialog title="导出预览" :visible.sync="exportDialogVisible" 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-size: 12px; color: #909399;">勾选的列将显示在导出的产品表中</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="exportDialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="confirmExport">确认导出</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listOrder, updateOrder } from "@/api/crm/order";
|
||||
import contractLogo from '@/assets/images/contractLogo.png';
|
||||
import {
|
||||
parseProductContent,
|
||||
convertToChinese
|
||||
} from '@/utils/productContent';
|
||||
|
||||
export default {
|
||||
name: "ContractList",
|
||||
@@ -200,23 +170,6 @@ export default {
|
||||
signLocation: undefined,
|
||||
status: undefined,
|
||||
},
|
||||
// 导出预览
|
||||
exportDialogVisible: false,
|
||||
exportRow: null,
|
||||
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 },
|
||||
],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
@@ -277,230 +230,6 @@ export default {
|
||||
toggleMoreFilter() {
|
||||
this.showMoreFilter = !this.showMoreFilter;
|
||||
},
|
||||
/** 导出合同 - 打开预览对话框 */
|
||||
handleExport(row) {
|
||||
this.exportRow = row;
|
||||
this.exportDialogVisible = true;
|
||||
},
|
||||
/** 全选/取消全选 */
|
||||
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();
|
||||
},
|
||||
/** 根据列配置生成产品表HTMl */
|
||||
buildProductTableHtml(productData, products) {
|
||||
const activeCols = this.columnConfigs.filter(c => c.checked);
|
||||
const hasCol = (key) => activeCols.some(c => c.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; // +1 for 序号
|
||||
|
||||
// 数据行
|
||||
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) {
|
||||
// 前 2 个活动列(spec、material)与序号一起被"合计" colspan=3 覆盖
|
||||
if (cellIdx === 0) {
|
||||
totalCells += `<td colspan="3" style="border:1px solid #000;padding:4px 4px;font-weight:bold;text-align:center;">合 计</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}
|
||||
<tr>${totalCells}</tr>
|
||||
<tr>
|
||||
<td colspan="${colCount}" style="border:1px solid #000;padding:4px 6px;font-weight:bold;text-align:left;">合计人民币(大写):${totalAmountInWords}</td>
|
||||
</tr>
|
||||
${productData.remark ? `<tr><td colspan="${colCount}" style="border:1px solid #000;padding:4px 6px;text-align:left;">备注:${productData.remark}</td></tr>` : ''}
|
||||
</table>`;
|
||||
return html;
|
||||
},
|
||||
/** 生成预览HTML并更新iframe */
|
||||
generatePreviewHtml() {
|
||||
const row = this.exportRow;
|
||||
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);
|
||||
|
||||
// 解析合同内容
|
||||
let contractContentHtml = '';
|
||||
if (row.contractContent) {
|
||||
let htmlContent = row.contractContent;
|
||||
const pTagRegex = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
let match;
|
||||
const pContents = [];
|
||||
while ((match = pTagRegex.exec(htmlContent)) !== null) {
|
||||
let content = match[1].replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").trim();
|
||||
if (content) {
|
||||
const chineseNumberRegex = /^[一二三四五六七八九十]+、/;
|
||||
pContents.push(chineseNumberRegex.test(content) ? content : ' ' + content);
|
||||
}
|
||||
}
|
||||
if (pContents.length === 0) {
|
||||
const textContent = htmlContent.replace(/<[^>]*>/g, '').replace(/ /g, ' ').trim();
|
||||
if (textContent) pContents.push(textContent);
|
||||
}
|
||||
if (pContents.length > 0) {
|
||||
contractContentHtml = '<div style="margin-top:8px;font-size:12px;line-height:1.8;">' + pContents.join('<br/>') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 10mm; }
|
||||
body { background: #fff; padding: 0; display: block; }
|
||||
.a4-page { box-shadow: none; margin: 0; }
|
||||
table tr { page-break-inside: avoid; }
|
||||
}
|
||||
</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">
|
||||
<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;
|
||||
}
|
||||
});
|
||||
},
|
||||
/** 确认导出PDF - 借助浏览器原生打印 */
|
||||
confirmExport() {
|
||||
const iframe = this.$refs.previewFrame;
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.print();
|
||||
}
|
||||
this.exportDialogVisible = false;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<ContractList ref="orderList" @add="handleAdd" @update="handleUpdate" @delete="handleDelete"
|
||||
@export="handleExport" @exportContract="handleExportContract" @rowClick="handleRowClick"
|
||||
@detailEdit="handleDetailEdit" />
|
||||
|
||||
<ContractExportDialog :visible.sync="exportDialogVisible" :row="exportRow" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
@@ -184,6 +186,7 @@ import { listDeliveryWaybill } from "@/api/wms/deliveryWaybill";
|
||||
import ContractList from "./components/ContractList.vue";
|
||||
import ContractPreview from "./components/ContractPreview.vue";
|
||||
import ContractTabs from "./components/ContractTabs.vue";
|
||||
import ContractExportDialog from "./components/ContractExportDialog.vue";
|
||||
import ProductContent from "./components/ProductContent.vue";
|
||||
import ContractTemplateManager from "./components/ContractTemplateManager.vue";
|
||||
import CustomerSelect from "@/components/KLPService/CustomerSelect/index.vue";
|
||||
@@ -194,6 +197,7 @@ export default {
|
||||
ContractList,
|
||||
ContractPreview,
|
||||
ContractTabs,
|
||||
ContractExportDialog,
|
||||
ProductContent,
|
||||
ContractTemplateManager,
|
||||
CustomerSelect,
|
||||
@@ -229,6 +233,9 @@ export default {
|
||||
},
|
||||
// 表单参数
|
||||
form: {},
|
||||
// 导出预览
|
||||
exportDialogVisible: false,
|
||||
exportRow: null,
|
||||
// 表单校验
|
||||
rules: {
|
||||
contractId: [
|
||||
@@ -558,10 +565,9 @@ export default {
|
||||
})
|
||||
},
|
||||
/** 导出按钮操作 */
|
||||
handleExport() {
|
||||
this.download('crm/order/export', {
|
||||
...this.queryParams
|
||||
}, `order_${new Date().getTime()}.xlsx`)
|
||||
handleExport(row) {
|
||||
this.exportRow = row;
|
||||
this.exportDialogVisible = true;
|
||||
},
|
||||
/** 导出合同操作 */
|
||||
handleExportContract(row) {
|
||||
|
||||
Reference in New Issue
Block a user