Files
shanxia2.0/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue
T

4178 lines
152 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 合同详情 -->
<el-dialog
v-model="visible"
title="合同详情"
width="100%"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout contract-detail-dialog"
modal-class="order-module-dialog-mask"
@close="handleDetailDrawerClose"
>
<div class="contract-detail-body" v-loading="detailLoading">
<!-- 头部操作按钮 -->
<div class="detail-header">
<div class="header-left">
<span class="detail-title">{{ detailContract?.contractNo }} - {{ detailContract?.contractName }}</span>
<el-tag
v-if="detailContract?.status"
:type="dictStore.getTagType('crm_contract_status', detailContract?.status)"
size="large"
class="ml-2"
>{{ dictStore.getLabel('crm_contract_status', detailContract?.status) }}</el-tag>
</div>
<div class="detail-actions">
<el-button
v-if="manageActions"
class="btn-ghost"
@click="handleDetailCopy()"
>
<el-icon><DocumentCopy /></el-icon>合同复制
</el-button>
<el-button
v-if="manageActions && isContractEditableStatus(detailContract?.status)"
class="btn-ghost"
@click="handleDetailEdit"
>
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button
class="btn-ghost"
:disabled="detailContract?.contractTemplate === 'customer'"
@click="handleDetailPrint"
>
<el-icon><Printer /></el-icon>打印
</el-button>
<el-button
v-if="
['draft', 'approving', 'approved', 'executing'].includes(detailContract?.status || '')
"
class="btn-ghost"
@click="handleDetailInvalid"
>
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button
v-else-if="detailContract?.status === 'invalid'"
class="btn-ghost"
:loading="restoreLoading"
@click="handleDetailRestore"
>
<el-icon><RefreshLeft /></el-icon>作废恢复
</el-button>
<el-button
v-if="
manageActions &&
detailContract?.status === 'approved' &&
detailContract?.approvalStatus === 'approved'
"
class="btn-ghost"
@click="handleDetailExecute"
>
<el-icon><VideoPlay /></el-icon>开始执行
</el-button>
<el-button
v-if="
manageActions &&
detailContract?.status === 'draft' &&
canDeleteByCreator(detailContract)
"
class="btn-ghost"
@click="handleDetailDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="manageActions && detailContract?.status === 'draft'"
class="btn-ghost"
@click="handleDetailSubmit"
>
<el-icon><Promotion /></el-icon>提交审批
</el-button>
<el-button
v-if="manageActions && detailContract?.status === 'executing'"
class="btn-ghost"
@click="handleDetailComplete"
>
<el-icon><CircleCheck /></el-icon>完成合同
</el-button>
</div>
</div>
<el-tabs v-model="detailActiveTab" class="mt-4">
<!-- 合同信息 -->
<el-tab-pane label="合同信息" name="contract">
<div v-if="detailContract" class="contract-info-tab">
<div class="contract-info-section">
<h3>基本信息</h3>
<ViewKvTable
:fields="detailBasicViewFields"
mode="quad"
@field-view="handleBasicFieldView"
/>
<p v-if="detailContract.contractTemplate === 'customer'" class="contract-template-hint">
合同在附件中,下载电脑中打印,不通过系统打印
</p>
</div>
<div class="parties-section">
<div class="parties-section-toolbar">
<el-button text type="primary" size="small" @click="partiesExpanded = !partiesExpanded">
<el-icon>
<ArrowUp v-if="partiesExpanded" />
<ArrowDown v-else />
</el-icon>
{{ partiesExpanded ? '收起供需方' : '展开供需方' }}
</el-button>
</div>
<div class="parties-row">
<div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
<div class="party-panel__header">
<div class="party-panel__title">供方信息</div>
</div>
<div v-if="!partiesExpanded" class="party-panel__summary">
<span class="party-panel__summary-label">单位名称</span>
<span
class="party-panel__summary-value"
:class="{ 'is-empty': !detailContract?.partyA }"
>
{{ detailContract?.partyA || '未填写' }}
</span>
</div>
<ViewKvTable v-show="partiesExpanded" :fields="detailSupplierViewFields" mode="dual" />
</div>
<div class="party-flow-bridge" aria-hidden="true">
<span class="party-flow-bridge__label">供货</span>
<span class="party-flow-bridge__arrow">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M5 12h11.5"
stroke="currentColor"
stroke-width="2.75"
stroke-linecap="round"
/>
<path
d="M13.5 8.5 18.5 12l-5 3.5"
stroke="currentColor"
stroke-width="2.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
<div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
<div class="party-panel__header">
<div class="party-panel__title">需方信息</div>
</div>
<div v-if="!partiesExpanded" class="party-panel__summary">
<span class="party-panel__summary-label">单位名称</span>
<span
class="party-panel__summary-value"
:class="{ 'is-empty': !detailContract?.partyB }"
>
{{ detailContract?.partyB || '未填写' }}
</span>
</div>
<ViewKvTable v-show="partiesExpanded" :fields="detailBuyerViewFields" mode="dual" />
</div>
</div>
</div>
<!-- 产品明细部分 -->
<el-divider content-position="left">产品明细</el-divider>
<div class="panel dialog-list-panel mb-4">
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="crm-contract-components-contract-detail-dialog" :data="detailContract.products" class="list-table" stripe empty-text="暂无产品明细">
<el-table-column label="序号" type="index" width="60" align="center" header-align="center" />
<el-table-column label="产品名称" min-width="120" :show-overflow-tooltip="true">
<template #default="{ row }">
<span>{{ row.standardProductName || row.productName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="别名" min-width="80" :show-overflow-tooltip="true">
<template #default="{ row }">
<span>{{ row.aliasName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="72" :show-overflow-tooltip="true">
<template #default="{ row }">
<span>{{ formatBrandConstraintLabel(row.brandMode, row.brandName) }}</span>
</template>
</el-table-column>
<el-table-column label="型号/规格" prop="model" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">
<span>{{ scope.row.model || scope.row.productModel || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="产品分类" prop="categoryName" min-width="110" :show-overflow-tooltip="true" />
<el-table-column label="税率(%)" prop="taxRate" min-width="72" align="center">
<template #default="scope">
<span>{{ scope.row.taxRate || 0 }}%</span>
</template>
</el-table-column>
<el-table-column label="单位" prop="unit" min-width="56" align="center">
<template #default="scope">
<DictTag dict-type="crm_product_unit" :value="scope.row.unit" />
</template>
</el-table-column>
<el-table-column label="数量" prop="quantity" min-width="96" align="right">
<template #default="scope">
<span v-if="isFrameworkContractType(detailContract.contractType)" class="text-muted">
{{ displayFrameworkQuantity(scope.row, true) }}
</span>
<span v-else>{{ scope.row.quantity }}</span>
</template>
</el-table-column>
<el-table-column
v-if="!hideProductPrice"
label="含税单价(元)"
prop="price"
width="130"
align="right"
>
<template #default="scope">
<span v-if="isFrameworkContractType(detailContract.contractType)" class="text-muted">
{{ displayFrameworkMoney(scope.row, 'price', true) }}
</span>
<span v-else class="money-display">¥{{ parseFloat(scope.row.price || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="!hideProductPrice"
label="含税总价(元)"
prop="subtotal"
width="130"
align="right"
>
<template #default="scope">
<span v-if="isFrameworkContractType(detailContract.contractType)" class="text-muted">
{{ displayFrameworkMoney(scope.row, 'subtotal', true) }}
</span>
<span v-else class="money-display">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="!hideProductPrice"
label="税费(元)"
prop="taxAmount"
width="120"
align="right"
>
<template #default="scope">
<span v-if="isFrameworkContractType(detailContract.contractType)" class="text-muted">
{{ displayFrameworkMoney(scope.row, 'taxAmount', true) }}
</span>
<span v-else class="money-display">¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column
label="已归档数量"
prop="archivedQty"
min-width="150"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<div v-if="hasMultiPackageQtyBreakdown(row)" class="pkg-qty-breakdown">
<div
v-for="item in row.packageQtyBreakdown"
:key="item.packageSpec"
class="pkg-qty-line"
>
<span class="pkg-qty-label">{{ item.packageSpec }}:</span>
<span class="plan-qty-num">{{ formatPlanQty(item.archivedQty) }}</span>
</div>
</div>
<span v-else class="plan-qty-num">{{ formatPlanQty(row.archivedQty) }}</span>
</template>
</el-table-column>
<el-table-column
label="未归档已安排"
prop="arrangedUnarchivedQty"
min-width="160"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<div v-if="hasMultiPackageQtyBreakdown(row)" class="pkg-qty-breakdown">
<div
v-for="item in row.packageQtyBreakdown"
:key="item.packageSpec + '-unarchived'"
class="pkg-qty-line"
>
<span class="pkg-qty-label">{{ item.packageSpec }}:</span>
<span class="plan-qty-num">{{ formatPlanQty(item.arrangedUnarchivedQty) }}</span>
</div>
</div>
<span v-else class="plan-qty-num">{{ formatPlanQty(row.arrangedUnarchivedQty) }}</span>
</template>
</el-table-column>
<el-table-column
label="已安排合计"
prop="arrangedTotalQty"
min-width="150"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<div v-if="hasMultiPackageQtyBreakdown(row)" class="pkg-qty-breakdown">
<div
v-for="item in row.packageQtyBreakdown"
:key="item.packageSpec + '-total'"
class="pkg-qty-line"
>
<span class="pkg-qty-label">{{ item.packageSpec }}:</span>
<span class="plan-qty-num">{{ formatPlanQty(item.arrangedTotalQty) }}</span>
</div>
</div>
<span v-else class="plan-qty-num">{{ formatPlanQty(row.arrangedTotalQty) }}</span>
</template>
</el-table-column>
<el-table-column
label="剩余数量"
prop="remainingQty"
min-width="100"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<span
class="plan-qty-num"
:class="{ 'is-over-delivery': (resolveLineRemainingQty(row) ?? 0) < 0 }"
>{{ formatRemainingQtyLabel(resolveLineRemainingQty(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="包装规格" prop="packageSpec" min-width="150">
<template #default="scope">
<div v-if="resolvePackageSpecsFromPayload(scope.row).length > 1" class="pkg-spec-tags">
<el-tag
v-for="spec in resolvePackageSpecsFromPayload(scope.row)"
:key="spec"
size="small"
type="info"
>
{{ spec }}
</el-tag>
</div>
<span v-else>{{ formatPackageSpecsJoin(scope.row) || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="送货方式" prop="deliveryMethod" min-width="150" :show-overflow-tooltip="true">
<template #default="scope">
<span>{{ scope.row.deliveryMethod || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="150" :show-overflow-tooltip="true">
<template #default="scope">
<span>{{ scope.row.remark || '-' }}</span>
</template>
</el-table-column>
</ListTable>
</div>
</div>
<template v-if="isFrameworkContractType(detailContract.contractType) && !hideProductPrice">
<el-divider content-position="left">框架合同调价明细</el-divider>
<p class="text-muted" style="font-size: 13px; margin-bottom: 12px">按生效区间记录含税单价及调价函;后续结算可据此核对。</p>
<div
v-for="(prod, pIdx) in detailContract.products || []"
:key="'fw-tier-drawer-' + (prod.id ?? pIdx)"
class="framework-tier-detail-block mb-4"
>
<div class="fw-tier-detail-title">
{{ prod.productName || '产品' + (pIdx + 1) }}
<template v-if="prod.model || prod.productModel || formatPackageSpecsJoin(prod)">
({{ prod.model || prod.productModel || '-' }} {{ formatPackageSpecsJoin(prod) || '-' }})
</template>
</div>
<div class="panel dialog-list-panel mb-3">
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="crm-contract-components-contract-detail-dialog-2" :data="prod.priceTiers || []" class="list-table" stripe empty-text="暂无调价记录">
<el-table-column type="index" label="#" width="52" align="center" header-align="center" />
<el-table-column label="开始日期" prop="startDate" min-width="120" align="center" />
<el-table-column label="结束日期" prop="endDate" min-width="120" align="center" />
<el-table-column label="含税单价(元)" width="130" align="right">
<template #default="scope">
<span class="money-display">¥{{ parseFloat(String(scope.row.price ?? 0)).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="调价函" min-width="260" align="center">
<template #default="scope">
<div v-if="detailTierLetterOssIds(scope.row).length" class="tier-letter-thumbs">
<div
v-for="(oid, li) in detailTierLetterOssIds(scope.row)"
:key="'drawer-tier-letter-' + li + '-' + oid"
class="tier-letter-thumb-wrap"
>
<ImagePreview :src="String(oid)" class="tier-letter-thumb-img" :isOssId="true" :width="72" :height="72" />
<el-link type="primary" :underline="false" class="tier-letter-download-link" @click="handleDetailDownloadTierLetter(oid)">
下载
</el-link>
</div>
</div>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</ListTable>
</div>
</div>
</div>
</template>
<!-- 合计金额:合计总金额 = 合计金额 + 运费 + 桶押金 -->
<template v-if="!hideProductPrice">
<el-divider />
<div class="total-section">
<ViewKvTable :fields="detailTotalSummaryFields" mode="quad" />
</div>
<!-- 金额统计 -->
<el-divider content-position="left">金额统计</el-divider>
<el-row :gutter="20" class="amount-cards">
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="合同金额(元)" :value="detailContract.amount || 0" :precision="2" class="money-statistic">
<template #prefix>¥</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="已回款" :value="detailContract.receivedAmount || 0" :precision="2">
<template #prefix>¥</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="未回款" :value="getDetailUnreceivedAmount()" :precision="2">
<template #prefix>¥</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="回款进度" :value="getDetailReceivedRate()" :precision="1">
<template #suffix>%</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="amount-cards mt-3">
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="已开票" :value="detailContract.invoicedAmount || 0" :precision="2">
<template #prefix>¥</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="未开票" :value="getDetailUninvoicedAmount()" :precision="2">
<template #prefix>¥</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="开票进度" :value="getDetailInvoicedRate()" :precision="1">
<template #suffix>%</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
</template>
</div>
<el-empty v-else description="加载中..." />
</el-tab-pane>
<!-- 运输计划 -->
<el-tab-pane label="运输计划" name="plans">
<PlContractLinkedPlansPanel
:contract-id="detailContract?.id"
:active="detailActiveTab === 'plans'"
/>
</el-tab-pane>
<!-- 回款记录 -->
<el-tab-pane label="回款记录" name="payment">
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
<el-button class="btn-main" type="primary" @click="handleDetailAddPayment">
<el-icon><Plus /></el-icon>新增回款
</el-button>
</div>
</div>
</div>
<div class="list-body list-wrap">
<ListTable table-key="crm-contract-components-contract-detail-dialog-3"
:data="detailPaymentList"
class="list-table"
stripe
v-loading="detailPaymentLoading"
empty-text="暂无回款记录"
@row-dblclick="handleDetailViewPayment"
>
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column label="回款单号" prop="paymentNo" min-width="150" align="center" header-align="center">
<template #default="scope">
<el-link type="primary" @click="handleDetailViewPayment(scope.row)">
{{ scope.row.paymentNo }}
</el-link>
</template>
</el-table-column>
<el-table-column label="回款金额" prop="amount" min-width="130" align="right">
<template #default="scope">
<span class="text-success font-weight-bold"
>¥{{ parseFloat(scope.row.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span
>
</template>
</el-table-column>
<el-table-column label="回款日期" prop="paymentDate" width="110" align="center" />
<el-table-column label="回款方式" prop="paymentMethod" width="100" align="center" />
<el-table-column label="回款账户" prop="paymentAccount" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="创建人" prop="createBy" width="100" align="center" />
<el-table-column label="创建时间" prop="createTime" width="160" align="center">
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
</ListTable>
</div>
</div>
</el-tab-pane>
<!-- 开票记录 -->
<el-tab-pane label="开票记录" name="invoice">
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
<el-button class="btn-main" type="primary" @click="handleDetailAddInvoice">
<el-icon><Plus /></el-icon>新增开票
</el-button>
</div>
</div>
</div>
<div class="list-body list-wrap">
<ListTable table-key="crm-contract-components-contract-detail-dialog-4"
:data="detailInvoiceList"
class="list-table"
stripe
v-loading="detailInvoiceLoading"
empty-text="暂无开票记录"
@row-dblclick="handleDetailViewInvoice"
>
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column label="发票号码" prop="invoiceNo" min-width="150" align="center" header-align="center">
<template #default="scope">
<el-link type="primary" @click="handleDetailViewInvoice(scope.row)">
{{ scope.row.invoiceNo }}
</el-link>
</template>
</el-table-column>
<el-table-column label="发票类型" prop="invoiceType" width="120" align="center" />
<el-table-column label="开票金额" prop="amount" min-width="130" align="right">
<template #default="scope">
<span class="text-primary font-weight-bold"
>¥{{ parseFloat(scope.row.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span
>
</template>
</el-table-column>
<el-table-column label="开票日期" prop="invoiceDate" width="110" align="center" />
<el-table-column label="发票状态" prop="status" width="100" align="center" />
<el-table-column label="创建人" prop="createBy" width="100" align="center" />
<el-table-column label="创建时间" prop="createTime" width="160" align="center">
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
</ListTable>
</div>
</div>
</el-tab-pane>
<!-- 审批流程 -->
<el-tab-pane label="审批流程" name="approval">
<!-- 审批状态卡片(仅在有审批流程时显示) -->
<el-alert
v-if="detailContract?.status === 'approving'"
title="审批中"
type="info"
description="合同正在审批流程中,请耐心等待审批结果"
:closable="false"
class="mb-4"
/>
<el-alert
v-else-if="detailContract?.status === 'approved' && detailContract?.approvalStatus === 'approved'"
title="审批已通过"
type="success"
description="合同已通过全部审批流程"
:closable="false"
class="mb-4"
/>
<el-alert
v-else-if="detailContract?.approvalStatus === 'rejected'"
title="审批已拒绝"
type="error"
:description="`拒绝原因:${detailContract?.rejectReason || '未填写'}`"
:closable="false"
class="mb-4"
/>
<el-alert
v-else-if="['executing', 'finished'].includes(detailContract?.status || '')"
title="审批已完成"
type="success"
description="合同已通过审批并在执行中"
:closable="false"
class="mb-4"
/>
<el-empty v-if="detailApprovalList.length === 0" description="暂无审批记录" />
<el-timeline v-else>
<el-timeline-item
v-for="item in detailApprovalList"
:key="item.id"
:timestamp="`第${item.step}步 - ${item.createTime}`"
placement="top"
:color="getDetailApprovalColor(item.approvalResult)"
:hollow="item.approvalResult === 'pending'"
>
<el-card>
<div class="approval-item">
<div class="approval-header">
<div>
<el-avatar :size="32" class="mr-2">{{ item.approverName?.substring(0, 1) }}</el-avatar>
<span class="approver-name">{{ item.approverName }}</span>
<el-tag v-if="item.approvalResult === 'pending'" type="info" size="small" class="ml-2">待审批</el-tag>
</div>
<div>
<el-tag v-if="item.approvalResult === 'approved'" type="success" size="small">已通过</el-tag>
<el-tag v-else-if="item.approvalResult === 'rejected'" type="danger" size="small">已拒绝</el-tag>
<el-tag v-else type="info" size="small">待审批</el-tag>
</div>
</div>
<div v-if="item.approvalOpinion" class="approval-opinion mt-3">
<div class="opinion-label">
<el-icon class="mr-1"><ChatLineSquare /></el-icon>
<span>审批意见:</span>
</div>
<div class="opinion-content">{{ item.approvalOpinion }}</div>
</div>
<div v-if="item.approvalTime" class="approval-time mt-2">
<el-icon class="mr-1"><Clock /></el-icon>
<span>审批时间:{{ item.approvalTime }}</span>
</div>
<div v-if="item.remark" class="approval-remark mt-2">
<el-icon class="mr-1"><InfoFilled /></el-icon>
<span>备注:{{ item.remark }}</span>
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<!-- 审批统计 -->
<el-divider v-if="detailApprovalList.length > 0" content-position="left">审批统计</el-divider>
<el-row v-if="detailApprovalList.length > 0" :gutter="20">
<el-col :span="6">
<el-statistic title="审批步骤" :value="detailApprovalList.length" suffix="步" />
</el-col>
<el-col :span="6">
<el-statistic
title="已审批"
:value="detailApprovalList.filter((item) => item.approvalResult && item.approvalResult !== 'pending').length"
suffix="步"
/>
</el-col>
<el-col :span="6">
<el-statistic title="待审批" :value="detailApprovalList.filter((item) => item.approvalResult === 'pending').length" suffix="步" />
</el-col>
<el-col :span="6">
<el-statistic
title="审批进度"
:value="
detailApprovalList.length > 0
? Math.round(
(detailApprovalList.filter((item) => item.approvalResult && item.approvalResult !== 'pending').length /
detailApprovalList.length) *
100
)
: 0
"
suffix="%"
/>
</el-col>
</el-row>
</el-tab-pane>
<!-- 操作记录 -->
<el-tab-pane label="操作记录" name="operation">
<!-- 合同作废记录 -->
<div v-if="detailContract?.status === 'invalid' || detailContract?.invalidReason">
<el-divider content-position="left">
<el-icon class="mr-1"><CircleClose /></el-icon>
合同作废记录
</el-divider>
<el-card shadow="hover" class="mb-4">
<el-descriptions :column="2" border>
<el-descriptions-item label="作废状态">
<el-tag type="danger" size="large">已作废</el-tag>
</el-descriptions-item>
<el-descriptions-item label="作废时间">
{{ detailContract?.invalidTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="作废原因" :span="2">
<DictTag v-if="detailContract?.invalidReason" dict-type="crm_invalid_reason" :value="detailContract?.invalidReason" />
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="详细说明" :span="2">
<div class="remark-content">{{ detailContract?.remark || '-' }}</div>
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<!-- 合同完成记录 -->
<div v-if="detailContract?.status === 'finished'">
<el-divider content-position="left">
<el-icon class="mr-1"><CircleCheck /></el-icon>
合同完成记录
</el-divider>
<el-card shadow="hover" class="mb-4">
<el-descriptions :column="2" border>
<el-descriptions-item label="完成状态">
<el-tag type="success" size="large">已完成</el-tag>
</el-descriptions-item>
<el-descriptions-item label="实际完成日期">
{{ detailContract?.actualEndDate || detailContract?.endDate || '-' }}
</el-descriptions-item>
<el-descriptions-item label="合同执行时长" :span="2">
<span v-if="detailContract?.startDate && detailContract?.endDate">
{{ calculateDetailDuration(detailContract?.startDate, detailContract?.endDate) }} 天
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="完成说明" :span="2">
<div class="remark-content">{{ detailContract?.remark || '-' }}</div>
</el-descriptions-item>
<el-descriptions-item v-if="!hideProductPrice" label="执行情况汇总" :span="2">
<el-row :gutter="20" class="mt-2">
<el-col :span="8">
<el-statistic title="合同金额(元)" :value="detailContract?.amount || 0" :precision="2" class="money-statistic">
<template #prefix>¥</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="已回款金额" :value="detailContract?.receivedAmount || 0" :precision="2">
<template #prefix>¥</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="回款进度" :value="getDetailReceivedRate()" :precision="1">
<template #suffix>%</template>
</el-statistic>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-3">
<el-col :span="8">
<el-statistic title="已开票金额" :value="detailContract?.invoicedAmount || 0" :precision="2">
<template #prefix>¥</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="未开票金额" :value="getDetailUninvoicedAmount()" :precision="2">
<template #prefix>¥</template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="开票进度" :value="getDetailInvoicedRate()" :precision="1">
<template #suffix>%</template>
</el-statistic>
</el-col>
</el-row>
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<!-- 合同执行记录 -->
<div v-if="detailContract?.status === 'executing' || detailContract?.status === 'finished'">
<el-divider content-position="left">
<el-icon class="mr-1"><VideoPlay /></el-icon>
合同执行记录
</el-divider>
<el-card shadow="hover" class="mb-4">
<el-descriptions :column="2" border>
<el-descriptions-item label="执行状态">
<el-tag v-if="detailContract?.status === 'executing'" type="warning" size="large">执行中</el-tag>
<el-tag v-else-if="detailContract?.status === 'finished'" type="success" size="large">已完成</el-tag>
</el-descriptions-item>
<el-descriptions-item label="执行开始日期">
{{ detailContract?.executeStartDate || detailContract?.startDate || '-' }}
</el-descriptions-item>
<el-descriptions-item label="预计完成日期">
{{ detailContract?.expectedEndDate || detailContract?.endDate || '-' }}
</el-descriptions-item>
<el-descriptions-item label="已执行天数">
<span v-if="detailContract?.executeStartDate || detailContract?.startDate">
{{ calculateDetailExecutedDays(detailContract?.executeStartDate || detailContract?.startDate) }} 天
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="执行说明" :span="2">
<div class="remark-content">{{ detailContract?.executeRemark || '-' }}</div>
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<!-- 合同扫描件 -->
<el-divider content-position="left">
<el-icon class="mr-1"><Document /></el-icon>
合同扫描件
</el-divider>
<el-card shadow="hover" class="mb-4">
<template #header>
<div class="card-header">
<span>已上传的合同文件(只读)</span>
<el-button
type="primary"
class="detail-upload-btn"
@click="handleDetailUploadFile"
>
<el-icon><Upload /></el-icon>
上传扫描件
</el-button>
</div>
</template>
<div v-if="detailReadonlyAttachmentList.length > 0" class="detail-attachment-list">
<div v-for="(file, index) in detailReadonlyAttachmentList" :key="`${file.ossId}-${index}`" class="detail-attachment-item">
<div
class="detail-attachment-preview"
:title="isDetailImageFile(file) || isDetailPdfFile(file) ? '点击预览' : '点击查看'"
@click.stop="handleDetailPreviewFile(file)"
>
<ImagePreview
v-if="isDetailImageFile(file)"
:src="file.ossId"
class="detail-attachment-thumb-img detail-attachment-thumb-img--passive"
:isOssId="true"
/>
<div v-else class="detail-attachment-preview__icon detail-attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
<span class="detail-attachment-preview__ext">{{ getDetailFileExt(getDetailDisplayFileName(file)) }}</span>
</div>
</div>
<div class="detail-attachment-footer">
<span class="detail-attachment-name" :title="getDetailDisplayFileName(file)">{{ getDetailShortFileName(getDetailDisplayFileName(file)) }}</span>
<div class="detail-attachment-category">{{ getDetailAttachmentCategoryLabel(file.category) }}</div>
<div class="detail-attachment-actions">
<el-button type="primary" size="small" @click.stop="handleDetailPreviewFile(file)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button type="success" size="small" @click.stop="handleDetailDownloadFile(file)">
<el-icon><Download /></el-icon>
下载
</el-button>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂未上传任何文件" />
</el-card>
<el-empty v-if="!hasDetailOperationRecord" description="暂无操作记录" />
</el-tab-pane>
</el-tabs>
</div>
</el-dialog>
<!-- 生成合同对话框 -->
<el-dialog
v-model="generateContractDialog.visible"
width="1200px"
align-center
append-to-body
class="dialog-form-layout generate-print-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<template #header>
<div class="generate-print-dialog-header">
<span class="generate-print-dialog-title">{{ isPreviewMode ? '预览合同' : '编辑合同' }}</span>
<p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
编辑模式:鼠标移到表头格子的右边界可调列宽,表格总宽不变,也不超出页面宽度;移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
</p>
</div>
</template>
<TinyMceEditor
ref="contractEditorRef"
v-model="contractContent"
:height="600"
:disabled="isPreviewMode"
:toolbar="!isPreviewMode"
:contractNo="detailContract?.contractNo"
/>
<template #footer>
<div class="generate-contract-dialog-actions">
<el-button class="btn-ghost" @click="generateContractDialog.visible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
<el-button class="btn-ghost" @click="togglePreviewMode">
<el-icon><View /></el-icon>{{ isPreviewMode ? '编辑模式' : '预览模式' }}
</el-button>
<el-button class="btn-ghost" @click="handleSaveTemplate">
<el-icon><DocumentChecked /></el-icon>保存模板
</el-button>
<el-button class="btn-ghost" @click="toggleRedSeal">
<el-icon>
<component :is="showRedSeal ? Hide : Stamp" />
</el-icon>
{{ showRedSeal ? '隐藏红章' : '显示红章' }}
</el-button>
<el-button class="btn-ghost" @click="handlePrintContract">
<el-icon><Printer /></el-icon>打印
</el-button>
<el-button class="btn-ghost" :loading="wordGenerating" @click="handleGenerateWordFromDialog">
<el-icon><Document /></el-icon>生成 Word
</el-button>
<el-button class="btn-main" type="primary" :loading="pdfGenerating" @click="handleGeneratePdfFromDialog">
<el-icon><Download /></el-icon>生成 PDF
</el-button>
</div>
</template>
</el-dialog>
<ApprovalDialog ref="approvalDialogRef" @success="handleActionSuccess" />
<ExecuteDialog ref="executeDialogRef" @success="handleActionSuccess" />
<InvalidDialog ref="invalidDialogRef" @success="handleActionSuccess" />
<UploadDialog ref="uploadDialogRef" @success="handleActionSuccess" />
<ContractFormDialog
v-model:visible="formDialogVisible"
:edit-id="formDialogEditId"
:copy-source="formDialogCopySource"
@success="handleFormSuccess"
/>
<CustomerDetailDialog v-if="customerDialogActive" ref="customerDetailRef" />
<OpportunityDetailDrawer v-if="opportunityDialogActive" ref="opportunityDetailRef" />
<QuoteDetailDialog v-if="quoteDialogActive" ref="quoteDetailRef" />
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, defineAsyncComponent, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from '../contractType'
import {
displayFrameworkMoney,
displayFrameworkQuantity,
formatFrameworkQuantityForPrint
} from '../frameworkProductPricing'
import {
CircleCheck,
CircleClose,
ChatLineSquare,
Clock,
Close,
Delete,
Document,
DocumentCopy,
DocumentChecked,
Download,
Edit,
Hide,
InfoFilled,
Plus,
Printer,
Promotion,
RefreshLeft,
Stamp,
Upload,
VideoPlay,
View,
ArrowDown,
ArrowUp
} from '@element-plus/icons-vue';
import {
getContract as getContractDetail,
delContract,
listContractApproval,
restoreContract,
saveTemplate,
getTemplate,
type ContractVO
} from '@/api/crm/contract';
import { getQuote } from '@/api/crm/quote';
import { getOpportunity } from '@/api/crm/opportunity';
import { useDictStore } from '@/store/modules/dict';
import DictTag from '@/components/DictTag/index.vue';
import { ElMessage } from 'element-plus';
import { $modal } from '@/utils/modal';
import { applyAliasDisplayMeta, formatBrandConstraintLabel, formatBrandForPrint } from '@/utils/crm-product-brand'
import { fetchProductDetail } from '@/api/crm/product'
import { parseTime } from '@/utils/format';
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete';
import FileSaver from 'file-saver'
import {
buildContractDocxFileName,
generateContractDocxBlob,
prepareContractHtmlForDocx
} from '@/utils/crm/contract-html-to-docx'
import ApprovalDialog from './ApprovalDialog.vue';
import ExecuteDialog from './ExecuteDialog.vue';
import InvalidDialog from './InvalidDialog.vue';
import UploadDialog from './UploadDialog.vue';
import ContractFormDialog from './ContractFormDialog.vue';
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
import type { ViewField } from '@/components/business/system-view-dialog/types';
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import {
parseSettlementClauseDrafts,
resolveSettlementClauseContent
} from '../settlementClause';
const CustomerDetailDialog = defineAsyncComponent(
() => import('@/views/crm/customer/CustomerDetailDialog.vue')
)
const OpportunityDetailDrawer = defineAsyncComponent(
() => import('@/views/crm/opportunity/components/OpportunityDetailDrawer.vue')
)
const QuoteDetailDialog = defineAsyncComponent(
() => import('@/views/crm/quote/components/QuoteDetailDialog.vue')
)
import ImagePreview from '@/components/ImagePreview/index.vue';
import TinyMceEditor from '@/components/TinyMceEditor/index.vue';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
import { applySavedProductTableLayout, stampSavedProductTableLayout } from '@/utils/crm/contract-table-layout';
import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
import { countContractPdfPages } from '@/utils/crm/contract-pdf-pages';
import {
downloadCrmAttachment,
getCrmAttachmentCategoryLabel,
isCrmPdfFile,
openCrmAttachmentPreviewInNewTab,
resolveCrmAttachmentFiles
} from '@/utils/crm-attachment';
import {
collectAllContractPackageSpecs,
formatPackageSpecsJoin,
hasMultiPackageQtyBreakdown,
resolveLineRemainingQty,
formatRemainingQtyLabel,
resolvePackageSpecsFromPayload
} from '@/utils/crm-package-spec';
const dictStore = useDictStore();
const router = useRouter();
const props = withDefaults(
defineProps<{
hideProductPrice?: boolean
/** 是否展示制单/履约类操作(修改、删除、提交审批、执行等) */
manageActions?: boolean
}>(),
{
hideProductPrice: false,
manageActions: true
}
)
const visible = ref(false);
const detailLoading = ref(false);
const detailActiveTab = ref('contract');
const currentDetailId = ref<string | number>();
const detailContract = ref<ContractVO>();
/** 供方 / 需方信息展开状态(默认缩略,两侧共用) */
const partiesExpanded = ref(false);
const detailPaymentList = ref<any[]>([]);
const detailPaymentLoading = ref(false);
const detailInvoiceList = ref<any[]>([]);
const detailInvoiceLoading = ref(false);
const detailApprovalList = ref<any[]>([]);
const detailContractFileList = ref<any[]>([]);
const detailAttachmentFileList = ref<any[]>([]);
const detailReadonlyAttachmentList = computed(() => {
const contractFiles = detailContractFileList.value.map((file: any) => ({
...file,
category: file.category || 'main_contract'
}));
const extraFiles = detailAttachmentFileList.value.map((file: any) => ({
...file,
category: file.category || 'other'
}));
return [...contractFiles, ...extraFiles];
});
const generateContractDialog = reactive({
visible: false
});
const contractContent = ref('');
const contractEditorRef = ref<{ getHtml: () => string } | null>(null);
const readEditorHtml = () => contractEditorRef.value?.getHtml?.() || contractContent.value;
const isPreviewMode = ref(false);
/** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
const showRedSeal = ref(true);
const pdfGenerating = ref(false);
const wordGenerating = ref(false);
const currentContractId = ref<string | number>();
const formDialogVisible = ref(false);
const formDialogEditId = ref<string | null>(null);
const formDialogCopySource = ref<ContractVO | null>(null);
const resetFormDialogState = () => {
formDialogEditId.value = null
formDialogCopySource.value = null
}
watch(formDialogVisible, (open) => {
if (!open) {
resetFormDialogState()
}
})
const approvalDialogRef = ref();
const executeDialogRef = ref();
const invalidDialogRef = ref();
const uploadDialogRef = ref();
type DetailOpenerRef = { open: (id: string | number) => void | Promise<void> } | null
const customerDetailRef = ref<DetailOpenerRef>(null)
const opportunityDetailRef = ref<DetailOpenerRef>(null)
const quoteDetailRef = ref<DetailOpenerRef>(null)
const customerDialogActive = ref(false)
const opportunityDialogActive = ref(false)
const quoteDialogActive = ref(false)
async function handleBasicFieldView(field: ViewField) {
const id = field.viewId
if (!id || !field.viewTarget) return
if (field.viewTarget === 'customer') {
customerDialogActive.value = true
await nextTick()
await customerDetailRef.value?.open(id)
return
}
if (field.viewTarget === 'opportunity') {
opportunityDialogActive.value = true
await nextTick()
await opportunityDetailRef.value?.open(id)
return
}
if (field.viewTarget === 'quote') {
quoteDialogActive.value = true
await nextTick()
await quoteDetailRef.value?.open(id)
}
}
const emit = defineEmits<{ success: [] }>();
function parseDetailOssIds(fileStr: string): string[] {
if (!fileStr) return [];
const trimmed = fileStr.trim();
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.map((item: any) => String(typeof item === 'string' ? item : item?.ossId ?? item))
.filter((id: string) => id);
}
} catch {
// fall through to comma-separated
}
}
return trimmed
.split(',')
.map((id: string) => id.trim())
.filter((id: string) => id);
}
function handleActionSuccess() {
loadDetailData();
emit('success');
}
function handleFormSuccess() {
formDialogVisible.value = false;
resetFormDialogState();
loadDetailData();
emit('success');
}
async function open(id: string | number) {
currentDetailId.value = id;
visible.value = true;
detailActiveTab.value = 'contract';
partiesExpanded.value = false;
await loadDetailData();
}
defineExpose({ open });
/** 等待容器内图片解码完成,避免 html2canvas 截到未加载资源导致发糊 */
async function waitForImagesLoaded(container: HTMLElement) {
const imgs = Array.from(container.querySelectorAll('img'));
await Promise.all(
imgs.map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete && img.naturalWidth > 0) {
resolve();
return;
}
img.onload = () => resolve();
img.onerror = () => resolve();
})
)
);
}
/** 截图倍率:打印/PDF 需要高于屏幕 DPR,否则入 PDF 后文字易糊 */
function getContractPdfCanvasScale() {
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
return Math.min(4, Math.max(3, dpr * 2));
}
// ==================== 详情抽屉相关方法 ====================
/** 加载详情数据 */
const loadDetailData = async () => {
if (!currentDetailId.value) return;
detailLoading.value = true;
try {
const res = await getContractDetail(String(currentDetailId.value));
detailContract.value = res.data;
await enrichContractProductAliasDisplay(detailContract.value?.products || [])
// 加载关联商机名称
if (detailContract.value?.opportunityId) {
try {
const oppRes = await getOpportunity(detailContract.value.opportunityId);
if (oppRes?.data) {
detailContract.value.opportunityName = oppRes.data.opportunityName;
}
} catch (error) {
console.warn('加载商机信息失败:', error);
}
}
// 加载关联报价单号
if (detailContract.value?.quoteId) {
try {
const quoteRes = await getQuote(detailContract.value.quoteId);
if (quoteRes?.data) {
detailContract.value.quoteNo = quoteRes.data.quoteNo;
}
} catch (error) {
console.warn('加载报价单信息失败:', error);
}
}
// 加载审批记录
loadDetailApprovalRecords();
// 异步加载附件信息
loadDetailContractAttachments();
} finally {
detailLoading.value = false;
}
};
async function enrichContractProductAliasDisplay(lines: any[]) {
await Promise.all(
(lines || []).map(async (line) => {
if (!line?.productId) {
line.standardProductName = line?.productName
line.aliasName = undefined
return
}
try {
const res: any = await fetchProductDetail({ id: String(line.productId) })
const data = res?.data ?? res
if (data) applyAliasDisplayMeta(line, data)
} catch (e) {
console.warn('enrich contract alias display failed', line.productId, e)
line.standardProductName = line.productName
line.aliasName = undefined
}
})
)
}
/** 加载合同附件信息 */
const loadDetailContractAttachments = async () => {
if (!detailContract.value) return;
// 加载主合同文件
if (detailContract.value.contractFile) {
try {
const ossIds = parseDetailOssIds(detailContract.value.contractFile);
if (ossIds.length > 0) {
detailContractFileList.value = loadDetailAttachmentsByOssIds(ossIds);
}
} catch (error) {
console.error('加载主合同文件失败:', error);
}
}
// 加载附件文件
if (detailContract.value.attachmentFiles) {
try {
const attachments = parseDetailAttachments(detailContract.value.attachmentFiles);
if (attachments.length > 0) {
detailAttachmentFileList.value = loadDetailAttachmentsWithCategory(attachments);
}
} catch (error) {
console.error('加载附件文件失败:', error);
}
}
};
/** 解析附件列表(支持JSON格式和逗号分隔格式) */
const parseDetailAttachments = (fileStr: string): Array<{ ossId: string; category?: string }> => {
if (!fileStr) return [];
// 尝试解析为JSON格式
if (fileStr.trim().startsWith('[')) {
try {
const parsed = JSON.parse(fileStr);
if (Array.isArray(parsed)) {
return parsed.map((item: any) => ({
ossId: typeof item === 'string' ? item : item.ossId || item,
category: typeof item === 'object' ? item.category || 'other' : 'other'
}));
}
} catch (e) {
console.warn('解析JSON格式附件失败,尝试其他格式:', e);
}
}
// 兼容旧格式:逗号分隔的ossId
return fileStr
.split(',')
.map((id: string) => id.trim())
.filter((id: string) => id)
.map((ossId) => ({ ossId, category: 'other' }));
};
/** 通过 ossId 列表加载主合同文件信息 */
const loadDetailAttachmentsByOssIds = (ossIds: string[]) => {
return resolveCrmAttachmentFiles(ossIds.map((ossId) => ({ ossId, category: 'other' })));
};
/** 通过附件列表加载附件信息(支持分类) */
const loadDetailAttachmentsWithCategory = (attachments: Array<{ ossId: string; category?: string }>) => {
if (attachments.length === 0) return [];
return resolveCrmAttachmentFiles(
attachments.map((attachment) => ({
ossId: attachment.ossId,
category: attachment.category || 'other'
}))
);
};
const getDetailAttachmentCategoryLabel = (category?: string) => {
if (category === 'main_contract') return '主合同文件';
return getCrmAttachmentCategoryLabel(category || 'other');
};
/** 加载审批记录 */
const loadDetailApprovalRecords = async () => {
try {
if (detailContract.value?.id) {
const res = await listContractApproval(detailContract.value.id);
detailApprovalList.value = res.data || [];
}
} catch (error) {
console.error('加载审批记录失败:', error);
detailApprovalList.value = [];
}
};
/** 关闭抽屉 */
const handleDetailDrawerClose = () => {
detailActiveTab.value = 'contract';
detailContract.value = undefined;
currentDetailId.value = undefined;
detailPaymentList.value = [];
detailInvoiceList.value = [];
detailApprovalList.value = [];
detailContractFileList.value = [];
detailAttachmentFileList.value = [];
};
/** 详情抽屉 - 合同复制 */
const handleDetailCopy = () => {
if (!detailContract.value?.id) {
$modal.msgWarning('未找到合同数据')
return
}
visible.value = false
formDialogEditId.value = null
formDialogCopySource.value = detailContract.value
formDialogVisible.value = true
}
/** 详情抽屉 - 编辑 */
const handleDetailEdit = async () => {
if (!detailContract.value?.id) return
if (!isContractEditableStatus(detailContract.value.status)) {
$modal.msgWarning('已完成的合同不能修改')
return
}
const confirmMsg = getContractReeditConfirmMessage(detailContract.value.status)
if (confirmMsg) {
try {
await $modal.confirm(confirmMsg)
} catch {
return
}
}
visible.value = false
resetFormDialogState()
formDialogEditId.value = String(detailContract.value.id)
formDialogVisible.value = true
}
/** 详情抽屉 - 删除 */
const handleDetailDelete = async () => {
if (!detailContract.value) return;
if (detailContract.value.status !== 'draft') {
$modal.msgWarning('只能删除草稿状态的合同');
return;
}
try {
await $modal.confirm('是否确认删除该合同?删除后将无法恢复。');
await delContract([detailContract.value.id]);
$modal.msgSuccess('删除成功');
visible.value = false;
emit('success');
} catch (error) {
console.error(error);
}
};
/** 详情抽屉 - 提交审批 */
const handleDetailSubmit = () => {
approvalDialogRef.value?.open(detailContract.value?.id, 'submit');
};
/** 详情抽屉 - 开始执行 */
const handleDetailExecute = () => {
executeDialogRef.value?.open(detailContract.value?.id, 'execute');
};
/** 详情抽屉 - 完成合同 */
const handleDetailComplete = () => {
executeDialogRef.value?.open(detailContract.value?.id, 'complete');
};
/** 详情抽屉 - 作废 */
const handleDetailInvalid = () => {
invalidDialogRef.value?.open(detailContract.value?.id);
};
/** 详情抽屉 - 作废恢复 */
const restoreLoading = ref(false);
const handleDetailRestore = async () => {
const id = detailContract.value?.id;
if (!id) return;
await $modal.confirm('确定恢复该合同为草稿吗?恢复后需重新提交审批。');
restoreLoading.value = true;
try {
await restoreContract({ id });
$modal.msgSuccess('已恢复为草稿');
await handleActionSuccess();
} finally {
restoreLoading.value = false;
}
};
/** 详情抽屉 - 打印 */
const handleDetailPrint = async () => {
try {
// 显示加载提示
$modal.loading('正在生成合同...');
// 保存当前合同ID
const contractId = detailContract.value.id;
currentContractId.value = contractId;
// 先从服务器获取最新的合同数据(包括产品列表等)
let contractData = detailContract.value;
// 保存原有的报价单号(服务器返回的数据中可能没有这个字段)
const originalQuoteNo = detailContract.value.quoteNo;
try {
console.log('从服务器获取最新合同数据,合同ID:', contractId);
const contractRes = await getContractDetail(contractId);
if (contractRes.data) {
contractData = contractRes.data;
// 关键:同时更新 detailContract.value,确保后续使用的是最新数据
detailContract.value = contractData;
// 恢复报价单号(服务器返回的数据中可能没有这个字段)
detailContract.value.quoteNo = originalQuoteNo;
console.log('✅ 获取最新合同数据成功,产品数量:', contractData.products?.length || 0);
}
} catch (fetchError) {
console.warn('⚠️ 获取最新合同数据失败,使用详情抽屉中的数据:', fetchError);
}
// 先尝试从数据库获取已保存的模板
let htmlContent = '';
try {
console.log('尝试从数据库获取模板,合同ID:', contractId);
const templateRes = await getTemplate(contractId);
console.log('获取模板响应:', templateRes);
if (templateRes.data && templateRes.data.trim()) {
htmlContent = templateRes.data;
console.log('✅ 从数据库加载模板成功,长度:', htmlContent.length);
} else {
// 如果没有保存的模板,则生成新的HTML内容(含占位符)
htmlContent = generateContractTemplate(contractData);
console.log('❌ 数据库无模板,生成新的HTML内容,长度:', htmlContent.length);
}
} catch (error) {
// 如果获取模板失败,则生成新的HTML内容(含占位符)
console.warn('⚠️ 获取模板失败,将生成新模板:', error);
htmlContent = generateContractTemplate(contractData);
console.log('生成新的HTML内容,长度:', htmlContent.length);
}
// 统一用数据库最新数据替换占位符(无论模板来自数据库还是新生成的,都可能含占位符)
try {
htmlContent = replacePlaceholders(htmlContent, contractData);
console.log('占位符替换完成');
} catch (replaceError) {
console.error('替换占位符失败,使用原始内容:', replaceError);
}
// 先设置内容,再打开对话框(每次打开默认显示红章)
contractContent.value = htmlContent;
showRedSeal.value = true;
// 关闭加载提示
$modal.closeLoading();
// 打开对话框
generateContractDialog.visible = true;
} catch (error) {
console.error('生成合同失败:', error);
$modal.closeLoading();
$modal.msgError('生成合同失败');
}
};
/** 详情抽屉 - 新增回款 */
const handleDetailAddPayment = () => {
ElMessage.info('开发中,后续完善');
};
/** 详情抽屉 - 查看回款 */
const handleDetailViewPayment = (row: any) => {
visible.value = false;
router.push({
path: '/crm/payment/detail',
query: { id: row.id }
});
};
/** 详情抽屉 - 新增开票 */
const handleDetailAddInvoice = () => {
ElMessage.info('开发中,后续完善');
};
/** 详情抽屉 - 查看发票 */
const handleDetailViewInvoice = (row: any) => {
visible.value = false;
router.push({
path: '/crm/invoice/detail',
query: { id: row.id }
});
};
/** 详情抽屉 - 上传文件 */
const handleDetailUploadFile = () => {
uploadDialogRef.value?.open(detailContract.value?.id);
};
/** 详情抽屉 - 格式化金额 */
const formatDetailAmount = (amount: number | undefined): string => {
if (!amount) return '0.00';
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
/** 产品明细下方合计:合计总金额 = 合计金额(产品)+ 运费 + 桶押金 */
const detailTotalSummaryFields = computed<ViewField[]>(() => {
const c = detailContract.value
if (!c) return []
const productAmount = Number(c.amount) || 0
const freight = Number(c.freight) || 0
const barrelDeposit = Number(c.barrelDeposit) || 0
const grandTotal = productAmount + freight + barrelDeposit
return [
{ label: '合计总金额(元)', value: `¥${formatDetailAmount(grandTotal)}`, money: true },
{ label: '合计金额(元)', value: `¥${formatDetailAmount(productAmount)}`, money: true },
{ label: '运费(元)', value: `¥${formatDetailAmount(freight)}`, money: true },
{ label: '桶押金(元)', value: `¥${formatDetailAmount(barrelDeposit)}`, money: true }
]
})
function detailTagField(
label: string,
dictType: string,
value?: string | null,
extra?: Partial<ViewField>
): ViewField {
return {
label,
value: value ? dictStore.getLabel(dictType, value) : '—',
tagType: value ? dictStore.getTagType(dictType, value) : undefined,
...extra
};
}
const detailBasicViewFields = computed<ViewField[]>(() => {
const c = detailContract.value;
if (!c) return [];
return [
{ label: '合同编号', value: c.contractNo, long: false },
{ label: '合同名称', value: c.contractName, long: false },
detailTagField('合同类型', 'crm_contract_type', c.contractType),
detailTagField('合同模版', 'crm_contract_template', c.contractTemplate, { long: false }),
{ label: '客户名称', value: c.customerName, viewTarget: 'customer', viewId: c.customerId },
{
label: '关联商机',
value: c.opportunityName || '—',
long: false,
viewTarget: 'opportunity',
viewId: c.opportunityId
},
{
label: '关联报价单',
value: c.quoteNo || '—',
viewTarget: 'quote',
viewId: c.quoteId
},
{ label: '联系人', value: c.contactName },
{ label: '联系电话', value: c.contactMobile },
{ label: '签订日期', value: c.signDate },
{
label: '合同期限',
value: c.startDate && c.endDate ? `${c.startDate} ~ ${c.endDate}` : '—',
long: false
},
{ label: '负责人', value: c.ownerUserName },
...(props.hideProductPrice
? []
: [
{ label: '合同金额(元)', value: `¥${formatDetailAmount(c.amount)}`, money: true },
{ label: '运费(元)', value: `¥${formatDetailAmount(c.freight)}`, money: true },
{ label: '桶押金(元)', value: `¥${formatDetailAmount(c.barrelDeposit)}`, money: true }
]),
{ label: '交易主体', value: c.supplierName },
{
label: '结算方式',
value: c.settlementMethod ? dictStore.getLabel('crm_settlement_method', c.settlementMethod) : '—',
danger: true
}
];
});
const detailSupplierViewFields = computed<ViewField[]>(() => {
const c = detailContract.value;
if (!c) return [];
return [
{ label: '单位名称(章)', value: c.partyA },
{ label: '单位地址', value: c.partyAAddress },
{ label: '法定代表人', value: c.partyALegalPerson },
{ label: '代理人', value: c.partyAAgent },
{ label: '电话/传真', value: c.partyAPhoneFax },
{ label: '开户银行', value: c.partyABank },
{ label: '账号', value: c.partyAAccount }
];
});
const detailBuyerViewFields = computed<ViewField[]>(() => {
const c = detailContract.value;
if (!c) return [];
return [
{ label: '单位名称(章)', value: c.partyB },
{ label: '单位地址', value: c.partyBAddress },
{ label: '法定代表人', value: c.partyBLegalPerson },
{ label: '代理人', value: c.partyBAgent },
{ label: '电话/传真', value: c.partyBPhoneFax },
{ label: '开户银行', value: c.partyBBank },
{ label: '账号', value: c.partyBAccount }
];
});
/** 详情抽屉 - 获取未回款金额 */
const getDetailUnreceivedAmount = (): number => {
return (detailContract.value?.amount || 0) - (detailContract.value?.receivedAmount || 0);
};
/** 详情抽屉 - 获取回款进度 */
const getDetailReceivedRate = (): number => {
if (!detailContract.value?.amount || detailContract.value.amount <= 0) return 0;
return ((detailContract.value?.receivedAmount || 0) / detailContract.value.amount) * 100;
};
/** 详情抽屉 - 获取未开票金额 */
const getDetailUninvoicedAmount = (): number => {
return (detailContract.value?.amount || 0) - (detailContract.value?.invoicedAmount || 0);
};
/** 详情抽屉 - 获取开票进度 */
const getDetailInvoicedRate = (): number => {
if (!detailContract.value?.amount || detailContract.value.amount <= 0) return 0;
return ((detailContract.value?.invoicedAmount || 0) / detailContract.value.amount) * 100;
};
/** 详情抽屉 - 获取审批状态颜色 */
const getDetailApprovalColor = (approvalResult?: string) => {
if (!approvalResult) return '#909399';
switch (approvalResult) {
case 'approved':
return '#67C23A';
case 'rejected':
return '#F56C6C';
case 'pending':
return '#909399';
default:
return '#909399';
}
};
/** 详情抽屉 - 计算执行时长 */
const calculateDetailDuration = (startDate: string, endDate: string): number => {
if (!startDate || !endDate) return 0;
const start = new Date(startDate).getTime();
const end = new Date(endDate).getTime();
return Math.ceil((end - start) / (1000 * 60 * 60 * 24));
};
/** 详情抽屉 - 计算已执行天数 */
const calculateDetailExecutedDays = (startDate: string): number => {
if (!startDate) return 0;
const start = new Date(startDate).getTime();
const now = new Date().getTime();
return Math.ceil((now - start) / (1000 * 60 * 60 * 24));
};
/** 详情抽屉 - 判断是否有操作记录 */
const hasDetailOperationRecord = computed(() => {
return (
detailContract.value?.status === 'invalid' ||
detailContract.value?.invalidReason ||
detailContract.value?.status === 'finished' ||
detailContract.value?.status === 'executing' ||
detailContractFileList.value.length > 0 ||
detailAttachmentFileList.value.length > 0
);
});
/** 详情抽屉 - 判断是否为图片文件 */
const isDetailImageFile = (file: any) => {
if (!file) return false;
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
const ext = getDetailFileExt(getDetailDisplayFileName(file)).toLowerCase();
if (imageExts.includes(ext)) return true;
const ossExt = getDetailFileExt(String(file?.ossId || file?.url || '')).toLowerCase();
if (imageExts.includes(ossExt)) return true;
return imageExts.includes(ext);
};
const isDetailPdfFile = (file: any) =>
isCrmPdfFile({
name: getDetailDisplayFileName(file),
ossId: file?.ossId,
url: file?.url
});
/** 详情抽屉 - 获取文件扩展名 */
const getDetailFileExt = (fileName: string) => {
if (!fileName) return 'FILE';
const ext = fileName.split('.').pop()?.toUpperCase();
return ext || 'FILE';
};
/** 详情抽屉 - 规范化附件显示名称,去掉「附件_」及路径前缀 */
const getDetailDisplayFileName = (file: any) => {
const rawName = String(file?.name || file?.ossId || file?.url || '').trim();
if (!rawName) return '未命名附件';
let normalized = rawName.replace(/^附件_/, '');
if (normalized.includes('/')) {
const parts = normalized.split('/');
normalized = parts[parts.length - 1] || normalized;
}
return normalized || '未命名附件';
};
/** 详情抽屉 - 获取缩写的文件名 */
const getDetailShortFileName = (fileName: string) => {
if (!fileName) return '';
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex === -1) {
return fileName.length > 15 ? fileName.substring(0, 15) + '...' : fileName;
}
const name = fileName.substring(0, lastDotIndex);
const ext = fileName.substring(lastDotIndex);
if (name.length <= 12) {
return fileName;
}
return name.substring(0, 12) + '...' + ext;
};
/** 详情抽屉 - 调价函 OSS ID 列表(兼容旧数据仅 letterOssId) */
const detailTierLetterOssIds = (row: any): string[] => {
if (!row) return [];
if (Array.isArray(row.letterOssIds) && row.letterOssIds.length) {
return row.letterOssIds.map((id: any) => String(id));
}
if (row.letterOssId != null && row.letterOssId !== '') {
return [String(row.letterOssId)];
}
return [];
};
/** 详情抽屉 - 下载框架合同调价函 */
const handleDetailDownloadTierLetter = (ossId: string | number | undefined) => {
if (ossId == null || ossId === '') {
$modal.msgError('无法获取调价函文件');
return;
}
handleDetailDownloadFile({ ossId: String(ossId), name: '调价函' });
};
/** 详情抽屉 - 新标签页预览 */
const handleDetailPreviewFile = async (file: any) => {
await openCrmAttachmentPreviewInNewTab({
...file,
name: getDetailDisplayFileName(file)
});
};
/** 详情抽屉 - 下载文件 */
const handleDetailDownloadFile = async (file: any) => {
if (!file.ossId && !file.url) {
$modal.msgError('无法获取文件下载地址');
return;
}
try {
await downloadCrmAttachment(file);
$modal.msgSuccess('下载成功');
} catch (error) {
console.error('下载文件失败:', error);
$modal.msgError('下载文件失败,请稍后重试');
}
};
/** 产品明细中是否含「自提」送货方式(字典 crm_shfs) */
const hasSelfPickupProduct = (products?: Array<{ deliveryMethod?: string }> | null): boolean =>
!!products?.some((p) => String(p.deliveryMethod || '').trim() === '自提');
/** 替换模板中的占位符为数据库最新数据(运费取合同自身 freight) */
const replacePlaceholders = (htmlContent: string, contractData: ContractVO | undefined): string => {
if (!htmlContent || !contractData) return htmlContent;
let result = htmlContent;
// 格式化日期(用于顶部信息,格式: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 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 resultStr = '';
if (integerPart === 0) {
resultStr = '零';
} 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) {
resultStr += '零';
}
resultStr += digits[digit] + unit;
zeroCount = 0;
}
}
}
resultStr += '元';
if (decimalPart === '00') {
resultStr += '整';
} else {
const jiao = parseInt(decimalPart[0]);
const fen = parseInt(decimalPart[1]);
if (jiao > 0) {
resultStr += digits[jiao] + decimalUnits[0];
}
if (fen > 0) {
if (jiao === 0) {
resultStr += '零';
}
resultStr += digits[fen] + decimalUnits[1];
}
}
return resultStr;
};
// 辅助函数:安全地将值转换为数字并格式化
const formatNumber = (value: any, decimals: number = 2): string => {
const num = parseFloat(value || 0);
return isNaN(num) ? '0.00' : num.toFixed(decimals);
};
// 获取合同类型名称(label)
const getContractTypeName = (): string => {
if (!contractData.contractType) return '销售合同';
const typeLabel = dictStore.getLabel('crm_contract_type', contractData.contractType);
return typeLabel || '销售合同';
};
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
// 计算产品相关数据
const productTotal = parseFloat(String(contractData.amount || 0));
const productTotalChinese = numberToChinese(productTotal);
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 packageSpecs = collectAllContractPackageSpecs(contractData.products).join('、');
// 获取单位字典标签的辅助函数
const getUnitLabel = (unitValue: string | undefined): string => {
if (!unitValue) return '';
const unitLabel = dictStore.getLabel('crm_product_unit', unitValue);
return unitLabel || unitValue || '';
};
// 构建产品明细表格行(使用 0.3px 边框)
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;
const taxRateNum = Number(product.taxRate);
const taxRateText = Number.isFinite(taxRateNum) ? `${taxRateNum}%` : '0%';
const packageCell = formatPackageSpecsJoin(product);
const pricingCells = isFrameworkContract
? '<td colspan="4" style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">随行就市</td>'
: `
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(product.price)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: right; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(beforeTax)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: right; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(taxAmount)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: right; font-weight: bold; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(subtotal)}</td>`;
return `
<tr>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.productName || ''}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.model || product.productModel || ''}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${formatBrandForPrint(product.brandMode, product.brandName)}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${getUnitLabel(product.unit)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatFrameworkQuantityForPrint(product, isFrameworkContract)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${taxRateText}</td>
${pricingCells}
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${packageCell}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.remark || ''}</td>
</tr>`;
})
.join('') ||
'<tr><td colspan="12" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">暂无产品信息</td></tr>';
// 定义替换映射表
const getSettlementClauseContent = (): string =>
resolveSettlementClauseContent({
settlementMethod: contractData.settlementMethod,
settlementClauseDrafts: contractData.settlementClauseDrafts,
labelResolver: (method) => dictStore.getLabel('crm_settlement_method', method) || undefined
});
const replacements: Record<string, string> = {
// 合同基本信息
'{{contractNo}}': contractData.contractNo || '',
'{{signDate}}': formatDate(contractData.signDate),
'{{contractType}}': getContractTypeName(),
'{{startDate}}': formatDateChinese(contractData.startDate),
'{{endDate}}': formatDateChinese(contractData.endDate),
'{{amount}}': formatNumber(productTotal),
'{{amountChinese}}': productTotalChinese,
'{{customerName}}': contractData.customerName || '',
// 供方信息
'{{partyA}}': contractData.partyA || '安庆市山峡化工有限公司',
'{{partyAAddress}}': contractData.partyAAddress || '安徽省安庆市迎江大道5号',
'{{partyALegalPerson}}': contractData.partyALegalPerson || '',
'{{partyAAgent}}': contractData.partyAAgent || '',
'{{partyAPhoneFax}}': contractData.partyAPhoneFax || '0556-5396799',
'{{partyABank}}': contractData.partyABank || '中国建设银行安庆市城北支行',
'{{partyAAccount}}': contractData.partyAAccount || '34050168680800000121',
// 需方信息
'{{partyB}}': contractData.partyB || contractData.customerName || '',
'{{partyBAddress}}': contractData.partyBAddress || '',
'{{partyBLegalPerson}}': contractData.partyBLegalPerson || '',
'{{partyBAgent}}': contractData.partyBAgent || '',
'{{partyBPhoneFax}}': contractData.partyBPhoneFax || '',
'{{partyBBank}}': contractData.partyBBank || '',
'{{partyBAccount}}': contractData.partyBAccount || '',
// 计算值
'{{productTotalBeforeTax}}': formatNumber(productTotalBeforeTax),
'{{totalTax}}': formatNumber(totalTax),
'{{packageSpecs}}': packageSpecs || '散水包装',
// 第五条结算方式内容(根据最新结算方式动态生成)
'{{SETTLEMENT_METHOD_CONTENT}}': getSettlementClauseContent()
};
// 替换普通占位符
for (const [placeholder, value] of Object.entries(replacements)) {
// 使用正则表达式全局替换,支持大小写不敏感
const regex = new RegExp(placeholder.replace(/\{/g, '\\{').replace(/\}/g, '\\}'), 'gi');
result = result.replace(regex, value);
}
// 特殊处理:替换产品表格
// 匹配 {{productTable}} 占位符
result = result.replace(/\{\{productTable\}\}/gi, productRows);
// 旧模板表头无「品牌」时,在「规格」后插入
if (!/规格<\/th>[\s\S]{0,200}?品牌<\/th>/i.test(result)) {
result = result.replace(
/(<th[^>]*>\s*规格\s*<\/th>)/i,
`$1
<th style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 8%;">品牌</th>`
)
}
// 旧模板表头无「税率」时,在「用量」后插入
if (!/用量(?:(吨))?<\/th>[\s\S]{0,200}?税率<\/th>/i.test(result)) {
result = result.replace(
/(<th[^>]*>\s*用量(?:(吨))?\s*<\/th>)/i,
`$1
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">税率</th>`
)
}
// 产品表列宽:含税金额收窄、包装规格加宽(兼容已保存模板)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*含税金额(元)\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}8${suffix}`
)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*包装规格\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}11${suffix}`
)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*产品名称\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}13${suffix}`
)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*备注\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}5${suffix}`
)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*单位\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}7${suffix}`
)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*用量(?:(吨))?\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}9${suffix}`
)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*含税单价(元(?:\/吨)?)\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}10${suffix}`
)
result = result.replace(
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*未含税金额(元)\s*<\/th>)/gi,
(_m, prefix, suffix) => `${prefix}10${suffix}`
)
// 合计区(12 列):运费/桶押金 → 不含税/税额 → 含税金额合计 → 提示
const freightRaw = Number(contractData.freight);
const freightAmount = Number.isFinite(freightRaw) && freightRaw > 0 ? freightRaw : 0;
const depositRaw = Number(contractData.barrelDeposit);
const barrelDepositAmount = Number.isFinite(depositRaw) && depositRaw > 0 ? depositRaw : 0;
const totalWithExtras = productTotal + freightAmount + barrelDepositAmount;
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
const formatRmbAmountText = (amount: number) =>
isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}<br/>(¥${formatNumber(amount)})`;
const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax);
const taxTotalText = formatRmbAmountText(totalTax);
const totalWithExtrasText = formatRmbAmountText(totalWithExtras);
const tipRowText = hasSelfPickupProduct(contractData.products)
? '含税、客户自提结算以实际到货吨位为准'
: '含税、含运费、装卸费(一票制)&nbsp;&nbsp;&nbsp;&nbsp;结算以实际到货吨位为准';
const productTotalRow = `
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">运费(元)</td>
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${freightText}</td>
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">桶押金(元)</td>
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
</tr>
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">不含税金额合计<br/>(人民币)</td>
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${beforeTaxTotalText}</td>
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">税额合计<br/>(人民币)</td>
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${taxTotalText}</td>
</tr>
<tr style="background-color: #fff; font-weight: bold;">
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">含税金额合计<br/>(人民币)</td>
<td colspan="10" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${totalWithExtrasText}</td>
</tr>
<tr>
<td colspan="12" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 11px; background-color: #fff;">
${tipRowText}
</td>
</tr>
`;
// 优先替换占位符;否则替换已展开的「运费~提示」合计块(兼容旧模板,打印/PDF 共用)
if (/\{\{productTotalRow\}\}/i.test(result)) {
result = result.replace(/\{\{productTotalRow\}\}/gi, productTotalRow);
} else {
const summaryBlockPattern =
/(?:<tr[^>]*font-weight:\s*bold[^>]*>[\s\S]*?<\/tr>\s*){1,3}<tr>[\s\S]*?结算以实际到货吨位为准[\s\S]*?<\/tr>/i;
if (summaryBlockPattern.test(result)) {
result = result.replace(summaryBlockPattern, productTotalRow.trim());
}
}
// 特殊处理:印章图片
// 从合同编号中提取供方编码
let supplierCode = 'SX';
if (contractData.contractNo) {
const match = contractData.contractNo.match(/^([A-Z]+)\/X-JT/);
if (match && match[1]) {
supplierCode = match[1];
}
}
// 根据公司名称决定印章文件
const getSealImageByCompanyName = (companyName: string | undefined): string => {
if (!companyName) return '';
if (companyName.includes('云栋') || companyName.includes('安徽云栋化工有限公司')) {
return '/src/assets/images/yd.png';
}
if (companyName.includes('山峡') || companyName.includes('安庆市山峡化工有限公司')) {
return supplierCode === 'SX' ? '/src/assets/images/yz.png' : '/src/assets/images/yd.png';
}
return '';
};
// 生成印章HTML
const getSealHtml = (companyName: string | undefined): string => {
const sealSrc = getSealImageByCompanyName(companyName);
if (!sealSrc) return '';
return `<img src="${sealSrc}" alt="印章" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; height: 140px; opacity: 0.85; z-index: 10;" />`;
};
// 替换印章占位符
result = result.replace(/\{\{partyASeal\}\}/gi, getSealHtml(contractData.partyA));
result = result.replace(/\{\{partyBSeal\}\}/gi, getSealHtml(contractData.partyB));
return applySavedProductTableLayout(result);
};
/** 替换模板第五条内容 */
const replaceFifthClauseContent = (htmlContent: string, replacement: string): string => {
// 查找第五条标题
const marker = '第五条、结算方式及期限';
const titleIndex = htmlContent.indexOf(marker);
if (titleIndex === -1) {
console.warn('未找到第五条标题');
return htmlContent;
}
// 找到标题后的内容 div(style 包含 line-height: 1.8; margin: 0;)
const contentMarker = '<div style="line-height: 1.8; margin: 0;">';
const contentStart = htmlContent.indexOf(contentMarker, titleIndex);
if (contentStart === -1) {
console.warn('未找到第五条内容开始标记');
return htmlContent;
}
// 找到内容结束标记
const contentEnd = htmlContent.indexOf('</div>', contentStart + contentMarker.length);
if (contentEnd === -1) {
console.warn('未找到第五条内容结束标记');
return htmlContent;
}
// 替换内容部分
return htmlContent.slice(0, contentStart + contentMarker.length) + replacement + htmlContent.slice(contentEnd);
};
const extractFifthClauseContent = (htmlContent: string): string => {
const marker = '第五条、结算方式及期限';
const titleIndex = htmlContent.indexOf(marker);
if (titleIndex === -1) return '';
const contentMarker = '<div style="line-height: 1.8; margin: 0;">';
const contentStart = htmlContent.indexOf(contentMarker, titleIndex);
if (contentStart === -1) return '';
const contentEnd = htmlContent.indexOf('</div>', contentStart + contentMarker.length);
if (contentEnd === -1) return '';
const content = htmlContent.slice(contentStart + contentMarker.length, contentEnd);
return content.replace(/\u00a0/g, ' ').trim();
};
/** 生成合同HTML模版 */
const generateContractTemplate = (contractData: ContractVO | undefined): string => {
if (!contractData) return '';
// 获取合同类型名称(label)
const getContractTypeName = (): string => {
if (!contractData.contractType) return '销售合同';
const typeLabel = dictStore.getLabel('crm_contract_type', contractData.contractType);
return typeLabel || '销售合同';
};
// 辅助函数:安全地将值转换为数字并格式化
const formatNumber = (value: any, decimals: number = 2): string => {
const num = parseFloat(value || 0);
return isNaN(num) ? '0.00' : num.toFixed(decimals);
};
// 根据结算方式生成第五条内容
// 使用占位符,方便后续动态替换
const settlementMethod = contractData.settlementMethod || 'arrive_payment'; // 默认货到付款
const 第五条Content = '{{SETTLEMENT_METHOD_CONTENT}}'; // 占位符
// 注意:实际内容在打印/生成PDF时通过 replacePlaceholders 动态替换
// 第三条:任一产品送货方式为「自提」时使用客户自提条款(仅新生成模板写入)
const isSelfPickup = hasSelfPickupProduct(contractData.products);
const article3Content = isSelfPickup
? `<div style="margin: 0;">1、交货方式:需方自提,供方需提供需方要求产品。</div>
<div style="margin: 0;">2、运输:在运输过程中发生的一切费用由需方承担,到达本公司卸货过程中所发生的一些费用由需方承担。</div>`
: `<div style="margin: 0;">1、交货方式:供方送货,供方需提供需方要求产品,送达至需方要求地点。</div>
<div style="margin: 0;">2、运输:在运输过程中发生的一切费用由供方承担,到达本公司卸货过程中所发生的一些费用由供方承担。</div>`;
// 数字转中文大写
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 packageSpecs = collectAllContractPackageSpecs(contractData.products).join('、');
// 从合同编号中提取供方编码
// 合同编号格式:{供方编码}/X-JT + yyyyMMdd + 3位流水号
// 例如:SX/X-JT20250926002 或 YD/X-JT20250926002
let supplierCode = 'SX'; // 默认值
if (contractData.contractNo) {
const contractNo = contractData.contractNo;
const match = contractNo.match(/^([A-Z]+)\/X-JT/);
if (match && match[1]) {
supplierCode = match[1];
}
}
// 根据公司名称决定印章文件
// 判断函数:根据公司名称返回对应的印章文件路径
const getSealImageByCompanyName = (companyName: string | undefined): string => {
if (!companyName) return '/src/assets/images/yz.png'; // 默认
// 如果是云栋公司,使用 yd.png
if (companyName.includes('云栋') || companyName.includes('安徽云栋化工有限公司')) {
return '/src/assets/images/yd.png';
}
// 如果是山峡公司,根据供方编码决定
if (companyName.includes('山峡') || companyName.includes('安庆市山峡化工有限公司')) {
return supplierCode === 'SX' ? '/src/assets/images/yz.png' : '/src/assets/images/yd.png';
}
// 默认使用 yz.png
return '/src/assets/images/yz.png';
};
// 获取单位字典标签的辅助函数
const getUnitLabel = (unitValue: string | undefined): string => {
if (!unitValue) return '';
const unitLabel = dictStore.getLabel('crm_product_unit', unitValue);
return unitLabel || unitValue || '';
};
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
// 构建产品明细表格行(使用 0.3px 边框)
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;
const taxRateNum = Number(product.taxRate);
const taxRateText = Number.isFinite(taxRateNum) ? `${taxRateNum}%` : '0%';
const packageCell = formatPackageSpecsJoin(product);
return `
<tr>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.productName || ''}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.model || product.productModel || ''}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${formatBrandForPrint(product.brandMode, product.brandName)}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${getUnitLabel(product.unit)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatFrameworkQuantityForPrint(product, isFrameworkContract)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${taxRateText}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(product.price)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: right; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(beforeTax)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: right; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(taxAmount)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: right; font-weight: bold; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(subtotal)}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${packageCell}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.remark || ''}</td>
</tr>`;
})
.join('') ||
'<tr><td colspan="12" style="padding: 5px 6px; border: 0.3px 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: left; 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;">产品{{contractType}}</h1>
<!-- 合同基本信息 -->
<div style="margin-bottom: 0; font-size: 12px; line-height: 1.5;">
<div style="display: flex; margin-bottom: 0;">
<div style="flex: 1;">签订日期:{{signDate}}</div>
</div>
<div style="display: flex; margin-bottom: 0; position: relative;">
<div style="flex: 1;">供方:{{partyA}}</div>
<div style="position: absolute; right: 0px; min-width: 300px;">合同编号:{{contractNo}}</div>
</div>
<div style="display: flex; margin-bottom: 0; position: relative;">
<div style="flex: 1;">需方:{{partyB}}</div>
<div style="position: absolute; right: 0px; min-width: 300px;">签订地点:安徽安庆</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; color: #000;">第一条、标的、数量、价格及其他</div>
<!-- 产品表格 -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 0; border: 0.3px solid #000; table-layout: fixed;">
<thead>
<tr style="background-color: #fff;">
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 13%;">产品名称</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 6%;">规格</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 6%;">品牌</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 7%;">单位</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 9%;">用量</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">税率</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">含税单价(元)</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">未含税金额(元)</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">税费(元)</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 8%;">含税金额(元)</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 11%;">包装规格</th>
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">备注</th>
</tr>
</thead>
<tbody>
{{productTable}}
<!-- 运费/桶押金 + 不含税/税额 + 含税合计 + 提示(由 {{productTotalRow}} 一并输出) -->
{{productTotalRow}}
</tbody>
</table>
</div>
<!-- 第二条 -->
<div style="margin-bottom: 5px; line-height: 1.5; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 0; color: #000;">第二条、产品质量及包装要求</div>
<div style="line-height: 1.8;">
<div style="margin: 0;">1、产品质量要求:产品质量以双方认可的产品质量标准为验收标准。</div>
<div style="margin: 0;">2、产品包装规格:{{packageSpecs}}。</div>
</div>
</div>
<!-- 第三条 -->
<div style="margin-bottom: 5px; line-height: 1.5; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 0; color: #000;">第三条、交货方式及运输方式</div>
<div style="line-height: 1.8;">
${article3Content}
</div>
</div>
<!-- 第四条 -->
<div style="margin-bottom: 5px; line-height: 1.5; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 0; color: #000;">第四条、验收标准</div>
<div style="line-height: 1.8; margin: 0;">
按合同第一条规定,货物到达交货地点后,需方应在卸车前检验。如检验结果达到合同约定的质量标准,需方应无条件卸货。如检验结果未达到标准双方协商折价、换货或终止合同;需方验收合格应当日内安排卸货。
</div>
</div>
<!-- 第五条 -->
<div style="margin-bottom: 5px; line-height: 1.5; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 0; color: #000;">第五条、结算方式及期限</div>
<div style="line-height: 1.8; margin: 0;">
${第五条Content}
</div>
</div>
<!-- 第六条 -->
<div style="margin-bottom: 5px; line-height: 1.5; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 0; color: #000;">第六条、合同有效期限</div>
<div style="line-height: 1.8; margin: 0;">
合同于{{startDate}}生效,有效期至{{endDate}}。本合同一式两份,经双方盖章后生效,双方盖章后扫描件、传真件与合同原件具有同等效力。
</div>
</div>
<!-- 第七条 -->
<div style="margin-bottom: 5px; line-height: 1.5; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 0; color: #000;">第七条、其他补充及规定</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>
<!-- 印章图片 -->
{{partyASeal}}
<div style="margin-bottom: 0;">单位名称(章):{{partyA}}</div>
<div style="margin-bottom: 0;">单位地址:{{partyAAddress}}</div>
<div style="margin-bottom: 0;">法定代表人:{{partyALegalPerson}}</div>
<div style="margin-bottom: 0;">代理人:{{partyAAgent}}</div>
<div style="margin-bottom: 0;">电话/传真:{{partyAPhoneFax}}</div>
<div style="margin-bottom: 0;">开户银行:{{partyABank}}</div>
<div style="margin-bottom: 0;">账号:{{partyAAccount}}</div>
</div>
<div style="flex: 1; padding-left: 10px; border: 1px solid #000; border-left: none; 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>
<!-- 印章图片 -->
{{partyBSeal}}
<div style="margin-bottom: 0;">单位名称(章):{{partyB}}</div>
<div style="margin-bottom: 0;">单位地址:{{partyBAddress}}</div>
<div style="margin-bottom: 0;">法定代表人:{{partyBLegalPerson}}</div>
<div style="margin-bottom: 0;">代理人:{{partyBAgent}}</div>
<div style="margin-bottom: 0;">电话/传真:{{partyBPhoneFax}}</div>
<div style="margin-bottom: 0;">开户银行:{{partyBBank}}</div>
<div style="margin-bottom: 0;">账号:{{partyBAccount}}</div>
</div>
</div>
</div>
`;
};
/** 保存合同模板 */
const handleSaveTemplate = async () => {
if (!currentContractId.value) {
$modal.msgWarning('无法获取合同ID');
return;
}
try {
console.log('========== 开始保存模板 ==========');
console.log('合同ID:', currentContractId.value);
$modal.loading('正在保存模板...');
// 读取编辑器正在显示的 HTML,避免导出仍用拖拽前的列宽
const currentContent = readEditorHtml();
console.log('当前编辑器内容长度:', currentContent.length);
// 重新获取最新合同数据
const contractRes = await getContractDetail(currentContractId.value);
const latestContractData = contractRes?.data || detailContract.value;
if (!latestContractData) {
throw new Error('未获取到合同数据,无法保存模板');
}
// 用最新数据生成带占位符的模板
const templateWithPlaceholders = generateContractTemplate(latestContractData);
console.log('生成的模板长度:', templateWithPlaceholders.length);
console.log('模板包含占位符 {{productTable}}:', templateWithPlaceholders.includes('{{productTable}}'));
// 将用户修改的内容中的关键动态字段替换为占位符
// 保持用户修改的固定文本内容,只更新动态字段部分
let finalTemplate = currentContent;
const currentMethod = latestContractData.settlementMethod || 'arrive_payment';
const editedFifthClause = extractFifthClauseContent(currentContent);
const settlementClauseDraftsMap = parseSettlementClauseDrafts(latestContractData.settlementClauseDrafts);
if (editedFifthClause) {
settlementClauseDraftsMap[currentMethod] = editedFifthClause;
}
const settlementClauseDrafts = JSON.stringify(settlementClauseDraftsMap);
// 1. 替换第五条结算方式为占位符
finalTemplate = replaceFifthClauseContent(finalTemplate, '{{SETTLEMENT_METHOD_CONTENT}}');
// 2. 替换产品表格为占位符
const hasOldProductTable = currentContent.includes(
'<tr>\n<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">'
);
if (hasOldProductTable || templateWithPlaceholders.includes('{{productTable}}')) {
// 从新模板中提取产品表格相关部分
// 1. 提取产品表格 HTML(tbody 中的 tr)
const productTableMatch = templateWithPlaceholders.match(/<tbody>[\s\S]*?<\/tbody>/);
const productTableHtml = productTableMatch ? productTableMatch[0] : '';
// 2. 提取运费/桶押金 + 不含税/税额 + 含税合计(连续 2~3 行加粗,兼容旧版)
const totalRowMatch = templateWithPlaceholders.match(
/(?:<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<\/tr>\s*){2,3}/
);
const totalRowHtml = totalRowMatch ? totalRowMatch[0] : '';
// 3. 提取提示行(兼容一票制 / 客户自提文案,以及 colspan 10/11/12)
const tipRowMatch = templateWithPlaceholders.match(
/<tr>[\s\S]*?<td colspan="1[012]"[\s\S]*?结算以实际到货吨位为准[\s\S]*?<\/td>[\s\S]*?<\/tr>/
);
const tipRowHtml = tipRowMatch ? tipRowMatch[0] : '';
// 替换产品表格部分
if (productTableHtml) {
// 找到 tbody 并替换其中的内容
finalTemplate = finalTemplate.replace(
/<tbody>[\s\S]*?<\/tbody>/,
`<tbody>${productTableHtml.replace('<tbody>', '').replace('</tbody>', '')}</tbody>`
);
}
// 替换运费/桶押金行 + 合计相关行(兼容旧版 1~2 行、新版 3 行)
if (totalRowHtml) {
const totalRowPattern =
/(?:<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<\/tr>\s*){1,3}/;
if (totalRowPattern.test(finalTemplate)) {
finalTemplate = finalTemplate.replace(totalRowPattern, totalRowHtml);
}
}
// 替换提示行(兼容旧一票制与客户自提提示)
if (tipRowHtml) {
const tipRowPattern =
/<tr>[\s\S]*?<td colspan="1[012]"[\s\S]*?>含税、(?:含运费|客户自提)[\s\S]*?<\/tr>/;
if (tipRowPattern.test(finalTemplate)) {
finalTemplate = finalTemplate.replace(tipRowPattern, tipRowHtml);
}
}
}
// 3. 替换其他动态字段为占位符
// 这些字段每次打开时都会用数据库最新数据替换
finalTemplate = replaceDynamicFields(finalTemplate, latestContractData);
finalTemplate = stampSavedProductTableLayout(currentContent, finalTemplate);
console.log('最终保存的模板长度:', finalTemplate.length);
console.log('最终模板包含 {{signDate}}:', finalTemplate.includes('{{signDate}}'));
console.log('最终模板包含 {{contractNo}}:', finalTemplate.includes('{{contractNo}}'));
console.log('最终模板包含 {{partyB}}:', finalTemplate.includes('{{partyB}}'));
console.log('最终模板包含 {{productTable}}:', finalTemplate.includes('{{productTable}}'));
// 保存用户修改后的内容(带占位符)
const response = await saveTemplate(currentContractId.value, finalTemplate, settlementClauseDrafts);
console.log('保存响应:', response);
console.log('模板保存成功');
$modal.closeLoading();
$modal.msgSuccess('模板保存成功');
} catch (error) {
console.error('保存模板失败:', error);
$modal.closeLoading();
$modal.msgError('保存模板失败');
}
};
/**
* 替换模板中的动态字段为占位符
* 这些字段每次打开富文本时都会用数据库最新数据替换
*/
const replaceDynamicFields = (template: string, contractData: ContractVO): string => {
let result = template;
// 格式化日期(用于顶部信息,格式: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 getContractTypeName = (): string => {
if (!contractData.contractType) return '销售合同';
const typeLabel = dictStore.getLabel('crm_contract_type', contractData.contractType);
return typeLabel || '销售合同';
};
// 定义需要替换的动态字段映射
const dynamicReplacements: Record<string, string> = {
// 合同基本信息
'{{contractNo}}': contractData.contractNo || '',
'{{signDate}}': formatDate(contractData.signDate),
'{{contractType}}': getContractTypeName(),
'{{startDate}}': formatDateChinese(contractData.startDate),
'{{endDate}}': formatDateChinese(contractData.endDate),
'{{customerName}}': contractData.customerName || '',
// 供方信息
'{{partyA}}': contractData.partyA || '安庆市山峡化工有限公司',
'{{partyAAddress}}': contractData.partyAAddress || '安徽省安庆市迎江大道5号',
'{{partyALegalPerson}}': contractData.partyALegalPerson || '',
'{{partyAAgent}}': contractData.partyAAgent || '',
'{{partyAPhoneFax}}': contractData.partyAPhoneFax || '0556-5396799',
'{{partyABank}}': contractData.partyABank || '中国建设银行安庆市城北支行',
'{{partyAAccount}}': contractData.partyAAccount || '34050168680800000121',
// 需方信息
'{{partyB}}': contractData.partyB || contractData.customerName || '',
'{{partyBAddress}}': contractData.partyBAddress || '',
'{{partyBLegalPerson}}': contractData.partyBLegalPerson || '',
'{{partyBAgent}}': contractData.partyBAgent || '',
'{{partyBPhoneFax}}': contractData.partyBPhoneFax || '',
'{{partyBBank}}': contractData.partyBBank || '',
'{{partyBAccount}}': contractData.partyBAccount || ''
};
// 执行替换:将动态字段的当前值替换为占位符
for (const [placeholder, currentValue] of Object.entries(dynamicReplacements)) {
if (currentValue) {
// 转义特殊字符用于正则表达式
const escapedValue = currentValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 替换时忽略空白符差异
const regex = new RegExp(escapedValue, 'g');
result = result.replace(regex, placeholder);
}
}
// 处理包装规格
const uniqueSpecs = collectAllContractPackageSpecs(contractData.products).join('、');
const packageSpecsValue = uniqueSpecs || '散水包装';
if (packageSpecsValue) {
const escapedPackageSpecs = packageSpecsValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
result = result.replace(new RegExp(escapedPackageSpecs, 'g'), '{{packageSpecs}}');
}
// 处理印章:保存模板时将当前富文本中的印章图片还原为动态占位符
// 1) 先移除已渲染的印章图片,避免模板固化成静态印章
result = result.replace(/<img[^>]*alt="印章"[^>]*>/gi, '');
// 2) 优先根据“印章图片”注释位回填占位符(第一处=供方,第二处=需方)
let sealCommentIndex = 0;
result = result.replace(/<!--\s*印章图片\s*-->/g, (match) => {
sealCommentIndex += 1;
if (sealCommentIndex === 1) {
return `${match}\n {{partyASeal}}`;
}
if (sealCommentIndex === 2) {
return `${match}\n {{partyBSeal}}`;
}
return match;
});
// 3) 兜底:如果注释被用户改动,仍然确保供需方名称行前存在印章占位符
if (result.includes('单位名称(章):{{partyA}}') && !result.includes('{{partyASeal}}')) {
result = result.replace(/(<div[^>]*>单位名称(章):\{\{partyA\}\}<\/div>)/, '{{partyASeal}}\n $1');
}
if (result.includes('单位名称(章):{{partyB}}') && !result.includes('{{partyBSeal}}')) {
result = result.replace(/(<div[^>]*>单位名称(章):\{\{partyB\}\}<\/div>)/, '{{partyBSeal}}\n $1');
}
// 4) 清理可能的重复占位符
result = result.replace(/(\{\{partyASeal\}\}\s*){2,}/g, '{{partyASeal}}\n');
result = result.replace(/(\{\{partyBSeal\}\}\s*){2,}/g, '{{partyBSeal}}\n');
return result;
};
const SEAL_IMG_RE = /<img[^>]*alt=["']印章["'][^>]*>/gi;
const getSupplierCodeFromContractNo = (contractNo?: string): string => {
if (!contractNo) return 'SX';
const match = contractNo.match(/^([A-Z]+)\/X-JT/);
return match?.[1] || 'SX';
};
const resolveSealImageByCompanyName = (companyName: string | undefined, supplierCode: string): string => {
if (!companyName) return '';
if (companyName.includes('云栋') || companyName.includes('安徽云栋化工有限公司')) {
return '/src/assets/images/yd.png';
}
if (companyName.includes('山峡') || companyName.includes('安庆市山峡化工有限公司')) {
return supplierCode === 'SX' ? '/src/assets/images/yz.png' : '/src/assets/images/yd.png';
}
return '';
};
const buildSealHtml = (companyName: string | undefined, supplierCode: string): string => {
const sealSrc = resolveSealImageByCompanyName(companyName, supplierCode);
if (!sealSrc) return '';
return `<img src="${sealSrc}" alt="印章" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; height: 140px; opacity: 0.85; z-index: 10;" />`;
};
const stripSealImages = (html: string): string => html.replace(SEAL_IMG_RE, '');
const injectSealImages = (html: string, contractData: ContractVO): string => {
let result = stripSealImages(html);
const supplierCode = getSupplierCodeFromContractNo(contractData.contractNo);
const partyASeal = buildSealHtml(contractData.partyA, supplierCode);
const partyBSeal = buildSealHtml(contractData.partyB, supplierCode);
if (/<!--\s*印章图片[^>]*-->/.test(result)) {
let sealCommentIndex = 0;
return result.replace(/<!--\s*印章图片[^>]*-->/g, (match) => {
sealCommentIndex += 1;
if (sealCommentIndex === 1) return `${match}\n ${partyASeal}`;
if (sealCommentIndex === 2) return `${match}\n ${partyBSeal}`;
return match;
});
}
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (partyASeal && contractData.partyA) {
const escaped = escapeRegExp(contractData.partyA);
result = result.replace(
new RegExp(`(<div[^>]*>单位名称(章):\\s*${escaped}</div>)`),
`${partyASeal}\n $1`
);
}
if (partyBSeal && contractData.partyB) {
const escaped = escapeRegExp(contractData.partyB);
result = result.replace(
new RegExp(`(<div[^>]*>单位名称(章):\\s*${escaped}</div>)`),
`${partyBSeal}\n $1`
);
}
return result;
};
/** 切换红章显示/隐藏(同步影响编辑器、打印、PDF) */
const toggleRedSeal = () => {
if (showRedSeal.value) {
contractContent.value = stripSealImages(contractContent.value);
showRedSeal.value = false;
return;
}
if (detailContract.value) {
contractContent.value = injectSealImages(contractContent.value, detailContract.value);
}
showRedSeal.value = true;
};
/** 切换预览/编辑模式 */
const togglePreviewMode = () => {
isPreviewMode.value = !isPreviewMode.value;
};
/** 打印合同 */
const handlePrintContract = () => {
contractContent.value = readEditorHtml();
// 从合同编号中提取供方编码
// 合同编号格式:{供方编码}/X-JT + yyyyMMdd + 3位流水号
// 例如:SX/X-JT20250926002
let supplierCode = 'SX'; // 默认值
if (detailContract.value?.contractNo) {
const contractNo = detailContract.value.contractNo;
const match = contractNo.match(/^([A-Z]+)\/X-JT/);
if (match && match[1]) {
supplierCode = match[1];
}
}
// 判断是否为SX供方编码
const isSXSupplier = supplierCode === 'SX';
// 获取完整的水印图片URL
const watermarkUrl = window.location.origin + '/watermask.jpg';
// 创建打印窗口
const printWindow = window.open('', '_blank');
if (printWindow) {
// 根据供方编码决定是否显示背景图片
const backgroundStyle = isSXSupplier ? contractWatermarkPrintCss(watermarkUrl) : '';
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>打印合同</title>
<style>
body {
margin: 0;
padding: 20px;
position: relative;
}
${backgroundStyle}
@media print {
body {
margin: 0;
padding: 20px;
}
}
</style>
</head>
<body>
${contractContent.value}
</body>
</html>
`);
printWindow.document.close();
// 等待内容加载完成后,根据供方编码决定是否隐藏logo
setTimeout(() => {
if (!isSXSupplier) {
// 如果不是SX供方编码,隐藏logo
// 查找所有包含logo的图片元素
const logoImages = printWindow.document.querySelectorAll('img');
logoImages.forEach((img: any) => {
const src = img.src || img.getAttribute('src') || '';
// 检查是否是logo图片(路径包含logo.gif或logo)
if (src.includes('logo.gif') || src.includes('/logo') || src.includes('logo')) {
// 隐藏logo及其父容器(如果有)
img.style.display = 'none';
const parent = img.parentElement;
if (parent && parent.tagName === 'DIV' && parent.children.length === 1) {
parent.style.display = 'none';
}
}
});
}
// 打印
printWindow.print();
}, 1000);
}
};
/** 从打印弹窗生成 Word(前端 HTML→DOCX) */
const handleGenerateWordFromDialog = async () => {
if (!detailContract.value?.id) {
$modal.msgError('合同信息不存在');
return;
}
wordGenerating.value = true;
try {
$modal.loading('正在生成 Word...');
let htmlContent = readEditorHtml();
const res = await getContractDetail(detailContract.value.id);
if (res?.data) {
htmlContent = replacePlaceholders(htmlContent, res.data);
contractContent.value = htmlContent;
}
const prepared = prepareContractHtmlForDocx({
html: htmlContent,
contractNo: detailContract.value.contractNo,
origin: window.location.origin
});
const blob = await generateContractDocxBlob(
prepared,
detailContract.value.contractName || detailContract.value.contractNo
);
const fileName = buildContractDocxFileName(
detailContract.value.contractName,
detailContract.value.contractNo
);
FileSaver.saveAs(blob, fileName);
$modal.closeLoading();
$modal.msgSuccess('Word 生成成功');
} catch (error) {
console.error('生成 Word 失败:', error);
$modal.closeLoading();
$modal.msgError('生成 Word 失败,请重试');
} finally {
wordGenerating.value = false;
}
};
/** 从打印弹窗生成PDF(前端实现) */
const handleGeneratePdfFromDialog = async () => {
if (!detailContract.value?.id) {
$modal.msgError('合同信息不存在');
return;
}
pdfGenerating.value = true;
try {
$modal.loading('正在生成PDF...');
// 从合同编号中提取供方编码
let supplierCode = 'SX'; // 默认值
if (detailContract.value?.contractNo) {
const contractNo = detailContract.value.contractNo;
const match = contractNo.match(/^([A-Z]+)\/X-JT/);
if (match && match[1]) {
supplierCode = match[1];
}
}
// 判断是否为SX供方编码
const isSXSupplier = supplierCode === 'SX';
// 获取合同内容并用最新数据刷新合计区(打印弹窗与 PDF 同源)
let htmlContent = readEditorHtml();
const res = await getContractDetail(detailContract.value.id);
if (res?.data) {
htmlContent = replacePlaceholders(htmlContent, res.data);
contractContent.value = htmlContent;
}
// 1. 创建临时容器(border-box:794 含 padding)
const tempDiv = document.createElement('div');
tempDiv.style.position = 'absolute';
tempDiv.style.left = '-9999px';
tempDiv.style.top = '0';
tempDiv.style.width = '794px'; // A4纸宽度
tempDiv.style.boxSizing = 'border-box';
tempDiv.style.overflow = 'hidden';
tempDiv.style.background = 'white';
tempDiv.style.padding = '20px';
// 创建带水印的HTML结构
const watermarkDiv = document.createElement('div');
watermarkDiv.style.position = 'relative';
watermarkDiv.style.width = '100%';
watermarkDiv.style.maxWidth = '100%';
watermarkDiv.style.boxSizing = 'border-box';
// 添加内容层
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'position: relative; z-index: 1; width: 100%; max-width: 100%; box-sizing: border-box; overflow: hidden;';
// 如果不是SX供方编码,在设置HTML之前移除logo
let contentHtml = htmlContent;
if (!isSXSupplier) {
// 使用正则表达式移除logo图片及其父容器
// 匹配包含logo的img标签及其父div(如果父div只有一个子元素)
contentHtml = contentHtml.replace(/<div[^>]*>\s*<img[^>]*(?:logo\.gif|logo)[^>]*>[\s\S]*?<\/div>/gi, (match) => {
// 检查是否包含logo
if (match.includes('logo.gif') || match.includes('/logo') || (match.includes('logo') && !match.includes('watermask'))) {
return ''; // 移除整个div
}
return match;
});
// 也处理单独的img标签
contentHtml = contentHtml.replace(/<img[^>]*(?:logo\.gif|logo)[^>]*(?![^<]*watermask)[^>]*>/gi, '');
}
contentDiv.innerHTML = contentHtml;
const fitStyle = document.createElement('style');
fitStyle.textContent = `
table { max-width: 100% !important; table-layout: fixed !important; box-sizing: border-box !important; }
table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: break-word; }
`;
contentDiv.prepend(fitStyle);
watermarkDiv.appendChild(contentDiv);
tempDiv.appendChild(watermarkDiv);
document.body.appendChild(tempDiv);
// 水印为背景图,预加载避免截图时未就绪
if (isSXSupplier) {
await new Promise<void>((resolve) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = () => resolve();
img.src = '/watermask.jpg';
});
}
await waitForImagesLoaded(tempDiv);
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
// 编辑器拖拽后表宽常大于 A4 内容区;截图前等比缩进,避免 PDF 右侧裁切
const pdfContentMaxWidth = Math.max(1, tempDiv.clientWidth - 40);
fitRichTextTablesToMaxWidth(contentDiv, pdfContentMaxWidth);
if (isSXSupplier) {
appendContractPageWatermarks(watermarkDiv, tempDiv.clientWidth || 794);
await waitForImagesLoaded(watermarkDiv);
}
// 如果不是SX供方编码,再次检查并隐藏可能遗漏的logo
if (!isSXSupplier) {
const logoImages = contentDiv.querySelectorAll('img');
logoImages.forEach((img: any) => {
const src = img.src || img.getAttribute('src') || '';
// 检查是否是logo图片(路径包含logo.gif或logo)
if (src.includes('logo.gif') || src.includes('/logo') || (src.includes('logo') && !src.includes('watermask'))) {
// 隐藏logo及其父容器(如果有)
img.style.display = 'none';
const parent = img.parentElement;
if (parent && parent.tagName === 'DIV' && parent.children.length === 1) {
parent.style.display = 'none';
}
}
});
}
// 2. 使用html2canvas转换为canvas(PNG 无损,避免 JPEG 压糊文字)
const canvasOptions = {
scale: getContractPdfCanvasScale(),
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
width: 794,
windowWidth: 794
};
// 先排除装饰水印判断实际内容页数,模板底部 padding 不应生成空白尾页。
let canvas = await html2canvas(tempDiv, {
...canvasOptions,
ignoreElements: (element) => element.hasAttribute('data-contract-watermark')
});
const context = canvas.getContext('2d');
if (!context) throw new Error('无法读取合同截图');
const pageCount = countContractPdfPages(context.getImageData(0, 0, canvas.width, canvas.height));
if (isSXSupplier) {
canvas = await html2canvas(tempDiv, canvasOptions);
}
// 3. 移除临时容器
document.body.removeChild(tempDiv);
// 4. 计算PDF尺寸
const imgWidth = 210; // A4纸宽度(mm)
const pageHeight = 297; // A4纸高度(mm)
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// 5. 创建PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
compress: true // 无损压缩整页 PNG,避免原始像素直接写入导致 PDF 过大
});
const imgData = canvas.toDataURL('image/png');
// 6. 只输出有正文的页面,保留原截图比例和分页位置。
for (let page = 0; page < pageCount; page += 1) {
if (page > 0) pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, -page * pageHeight, imgWidth, imgHeight);
}
// 7. 下载PDF
const fileName = detailContract.value.contractName || detailContract.value.contractNo;
const safeFileName = fileName.replace(/[/\\:*?"<>|]/g, '-');
pdf.save(`${safeFileName}合同.pdf`);
$modal.closeLoading();
$modal.msgSuccess('PDF生成成功');
} catch (error) {
console.error('生成PDF失败:', error);
$modal.closeLoading();
$modal.msgError('生成PDF失败,请重试');
} finally {
pdfGenerating.value = false;
}
};
onMounted(async () => {
await dictStore.loadAllDict();
});
function formatPlanQty(value: unknown) {
const n = parseFloat(String(value ?? 0))
if (Number.isNaN(n)) return '0.000'
return n.toFixed(3)
}
</script>
<style scoped lang="scss">
.framework-tier-detail-block {
.fw-tier-detail-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
color: var(--el-text-color-primary);
}
}
:deep(th.col-plan-qty),
:deep(td.col-plan-qty) {
color: #059669 !important;
font-weight: 600;
}
:deep(th.col-plan-qty) {
background: #ecfdf5 !important;
}
.plan-qty-num {
color: #059669;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.plan-qty-num.is-over-delivery {
color: #dc2626 !important;
}
.pkg-qty-breakdown {
display: flex;
flex-direction: column;
gap: 2px;
}
.pkg-qty-line {
display: flex;
justify-content: flex-end;
gap: 4px;
line-height: 1.35;
}
.pkg-qty-label {
color: var(--el-text-color-secondary);
font-size: 12px;
font-weight: 400;
}
.pkg-spec-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tier-letter-thumbs {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
align-items: flex-start;
}
.tier-letter-thumb-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.tier-letter-thumb-wrap :deep(.el-image) {
width: 72px !important;
height: 72px !important;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
background-color: var(--el-fill-color-light);
}
.tier-letter-thumb-wrap :deep(.el-image__inner) {
width: 100% !important;
height: 100% !important;
object-fit: cover;
}
.tier-letter-thumb-img {
display: block;
}
.tier-letter-download-link {
font-size: 12px;
}
// 详情抽屉样式
.contract-detail-body {
padding: 20px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 16px;
.header-left {
display: flex;
align-items: center;
}
.detail-title {
font-size: 18px;
font-weight: bold;
color: #303133;
}
.detail-actions {
display: flex;
gap: 8px;
}
}
.contract-info-section {
margin-bottom: 20px;
h3 {
margin: 0 0 14px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: var(--section-bar-title-color, var(--primary-dark, #059669));
border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
line-height: 1;
}
}
.contract-template-hint {
margin: 10px 0 0;
font-size: 12px;
color: #d97706;
line-height: 1.5;
}
.generate-print-dialog-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
width: 100%;
min-height: 24px;
padding-right: 28px;
box-sizing: border-box;
}
.generate-print-dialog-title {
flex-shrink: 0;
font-size: 18px;
font-weight: 600;
line-height: 24px;
color: var(--el-text-color-primary);
}
.generate-print-dialog-edit-hint {
margin: 0;
text-align: left;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
color: #ef4444;
}
.parties-section {
margin: 8px 0 20px;
}
.parties-section-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.parties-row {
display: flex;
align-items: stretch;
gap: 0;
min-height: 0;
}
.party-panel {
flex: 1 1 0;
min-width: 0;
padding: 16px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
background: #fff;
&.is-collapsed {
padding: 12px 16px;
}
}
.party-panel__header {
display: flex;
align-items: center;
margin-bottom: 12px;
.party-panel.is-collapsed & {
margin-bottom: 8px;
}
}
.party-panel__title {
margin-bottom: 0;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: var(--section-bar-title-color, var(--primary-dark, #059669));
line-height: 1.5;
border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
}
.party-panel__summary {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
padding: 2px 0 2px 10px;
}
.party-panel__summary-label {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.5;
}
.party-panel__summary-value {
min-width: 0;
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-empty {
font-weight: 400;
color: var(--mint-text-muted, #94a3b8);
}
}
.party-flow-bridge {
flex: 0 0 88px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 8px;
}
.party-flow-bridge__label {
font-size: 12px;
font-weight: 600;
color: var(--primary-dark, #168f45);
letter-spacing: 0.06em;
padding: 4px 10px;
border-radius: 999px;
background: #e6f8ed;
border: 1px solid #a8e0b8;
line-height: 1.2;
}
.party-flow-bridge__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--primary-dark, #168f45);
background: linear-gradient(145deg, #e6f8ed 0%, #d4f0de 100%);
border: 1px solid #a8e0b8;
box-shadow: 0 2px 8px rgba(28, 168, 81, 0.14);
svg {
width: 22px;
height: 22px;
display: block;
}
}
@media (max-width: 1100px) {
.parties-row {
flex-direction: column;
min-height: 0;
}
.party-flow-bridge {
flex: 0 0 auto;
flex-direction: row;
gap: 12px;
padding: 12px 0;
}
.party-flow-bridge__arrow {
transform: rotate(90deg);
}
}
.date-shortcut-group {
margin-left: 8px;
}
.total-section {
padding: 12px 0;
}
.amount-cards {
margin-top: 10px;
:deep(.money-statistic) {
.el-statistic__content,
.el-statistic__prefix {
color: #dc2626;
font-weight: 600;
}
}
}
.approval-item {
.approval-header {
display: flex;
justify-content: space-between;
align-items: center;
> div {
display: flex;
align-items: center;
}
.approver-name {
font-weight: 600;
font-size: 15px;
color: #303133;
}
}
.approval-opinion {
.opinion-label {
display: flex;
align-items: center;
color: #606266;
font-weight: 500;
margin-bottom: 8px;
}
.opinion-content {
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
color: #303133;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
}
.approval-time,
.approval-remark {
display: flex;
align-items: center;
color: #1e293b;
font-size: 13px;
}
}
.mr-1 {
margin-right: 4px;
}
.mr-2 {
margin-right: 8px;
}
.ml-2 {
margin-left: 8px;
}
.mt-2 {
margin-top: 8px;
}
.mt-3 {
margin-top: 12px;
}
.mb-3 {
margin-bottom: 12px;
}
.mb-4 {
margin-bottom: 16px;
}
.remark-content {
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
color: #303133;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
min-height: 60px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 附件展示样式 */
.attachment-section {
margin-top: 16px;
padding: 16px;
background: #fafafa;
border: 1px solid #e4e7ed;
border-radius: 8px;
}
.attachment-header {
display: flex;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e4e7ed;
}
.attachment-title {
margin-left: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.attachment-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 12px;
}
/* 文件卡片样式 */
.upload-file-card {
position: relative;
width: 100%;
border-radius: 6px;
overflow: hidden;
background: #fff;
transition: all 0.3s;
}
.upload-file-card:hover {
transform: translateY(-2px);
}
.upload-file-thumb {
position: relative;
width: 100%;
height: 110px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.upload-thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-file-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #1e293b;
font-size: 14px;
}
.upload-file-placeholder .el-icon {
font-size: 28px;
margin-bottom: 4px;
}
.upload-file-placeholder .file-ext {
font-size: 12px;
text-transform: uppercase;
text-align: center;
}
.upload-file-name {
padding: 6px 8px;
font-size: 11px;
color: #303133;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #fafafa;
border-top: 1px solid #f0f0f0;
line-height: 1.2;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
word-break: break-all;
max-width: 100%;
}
.upload-file-card-actions {
display: flex;
justify-content: center;
align-items: center;
padding: 6px 8px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
transition: all 0.3s;
height: 38px;
}
.upload-file-card-actions .el-button {
padding: 4px 12px;
border: 1px solid #e0e0e0;
background: #fff;
font-size: 12px;
border-radius: 3px;
transition: all 0.3s;
}
.upload-file-card-actions .el-button:hover {
background: #f0f9ff;
border-color: #67c23a;
}
.module-title {
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 8px;
}
.module-title-bar {
width: 4px;
height: 18px;
background-color: #67c23a;
border-radius: 2px;
flex-shrink: 0;
}
.module-title-text {
font-size: 18px;
font-weight: 600;
color: #303133;
}
/* 列设置对话框样式 */
.column-setting-content {
max-height: 400px;
overflow-y: auto;
.column-item {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
:deep(.el-checkbox) {
width: 100%;
}
}
}
.attachment-list-cell {
display: flex;
flex-wrap: nowrap;
gap: 8px;
justify-content: flex-start;
align-items: flex-start;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
padding: 4px 0;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
.attachment-thumb-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
width: 58px;
}
.attachment-preview-wrap {
position: relative;
width: 50px;
height: 50px;
flex-shrink: 0;
}
.attachment-thumb-image {
width: 50px;
height: 50px;
border-radius: 4px;
border: 1px solid #e2e8f0;
object-fit: cover;
display: block;
}
.attachment-file-icon {
width: 50px;
height: 50px;
border-radius: 4px;
border: 1px solid #e2e8f0;
background: #f8fafc;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.2s;
.el-icon {
font-size: 22px;
color: #1e293b;
}
&:hover {
border-color: #1ca851;
background: #e6f8ed;
.el-icon {
color: #1ca851;
}
}
}
.attachment-download-btn {
position: absolute;
right: -4px;
bottom: -4px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
z-index: 2;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.attachment-category-label {
display: block;
width: 100%;
font-size: 11px;
line-height: 1.3;
color: #1e293b;
text-align: center;
white-space: normal;
word-break: break-all;
}
}
.contract-product-expand {
padding: 12px 16px;
background-color: #fafafa;
}
.tier-letter-thumbs {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
align-items: flex-start;
}
.tier-letter-thumb-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.tier-letter-thumb-wrap :deep(.el-image) {
width: 72px !important;
height: 72px !important;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
background-color: var(--el-fill-color-light);
}
.tier-letter-thumb-wrap :deep(.el-image__inner) {
width: 100% !important;
height: 100% !important;
object-fit: cover;
}
.tier-letter-thumb-img {
display: block;
}
.tier-letter-download-link {
font-size: 12px;
}
.contract-list-toolbar-row {
flex-wrap: wrap;
}
.list-toolbar-date-shortcuts {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
padding-right: 8px;
margin-right: 4px;
border-right: 1px solid var(--mint-border, #d9e8e2);
}
.list-toolbar-date-shortcuts :deep(.date-shortcut-btn.el-button) {
height: 32px;
padding: 0 14px;
font-size: 13px;
}
.list-toolbar-date-shortcuts :deep(.date-shortcut-btn.is-active.el-button) {
color: var(--mint-primary-dark, #168f45) !important;
background: var(--mint-primary-bg, #e6f8ed) !important;
border-color: rgba(28, 168, 81, 0.35) !important;
}
.quick-search-input {
width: 280px;
flex-shrink: 0;
}
.quick-search-icon {
cursor: pointer;
color: var(--mint-text);
}
.quick-search-icon:hover {
color: var(--mint-primary, #1ca851);
}
.panel-stats {
padding: 10px 24px 0;
}
.contract-detail-body {
/* 由外层 el-dialog__body 负责滚动;避免与内部重复滚动条 */
max-height: none;
overflow-y: visible;
}
.contract-detail-dialog :deep(.el-dialog) {
height: calc(100vh - 80px);
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
}
.contract-detail-dialog :deep(.el-dialog__body) {
padding-top: 12px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.detail-upload-btn {
min-height: 32px;
}
.detail-attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.detail-attachment-item {
width: 148px;
min-height: 200px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
&:hover {
border-color: #1ca851;
box-shadow: 0 2px 8px rgba(28, 168, 81, 0.1);
}
}
.detail-attachment-preview {
width: 100%;
height: 110px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
overflow: hidden;
cursor: pointer;
}
.detail-attachment-thumb-img {
width: 100%;
height: 100%;
display: block;
&--passive {
pointer-events: none;
}
}
.detail-attachment-preview__icon {
width: 48px;
min-height: 48px;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.detail-attachment-preview__icon--doc {
background: #e0f2fe;
color: #0284c7;
}
.detail-attachment-preview__ext {
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #1e293b;
}
.detail-attachment-footer {
display: flex;
flex-direction: column;
padding: 6px 8px;
}
.detail-attachment-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 11px;
color: #1e293b;
line-height: 1.4;
word-break: break-all;
}
.detail-attachment-category {
margin-top: 6px;
min-height: 22px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
}
.detail-attachment-actions {
margin-top: 6px;
display: flex;
justify-content: center;
}
</style>