feat(contract): 新增产品内容组件并优化合同管理功能

refactor(contract): 重构合同预览和列表组件
fix(contract): 修复合同ID类型校验问题
style(contract): 优化合同列表样式
docs(contract): 更新合同默认内容模板
This commit is contained in:
砂糖
2026-04-02 16:49:07 +08:00
parent 5ec293fc94
commit 74eae50ab0
8 changed files with 1096 additions and 53 deletions

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询合同产品明细列表
export function listContractProduct(query) {
return request({
url: '/crm/contractProduct/list',
method: 'get',
params: query
})
}
// 查询合同产品明细详细
export function getContractProduct(contractProductId) {
return request({
url: '/crm/contractProduct/' + contractProductId,
method: 'get'
})
}
// 新增合同产品明细
export function addContractProduct(data) {
return request({
url: '/crm/contractProduct',
method: 'post',
data: data
})
}
// 修改合同产品明细
export function updateContractProduct(data) {
return request({
url: '/crm/contractProduct',
method: 'put',
data: data
})
}
// 删除合同产品明细
export function delContractProduct(contractProductId) {
return request({
url: '/crm/contractProduct/' + contractProductId,
method: 'delete'
})
}

View File

@@ -1,6 +1,6 @@
<template>
<div>
<KLPTable v-loading="loading" :data="data" :floatLayer="true" :floatLayerConfig="floatLayerConfig">
<KLPTable :data="data" :floatLayer="true" :floatLayerConfig="floatLayerConfig">
<el-table-column label="入场钢卷号" align="center" prop="enterCoilNo">
<template slot-scope="scope">
<coil-no :coil-no="scope.row.enterCoilNo"></coil-no>

View File

@@ -3,32 +3,25 @@
<!-- 筛选区和按钮操作区合并 -->
<div class="filter-section" style="padding: 10px; border-bottom: 1px solid #e4e7ed;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 10px;">
<el-input
v-model="queryParams.contractName"
placeholder="请输入合同名称"
clearable
@keyup.enter.native="handleQuery"
style="width: 200px;"
/>
<el-button
icon="el-icon-search"
size="mini"
@click="toggleMoreFilter"
:type="showMoreFilter ? 'primary' : 'default'"
>
{{ showMoreFilter ? '收起' : '更多' }}
<div style="display: flex; align-items: center; gap: 4px;">
<el-input v-model="queryParams.contractName" placeholder="请输入合同名称" clearable @keyup.enter.native="handleQuery"
style="width: 200px;" />
<el-button icon="el-icon-sort" size="mini" @click="toggleMoreFilter"
:type="showMoreFilter ? 'primary' : 'default'">
<!-- {{ showMoreFilter ? '收起' : '更多' }} -->
</el-button>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="$emit('add')">新增</el-button>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery"></el-button>
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="$emit('add')"></el-button>
</div>
</div>
<!-- 更多筛选条件 -->
<div v-show="showMoreFilter" class="more-filter" style="margin-top: 10px; padding-top: 10px; border-top: 1px dashed #e4e7ed;">
<div v-show="showMoreFilter" class="more-filter"
style="margin-top: 10px; padding-top: 10px; border-top: 1px dashed #e4e7ed;">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="80px">
<el-form-item label="合同编号" prop="contractNo">
<el-input v-model="queryParams.contractNo" placeholder="请输入合同编号" clearable @keyup.enter.native="handleQuery" />
<el-input v-model="queryParams.contractNo" placeholder="请输入合同编号" clearable
@keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="供方" prop="supplier">
<el-input v-model="queryParams.supplier" placeholder="请输入供方" clearable @keyup.enter.native="handleQuery" />
@@ -67,32 +60,27 @@
<div class="custom-list" v-loading="loading">
<div class="list-body">
<div
v-for="row in contractList"
:key="row.contractId"
class="list-item"
<div v-for="row in contractList" :key="row.contractId" class="list-item"
style="padding: 10px; border-bottom: 2px solid #dddddd; cursor: pointer;"
:class="{ 'list-item-active': selectedRow === row }"
@click="handleRowClick(row)"
>
:class="{ 'list-item-active': selectedRow === row }" @click="handleRowClick(row)">
<!-- 合同名称和编号 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: bold;">{{ row.contractName }}</div>
<div style="font-size: 12px; color: #606266;">{{ row.contractNo }}</div>
</div>
<!-- 供方和需方 -->
<div style="font-size: 12px; color: #909399; margin-bottom: 6px;">
<span>供方: {{ row.supplier }}</span>
<span style="margin-left: 20px;">需方: {{ row.customer }}</span>
</div>
<!-- 签订时间和交货日期 -->
<div style="font-size: 12px; color: #909399; margin-bottom: 6px;">
<span>签订时间: {{ parseTime(row.signTime, '{y}-{m}-{d}') }}</span>
<span style="margin-left: 20px;">交货日期: {{ parseTime(row.deliveryDate, '{y}-{m}-{d}') }}</span>
</div>
<!-- 签订地点和状态 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-size: 12px; color: #909399;">
@@ -104,11 +92,10 @@
{{ row.status == 0 ? '草稿' : row.status == 1 ? '已生效' : row.status == 2 ? '已作废' : '已完成' }}
</el-tag>
</div>
<!-- 操作按钮独占一行 -->
<div style="display: flex; gap: 10px; padding-top: 8px; border-top: 1px dashed #f0f0f0;">
<el-button size="mini" type="text" icon="el-icon-download"
@click.stop="$emit('exportContract', 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-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>
@@ -126,6 +113,8 @@
<script>
import { listContract, updateContract } from "@/api/crm/contract";
import * as ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
export default {
name: "ContractList",
@@ -198,7 +187,492 @@ export default {
// 切换更多筛选显示/隐藏
toggleMoreFilter() {
this.showMoreFilter = !this.showMoreFilter;
}
},
/** 导出合同 */
async handleExport(row) {
// 1. 创建excel
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('产品销售合同');
// 2. 设置列宽
worksheet.columns = [
{ width: 10 },
{ width: 20 },
{ width: 15 },
{ width: 15 },
{ width: 15 },
{ width: 15 },
{ width: 15 },
{ width: 20 }
];
// 3. 合并单元格并设置内容
// 公司信息
worksheet.mergeCells('A1:H1');
worksheet.getCell('A1').value = '嘉祥科伦普重工有限公司';
worksheet.getCell('A1').font = { size: 16, bold: true };
worksheet.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle' };
// 合同标题
worksheet.mergeCells('A2:F2');
worksheet.getCell('A2').value = '产品销售合同';
worksheet.getCell('A2').font = { size: 18, bold: true };
worksheet.getCell('A2').alignment = { horizontal: 'center', vertical: 'middle' };
// 合同编号
worksheet.mergeCells('G2:H2');
worksheet.getCell('G2').value = `合同编号:${row.contractNo || ''}`;
worksheet.getCell('G2').alignment = { horizontal: 'right', vertical: 'middle' };
// 供方信息
worksheet.getCell('A3').value = `供方(甲方):${row.supplier || '嘉祥科伦普重工有限公司'}`;
worksheet.getCell('A3').alignment = { horizontal: 'left', vertical: 'middle' };
// 签订时间
worksheet.getCell('E3').value = `签订时间:${row.signTime ? this.parseTime(row.signTime, '{y}年{m}月{d}日') : '2026年 月 日'}`;
worksheet.getCell('E3').alignment = { horizontal: 'left', vertical: 'middle' };
// 需方信息
worksheet.getCell('A4').value = `需方(乙方):${row.customer || ''}`;
worksheet.getCell('A4').alignment = { horizontal: 'left', vertical: 'middle' };
// 签订地点
worksheet.getCell('E4').value = `签订地点:${row.signLocation || '山东省济宁市嘉祥县'}`;
worksheet.getCell('E4').alignment = { horizontal: 'left', vertical: 'middle' };
// 产品内容标题
worksheet.getCell('A5').value = '一、产品内容';
worksheet.getCell('A5').font = { bold: true };
// 产品名称和生产厂家
worksheet.mergeCells('A6:C6');
worksheet.getCell('A6').value = `产品名称:${row.productName || '冷硬钢卷'}`;
worksheet.getCell('A6').alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.getCell('A6').border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.mergeCells('D6:H6');
worksheet.getCell('D6').value = `生产厂家:${row.manufacturer || '嘉祥科伦普重工有限公司'}`;
worksheet.getCell('D6').alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.getCell('D6').border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
// 产品表格标题(分为两行)
// 合并序号、规格、材质、备注列的单元格(两行)
const mergedHeaders = ['序号', '规格mm', '材质', '备注'];
const mergedColumns = [1, 2, 3, 8];
mergedColumns.forEach((col, index) => {
// 合并两行
worksheet.mergeCells(7, col, 8, col);
const cell = worksheet.getCell(7, col);
cell.value = mergedHeaders[index];
cell.font = { bold: true };
cell.fill = { type: 'pattern', pattern: 'solid' };
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
});
// 数量、含税单价、不含税单价、含税总额的标题和单位各占一行
const quantityHeaders = ['数量', '(吨)'];
const taxPriceHeaders = ['含税单价', '(元/吨)'];
const noTaxPriceHeaders = ['不含税单价', '(元/吨)'];
const taxTotalHeaders = ['含税总额', '(元)'];
// 数量列
worksheet.getCell(7, 4).value = quantityHeaders[0];
worksheet.getCell(8, 4).value = quantityHeaders[1];
// 含税单价列
worksheet.getCell(7, 5).value = taxPriceHeaders[0];
worksheet.getCell(8, 5).value = taxPriceHeaders[1];
// 不含税单价列
worksheet.getCell(7, 6).value = noTaxPriceHeaders[0];
worksheet.getCell(8, 6).value = noTaxPriceHeaders[1];
// 含税总额列
worksheet.getCell(7, 7).value = taxTotalHeaders[0];
worksheet.getCell(8, 7).value = taxTotalHeaders[1];
// 设置样式
for (let row = 7; row <= 8; row++) {
for (let col = 4; col <= 7; col++) {
const cell = worksheet.getCell(row, col);
cell.font = { bold: true };
cell.fill = { type: 'pattern', pattern: 'solid' };
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
}
}
// 产品表格数据, 来源于一个json字符串productContentjson结构参考ProductContent.vue
// 解析产品内容
let productData = {
products: [],
productName: row.productName || '冷硬钢卷',
remark: '',
totalQuantity: 0,
totalTaxTotal: 0,
totalAmountInWords: '零元整'
};
if (row.productContent) {
try {
productData = JSON.parse(row.productContent);
} catch (error) {
console.error('解析产品内容失败:', error);
}
}
// 更新产品名称
worksheet.getCell('A6').value = `产品名称:${productData.productName}`;
// 产品表格数据
let currentRow = 9;
let totalQuantity = 0;
let totalTaxTotal = 0;
if (productData.products && productData.products.length > 0) {
productData.products.forEach((product, index) => {
const rowNum = currentRow + index;
worksheet.getCell(`A${rowNum}`).value = index + 1;
worksheet.getCell(`B${rowNum}`).value = product.spec || '';
worksheet.getCell(`C${rowNum}`).value = product.material || '';
worksheet.getCell(`D${rowNum}`).value = product.quantity || 0;
worksheet.getCell(`E${rowNum}`).value = product.taxPrice || 0;
worksheet.getCell(`F${rowNum}`).value = product.noTaxPrice || 0;
worksheet.getCell(`G${rowNum}`).value = product.taxTotal || 0;
worksheet.getCell(`H${rowNum}`).value = product.remark || '';
// 设置数据行边框
for (let i = 1; i <= 8; i++) {
const cell = worksheet.getCell(rowNum, i);
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
}
// 累加合计
totalQuantity += parseFloat(product.quantity || 0);
totalTaxTotal += parseFloat(product.taxTotal || 0);
});
currentRow += productData.products.length;
} else {
// 至少一行空数据
worksheet.getCell(`A${currentRow}`).value = 1;
worksheet.getCell(`B${currentRow}`).value = '';
worksheet.getCell(`C${currentRow}`).value = 'SPCC';
worksheet.getCell(`D${currentRow}`).value = '';
worksheet.getCell(`E${currentRow}`).value = '';
worksheet.getCell(`F${currentRow}`).value = 0.00;
worksheet.getCell(`G${currentRow}`).value = 0;
worksheet.getCell(`H${currentRow}`).value = '';
// 设置数据行边框
for (let i = 1; i <= 8; i++) {
const cell = worksheet.getCell(currentRow, i);
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
}
currentRow++;
}
// 合计行
worksheet.getCell(`A${currentRow}`).value = '合 计';
worksheet.getCell(`A${currentRow}`).font = { bold: true };
worksheet.getCell(`A${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'center', vertical: 'middle' };
worksheet.getCell(`D${currentRow}`).value = productData.totalQuantity || totalQuantity;
worksheet.getCell(`D${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`D${currentRow}`).alignment = { horizontal: 'center', vertical: 'middle' };
worksheet.getCell(`G${currentRow}`).value = productData.totalTaxTotal || totalTaxTotal;
worksheet.getCell(`G${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`G${currentRow}`).alignment = { horizontal: 'center', vertical: 'middle' };
worksheet.getCell(`H${currentRow}`).value = '';
worksheet.getCell(`H${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`H${currentRow}`).alignment = { horizontal: 'center', vertical: 'middle' };
currentRow++;
// 大写金额
worksheet.mergeCells(`A${currentRow}:B${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = `合计人民币(大写)`;
worksheet.getCell(`A${currentRow}`).font = { bold: true };
worksheet.getCell(`A${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.mergeCells(`C${currentRow}:H${currentRow}`);
worksheet.getCell(`C${currentRow}`).value = `${productData.totalAmountInWords || row.totalAmountUpper || '零元整'}`;
worksheet.getCell(`C${currentRow}`).font = { bold: true };
worksheet.getCell(`C${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`C${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
currentRow++;
// 备注行
worksheet.mergeCells(`B${currentRow}:H${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = '备注:';
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'top' };
worksheet.getCell(`A${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`B${currentRow}`).alignment = { horizontal: 'left', vertical: 'top' };
worksheet.getCell(`B${currentRow}`).value = productData.remark || '';
worksheet.getCell(`B${currentRow}`).border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
worksheet.getCell(`B${currentRow}`).alignment = { horizontal: 'left', vertical: 'top', wrapText: true };
currentRow++;
// 空白行保持至少5行空白
// const emptyRows = Math.max(5, 15 - currentRow + 1);
// for (let i = 0; i < emptyRows; i++) {
// const rowNum = currentRow + i;
// for (let j = 1; j <= 8; j++) {
// const cell = worksheet.getCell(rowNum, j);
// cell.border = {
// top: { style: 'thin' },
// left: { style: 'thin' },
// bottom: { style: 'thin' },
// right: { style: 'thin' }
// };
// }
// }
// currentRow += emptyRows;
// 产品表格之后是其他合同内容contractContent字段存储的是HTML格式的富文本
if (row.contractContent) {
// 优化HTML处理保留p标签作为换行符将多个p标签单元格合并
let htmlContent = row.contractContent;
// 提取所有p标签内容
const pTagRegex = /<p[^>]*>([\s\S]*?)<\/p>/g;
let match;
const pContents = [];
while ((match = pTagRegex.exec(htmlContent)) !== null) {
let content = match[1];
// 移除其他HTML标签
content = content.replace(/<[^>]*>/g, '');
// 处理HTML实体
content = content.replace(/&nbsp;/g, ' ');
content = content.replace(/&lt;/g, '<');
content = content.replace(/&gt;/g, '>');
content = content.replace(/&amp;/g, '&');
content = content.replace(/&quot;/g, '"');
content = content.replace(/&#39;/g, "'");
// 清理空格和换行
content = content.trim();
if (content) {
pContents.push(content);
}
}
// 如果没有提取到p标签内容尝试提取所有文本内容
if (pContents.length === 0) {
let textContent = htmlContent.replace(/<[^>]*>/g, '');
textContent = textContent.replace(/&nbsp;/g, ' ').trim();
if (textContent) {
pContents.push(textContent);
}
}
// 直接合并单元格并设置内容,避免合并已合并的单元格
if (pContents.length > 0) {
// 计算需要的行数
const contentLines = pContents.reduce((total, content) => {
return total + (content.match(/\n/g) || []).length + 1;
}, 0);
// 合并单元格
const endRow = currentRow + Math.ceil(contentLines / 2); // 估算需要的行数
worksheet.mergeCells(`A${currentRow}:H${endRow}`);
// 设置合并后单元格的内容和样式
const mergedContent = pContents.join('\n');
worksheet.getCell(`A${currentRow}`).value = mergedContent;
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'top', wrapText: true };
// 调整合并后单元格的行高
const lineCount = (mergedContent.match(/\n/g) || []).length + 1;
const height = Math.max(60, lineCount * 15);
worksheet.getRow(currentRow).height = height;
// 调整currentRow
currentRow = endRow + 1;
}
}
// 设置合同尾部信息,每一行的前四列与后四列分别合并
// 内容参考
// 设置合同尾部信息,每一行的前四列与后四列分别合并
// 内容参考ContractPreview.vue
if (currentRow > 0) {
// 空行
currentRow++;
// 供方(甲方)信息
worksheet.mergeCells(`A${currentRow}:D${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = `供方(甲方):${row.supplier || '嘉祥科伦普重工有限公司'}`;
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.mergeCells(`E${currentRow}:H${currentRow}`);
worksheet.getCell(`E${currentRow}`).value = `需方(乙方):${row.customer || ''}`;
worksheet.getCell(`E${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
currentRow++;
// 地址信息
worksheet.mergeCells(`A${currentRow}:D${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = `地址:${row.supplierAddress || ''}`;
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.mergeCells(`E${currentRow}:H${currentRow}`);
worksheet.getCell(`E${currentRow}`).value = `地址:${row.customerAddress || ''}`;
worksheet.getCell(`E${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
currentRow++;
// 电话信息
worksheet.mergeCells(`A${currentRow}:D${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = `电话:${row.supplierPhone || ''}`;
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.mergeCells(`E${currentRow}:H${currentRow}`);
worksheet.getCell(`E${currentRow}`).value = `电话:${row.customerPhone || ''}`;
worksheet.getCell(`E${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
currentRow++;
// 开户行信息
worksheet.mergeCells(`A${currentRow}:D${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = `开户行:${row.supplierBank || ''}`;
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.mergeCells(`E${currentRow}:H${currentRow}`);
worksheet.getCell(`E${currentRow}`).value = `开户行:${row.customerBank || ''}`;
worksheet.getCell(`E${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
currentRow++;
// 账号信息
worksheet.mergeCells(`A${currentRow}:D${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = `账号:${row.supplierAccount || ''}`;
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.mergeCells(`E${currentRow}:H${currentRow}`);
worksheet.getCell(`E${currentRow}`).value = `账号:${row.customerAccount || ''}`;
worksheet.getCell(`E${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
currentRow++;
// 税号信息
worksheet.mergeCells(`A${currentRow}:D${currentRow}`);
worksheet.getCell(`A${currentRow}`).value = `税号:${row.supplierTaxNo || ''}`;
worksheet.getCell(`A${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
worksheet.mergeCells(`E${currentRow}:H${currentRow}`);
worksheet.getCell(`E${currentRow}`).value = `税号:${row.customerTaxNo || ''}`;
worksheet.getCell(`E${currentRow}`).alignment = { horizontal: 'left', vertical: 'middle' };
currentRow++;
}
// 4. 设置行高
worksheet.getRow(1).height = 40;
worksheet.getRow(2).height = 30;
worksheet.getRow(3).height = 20;
worksheet.getRow(4).height = 20;
worksheet.getRow(5).height = 20;
worksheet.getRow(6).height = 20;
worksheet.getRow(7).height = 25;
// 自动调整数据行高
for (let i = 8; i <= currentRow; i++) {
if (!worksheet.getRow(i).height) {
worksheet.getRow(i).height = 20;
}
}
// 5. 导出文件
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(blob, `合同_${row.contractNo || row.contractName || '未命名'}.xlsx`);
},
}
};
</script>
@@ -228,11 +702,11 @@ export default {
.list-header {
font-size: 12px;
}
.list-item {
font-size: 12px;
}
.list-item .el-button {
font-size: 10px;
}

View File

@@ -26,14 +26,32 @@
</el-descriptions>
<div style="margin-top: 20px;">
<h4 style="margin-bottom: 10px; color: #606266;">产品内容</h4>
<div v-html="contract.productContent" style="border: 1px solid #e4e7ed; padding: 10px; border-radius: 4px;"></div>
<h4 style="margin-bottom: 10px; color: #606266;">产品内容</h4>
<ProductContent v-model="contract.productContent" readonly />
<!-- <div v-html="contract.productContent" style="border: 1px solid #e4e7ed; padding: 10px; border-radius: 4px;"></div> -->
</div>
<div style="margin-top: 20px;">
<h4 style="margin-bottom: 10px; color: #606266;">合同内容</h4>
<div>
<!-- <h4 style="margin-bottom: 10px; color: #606266;">合同内容</h4> -->
<div v-html="contract.contractContent" style="border: 1px solid #e4e7ed; padding: 10px; border-radius: 4px;"></div>
</div>
<div style="border: 1px solid #e4e7ed; padding: 10px; border-radius: 4px;">
<el-descriptions :column="2">
<el-descriptions-item label="供方(甲方)">{{ contract.supplier }}</el-descriptions-item>
<el-descriptions-item label="需方(乙方)">{{ contract.customer }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ contract.supplierAddress }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ contract.customerAddress }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ contract.supplierPhone }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ contract.customerPhone }}</el-descriptions-item>
<el-descriptions-item label="开户行">{{ contract.supplierBank }}</el-descriptions-item>
<el-descriptions-item label="开户行">{{ contract.customerBank }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ contract.supplierAccount }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ contract.customerAccount }}</el-descriptions-item>
<el-descriptions-item label="税号">{{ contract.supplierTaxNo }}</el-descriptions-item>
<el-descriptions-item label="税号">{{ contract.customerTaxNo }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
<div v-else class="no-selection" style="display: flex; align-items: center; justify-content: center; height: 100%;">
<el-empty description="请先选择合同" />
@@ -42,10 +60,13 @@
</template>
<script>
import { updateContract } from '@/api/crm/contract';
import ProductContent from './ProductContent.vue';
export default {
name: "ContractPreview",
components: {
ProductContent
},
props: {
contract: {
type: Object,

View File

@@ -58,7 +58,7 @@ export default {
},
props: {
contractId: {
type: Number,
type: [Number, String],
default: null
},
financeList: {

View File

@@ -0,0 +1,430 @@
<template>
<div v-loading="loading">
<!-- 网格布局实现的表格共8列 -->
<div class="product-content">
<!-- 第一行合并所有八个单元格内容为嘉祥科伦普重工有限公司 -->
<div class="table-row table-header">
<div class="table-cell" colspan="3">
<div class="company-name">产品名称<el-input style="width: 50%" v-model="productName" placeholder="请输入产品名称" size="small" :readonly="readonly"/></div>
</div>
<div class="table-cell" colspan="5">
<div class="company-name">生产厂家嘉祥科伦普重工有限公司</div>
</div>
</div>
<!-- 第二行为表头 -->
<div class="table-row">
<div class="table-cell">序号</div>
<div class="table-cell">规格mm</div>
<div class="table-cell">材质</div>
<div class="table-cell">数量</div>
<div class="table-cell">含税单价/</div>
<div class="table-cell">不含税单价/</div>
<div class="table-cell">含税总额</div>
<div class="table-cell">
备注
<el-button
v-if="!readonly"
type="primary"
size="mini"
icon="el-icon-plus"
@click="addProduct"
style="margin-left: 10px;"
>
添加行
</el-button>
</div>
</div>
<!-- 产品行 -->
<div
v-for="(item, index) in products"
:key="index"
class="table-row"
:class="{ 'table-row-hover': !readonly }"
>
<div class="table-cell">
<div class="serial-number">
<span>{{ index + 1 }}</span>
<el-button
v-if="!readonly && products.length > 1"
type="text"
size="mini"
icon="el-icon-delete"
class="delete-btn"
@click="removeProduct(index)"
/>
</div>
</div>
<div class="table-cell">
<el-input
v-model="item.spec"
placeholder="请输入规格"
:readonly="readonly"
size="small"
/>
</div>
<div class="table-cell">
<el-input
v-model="item.material"
placeholder="请输入材质"
:readonly="readonly"
size="small"
/>
</div>
<div class="table-cell">
<el-input
v-model.number="item.quantity"
placeholder="请输入数量"
type="number"
:readonly="readonly"
size="small"
@change="calculateTotals"
/>
</div>
<div class="table-cell">
<el-input
v-model.number="item.taxPrice"
placeholder="请输入含税单价"
type="number"
:readonly="readonly"
size="small"
@change="calculateTotals"
/>
</div>
<div class="table-cell">
<el-input
v-model.number="item.noTaxPrice"
placeholder="请输入不含税单价"
type="number"
:readonly="readonly"
size="small"
@change="calculateTotals"
/>
</div>
<div class="table-cell">
<el-input
v-model.number="item.taxTotal"
placeholder="含税总额"
type="number"
:readonly="true"
size="small"
/>
</div>
<div class="table-cell">
<el-input
v-model="item.remark"
placeholder="请输入备注"
:readonly="readonly"
size="small"
/>
</div>
</div>
<!-- 合计行 -->
<div class="table-row table-total-row">
<div class="table-cell" colspan="3">合计</div>
<div class="table-cell">{{ totalQuantity }}</div>
<div class="table-cell"></div>
<div class="table-cell"></div>
<div class="table-cell">{{ totalTaxTotal }}</div>
<div class="table-cell"></div>
</div>
<!-- 合计人民币(大写) -->
<div class="table-row">
<div class="table-cell" colspan="8">
<span>合计人民币(大写)</span>
<span>{{ totalAmountInWords }}</span>
</div>
</div>
<!-- 备注 -->
<div class="table-row">
<div class="table-cell" colspan="8">
<span>备注</span>
<el-input
v-model="remark"
type="textarea"
placeholder="请输入备注"
:readonly="readonly"
:autosize="{ minRows: 2 }"
/>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ProductContent',
props: {
value: {
// 一个json字符串
type: String,
required: true,
},
readonly: {
type: Boolean,
default: false
}
},
data() {
return {
loading: false,
products: [],
remark: '',
productName: '',
}
},
computed: {
// 计算总数量
totalQuantity() {
return this.products.reduce((total, item) => {
return total + (item.quantity || 0);
}, 0);
},
// 计算总含税总额
totalTaxTotal() {
return this.products.reduce((total, item) => {
return total + (item.taxTotal || 0);
}, 0);
},
// 计算大写金额
totalAmountInWords() {
const amount = this.totalTaxTotal;
if (amount === 0) return '零元整';
const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
const units = ['', '拾', '佰', '仟'];
const bigUnits = ['', '万', '亿'];
let integerPart = Math.floor(amount);
let decimalPart = Math.round((amount - integerPart) * 100);
let result = '';
// 处理整数部分
if (integerPart > 0) {
let unitIndex = 0;
let bigUnitIndex = 0;
while (integerPart > 0) {
let section = integerPart % 10000;
if (section > 0) {
let sectionResult = '';
let temp = section;
let unitIndexInSection = 0;
while (temp > 0) {
let digit = temp % 10;
if (digit > 0) {
sectionResult = digits[digit] + units[unitIndexInSection] + sectionResult;
} else {
// 避免连续的零
if (sectionResult && !sectionResult.startsWith('零')) {
sectionResult = '零' + sectionResult;
}
}
temp = Math.floor(temp / 10);
unitIndexInSection++;
}
result = sectionResult + bigUnits[bigUnitIndex] + result;
}
integerPart = Math.floor(integerPart / 10000);
bigUnitIndex++;
}
result += '元';
}
// 处理小数部分
if (decimalPart === 0) {
result += '整';
} else {
if (decimalPart >= 10) {
result += digits[Math.floor(decimalPart / 10)] + '角';
}
if (decimalPart % 10 > 0) {
result += digits[decimalPart % 10] + '分';
}
}
return result;
},
// 生成JSON字符串
jsonContent() {
// 只存储非空行
const nonEmptyProducts = this.products.filter(item => {
return item.spec || item.material || item.quantity || item.taxPrice || item.noTaxPrice || item.remark;
});
const data = {
products: nonEmptyProducts,
productName: this.productName,
remark: this.remark,
totalQuantity: this.totalQuantity,
totalTaxTotal: this.totalTaxTotal,
totalAmountInWords: this.totalAmountInWords
};
return JSON.stringify(data, null, 2);
},
},
watch: {
// 监听jsonContent变化触发update事件
jsonContent: {
handler(newValue) {
this.$emit('input', newValue);
},
deep: true
},
// 监听value变化更新内部数据
value: {
handler(newValue) {
this.parseContent(newValue);
},
immediate: true
}
},
methods: {
// 解析content字符串
parseContent(content) {
try {
if (!content) {
return {
productName: '',
products: [{
_isEmpty: true,
spec: '',
material: '',
quantity: '',
taxPrice: '',
noTaxPrice: '',
remark: ''
}],
remark: ''
}
}
const data = JSON.parse(content);
this.products = data.products || [{}];
this.remark = data.remark || '';
this.productName = data.productName || '';
} catch (error) {
console.error('解析content失败:', error);
this.products = [{}];
this.remark = '';
}
},
// 计算金额
calculateTotals() {
this.products.forEach(item => {
if (item.quantity && item.taxPrice) {
item.taxTotal = item.quantity * item.taxPrice;
} else {
item.taxTotal = 0;
}
});
},
// 添加产品行
addProduct() {
this.products.push({});
},
// 删除产品行
removeProduct(index) {
if (this.products.length > 1) {
this.products.splice(index, 1);
this.calculateTotals();
}
}
},
mounted() {
// 确保至少有一个空行
if (this.products.length === 0) {
this.products.push({});
}
}
}
</script>
<style scoped>
.product-content {
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
}
.table-header {
background-color: #f5f7fa;
text-align: center;
border-bottom: 1px solid #e4e7ed;
}
.company-name {
font-size: 16px;
font-weight: bold;
}
.table-row {
display: grid;
grid-template-columns: 80px 150px 120px 100px 140px 150px 120px 1fr;
border-bottom: 1px solid #e4e7ed;
}
.table-row-hover:hover {
background-color: #f5f7fa;
}
.table-header-row {
background-color: #f5f7fa;
font-weight: bold;
}
.table-total-row {
background-color: #f5f7fa;
font-weight: bold;
}
.table-cell {
padding: 8px;
border-right: 1px solid #e4e7ed;
display: flex;
align-items: center;
}
.table-cell:last-child {
border-right: none;
}
.table-cell[colspan="3"] {
grid-column: span 3;
}
.table-cell[colspan="5"] {
grid-column: span 5;
}
.table-cell[colspan="8"] {
grid-column: span 8;
}
.serial-number {
position: relative;
display: inline-flex;
align-items: center;
}
.delete-btn {
opacity: 0;
margin-left: 10px;
}
.serial-number:hover .delete-btn {
opacity: 1;
}
.el-input {
width: 100%;
}
</style>

View File

@@ -69,7 +69,7 @@
</el-row>
<el-form-item label="产品内容">
<editor v-model="form.productContent" :min-height="192" />
<ProductContent v-model="form.productContent" :readonly="false" />
</el-form-item>
<el-form-item label="合同内容">
<editor v-model="form.contractContent" :min-height="192" />
@@ -150,13 +150,15 @@ import { getContract, delContract, addContract, updateContract, listContractOrde
import ContractList from "./components/ContractList.vue";
import ContractPreview from "./components/ContractPreview.vue";
import ContractTabs from "./components/ContractTabs.vue";
import ProductContent from "./components/ProductContent.vue";
export default {
name: "Contract",
components: {
ContractList,
ContractPreview,
ContractTabs
ContractTabs,
ProductContent
},
data() {
return {
@@ -300,20 +302,78 @@ export default {
reset() {
this.form = {
contractId: undefined,
contractName: undefined,
contractName: '产品销售合同',
contractNo: undefined,
supplier: undefined,
supplier: '嘉祥科伦普重工有限公司',
customer: undefined,
signTime: undefined,
deliveryDate: undefined,
signLocation: undefined,
productContent: undefined,
contractContent: undefined,
supplierAddress: undefined,
supplierPhone: undefined,
supplierBank: undefined,
supplierAccount: undefined,
supplierTaxNo: undefined,
contractContent: `二、交(提)货方式:
◆交(提)货时间:自 2026年 月 日起至 2026年 月 日止
◆交(提)货地点:供方所在地仓库
◆交(提)货方式:需方委托供方代办运输。
◆货物所有权自出供方厂区时转移,但需方未履行支付全部价款义务的,供方可以留置全部待发货物且所有权仍归供方所有。
◆交(提)货公差实际交货总重量不超出合同约定总重量的±10%。
◆交(提)货计量以供方出厂计量为准磅差不超过±3‰。
三、装车费用、运输费用和其他费用的分担:
◆委托供方代办运输:供方仓库的装车费用由供方承担,运输费用由需方承担。
四、技术要求、包装标准:
◆无特殊需求时,按供方厂家包装标准执行,包装物不回收。
◆未提及技术要求或超出相关规格标准的,按供方工厂现行标准执行。
五、对产品提出质量异议的期限和方法:
◆提出期限需方提货之日起10日内向供方书面提出并将产品封存不包括提货日
◆提出方式需方使用前须检查产品质量发现质量问题需妥善保管异议产品书面通知供方并提供供方所需资料。经供方质量部现场确认后对未使用的全新产品有质量问题的可换货对使用中发现质量问题的产品须保持问题产品的原状供方仅对有质量问题的单个钢卷产品参考供方正品和B级的价差予以补偿供方对其包括但不限于可期得利益、下家客户损失、商誉损失等均不承担责任。
◆不接受异议的情况:
1逾期反馈、运输不当、保管不善造成质量问题产品非正品、需方加工不当所产生的问题。
2因需方要求不包装合同中供方对价格进行了折让需方已知晓该产品不包装会产生包括但不限于氧化、变形、吊伤、擦伤、划伤、碰伤等质量问题的风险需方仍要求不包装基于以上原因需方同意供方对包括但不限于氧化、变形、吊伤、擦伤、划伤、碰伤等质量问题不承担任何责任。
六、结算及付款:
◆本合同为锁价合同,合同单价为锁定现汇含税出厂价价格。
◆自合同签订之日起1个工作日内需方预付全部货款否则供方有权单方解除合同
七、违约责任:按《中华人民共和国民法典》有关规定。
◆需方未按时付款,应按未付款的日万分之五承担逾期付款违约金;
◆需方中途解除合同的按合同价款50%承担违约金;需方拒绝接收货物,产生的损失由需方承担;
◆需方如错填到货地点或接货人,应承担供方因此所受的损失。
八、不可抗力和解决合同纠纷的方式:
◆双方的任何一方由于不可抗力的原因不能履行合同时,应及时向对方通报不能履行或不能完全履行的理由,在取得有关主管机关证明以后,允许延期履行、部分履行或者不履行合同,并根据情况可部分或全部免予承担违约责任。
◆协商解决,如协商不成,双方可向供方所在地人民法院起诉。
九、本合同自双方盖章并预付全部货款生效,传真件和扫描件与原件具同等法律效力,手改无效。
十、其他事项及说明: `,
supplierAddress: '山东省济宁市嘉祥县经济开发区生物产业园新民路北延',
supplierPhone: '0537-6625069',
supplierBank: '中国建设银行山东省济宁市嘉祥县华府支行',
supplierAccount: '3705 0168 6408 0000 1169',
supplierTaxNo: '91370829MA3FCC3C1F',
customerAddress: undefined,
customerPhone: undefined,
customerBank: undefined,

View File

@@ -9,6 +9,20 @@
<el-form-item label="创建人" prop="createBy">
<el-input v-model="queryParams.createBy" placeholder="请输入创建人" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="出入库类型" prop="inOutType">
<el-select v-model="queryParams.inOutType" placeholder="请选择类型" clearable @keyup.enter.native="handleQuery">
<el-option label="入库" :value="1" />
<el-option label="出库" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="业务类型" prop="operationType">
<el-select v-model="queryParams.operationType" placeholder="请选择类型" clearable @keyup.enter.native="handleQuery">
<el-option label="收货" :value="1" />
<el-option label="加工" :value="2" />
<el-option label="调拨" :value="3" />
<el-option label="发货" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>