zlwu 1 år sedan
förälder
incheckning
96c3e5fb51

+ 3 - 10
backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmContractServiceImpl.java

@@ -1256,25 +1256,18 @@ public class CrmContractServiceImpl implements ICrmContractService {
 
     @Override
     public String downloadContractPdf(Long id) {
-        // 1. 查询合同
+        // 查询合同
         CrmContractVo contract = queryById(id);
         if (contract == null) {
             throw new ServiceException("合同不存在");
         }
 
-        // 2. 如果合同已有文件,直接返回
+        // 返回合同文件路径(如果存在)
         if (StringUtils.isNotBlank(contract.getContractFile())) {
             return contract.getContractFile();
         }
 
-        // 3. TODO: 生成合同PDF
-        // 这里需要使用PDF生成库(如iText、Apache PDFBox等)
-        // 根据合同模板和数据生成PDF文件
-        // 将生成的PDF上传到OSS或本地存储
-        // 更新合同的contractFile字段
-
-        log.warn("合同PDF生成功能待实现,合同ID: {}", id);
-        throw new ServiceException("合同PDF生成功能待实现");
+        throw new ServiceException("合同PDF文件不存在");
     }
 
     @Override

+ 14 - 9
backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmInvoiceServiceImpl.java

@@ -1,8 +1,5 @@
 package org.dromara.crm.service.impl;
 
-import cn.dev33.satoken.stp.StpUtil;
-import cn.hutool.core.io.FileUtil;
-import cn.hutool.core.util.IdUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -444,8 +441,12 @@ public class CrmInvoiceServiceImpl implements ICrmInvoiceService {
         }
 
         // 3. 校验文件格式(PDF)
-        String originalFilename = file.getOriginalFilename();
-        if (StringUtils.isBlank(originalFilename) || !originalFilename.toLowerCase().endsWith(".pdf")) {
+        final String originalFilename = file.getOriginalFilename();
+        if (StringUtils.isBlank(originalFilename)) {
+            throw new ServiceException("文件名不能为空");
+        }
+        final String lowerFilename = originalFilename.toLowerCase();
+        if (!lowerFilename.endsWith(".pdf")) {
             throw new ServiceException("只支持PDF格式文件");
         }
 
@@ -455,7 +456,9 @@ public class CrmInvoiceServiceImpl implements ICrmInvoiceService {
         }
 
         // 5. 生成文件名(发票编号_时间戳.pdf)
-        String fileName = invoice.getInvoiceNo() + "_" + System.currentTimeMillis() + ".pdf";
+        // 将发票编号中的特殊字符替换为下划线,避免文件路径错误
+        String safeInvoiceNo = invoice.getInvoiceNo().replaceAll("[/\\\\:*?\"<>|]", "_");
+        String fileName = safeInvoiceNo + "_" + System.currentTimeMillis() + ".pdf";
 
         // 6. 保存文件到本地
         String fileUrl = saveFileToLocal(file, fileName);
@@ -483,11 +486,11 @@ public class CrmInvoiceServiceImpl implements ICrmInvoiceService {
         }
 
         // 3. 校验文件格式(JPG/PNG)
-        String originalFilename = file.getOriginalFilename();
+        final String originalFilename = file.getOriginalFilename();
         if (StringUtils.isBlank(originalFilename)) {
             throw new ServiceException("文件名不能为空");
         }
-        String lowerFilename = originalFilename.toLowerCase();
+        final String lowerFilename = originalFilename.toLowerCase();
         if (!lowerFilename.endsWith(".jpg") && !lowerFilename.endsWith(".jpeg") && !lowerFilename.endsWith(".png")) {
             throw new ServiceException("只支持JPG/PNG格式图片");
         }
@@ -499,7 +502,9 @@ public class CrmInvoiceServiceImpl implements ICrmInvoiceService {
 
         // 5. 生成文件名(发票编号_时间戳.扩展名)
         String extension = lowerFilename.substring(lowerFilename.lastIndexOf("."));
-        String fileName = invoice.getInvoiceNo() + "_" + System.currentTimeMillis() + extension;
+        // 将发票编号中的特殊字符替换为下划线,避免文件路径错误
+        String safeInvoiceNo = invoice.getInvoiceNo().replaceAll("[/\\\\:*?\"<>|]", "_");
+        String fileName = safeInvoiceNo + "_" + System.currentTimeMillis() + extension;
 
         // 6. 保存文件到本地
         String fileUrl = saveFileToLocal(file, fileName);

+ 3 - 1
backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmQuotePdfServiceImpl.java

@@ -60,7 +60,9 @@ public class CrmQuotePdfServiceImpl implements ICrmQuotePdfService {
         String htmlContent = buildQuoteHtml(quote);
 
         // 3. 保存HTML文件(可打印为PDF)
-        String fileName = "quote_" + quote.getQuoteNo() + "_" + System.currentTimeMillis() + ".html";
+        // 将报价单编号中的特殊字符替换为横线,避免文件路径错误
+        String safeQuoteNo = quote.getQuoteNo().replaceAll("[/\\\\:*?\"<>|]", "-");
+        String fileName = "报价单-" + safeQuoteNo + ".pdf";
         String dateStr = LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
         String relativePath = "/quote/" + dateStr + "/";
         String fullPath = uploadPath + relativePath;

+ 0 - 9
web/src/api/crm/contract/index.ts

@@ -514,15 +514,6 @@ export function completeContract(id: number | string, actualEndDate: string, com
   return finishContract({ id, remark: completeRemark })
 }
 
-/**
- * 生成合同PDF
- */
-export function generatePdf(id: number | string) {
-  return request({
-    url: `/crm/contract/generatePdf/${id}`,
-    method: 'post'
-  })
-}
 
 /**
  * 查询合同操作日志

+ 511 - 10
web/src/views/crm/contract/index.vue

@@ -1146,6 +1146,25 @@
 
     <!-- 客户详情抽屉 -->
     <CustomerDetail ref="customerDetailRef" />
+
+    <!-- 生成合同对话框 -->
+    <el-dialog
+      :title="isPreviewMode ? '预览合同' : '编辑合同'"
+      v-model="generateContractDialog.visible"
+      width="1200px"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <TinyMceEditor v-model="contractContent" :height="600" :disabled="isPreviewMode" :toolbar="!isPreviewMode" />
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="generateContractDialog.visible = false">关 闭</el-button>
+          <el-button type="info" icon="View" @click="togglePreviewMode">{{ isPreviewMode ? '编辑模式' : '预览模式' }}</el-button>
+          <el-button type="success" icon="Document" @click="handleGeneratePdfFromDialog">生成PDF</el-button>
+          <el-button type="primary" icon="Printer" @click="handlePrintContract">打印</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -1162,7 +1181,6 @@ import {
   executeContract,
   completeContract,
   invalidContract,
-  generatePdf,
   listContractApproval,
   type ContractQuery,
   type ContractVO
@@ -1176,8 +1194,11 @@ import InvalidDialog from './components/InvalidDialog.vue';
 import UploadDialog from './components/UploadDialog.vue';
 import CustomerDetail from '@/views/crm/customer/detail.vue';
 import ImagePreview from '@/components/ImagePreview/index.vue';
+import TinyMceEditor from '@/components/TinyMceEditor/index.vue';
 import { CircleCheck, CircleClose, ChatLineSquare, Clock, InfoFilled, VideoPlay, Document, Paperclip, Download } from '@element-plus/icons-vue';
 import request from '@/utils/request';
+import html2canvas from 'html2canvas';
+import jsPDF from 'jspdf';
 
 const router = useRouter();
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
@@ -1248,6 +1269,14 @@ const detailApprovalList = ref<any[]>([]);
 const detailContractFileList = ref<any[]>([]);
 const detailAttachmentFileList = ref<any[]>([]);
 
+// 打印相关
+const generateContractDialog = reactive({
+  visible: false
+});
+const contractContent = ref('');
+const isPreviewMode = ref(false);
+const pdfGenerating = ref(false);
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true;
@@ -1453,14 +1482,80 @@ const handleUpload = (row: ContractVO) => {
   uploadDialogRef.value?.open(row.id);
 };
 
-/** 生成PDF */
+/** 生成PDF(前端实现) */
 const handleGeneratePdf = async (row: ContractVO) => {
+  pdfGenerating.value = true;
   try {
-    const pdfUrl = await generatePdf(row.id);
+    proxy?.$modal.loading('正在生成PDF...');
+
+    // 1. 获取合同详情
+    const res = await getContract(row.id);
+    const contract = res.data;
+
+    // 2. 生成HTML内容
+    const htmlContent = generateContractTemplate(contract);
+
+    // 3. 创建临时容器
+    const tempDiv = document.createElement('div');
+    tempDiv.style.position = 'absolute';
+    tempDiv.style.left = '-9999px';
+    tempDiv.style.top = '0';
+    tempDiv.style.width = '794px'; // A4纸宽度
+    tempDiv.style.background = 'white';
+    tempDiv.innerHTML = htmlContent;
+    document.body.appendChild(tempDiv);
+
+    // 4. 使用html2canvas转换为canvas
+    const canvas = await html2canvas(tempDiv, {
+      scale: 2, // 提高清晰度
+      useCORS: true,
+      allowTaint: true,
+      backgroundColor: '#ffffff'
+    });
+
+    // 5. 移除临时容器
+    document.body.removeChild(tempDiv);
+
+    // 6. 计算PDF尺寸
+    const imgWidth = 210; // A4纸宽度(mm)
+    const imgHeight = (canvas.height * imgWidth) / canvas.width;
+
+    // 7. 创建PDF
+    const pdf = new jsPDF({
+      orientation: 'portrait',
+      unit: 'mm',
+      format: 'a4'
+    });
+
+    const imgData = canvas.toDataURL('image/jpeg', 1.0);
+
+    // 8. 如果内容高度超过一页,需要分页
+    const pageHeight = 297; // A4纸高度(mm)
+    let heightLeft = imgHeight;
+    let position = 0;
+
+    pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+    heightLeft -= pageHeight;
+
+    while (heightLeft >= 0) {
+      position = heightLeft - imgHeight;
+      pdf.addPage();
+      pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+      heightLeft -= pageHeight;
+    }
+
+    // 9. 下载PDF
+    const safeContractNo = contract.contractNo.replace(/[/\\:*?"<>|]/g, '-');
+    pdf.save(`合同-${safeContractNo}.pdf`);
+
+    proxy?.$modal.closeLoading();
     proxy?.$modal.msgSuccess('PDF生成成功');
-    window.open(pdfUrl, '_blank');
   } catch (error) {
     console.error('生成PDF失败:', error);
+    proxy?.$modal.closeLoading();
+    proxy?.$modal.msgError('生成PDF失败,请重试');
+  } finally {
+    pdfGenerating.value = false;
   }
 };
 
@@ -1675,12 +1770,27 @@ const handleDetailInvalid = () => {
 };
 
 /** 详情抽屉 - 打印 */
-const handleDetailPrint = () => {
-  // 打开新窗口打印
-  router.push({
-    path: '/crm/contract/detail',
-    query: { id: detailContract.value?.id }
-  });
+const handleDetailPrint = async () => {
+  try {
+    // 显示加载提示
+    proxy?.$modal.loading('正在生成合同...');
+
+    // 生成HTML内容
+    const htmlContent = generateContractTemplate(detailContract.value);
+
+    // 先设置内容,再打开对话框
+    contractContent.value = htmlContent;
+
+    // 关闭加载提示
+    proxy?.$modal.closeLoading();
+
+    // 打开对话框
+    generateContractDialog.visible = true;
+  } catch (error) {
+    console.error('生成合同失败:', error);
+    proxy?.$modal.closeLoading();
+    proxy?.$modal.msgError('生成合同失败');
+  }
 };
 
 /** 详情抽屉 - 查看客户 */
@@ -1905,6 +2015,397 @@ watch(detailActiveTab, (newTab) => {
   // 目前回款和开票记录在详情中已经包含,暂不需要额外加载
 });
 
+/** 生成合同HTML模版 */
+const generateContractTemplate = (contractData: ContractVO | undefined): string => {
+  if (!contractData) return '';
+
+  // 辅助函数:安全地将值转换为数字并格式化
+  const formatNumber = (value: any, decimals: number = 2): string => {
+    const num = parseFloat(value || 0);
+    return isNaN(num) ? '0.00' : num.toFixed(decimals);
+  };
+
+  // 数字转中文大写
+  const numberToChinese = (num: number): string => {
+    const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
+    const units = ['', '拾', '佰', '仟', '万', '拾', '佰', '仟', '亿'];
+    const decimalUnits = ['角', '分'];
+
+    if (num === 0) return '零元整';
+
+    // 分离整数和小数部分
+    const parts = num.toFixed(2).split('.');
+    const integerPart = parseInt(parts[0]);
+    const decimalPart = parts[1];
+
+    let result = '';
+
+    // 处理整数部分
+    if (integerPart === 0) {
+      result = '零';
+    } else {
+      const intStr = integerPart.toString();
+      const len = intStr.length;
+      let zeroCount = 0;
+
+      for (let i = 0; i < len; i++) {
+        const digit = parseInt(intStr[i]);
+        const unit = units[len - i - 1];
+
+        if (digit === 0) {
+          zeroCount++;
+        } else {
+          if (zeroCount > 0) {
+            result += '零';
+          }
+          result += digits[digit] + unit;
+          zeroCount = 0;
+        }
+      }
+    }
+
+    result += '元';
+
+    // 处理小数部分
+    if (decimalPart === '00') {
+      result += '整';
+    } else {
+      const jiao = parseInt(decimalPart[0]);
+      const fen = parseInt(decimalPart[1]);
+
+      if (jiao > 0) {
+        result += digits[jiao] + decimalUnits[0];
+      }
+      if (fen > 0) {
+        if (jiao === 0) {
+          result += '零';
+        }
+        result += digits[fen] + decimalUnits[1];
+      }
+    }
+
+    return result;
+  };
+
+  // 格式化日期(用于顶部信息,格式:2025.09.26)
+  const formatDate = (dateStr: string): string => {
+    if (!dateStr) return '';
+    const date = new Date(dateStr);
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+    return `${year}.${month}.${day}`;
+  };
+
+  // 格式化日期(用于合同条款,格式:2025 年 09 月 26 日)
+  const formatDateChinese = (dateStr: string): string => {
+    if (!dateStr) return '';
+    const date = new Date(dateStr);
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+    return `${year} 年 ${month} 月 ${day} 日`;
+  };
+
+  // 计算产品总金额(未含税)
+  const productTotalBeforeTax =
+    contractData.products?.reduce((sum, item) => {
+      const subtotal = parseFloat(String(item.subtotal || 0));
+      const taxAmount = parseFloat(String(item.taxAmount || 0));
+      return sum + (isNaN(subtotal) ? 0 : subtotal) - (isNaN(taxAmount) ? 0 : taxAmount);
+    }, 0) || 0;
+
+  // 计算总税费
+  const totalTax =
+    contractData.products?.reduce((sum, item) => {
+      const taxAmount = parseFloat(String(item.taxAmount || 0));
+      return sum + (isNaN(taxAmount) ? 0 : taxAmount);
+    }, 0) || 0;
+
+  // 计算产品总金额(含税)
+  const productTotal = parseFloat(String(contractData.amount || 0));
+  const productTotalChinese = numberToChinese(productTotal);
+
+  // 构建产品明细表格行
+  const productRows =
+    contractData.products
+      ?.map((product, index) => {
+        const subtotal = parseFloat(String(product.subtotal || 0));
+        const taxAmount = parseFloat(String(product.taxAmount || 0));
+        const beforeTax = subtotal - taxAmount;
+
+        return `
+    <tr>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${product.productName || ''}</td>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${product.model || product.productModel || ''}</td>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${product.quantity || 0}</td>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${formatNumber(product.price)}</td>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: right; font-size: 12px;">${formatNumber(beforeTax)}</td>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: right; font-size: 12px;">${formatNumber(taxAmount)}</td>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: right; font-weight: bold; font-size: 12px;">${formatNumber(subtotal)}</td>
+      <td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${product.remark || ''}</td>
+    </tr>`;
+      })
+      .join('') ||
+    '<tr><td colspan="8" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">暂无产品信息</td></tr>';
+
+  return `
+    <div style="width: 210mm; margin: 0 auto; padding: 0 15mm 20mm 15mm; font-family: '宋体', SimSun, serif; line-height: 1.6; box-sizing: border-box;">
+      <!-- Logo -->
+      <div style="text-align: right; margin-top: 15px; margin-bottom: 10px;">
+        <img src="/src/assets/images/logo.gif" alt="logo" style="height: 30px;" />
+      </div>
+
+      <!-- 标题 -->
+      <h1 style="text-align: center; font-size: 28px; margin: 0; font-weight: bold; line-height: 1.5;">山峡化工产品销售合同</h1>
+
+      <!-- 合同基本信息 -->
+      <div style="margin-bottom: 0; font-size: 12px; line-height: 1.5;">
+        <div style="display: flex; justify-content: space-between; margin-bottom: 0;">
+          <div>签订日期:${formatDate(contractData.signDate)}</div>
+          <div style="flex: 1;"></div>
+        </div>
+        <div style="display: flex; justify-content: space-between; margin-bottom: 0;">
+          <div>供方:${contractData.partyA || '安庆市山峡化工有限公司'}</div>
+          <div>合同编号:${contractData.contractNo || ''}</div>
+        </div>
+        <div style="display: flex; justify-content: space-between; margin-bottom: 0;">
+          <div>需方:${contractData.partyB || contractData.customerName || ''}</div>
+          <div>签订地点:安徽安庆</div>
+        </div>
+      </div>
+
+      <!-- 合同前言 -->
+      <div style="margin-bottom: 0; font-size: 12px; text-indent: 2em; line-height: 1.5;">
+        经供需本着平等互利、协调一致的原则,根据中华人民共和国民法典的相关规定,订立合同。
+      </div>
+
+      <!-- 第一条 -->
+      <div style="margin-bottom: 0;">
+        <div style="font-weight: bold; margin-bottom: 10px; font-size: 12px;">第一条、标的、数量、价格及其他</div>
+
+        <!-- 产品表格 -->
+        <table style="width: 100%; border-collapse: collapse; margin-bottom: 0; border: 1px solid #000;">
+          <thead>
+            <tr style="background-color: #fff;">
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 20%;">产品名称</th>
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 10%;">规格</th>
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 8%;">用量(吨)</th>
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 12%;">含税单价(元/吨)</th>
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 12%;">未含税金额(元)</th>
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 10%;">税费(元)</th>
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 13%;">含税金额(元)</th>
+              <th style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px; width: 15%;">备注</th>
+            </tr>
+          </thead>
+          <tbody>
+            ${productRows}
+            <!-- 合计行 -->
+            <tr style="background-color: #fff; font-weight: bold;">
+              <td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
+              <td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: right; font-size: 12px; padding-right: 15px;">${productTotalChinese}(¥${formatNumber(productTotal)})</td>
+            </tr>
+            <!-- 提示行 -->
+            <tr>
+              <td colspan="8" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 11px; background-color: #fff;">
+                含税、含运费、装卸费(一票制)&nbsp;&nbsp;&nbsp;&nbsp;结算以实际到货吨位为准
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+
+      <!-- 第二条 -->
+      <div style="margin-bottom: 0; line-height: 1.5; font-size: 12px;">
+        <div style="font-weight: bold; margin-bottom: 0;">第二条、产品质量及包装要求</div>
+        <div style="line-height: 1.8;">
+          <div style="margin: 0;">1、产品质量要求:产品质量以双方认可的产品质量标准为验收标准。</div>
+          <div style="margin: 0;">2、产品包装规格:散水包装。</div>
+        </div>
+      </div>
+
+      <!-- 第三条 -->
+      <div style="margin-bottom: 0; line-height: 1.5; font-size: 12px;">
+        <div style="font-weight: bold; margin-bottom: 0;">第三条、交货方式及运输方式</div>
+        <div style="line-height: 1.8;">
+          <div style="margin: 0;">1、交货方式:供方送货,供方需提供需方要求产品,送达至需方要求地点。</div>
+          <div style="margin: 0;">2、运输:在运输过程中发生的一切费用由供方承担,到达本公司卸货过程中所发生的一些费用由供方承担。</div>
+        </div>
+      </div>
+
+      <!-- 第四条 -->
+      <div style="margin-bottom: 0; line-height: 1.5; font-size: 12px;">
+        <div style="font-weight: bold; margin-bottom: 0;">第四条、验收标准</div>
+        <div style="line-height: 1.8; margin: 0;">
+          按合同第一条规定,货物到达交货地点后,需方应在卸车前检验。如检验结果达到合同约定的质量标准,需方应无条件卸货。如检验结果未达到标准双方协商折价、换货或终止合同;需方验收合格应当日内安排卸货。
+        </div>
+      </div>
+
+      <!-- 第五条 -->
+      <div style="margin-bottom: 0; line-height: 1.5; font-size: 12px;">
+        <div style="font-weight: bold; margin-bottom: 0;">第五条、结算方式及期限</div>
+        <div style="line-height: 1.8; margin: 0;">
+          货到付款,需方收到供方货共13%增值税专用发票,无异议后,尽快以电汇方式付款。
+        </div>
+      </div>
+
+      <!-- 第六条 -->
+      <div style="margin-bottom: 0; line-height: 1.5; font-size: 12px;">
+        <div style="font-weight: bold; margin-bottom: 0;">第六条、合同有效期限</div>
+        <div style="line-height: 1.8; margin: 0;">
+          合同于${formatDateChinese(contractData.startDate)}生效,有效期至${formatDateChinese(contractData.endDate)}。本合同一式两份,经双方盖章后生效,双方盖章后扫描件、传真件与合同原件具有同等效力。
+        </div>
+      </div>
+
+      <!-- 第七条 -->
+      <div style="margin-bottom: 0; line-height: 1.5; font-size: 12px;">
+        <div style="font-weight: bold; margin-bottom: 0;">第七条、其他补充及规定</div>
+        <div style="line-height: 1.8; margin: 0;">
+          其他:本合同如有未尽事宜,须经双方共同协商,作出补充规定。如发生纠纷,由双方当事人协商解决,协商或调解不成的,可合同签订地的人民法院提出诉讼。若涉及易制毒化学品,需方须办理易制毒购买备案证明
+        </div>
+      </div>
+
+      <!-- 双方信息 -->
+      <div style="display: flex; justify-content: space-between; margin-top: 10px;">
+        <div style="flex: 1; padding-right: 10px; border: 1px solid #000; padding: 8px; line-height: 1.3; font-size: 12px; position: relative;">
+          <div style="font-weight: bold; margin-bottom: 5px; padding-bottom: 5px; border-bottom: 1px solid #000;">供方</div>
+          <!-- 印章图片 -->
+          <img src="/src/assets/images/yz.png" alt="印章" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; height: 140px; opacity: 0.85; z-index: 10;" />
+          <div style="margin-bottom: 0;">单位名称(章):${contractData.partyA || '安庆市山峡化工有限公司'}</div>
+          <div style="margin-bottom: 0;">单位地址:${contractData.partyAAddress || '安徽省安庆市迎江区皖江大道5号'}</div>
+          <div style="margin-bottom: 0;">法定代表人:${contractData.partyALegalPerson || ''}</div>
+          <div style="margin-bottom: 0;">代理人:${contractData.partyAAgent || ''}</div>
+          <div style="margin-bottom: 0;">电话/传真:${contractData.partyAPhoneFax || '0556-5396799'}</div>
+          <div style="margin-bottom: 0;">开户银行:${contractData.partyABank || '中国建设银行安庆市城北支行'}</div>
+          <div style="margin-bottom: 0;">账号:${contractData.partyAAccount || '34050168680800000121'}</div>
+        </div>
+        <div style="flex: 1; padding-left: 10px; border: 1px solid #000; padding: 8px; line-height: 1.3; font-size: 12px;">
+          <div style="font-weight: bold; margin-bottom: 5px; padding-bottom: 5px; border-bottom: 1px solid #000;">需方</div>
+          <div style="margin-bottom: 0;">单位名称(章):${contractData.partyB || contractData.customerName || ''}</div>
+          <div style="margin-bottom: 0;">单位地址:${contractData.partyBAddress || ''}</div>
+          <div style="margin-bottom: 0;">法定代表人:${contractData.partyBLegalPerson || ''}</div>
+          <div style="margin-bottom: 0;">代理人:${contractData.partyBAgent || ''}</div>
+          <div style="margin-bottom: 0;">电话/传真:${contractData.partyBPhoneFax || ''}</div>
+          <div style="margin-bottom: 0;">开户银行:${contractData.partyBBank || ''}</div>
+          <div style="margin-bottom: 0;">账号:${contractData.partyBAccount || ''}</div>
+        </div>
+      </div>
+    </div>
+  `;
+};
+
+/** 打印合同 */
+const handlePrintContract = () => {
+  // 创建打印窗口
+  const printWindow = window.open('', '_blank');
+  if (printWindow) {
+    printWindow.document.write(`
+      <!DOCTYPE html>
+      <html>
+      <head>
+        <meta charset="utf-8">
+        <title>打印合同</title>
+        <style>
+          body { margin: 0; padding: 20px; }
+          @media print {
+            body { margin: 0; }
+          }
+        </style>
+      </head>
+      <body>
+        ${contractContent.value}
+      </body>
+      </html>
+    `);
+    printWindow.document.close();
+
+    // 等待内容加载完成后打印
+    setTimeout(() => {
+      printWindow.print();
+    }, 500);
+  }
+};
+
+/** 切换预览/编辑模式 */
+const togglePreviewMode = () => {
+  isPreviewMode.value = !isPreviewMode.value;
+};
+
+/** 从打印弹窗生成PDF(前端实现) */
+const handleGeneratePdfFromDialog = async () => {
+  if (!detailContract.value?.id) {
+    proxy?.$modal.msgError('合同信息不存在');
+    return;
+  }
+
+  pdfGenerating.value = true;
+  try {
+    proxy?.$modal.loading('正在生成PDF...');
+
+    // 1. 创建临时容器
+    const tempDiv = document.createElement('div');
+    tempDiv.style.position = 'absolute';
+    tempDiv.style.left = '-9999px';
+    tempDiv.style.top = '0';
+    tempDiv.style.width = '794px'; // A4纸宽度
+    tempDiv.style.background = 'white';
+    tempDiv.innerHTML = contractContent.value;
+    document.body.appendChild(tempDiv);
+
+    // 2. 使用html2canvas转换为canvas
+    const canvas = await html2canvas(tempDiv, {
+      scale: 2, // 提高清晰度
+      useCORS: true,
+      allowTaint: true,
+      backgroundColor: '#ffffff'
+    });
+
+    // 3. 移除临时容器
+    document.body.removeChild(tempDiv);
+
+    // 4. 计算PDF尺寸
+    const imgWidth = 210; // A4纸宽度(mm)
+    const imgHeight = (canvas.height * imgWidth) / canvas.width;
+
+    // 5. 创建PDF
+    const pdf = new jsPDF({
+      orientation: 'portrait',
+      unit: 'mm',
+      format: 'a4'
+    });
+
+    const imgData = canvas.toDataURL('image/jpeg', 1.0);
+
+    // 6. 如果内容高度超过一页,需要分页
+    const pageHeight = 297; // A4纸高度(mm)
+    let heightLeft = imgHeight;
+    let position = 0;
+
+    pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+    heightLeft -= pageHeight;
+
+    while (heightLeft >= 0) {
+      position = heightLeft - imgHeight;
+      pdf.addPage();
+      pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
+      heightLeft -= pageHeight;
+    }
+
+    // 7. 下载PDF
+    const safeContractNo = detailContract.value.contractNo.replace(/[/\\:*?"<>|]/g, '-');
+    pdf.save(`合同-${safeContractNo}.pdf`);
+
+    proxy?.$modal.closeLoading();
+    proxy?.$modal.msgSuccess('PDF生成成功');
+  } catch (error) {
+    console.error('生成PDF失败:', error);
+    proxy?.$modal.closeLoading();
+    proxy?.$modal.msgError('生成PDF失败,请重试');
+  } finally {
+    pdfGenerating.value = false;
+  }
+};
+
 onMounted(() => {
   getList();
 });

+ 2 - 3
web/src/views/crm/quote/detail.vue

@@ -446,10 +446,9 @@ const handleSubmit = () => {
 const handleGeneratePdf = async () => {
   try {
     const res = await generatePdf(quote.value!.id);
+    const pdfUrl = import.meta.env.VITE_APP_BASE_API + res.msg;
     ElMessage.success('PDF生成成功');
-    if (res.data) {
-      window.open(res.data, '_blank');
-    }
+    window.open(pdfUrl, '_blank');
   } catch (error) {
     console.error(error);
   }

+ 2 - 4
web/src/views/crm/quote/index.vue

@@ -1074,11 +1074,9 @@ const confirmSubmit = async () => {
 const handleGeneratePdfByApi = async (row: QuoteVO) => {
   try {
     const res = await generatePdf(row.id);
+    const pdfUrl = import.meta.env.VITE_APP_BASE_API + res.msg;
     proxy?.$modal.msgSuccess('PDF生成成功');
-    // TODO: 可以选择预览或下载
-    if (res.data) {
-      window.open(res.data, '_blank');
-    }
+    window.open(pdfUrl, '_blank');
   } catch (error) {
     console.error('生成PDF失败:', error);
   }