4178 lines
152 KiB
Vue
4178 lines
152 KiB
Vue
<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)
|
||
? '含税、客户自提结算以实际到货吨位为准'
|
||
: '含税、含运费、装卸费(一票制) 结算以实际到货吨位为准';
|
||
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>
|