1630 lines
53 KiB
Vue
1630 lines
53 KiB
Vue
<template>
|
||
<a-modal
|
||
v-model:open="visible"
|
||
:title="modalTitle"
|
||
width="92vw"
|
||
:footer="null"
|
||
:mask-closable="false"
|
||
:style="{ top: '24px' }"
|
||
:body-style="{
|
||
padding: 0,
|
||
height: 'calc(100vh - 160px)',
|
||
overflow: 'hidden',
|
||
}"
|
||
>
|
||
<div class="contract-template-layout">
|
||
<section class="config-panel">
|
||
<div class="panel-header">
|
||
<h3>合同正文配置</h3>
|
||
</div>
|
||
|
||
<a-form
|
||
ref="formRef"
|
||
layout="vertical"
|
||
:model="contractDetailState"
|
||
:disabled="isViewMode"
|
||
class="config-form"
|
||
>
|
||
<a-card size="small" title="创建方式与模板选择" class="panel-card">
|
||
<a-form-item label="创建方式">
|
||
<a-radio-group v-model:value="creationMode" button-style="solid">
|
||
<a-radio-button
|
||
v-for="item in contractCreateModeOptions"
|
||
:key="item.value"
|
||
:value="item.value"
|
||
>
|
||
{{ item.label }}
|
||
</a-radio-button>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
|
||
<a-form-item
|
||
v-if="creationMode === 'template'"
|
||
label="合同模板"
|
||
required
|
||
>
|
||
<a-select
|
||
v-model:value="templateKey"
|
||
:options="templateSelectOptions"
|
||
placeholder="请选择已发布模板"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<div v-if="creationMode === 'template'" class="template-tip">
|
||
<div class="template-tip-title">
|
||
{{ activeTemplate?.templateName || "未选择模板" }}
|
||
</div>
|
||
<div class="template-tip-desc">
|
||
{{ activeTemplate?.description || "请选择已发布模板" }}
|
||
</div>
|
||
</div>
|
||
</a-card>
|
||
|
||
<a-card
|
||
v-if="creationMode === 'template'"
|
||
size="small"
|
||
title="模板信息录入"
|
||
class="panel-card"
|
||
>
|
||
<div class="grid-two">
|
||
<a-form-item
|
||
v-for="field in launchFieldList"
|
||
:key="field.key"
|
||
:label="field.label"
|
||
:required="!field.readonly"
|
||
>
|
||
<a-input
|
||
:value="getLaunchFieldValue(field.key)"
|
||
:placeholder="`请输入${field.label}`"
|
||
:readonly="field.readonly"
|
||
@update:value="setLaunchFieldValue(field.key, $event)"
|
||
/>
|
||
</a-form-item>
|
||
</div>
|
||
</a-card>
|
||
<a-card
|
||
v-if="creationMode === 'manual'"
|
||
size="small"
|
||
title="合同正文上传"
|
||
class="panel-card"
|
||
>
|
||
<a-upload-dragger
|
||
:file-list="manualBodyFileList"
|
||
accept=".docx"
|
||
:before-upload="handleManualContractBodyUpload"
|
||
:max-count="1"
|
||
:multiple="false"
|
||
:disabled="isViewMode"
|
||
:show-upload-list="{ showRemoveIcon: !isViewMode }"
|
||
:remove="handleRemoveManualContractBody"
|
||
>
|
||
<p class="ant-upload-drag-icon">
|
||
<FileWordOutlined />
|
||
</p>
|
||
<p class="ant-upload-text">上传合同正文 Word 文件</p>
|
||
<p class="ant-upload-hint">
|
||
手动创建模式下,右侧将直接渲染当前上传的 `.docx` 文件。
|
||
</p>
|
||
</a-upload-dragger>
|
||
</a-card>
|
||
</a-form>
|
||
|
||
<div class="panel-footer">
|
||
<a-space wrap>
|
||
<a-button @click="visible = false">
|
||
{{ isViewMode ? "关闭" : "取消" }}
|
||
</a-button>
|
||
<a-button v-if="!isViewMode" @click="resetForm">
|
||
<ReloadOutlined />
|
||
重置
|
||
</a-button>
|
||
<a-button @click="refreshPreview" :loading="rendering">
|
||
<SettingOutlined />
|
||
刷新预览
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
@click="exportFilledDocument"
|
||
:loading="exporting"
|
||
>
|
||
<FileWordOutlined />
|
||
导出 Word
|
||
</a-button>
|
||
<a-button
|
||
v-if="!isViewMode"
|
||
type="primary"
|
||
@click="handleSave"
|
||
>
|
||
保存
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="preview-panel">
|
||
<div class="preview-header">
|
||
<div>
|
||
<h3>Word 渲染预览</h3>
|
||
<p>{{ previewDescription }}</p>
|
||
</div>
|
||
<a-space size="small">
|
||
<a-tag :color="creationMode === 'manual' ? 'orange' : 'geekblue'">
|
||
{{
|
||
creationMode === "manual"
|
||
? "手动创建"
|
||
: activeTemplate?.templateName || "未选择模板"
|
||
}}
|
||
</a-tag>
|
||
<a-tag color="green">可导出</a-tag>
|
||
</a-space>
|
||
</div>
|
||
|
||
<div class="preview-shell">
|
||
<div ref="previewRef" class="preview-stage"></div>
|
||
<div v-if="!hasPreviewSource && !rendering" class="preview-state">
|
||
<FileWordOutlined style="font-size: 40px; color: #94a3b8" />
|
||
<p>{{ emptyPreviewText }}</p>
|
||
</div>
|
||
<div v-if="rendering" class="preview-state preview-mask">
|
||
<a-spin size="large" />
|
||
<p>正在生成并渲染 Word 文档...</p>
|
||
</div>
|
||
<div v-else-if="previewError" class="preview-state preview-error">
|
||
<h4>文档预览失败</h4>
|
||
<p>{{ previewError }}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
</a-modal>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||
import Docxtemplater from "docxtemplater";
|
||
import dayjs from "dayjs";
|
||
import {
|
||
FileWordOutlined,
|
||
ReloadOutlined,
|
||
SettingOutlined,
|
||
} from "@ant-design/icons-vue";
|
||
import { message } from "ant-design-vue";
|
||
import PizZip from "pizzip";
|
||
import { renderAsync } from "docx-preview";
|
||
import { saveAs } from "file-saver";
|
||
import {
|
||
downloadTemplateFile,
|
||
queryPublishedManifestList,
|
||
} from "@/api/manifest.js";
|
||
import { uploadLaunchFile } from "@/api/launch.js";
|
||
import {
|
||
contractSourceOptions,
|
||
contractTypeOptions,
|
||
currencyOptions,
|
||
dateTypeOptions,
|
||
extraAmountFieldOptions,
|
||
getOptionLabel,
|
||
isElectronOptions,
|
||
paymentDirectionOptions,
|
||
sealTypeOptions,
|
||
textSourceOptions,
|
||
valuationModeOptions,
|
||
} from "../contractLaunchOptions.js";
|
||
|
||
// ========================================================================
|
||
// 以下为表单模型 + 字典 + 工具函数,直接内联在此文件中。
|
||
// 接入后端时,可保留这些函数,将入参来源/出参提交替换为真实接口即可。
|
||
// ========================================================================
|
||
|
||
// ========== 创建方式选项 ==========
|
||
const contractCreateModeOptions = [
|
||
{ label: '模板创建', value: 'template' },
|
||
{ label: '手动创建', value: 'manual' },
|
||
]
|
||
|
||
// ========== 可绑定业务字段列表(用于映射下拉和展示) ==========
|
||
const templateFieldList = [
|
||
{ value: 'contractName', label: '合同名称' },
|
||
{ value: 'contractCode', label: '合同编码' },
|
||
{ value: 'contractType', label: '合同类型' },
|
||
{ value: 'contractTypeCode', label: '合同类型编码' },
|
||
{ value: 'contractAmountDisplay', label: '合同金额' },
|
||
{ value: 'contractAmount', label: '合同金额数字' },
|
||
{ value: 'effectiveStart', label: '有效期起' },
|
||
{ value: 'effectiveEnd', label: '有效期止' },
|
||
{ value: 'effectivePeriod', label: '有效期区间' },
|
||
{ value: 'contractPeriodTypeLabel', label: '合同期限类型' },
|
||
{ value: 'periodExplain', label: '期限说明' },
|
||
{ value: 'paymentMethod', label: '付款方式' },
|
||
{ value: 'paymentDirectionLabel', label: '收支方向' },
|
||
{ value: 'valuationModeLabel', label: '计价方式' },
|
||
{ value: 'currencyNameLabel', label: '币种' },
|
||
{ value: 'sealTypeLabel', label: '签订方式' },
|
||
{ value: 'textSourceLabel', label: '文本来源' },
|
||
{ value: 'isElectronLabel', label: '签署方式' },
|
||
{ value: 'contractSourceLabel', label: '合同来源' },
|
||
{ value: 'dateTypeLabel', label: '固定期限日期类型' },
|
||
{ value: 'mandateType', label: '委托类型' },
|
||
{ value: 'cargoType', label: '货种' },
|
||
{ value: 'primaryContent', label: '合同标的说明' },
|
||
{ value: 'amountExplain', label: '金额说明' },
|
||
{ value: 'contractGistRemark', label: '签订依据说明' },
|
||
{ value: 'bodySummary', label: '正文摘要' },
|
||
{ value: 'assistDept', label: '协助部门' },
|
||
{ value: 'executorAccount', label: '合同执行人' },
|
||
{ value: 'selfCode', label: '二级单位合同编码' },
|
||
{ value: 'mainContractCode', label: '主合同编码' },
|
||
{ value: 'signingSubjectCode', label: '签约主体编码' },
|
||
{ value: 'invoiceDate', label: '开票时间' },
|
||
{ value: 'receiveDate', label: '收款时间' },
|
||
{ value: 'partyAName', label: '甲方名称' },
|
||
{ value: 'partyBName', label: '乙方名称' },
|
||
{ value: 'partyCName', label: '丙方名称' },
|
||
{ value: 'partyAContactName', label: '甲方联系人' },
|
||
{ value: 'partyBContactName', label: '乙方联系人' },
|
||
{ value: 'partyCContactName', label: '丙方联系人' },
|
||
{ value: 'partyAContactPhone', label: '甲方电话' },
|
||
{ value: 'partyBContactPhone', label: '乙方电话' },
|
||
{ value: 'partyCContactPhone', label: '丙方电话' },
|
||
{ value: 'partyAAddress', label: '甲方地址' },
|
||
{ value: 'partyBAddress', label: '乙方地址' },
|
||
{ value: 'partyCAddress', label: '丙方地址' },
|
||
{ value: 'partyACreditCode', label: '甲方统一社会信用代码' },
|
||
{ value: 'partyBCreditCode', label: '乙方统一社会信用代码' },
|
||
{ value: 'partyCCreditCode', label: '丙方统一社会信用代码' },
|
||
{ value: 'partyABankName', label: '甲方开户行' },
|
||
{ value: 'partyBBankName', label: '乙方开户行' },
|
||
{ value: 'partyCBankName', label: '丙方开户行' },
|
||
{ value: 'partyABankAccount', label: '甲方银行账号' },
|
||
{ value: 'partyBBankAccount', label: '乙方银行账号' },
|
||
{ value: 'partyCBankAccount', label: '丙方银行账号' },
|
||
{ value: 'counterpartySummary', label: '合同方摘要' },
|
||
{ value: 'attachmentSummary', label: '附件情况' },
|
||
{ value: 'contractGistFileSummary', label: '签订依据附件' },
|
||
{ value: 'contractBodyFileName', label: '合同正文文件名' },
|
||
{ value: 'extraAmountSummary', label: '额外金额' },
|
||
{ value: 'performancePlanSummary', label: '履行计划' },
|
||
{ value: 'templateLabel', label: '模板名称' },
|
||
{ value: 'signDate', label: '签署日期' },
|
||
]
|
||
|
||
// ========== 格式化合同金额(如 368,000.00 元) ==========
|
||
function formatContractAmount(value) {
|
||
if (value === undefined || value === null || value === '') return ''
|
||
return `${new Intl.NumberFormat('zh-CN', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
}).format(Number(value))} 元`
|
||
}
|
||
|
||
function getContractPeriodTypeLabel(value) {
|
||
if (value === '1') return '无固定期限'
|
||
return '固定期限'
|
||
}
|
||
|
||
function normalizeFileItem(item = {}) {
|
||
return {
|
||
uid: item.uid || `file_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
|
||
name: item.name || item.fileName || '',
|
||
size: item.size ?? item.fileSize ?? 0,
|
||
status: item.status || 'done',
|
||
filePath: item.filePath || '',
|
||
fileType: item.fileType || '',
|
||
fileCategory: item.fileCategory || '',
|
||
}
|
||
}
|
||
|
||
function splitFilesByCategory(source = {}) {
|
||
const sourceFiles = source.fileList || []
|
||
const bodyFile = sourceFiles.find((item) => item.fileCategory === 'body') || {}
|
||
return {
|
||
contractBodyFileName:
|
||
source.contractBodyFileName ||
|
||
source.contractTextFileName ||
|
||
bodyFile.fileName ||
|
||
'',
|
||
contractBodyFilePath:
|
||
source.contractBodyFilePath ||
|
||
source.contractTextFilePath ||
|
||
bodyFile.filePath ||
|
||
'',
|
||
contractBodyFileType:
|
||
source.contractBodyFileType ||
|
||
source.contractTextFileType ||
|
||
bodyFile.fileType ||
|
||
'',
|
||
contractBodyFileSize:
|
||
source.contractBodyFileSize ??
|
||
source.contractTextFileSize ??
|
||
bodyFile.fileSize ??
|
||
null,
|
||
otherAttachmentList: source.otherAttachmentList?.length
|
||
? source.otherAttachmentList.map(normalizeFileItem)
|
||
: sourceFiles
|
||
.filter((item) => item.fileCategory === 'other')
|
||
.map(normalizeFileItem),
|
||
contractGistFileList: source.contractGistFileList?.length
|
||
? source.contractGistFileList.map(normalizeFileItem)
|
||
: sourceFiles
|
||
.filter((item) => item.fileCategory === 'gist')
|
||
.map(normalizeFileItem),
|
||
}
|
||
}
|
||
|
||
function formatFileSummary(fileList = []) {
|
||
return fileList.length
|
||
? fileList.map((item) => item.name || item.fileName).filter(Boolean).join(';')
|
||
: '暂无附件'
|
||
}
|
||
|
||
function buildExtraAmountSummary(extraAmounts = {}) {
|
||
const parts = extraAmountFieldOptions
|
||
.filter((item) => extraAmounts[`${item.key}Enabled`])
|
||
.map((item) => `${item.label}${formatContractAmount(extraAmounts[item.key]) || '0.00 元'}`)
|
||
return parts.length ? parts.join(';') : '无'
|
||
}
|
||
|
||
function restoreExtraAmounts(source = {}, current = {}) {
|
||
const result = { ...current, ...(source.extraAmounts || {}) }
|
||
const selectedCodes = String(source.ewAmountType || '')
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
|
||
extraAmountFieldOptions.forEach((item) => {
|
||
const amount = source[item.amountField]
|
||
if (selectedCodes.includes(item.code) || amount || amount === 0) {
|
||
result[`${item.key}Enabled`] = selectedCodes.includes(item.code) || amount > 0
|
||
if (amount !== undefined && amount !== null) {
|
||
result[item.key] = amount
|
||
}
|
||
}
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
function buildPerformancePlanSummary(list = []) {
|
||
if (!list.length) return '暂无履行计划'
|
||
return list
|
||
.map((item, index) => {
|
||
const money = item.planMoney || item.planMoney === 0 ? `,金额${formatContractAmount(item.planMoney)}` : ''
|
||
return `${index + 1}. ${item.planMatter || '未填写事项'},${item.planWhere || '未填写条件'},${item.planTime || '未填写日期'}${money}`
|
||
})
|
||
.join('\n')
|
||
}
|
||
|
||
// ========== 合同方相关函数 ==========
|
||
|
||
/** 深拷贝合同方状态 */
|
||
function copyPartyState(input) {
|
||
const source = input || {}
|
||
const listState = buildPartyStateFromList(source.partyList)
|
||
return {
|
||
isThreePartyContract: Boolean(source.isThreePartyContract),
|
||
partyA: { roleKey: 'A', roleName: '甲方', companyName: '', ...(source.partyA || source.parties?.A || {}), ...(listState?.partyA || {}) },
|
||
partyB: { roleKey: 'B', roleName: '乙方', companyName: '', ...(source.partyB || source.parties?.B || {}), ...(listState?.partyB || {}) },
|
||
partyC: { roleKey: 'C', roleName: '丙方', companyName: '', ...(source.partyC || source.parties?.C || {}), ...(listState?.partyC || {}) },
|
||
...(listState ? { isThreePartyContract: listState.isThreePartyContract } : {}),
|
||
}
|
||
}
|
||
|
||
function mapLaunchPartyToFormParty(item = {}) {
|
||
return {
|
||
roleKey: item.roleCode || 'B',
|
||
roleName: item.roleCode === 'A' ? '甲方' : item.roleCode === 'C' ? '丙方' : '乙方',
|
||
partnerId: item.partnerId || null,
|
||
oppositeId: item.oppositeId || '',
|
||
companyName: item.companyName || '',
|
||
companyType: item.companyType || '',
|
||
oppCharacter: item.oppositeCharacter || '',
|
||
contactName: item.contactName || '',
|
||
contactPhone: item.contactPhone || '',
|
||
contactEmail: item.contactEmail || '',
|
||
contactAddress: item.contactAddress || '',
|
||
registeredAddress: item.registeredAddress || '',
|
||
legalRepresentative: item.legalRepresentative || '',
|
||
creditCode: item.creditCode || '',
|
||
bankName: item.bankName || '',
|
||
bankAccount: item.bankAccount || '',
|
||
naturalPersonIdType: item.naturalPersonIdType || '',
|
||
naturalPersonIdNumber: item.naturalPersonIdNumber || '',
|
||
isGroupInternal: item.isGroupInternal === '1' || item.isGroupInternal === true,
|
||
isHongKongMacaoTaiwan: item.isHongKongMacaoTaiwan === '1' || item.isHongKongMacaoTaiwan === true,
|
||
}
|
||
}
|
||
|
||
function buildPartyStateFromList(list = []) {
|
||
if (!Array.isArray(list) || !list.length) return null
|
||
const state = { partyA: {}, partyB: {}, partyC: {}, isThreePartyContract: false }
|
||
list.forEach((item) => {
|
||
const key = item.roleCode === 'A' ? 'partyA' : item.roleCode === 'C' ? 'partyC' : 'partyB'
|
||
state[key] = mapLaunchPartyToFormParty(item)
|
||
})
|
||
state.isThreePartyContract = Boolean(state.partyB.companyName && state.partyC.companyName)
|
||
return state
|
||
}
|
||
|
||
/** 获取有效的合同方列表 */
|
||
function getPartyList(partyState) {
|
||
const state = copyPartyState(partyState)
|
||
const list = [state.partyA, state.partyB]
|
||
if (state.isThreePartyContract) list.push(state.partyC)
|
||
return list
|
||
}
|
||
|
||
/** 获取合同方摘要(如 "甲方:xxx;乙方:xxx") */
|
||
function getPartySummary(partyState) {
|
||
return getPartyList(partyState)
|
||
.map((item) => `${item.roleName || '未知'}:${item.companyName || '未填写'}`)
|
||
.join(';')
|
||
}
|
||
|
||
/** 构建推送法务系统的相对方信息列表(排除甲方) */
|
||
function buildContractOpposites(partyState) {
|
||
return getPartyList(partyState)
|
||
.filter((item) => item.roleKey !== 'A')
|
||
.map((item) => ({
|
||
roleName: item.roleName,
|
||
oppositeName: item.companyName,
|
||
oppCharacter: item.oppCharacter || 'QY',
|
||
credirCode: item.oppCharacter === 'QY' ? item.creditCode : '',
|
||
idCard: item.oppCharacter === 'ZZR' ? item.naturalPersonIdNumber : '',
|
||
companyType: item.companyType,
|
||
companyNature: item.companyNature,
|
||
contactName: item.contactName,
|
||
contactPhone: item.contactPhone,
|
||
contactEmail: item.contactEmail,
|
||
}))
|
||
}
|
||
|
||
/** 构建合同方同步列表(用于推送法务) */
|
||
function buildPartySyncList(partyState) {
|
||
return getPartyList(partyState).map((item) => ({
|
||
roleKey: item.roleKey,
|
||
roleName: item.roleName,
|
||
companyName: item.companyName,
|
||
oppCharacter: item.oppCharacter,
|
||
companyType: item.companyType,
|
||
companyNature: item.companyNature,
|
||
organizationNature: item.organizationNature,
|
||
enterpriseNatureLevel2: item.enterpriseNatureLevel2,
|
||
isHongKongMacaoTaiwan: item.isHongKongMacaoTaiwan ? 1 : 0,
|
||
isGroupInternal: item.isGroupInternal ? 1 : 0,
|
||
creditCode: item.creditCode,
|
||
naturalPersonIdType: item.naturalPersonIdType,
|
||
naturalPersonIdNumber: item.naturalPersonIdNumber,
|
||
legalRepresentative: item.legalRepresentative,
|
||
registeredAddress: item.registeredAddress,
|
||
contactName: item.contactName,
|
||
contactPhone: item.contactPhone,
|
||
contactEmail: item.contactEmail,
|
||
bankName: item.bankName,
|
||
bankAccount: item.bankAccount,
|
||
}))
|
||
}
|
||
|
||
// ========== 履行计划 ==========
|
||
|
||
/** 创建一条空的履行计划 */
|
||
function createPerformancePlan() {
|
||
return {
|
||
id: `plan_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
|
||
planMatter: '',
|
||
planWhere: '',
|
||
planTime: '',
|
||
planDay: 0,
|
||
planMoney: '',
|
||
planSubjectCode: '',
|
||
planSubjectName: '',
|
||
planDetail: '',
|
||
}
|
||
}
|
||
|
||
// ========== 合同发起表单数据模型 ==========
|
||
|
||
/** 创建空白的合同表单数据 */
|
||
function createContractFormData() {
|
||
return {
|
||
// 合同基本信息
|
||
contractName: '', // 合同名称
|
||
contractCode: '', // 合同编码
|
||
contractType: '', // 合同类型
|
||
contractTypeCode: '', // 合同类型编码
|
||
contractAmount: null, // 合同金额(元)
|
||
effectiveStart: '', // 有效期起 YYYY-MM-DD
|
||
effectiveEnd: '', // 有效期止 YYYY-MM-DD
|
||
contractPeriodType: '0', // 合同期限类型: 0-固定期限 1-无固定期限
|
||
periodExplain: '', // 期限说明(无固定期限时填写)
|
||
paymentMethod: '', // 付款方式
|
||
paymentDirection: '0', // 收支方向
|
||
isElectron: '1', // 签署方式: 0-纸质 1-电子
|
||
textSource: 'standardText', // 文本来源
|
||
contractSource: 'ECP', // 合同来源
|
||
dateType: '0', // 固定期间日期类型:0-年 1-月 2-日
|
||
valuationMode: '', // 计价方式: 0-无金额 1-固定总价 2-预估总价 3-无法预估总价 4-固定单价
|
||
currencyName: '', // 币种: RMB/USD/HKD/EUR/IDR
|
||
sealType: '', // 签订方式: GZ/HTZYZ/...
|
||
mandateType: '', // 委托类型
|
||
cargoType: '', // 货种
|
||
primaryContent: '', // 合同主要内容
|
||
amountExplain: '', // 金额说明
|
||
contractGistRemark: '', // 签订依据说明
|
||
bodySummary: '', // 正文摘要
|
||
assistDept: '', // 协助部门
|
||
executorAccount: '', // 合同执行人手机号
|
||
selfCode: '', // 二级单位合同编码
|
||
mainContractCode: '', // 主合同编码
|
||
signingSubjectCode: '', // 签约主体编码
|
||
invoiceDate: '', // 开票时间
|
||
receiveDate: '', // 收款时间
|
||
|
||
// 文件
|
||
contractBodyFileName: '', // 合同正文文件名
|
||
contractBodyFilePath: '',
|
||
contractBodyFileType: '',
|
||
contractBodyFileSize: null,
|
||
contractBodyArrayBuffer: null, // 合同正文文件二进制
|
||
otherAttachmentList: [], // 其他附件列表
|
||
contractGistFileList: [], // 签订依据附件列表
|
||
|
||
// 合同方
|
||
partyState: {
|
||
isThreePartyContract: false,
|
||
partyA: { companyName: '' },
|
||
partyB: { companyName: '' },
|
||
partyC: { companyName: '' },
|
||
},
|
||
|
||
// 合同属性
|
||
isInvolveHongKongMacaoTaiwan: false, // 是否涉及港澳台
|
||
isPublicTender: false, // 是否公开招标
|
||
isForeignRelatedSubject: false, // 签约主体是否涉外
|
||
isIncludedInCurrentYearBudget: false, // 是否列入当年预算
|
||
isShared: false, // 是否共享
|
||
isMajorContract: false, // 是否重大合同
|
||
|
||
// 额外金额
|
||
extraAmounts: {
|
||
depositEnabled: false, deposit: 0,
|
||
pledgeEnabled: false, pledge: 0,
|
||
advanceEnabled: false, advance: 0,
|
||
guaranteeEnabled: false, guarantee: 0,
|
||
},
|
||
ewAmountType: '',
|
||
djAmount: 0,
|
||
yjAmount: 0,
|
||
yfkAmount: 0,
|
||
bzjAmount: 0,
|
||
|
||
// 履行计划
|
||
performancePlans: [],
|
||
|
||
// 签署日期
|
||
signDate: dayjs().format('YYYY-MM-DD'),
|
||
}
|
||
}
|
||
|
||
/** 深拷贝合同表单数据(用 source 覆盖默认值) */
|
||
function copyContractFormData(data = {}) {
|
||
const form = createContractFormData()
|
||
const source = data || {}
|
||
const fileState = splitFilesByCategory(source)
|
||
|
||
form.contractName = source.contractName ?? form.contractName
|
||
form.contractCode = source.contractCode ?? form.contractCode
|
||
form.contractType = source.contractType ?? form.contractType
|
||
form.contractTypeCode = source.contractTypeCode ?? source.contractType ?? form.contractTypeCode
|
||
form.contractAmount = source.contractAmount ?? form.contractAmount
|
||
form.effectiveStart = source.effectiveStart ? String(source.effectiveStart).slice(0, 10) : form.effectiveStart
|
||
form.effectiveEnd = source.effectiveEnd ? String(source.effectiveEnd).slice(0, 10) : form.effectiveEnd
|
||
form.contractPeriodType = source.contractPeriodType ?? form.contractPeriodType
|
||
form.periodExplain = source.periodExplain ?? form.periodExplain
|
||
form.paymentMethod = source.paymentMethod ?? form.paymentMethod
|
||
form.paymentDirection = source.paymentDirection ?? form.paymentDirection
|
||
form.isElectron = source.isElectron ?? form.isElectron
|
||
form.textSource = source.textSource ?? form.textSource
|
||
form.contractSource = source.contractSource ?? form.contractSource
|
||
form.dateType = source.dateType ?? form.dateType
|
||
form.valuationMode = source.valuationMode ?? form.valuationMode
|
||
form.currencyName = source.currencyName ?? form.currencyName
|
||
form.sealType = source.sealType ?? form.sealType
|
||
form.mandateType = source.mandateType ?? form.mandateType
|
||
form.cargoType = source.cargoType ?? form.cargoType
|
||
form.primaryContent = source.primaryContent ?? form.primaryContent
|
||
form.amountExplain = source.amountExplain ?? form.amountExplain
|
||
form.contractGistRemark = source.contractGistRemark ?? form.contractGistRemark
|
||
form.bodySummary = source.bodySummary ?? form.bodySummary
|
||
form.assistDept = source.assistDept ?? source.assistDepartmentName ?? form.assistDept
|
||
form.executorAccount = source.executorAccount ?? form.executorAccount
|
||
form.selfCode = source.selfCode ?? form.selfCode
|
||
form.mainContractCode = source.mainContractCode ?? form.mainContractCode
|
||
form.signingSubjectCode = source.signingSubjectCode ?? source.orgId ?? form.signingSubjectCode
|
||
form.invoiceDate = source.invoiceDate ? String(source.invoiceDate).slice(0, 10) : form.invoiceDate
|
||
form.receiveDate = source.receiveDate ? String(source.receiveDate).slice(0, 10) : form.receiveDate
|
||
form.contractBodyFileName = fileState.contractBodyFileName
|
||
form.contractBodyFilePath = fileState.contractBodyFilePath
|
||
form.contractBodyFileType = fileState.contractBodyFileType
|
||
form.contractBodyFileSize = fileState.contractBodyFileSize
|
||
form.contractBodyArrayBuffer = source.contractBodyArrayBuffer
|
||
? (source.contractBodyArrayBuffer instanceof ArrayBuffer ? source.contractBodyArrayBuffer.slice(0) : null)
|
||
: null
|
||
form.otherAttachmentList = fileState.otherAttachmentList
|
||
form.contractGistFileList = fileState.contractGistFileList
|
||
form.partyState = copyPartyState(source.partyState || source)
|
||
form.isInvolveHongKongMacaoTaiwan = source.isInvolveHongKongMacaoTaiwan ?? form.isInvolveHongKongMacaoTaiwan
|
||
form.isPublicTender = source.isPublicTender ?? form.isPublicTender
|
||
form.isForeignRelatedSubject = source.isForeignRelatedSubject ?? form.isForeignRelatedSubject
|
||
form.isIncludedInCurrentYearBudget = source.isIncludedInCurrentYearBudget ?? form.isIncludedInCurrentYearBudget
|
||
form.isShared = source.isShared ?? form.isShared
|
||
form.isMajorContract = source.isMajorContract ?? form.isMajorContract
|
||
form.signDate = source.signDate ? String(source.signDate).slice(0, 10) : form.signDate
|
||
form.extraAmounts = {
|
||
depositEnabled: false, deposit: 0,
|
||
pledgeEnabled: false, pledge: 0,
|
||
advanceEnabled: false, advance: 0,
|
||
guaranteeEnabled: false, guarantee: 0,
|
||
}
|
||
form.extraAmounts = restoreExtraAmounts(source, form.extraAmounts)
|
||
form.ewAmountType = source.ewAmountType ?? form.ewAmountType
|
||
form.djAmount = source.djAmount ?? form.djAmount
|
||
form.yjAmount = source.yjAmount ?? form.yjAmount
|
||
form.yfkAmount = source.yfkAmount ?? form.yfkAmount
|
||
form.bzjAmount = source.bzjAmount ?? form.bzjAmount
|
||
form.performancePlans = (source.performancePlans || []).map((item) => ({
|
||
...createPerformancePlan(),
|
||
...item,
|
||
planTime: item.planTime ? String(item.planTime).slice(0, 10) : '',
|
||
}))
|
||
|
||
return form
|
||
}
|
||
|
||
// ========== 模板变量预览数据构建 ==========
|
||
|
||
/** 将合同表单数据转换为模板变量字典(用于 docxtemplater 渲染) */
|
||
function buildTemplatePreviewData(formData, templateLabel = '', creationMode = 'template') {
|
||
const form = copyContractFormData(formData)
|
||
const attachmentSummary = formatFileSummary(form.otherAttachmentList)
|
||
const contractGistFileSummary = formatFileSummary(form.contractGistFileList)
|
||
const partyA = form.partyState.partyA || {}
|
||
const partyB = form.partyState.partyB || {}
|
||
const partyC = form.partyState.partyC || {}
|
||
|
||
return {
|
||
contractName: form.contractName || '未填写',
|
||
contractCode: form.contractCode || '未填写',
|
||
contractType: getOptionLabel(contractTypeOptions, form.contractType) || form.contractType || '未填写',
|
||
contractTypeCode: form.contractTypeCode || '',
|
||
contractAmountDisplay: formatContractAmount(form.contractAmount),
|
||
contractAmount: form.contractAmount ?? '',
|
||
effectiveStart: form.effectiveStart || '--',
|
||
effectiveEnd: form.effectiveEnd || '--',
|
||
effectivePeriod: form.contractPeriodType === '1'
|
||
? form.periodExplain || '无固定期限'
|
||
: `${form.effectiveStart || '--'} 至 ${form.effectiveEnd || '--'}`,
|
||
contractPeriodTypeLabel: getContractPeriodTypeLabel(form.contractPeriodType),
|
||
periodExplain: form.periodExplain || '',
|
||
paymentMethod: form.paymentMethod || '未填写',
|
||
paymentDirectionLabel: getOptionLabel(paymentDirectionOptions, form.paymentDirection),
|
||
valuationModeLabel: getOptionLabel(valuationModeOptions, form.valuationMode),
|
||
currencyNameLabel: getOptionLabel(currencyOptions, form.currencyName),
|
||
sealTypeLabel: getOptionLabel(sealTypeOptions, form.sealType),
|
||
textSourceLabel: getOptionLabel(textSourceOptions, form.textSource),
|
||
isElectronLabel: getOptionLabel(isElectronOptions, form.isElectron),
|
||
contractSourceLabel: getOptionLabel(contractSourceOptions, form.contractSource),
|
||
dateTypeLabel: getOptionLabel(dateTypeOptions, form.dateType),
|
||
mandateType: form.mandateType || '',
|
||
cargoType: form.cargoType || '',
|
||
primaryContent: form.primaryContent || form.cargoType || '',
|
||
amountExplain: form.amountExplain || '',
|
||
contractGistRemark: form.contractGistRemark || '',
|
||
bodySummary: form.bodySummary || form.primaryContent || '',
|
||
assistDept: form.assistDept || '',
|
||
executorAccount: form.executorAccount || '',
|
||
selfCode: form.selfCode || '',
|
||
mainContractCode: form.mainContractCode || '',
|
||
signingSubjectCode: form.signingSubjectCode || '',
|
||
invoiceDate: form.invoiceDate || '',
|
||
receiveDate: form.receiveDate || '',
|
||
counterpartySummary: getPartySummary(form.partyState),
|
||
attachmentSummary,
|
||
contractGistFileSummary,
|
||
contractBodyFileName: form.contractBodyFileName || '',
|
||
extraAmountSummary: buildExtraAmountSummary(form.extraAmounts),
|
||
performancePlanSummary: buildPerformancePlanSummary(form.performancePlans),
|
||
templateLabel: templateLabel || (creationMode === 'manual' ? '手动创建' : ''),
|
||
signDate: form.signDate || dayjs().format('YYYY-MM-DD'),
|
||
partyAName: partyA.companyName || '',
|
||
partyBName: partyB.companyName || '',
|
||
partyCName: form.partyState.isThreePartyContract ? partyC.companyName || '' : '',
|
||
partyAContactName: partyA.contactName || '',
|
||
partyBContactName: partyB.contactName || '',
|
||
partyCContactName: partyC.contactName || '',
|
||
partyAContactPhone: partyA.contactPhone || '',
|
||
partyBContactPhone: partyB.contactPhone || '',
|
||
partyCContactPhone: partyC.contactPhone || '',
|
||
partyAAddress: partyA.registeredAddress || partyA.contactAddress || '',
|
||
partyBAddress: partyB.registeredAddress || partyB.contactAddress || '',
|
||
partyCAddress: partyC.registeredAddress || partyC.contactAddress || '',
|
||
partyACreditCode: partyA.creditCode || '',
|
||
partyBCreditCode: partyB.creditCode || '',
|
||
partyCCreditCode: partyC.creditCode || '',
|
||
partyABankName: partyA.bankName || '',
|
||
partyBBankName: partyB.bankName || '',
|
||
partyCBankName: partyC.bankName || '',
|
||
partyABankAccount: partyA.bankAccount || '',
|
||
partyBBankAccount: partyB.bankAccount || '',
|
||
partyCBankAccount: partyC.bankAccount || '',
|
||
}
|
||
}
|
||
|
||
// ========== 提交报文构建 ==========
|
||
|
||
/** 构建推送法务系统的合同报文 */
|
||
function buildContractSubmitPayload(formData, extra = {}) {
|
||
const form = copyContractFormData(formData)
|
||
|
||
return {
|
||
creationMode: extra.creationMode || 'template',
|
||
templateKey: extra.templateKey || '',
|
||
templateName: extra.templateName || '',
|
||
basicInfo: {
|
||
contractName: form.contractName,
|
||
contractCode: form.contractCode,
|
||
contractType: form.contractType,
|
||
contractTypeCode: form.contractTypeCode,
|
||
contractAmount: form.contractAmount,
|
||
effectiveStart: form.effectiveStart,
|
||
effectiveEnd: form.effectiveEnd,
|
||
contractPeriodType: form.contractPeriodType,
|
||
periodExplain: form.periodExplain,
|
||
paymentMethod: form.paymentMethod,
|
||
paymentDirection: form.paymentDirection,
|
||
isElectron: form.isElectron,
|
||
textSource: form.textSource,
|
||
contractSource: form.contractSource,
|
||
dateType: form.dateType,
|
||
valuationMode: form.valuationMode,
|
||
currencyName: form.currencyName,
|
||
sealType: form.sealType,
|
||
mandateType: form.mandateType,
|
||
cargoType: form.cargoType,
|
||
primaryContent: form.primaryContent,
|
||
amountExplain: form.amountExplain,
|
||
contractGistRemark: form.contractGistRemark,
|
||
bodySummary: form.bodySummary,
|
||
assistDept: form.assistDept,
|
||
executorAccount: form.executorAccount,
|
||
selfCode: form.selfCode,
|
||
mainContractCode: form.mainContractCode,
|
||
signingSubjectCode: form.signingSubjectCode,
|
||
invoiceDate: form.invoiceDate,
|
||
receiveDate: form.receiveDate,
|
||
},
|
||
partyMaintenance: {
|
||
isThreePartyContract: form.partyState.isThreePartyContract ? 1 : 0,
|
||
parties: buildPartySyncList(form.partyState),
|
||
},
|
||
pushContractPayloadPreview: {
|
||
contractName: form.contractName,
|
||
contractTypeCode: form.contractTypeCode,
|
||
contractPeriod: form.contractPeriodType,
|
||
startTime: form.effectiveStart,
|
||
endTime: form.effectiveEnd,
|
||
periodExplain: form.periodExplain,
|
||
dateType: form.dateType,
|
||
valuationMode: form.valuationMode,
|
||
currencyName: form.currencyName,
|
||
sealType: form.sealType,
|
||
isText: form.textSource,
|
||
isElectron: form.isElectron,
|
||
paymentDirection: form.paymentDirection,
|
||
primaryContent: form.primaryContent,
|
||
amountExplain: form.amountExplain,
|
||
contractGistRemark: form.contractGistRemark,
|
||
isMajorContract: form.isMajorContract ? 1 : 0,
|
||
contractOpposites: buildContractOpposites(form.partyState),
|
||
otherAttach: form.otherAttachmentList.map((item) => ({
|
||
fileName: item.name || item.fileName,
|
||
filePath: item.filePath,
|
||
fileType: item.fileType,
|
||
fileSize: item.size ?? item.fileSize,
|
||
})),
|
||
contractGistFile: form.contractGistFileList.map((item) => ({
|
||
fileName: item.name || item.fileName,
|
||
filePath: item.filePath,
|
||
fileType: item.fileType,
|
||
fileSize: item.size ?? item.fileSize,
|
||
})),
|
||
contractBodyFileName: form.contractBodyFileName,
|
||
performancePlans: form.performancePlans,
|
||
},
|
||
}
|
||
}
|
||
|
||
const emit = defineEmits(["save"]);
|
||
|
||
const visible = ref(false);
|
||
const previewRef = ref(null);
|
||
const formRef = ref(null);
|
||
const modalMode = ref("create");
|
||
const creationMode = ref("template");
|
||
const templateKey = ref("");
|
||
const contractDetailState = ref(createContractFormData());
|
||
const rendering = ref(false);
|
||
const exporting = ref(false);
|
||
const previewError = ref("");
|
||
const previewBlob = ref(null);
|
||
const manualBodyFileList = ref([]);
|
||
const publishedTemplates = ref([]);
|
||
|
||
const templateBufferCache = new Map();
|
||
let previewTimer = null;
|
||
let renderTaskId = 0;
|
||
|
||
const templateSelectOptions = computed(() =>
|
||
publishedTemplates.value.map((item) => ({
|
||
label: item.templateName,
|
||
value: item.id,
|
||
})),
|
||
);
|
||
|
||
// TODO: 这里后续替换为“根据模板ID查询模板详情接口”
|
||
// TODO: 这里后续如果“已发布模板列表接口”不返回完整详情,再改成调用“模板详情接口”
|
||
//相当于详情接口返回了 映射 然后下面做匹配
|
||
const activeTemplate = computed(() =>
|
||
templateKey.value ? getManifestDetailLocal(templateKey.value) : null,
|
||
);
|
||
|
||
const isViewMode = computed(() => modalMode.value === "view");
|
||
|
||
const modalTitle = computed(() => "合同正文配置");
|
||
|
||
const templateFieldLabelMap = computed(() => {
|
||
const map = {};
|
||
//这个就是字典数据,包含所有定义的映射字段
|
||
templateFieldList.forEach((item) => {
|
||
map[item.value] = item.label;
|
||
});
|
||
return map;
|
||
});
|
||
|
||
const launchFieldList = computed(() => {
|
||
if (creationMode.value === "manual") {
|
||
return [];
|
||
}
|
||
// 模板模式:从 variableMappings 中取出 bindField
|
||
const mappings = activeTemplate.value?.variableMappings || [];
|
||
const fieldKeys = [];
|
||
|
||
mappings.forEach((item) => {
|
||
if (!item.bindField) return;
|
||
if (!fieldKeys.includes(item.bindField)) {
|
||
fieldKeys.push(item.bindField);
|
||
}
|
||
});
|
||
|
||
return fieldKeys.map((key) => ({
|
||
key,
|
||
label: templateFieldLabelMap.value[key] || key,
|
||
readonly: Object.prototype.hasOwnProperty.call(templateVariables.value, key),
|
||
}));
|
||
});
|
||
|
||
function getLaunchFieldValue(fieldKey) {
|
||
if (Object.prototype.hasOwnProperty.call(templateVariables.value, fieldKey)) {
|
||
return templateVariables.value[fieldKey] ?? "";
|
||
}
|
||
return contractDetailState.value[fieldKey] ?? "";
|
||
}
|
||
|
||
function setLaunchFieldValue(fieldKey, value) {
|
||
if (Object.prototype.hasOwnProperty.call(templateVariables.value, fieldKey)) {
|
||
return;
|
||
}
|
||
contractDetailState.value[fieldKey] = value;
|
||
}
|
||
|
||
const templateVariables = computed(() =>
|
||
buildTemplatePreviewData(
|
||
contractDetailState.value,
|
||
activeTemplate.value?.templateName || "",
|
||
creationMode.value,
|
||
),
|
||
);
|
||
|
||
const hasPreviewSource = computed(() => {
|
||
if (creationMode.value === "manual") {
|
||
return !!contractDetailState.value.contractBodyArrayBuffer;
|
||
}
|
||
return !!activeTemplate.value;
|
||
});
|
||
|
||
const previewDescription = computed(() =>
|
||
creationMode.value === "manual"
|
||
? "手动创建模式下,右侧直接渲染当前窗口上传的合同正文 Word 文件。"
|
||
: "模板创建模式下,系统会根据维护后的合同信息自动填充模板变量并生成合同正文。",
|
||
);
|
||
|
||
const emptyPreviewText = computed(() =>
|
||
creationMode.value === "manual"
|
||
? "请先在当前窗口上传合同正文 Word 文件。"
|
||
: "请选择模板并完善合同信息后再预览。",
|
||
);
|
||
|
||
watch(
|
||
() => visible.value,
|
||
(open) => {
|
||
if (open) {
|
||
nextTick(() => {
|
||
refreshPreview();
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => JSON.stringify(templateVariables.value),
|
||
() => {
|
||
if (visible.value) {
|
||
schedulePreview();
|
||
}
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => JSON.stringify(contractDetailState.value.otherAttachmentList),
|
||
() => {
|
||
if (visible.value) {
|
||
schedulePreview();
|
||
}
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => creationMode.value,
|
||
(mode, prevMode) => {
|
||
if (mode === prevMode) return;
|
||
|
||
if (mode === "template" && prevMode === "manual") {
|
||
contractDetailState.value.contractBodyFileName = "";
|
||
contractDetailState.value.contractBodyFilePath = "";
|
||
contractDetailState.value.contractBodyFileType = "";
|
||
contractDetailState.value.contractBodyFileSize = null;
|
||
contractDetailState.value.contractBodyArrayBuffer = null;
|
||
contractDetailState.value.textSource = "standardText";
|
||
syncManualBodyFileList();
|
||
}
|
||
|
||
previewBlob.value = null;
|
||
previewError.value = "";
|
||
if (visible.value) {
|
||
schedulePreview();
|
||
}
|
||
},
|
||
);
|
||
|
||
function syncManualBodyFileList() {
|
||
manualBodyFileList.value = contractDetailState.value.contractBodyFileName
|
||
? [
|
||
{
|
||
uid: "manual_contract_body",
|
||
name: contractDetailState.value.contractBodyFileName,
|
||
status: "done",
|
||
},
|
||
]
|
||
: [];
|
||
}
|
||
|
||
function loadPublishedTemplates() {
|
||
// TODO: 这里后续替换为“已发布模板列表接口”
|
||
return queryPublishedManifestList().then((list) => {
|
||
publishedTemplates.value = list;
|
||
});
|
||
}
|
||
|
||
function getManifestDetailLocal(templateId) {
|
||
return (
|
||
publishedTemplates.value.find((item) => item.id === templateId) || null
|
||
);
|
||
}
|
||
|
||
function schedulePreview() {
|
||
clearPreviewTimer();
|
||
previewTimer = window.setTimeout(() => {
|
||
refreshPreview();
|
||
}, 240);
|
||
}
|
||
|
||
function clearPreviewTimer() {
|
||
if (previewTimer) {
|
||
window.clearTimeout(previewTimer);
|
||
previewTimer = null;
|
||
}
|
||
}
|
||
|
||
async function fetchTemplateBuffer(templateFilePath) {
|
||
const cached = templateBufferCache.get(templateFilePath);
|
||
if (cached) {
|
||
return cached.slice(0);
|
||
}
|
||
|
||
const arrayBuffer = await downloadTemplateFile(templateFilePath);
|
||
const buffer =
|
||
arrayBuffer instanceof ArrayBuffer ? arrayBuffer : arrayBuffer?.buffer;
|
||
templateBufferCache.set(templateFilePath, buffer);
|
||
return buffer.slice(0);
|
||
}
|
||
|
||
async function readFileAsArrayBuffer(fileLike) {
|
||
const rawFile = fileLike?.originFileObj || fileLike?.file || fileLike;
|
||
if (!rawFile) {
|
||
throw new Error("未获取到上传文件");
|
||
}
|
||
|
||
if (typeof rawFile.arrayBuffer === "function") {
|
||
const buffer = await rawFile.arrayBuffer();
|
||
return buffer instanceof ArrayBuffer ? buffer : buffer?.buffer;
|
||
}
|
||
|
||
if (rawFile instanceof Blob) {
|
||
return await rawFile.arrayBuffer();
|
||
}
|
||
|
||
return await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(new Error("读取 Word 文件失败"));
|
||
reader.readAsArrayBuffer(rawFile);
|
||
});
|
||
}
|
||
|
||
async function buildFilledDocumentBlob() {
|
||
if (creationMode.value === "manual") {
|
||
if (!contractDetailState.value.contractBodyArrayBuffer) {
|
||
throw new Error("请先上传合同正文 Word 文件");
|
||
}
|
||
|
||
return new Blob(
|
||
[contractDetailState.value.contractBodyArrayBuffer.slice(0)],
|
||
{
|
||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
},
|
||
);
|
||
}
|
||
|
||
if (!activeTemplate.value) {
|
||
throw new Error("请选择合同模板");
|
||
}
|
||
|
||
const buffer = activeTemplate.value.templateArrayBuffer
|
||
? activeTemplate.value.templateArrayBuffer.slice(0)
|
||
: await fetchTemplateBuffer(
|
||
activeTemplate.value.templateFilePath ||
|
||
activeTemplate.value.templateFileUrl ||
|
||
activeTemplate.value.fileUrl,
|
||
);
|
||
const zip = new PizZip(buffer);
|
||
const doc = new Docxtemplater(zip, {
|
||
paragraphLoop: true,
|
||
linebreaks: true,
|
||
nullGetter(part) {
|
||
return part?.raw ? `[${part.raw}]` : "";
|
||
},
|
||
});
|
||
//这里是生成的测试数据
|
||
const sourceValues = buildTemplatePreviewData(
|
||
contractDetailState.value,
|
||
activeTemplate.value?.templateName || "",
|
||
creationMode.value,
|
||
);
|
||
const mappings = activeTemplate.value.variableMappings || [];
|
||
|
||
const renderData = {};
|
||
//这部分是匹配的关键
|
||
if (mappings.length) {
|
||
mappings.forEach((item) => {
|
||
if (!item.variableKey) return;
|
||
renderData[item.variableKey] =
|
||
sourceValues[item.bindField] ??
|
||
contractDetailState.value[item.bindField] ??
|
||
"";
|
||
});
|
||
} else {
|
||
Object.assign(renderData, sourceValues);
|
||
}
|
||
|
||
doc.render(renderData);
|
||
|
||
return doc.getZip().generate({
|
||
type: "blob",
|
||
mimeType:
|
||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
});
|
||
}
|
||
|
||
async function refreshPreview() {
|
||
if (!visible.value || !previewRef.value || !hasPreviewSource.value) {
|
||
if (previewRef.value) {
|
||
previewRef.value.innerHTML = "";
|
||
}
|
||
return;
|
||
}
|
||
|
||
const currentTaskId = ++renderTaskId;
|
||
rendering.value = true;
|
||
previewError.value = "";
|
||
|
||
try {
|
||
const blob = await buildFilledDocumentBlob();
|
||
if (currentTaskId !== renderTaskId || !previewRef.value) {
|
||
return;
|
||
}
|
||
|
||
previewBlob.value = blob;
|
||
previewRef.value.innerHTML = "";
|
||
|
||
await renderAsync(blob, previewRef.value, previewRef.value, {
|
||
className: "contract-docx-preview",
|
||
inWrapper: true,
|
||
ignoreWidth: false,
|
||
ignoreHeight: false,
|
||
});
|
||
} catch (error) {
|
||
if (currentTaskId !== renderTaskId) {
|
||
return;
|
||
}
|
||
|
||
previewBlob.value = null;
|
||
previewError.value = error?.message || "文档渲染失败";
|
||
if (previewRef.value) {
|
||
previewRef.value.innerHTML = "";
|
||
}
|
||
} finally {
|
||
if (currentTaskId === renderTaskId) {
|
||
rendering.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function exportFilledDocument() {
|
||
exporting.value = true;
|
||
try {
|
||
const blob = previewBlob.value || (await buildFilledDocumentBlob());
|
||
previewBlob.value = blob;
|
||
const fileName =
|
||
creationMode.value === "manual"
|
||
? contractDetailState.value.contractBodyFileName ||
|
||
`${contractDetailState.value.contractName || "合同正文"}.docx`
|
||
: `${contractDetailState.value.contractName || "合同正文"}.docx`;
|
||
saveAs(blob, fileName);
|
||
message.success("已导出 Word 文档");
|
||
} catch (error) {
|
||
message.warning(error?.message || "导出 Word 失败");
|
||
} finally {
|
||
exporting.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleManualContractBodyUpload(file) {
|
||
if (!/\.docx$/i.test(file.name)) {
|
||
message.warning("仅支持上传 .docx 格式的合同正文");
|
||
return false;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
const uploadRes = await uploadLaunchFile(formData);
|
||
if (String(uploadRes?.code || "") !== "0000" || !uploadRes?.result) {
|
||
message.error(uploadRes?.message || uploadRes?.msg || "合同正文上传失败");
|
||
return false;
|
||
}
|
||
|
||
contractDetailState.value.contractBodyFileName = file.name;
|
||
contractDetailState.value.contractBodyFilePath = uploadRes.result;
|
||
contractDetailState.value.contractBodyFileType =
|
||
file.name.split(".").pop()?.toLowerCase() || "docx";
|
||
contractDetailState.value.contractBodyFileSize = file.size;
|
||
contractDetailState.value.textSource = "draftBySelf";
|
||
contractDetailState.value.contractBodyArrayBuffer = await readFileAsArrayBuffer(
|
||
file,
|
||
);
|
||
syncManualBodyFileList();
|
||
previewBlob.value = null;
|
||
previewError.value = "";
|
||
schedulePreview();
|
||
message.success("合同正文已上传");
|
||
return false;
|
||
}
|
||
|
||
function handleRemoveManualContractBody() {
|
||
contractDetailState.value.contractBodyFileName = "";
|
||
contractDetailState.value.contractBodyFilePath = "";
|
||
contractDetailState.value.contractBodyFileType = "";
|
||
contractDetailState.value.contractBodyFileSize = null;
|
||
contractDetailState.value.contractBodyArrayBuffer = null;
|
||
syncManualBodyFileList();
|
||
previewBlob.value = null;
|
||
previewError.value = "";
|
||
schedulePreview();
|
||
return true;
|
||
}
|
||
|
||
async function uploadBlobAsLaunchFile(blob, fileName) {
|
||
const file = new File([blob], fileName, {
|
||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
});
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
const uploadRes = await uploadLaunchFile(formData);
|
||
if (String(uploadRes?.code || "") !== "0000" || !uploadRes?.result) {
|
||
throw new Error(uploadRes?.message || uploadRes?.msg || "合同正文上传失败");
|
||
}
|
||
contractDetailState.value.contractBodyFileName = fileName;
|
||
contractDetailState.value.contractBodyFilePath = uploadRes.result;
|
||
contractDetailState.value.contractBodyFileType = "docx";
|
||
contractDetailState.value.contractBodyFileSize = file.size;
|
||
}
|
||
|
||
async function ensureTemplateBodyFileUploaded() {
|
||
if (creationMode.value !== "template") return;
|
||
const blob = previewBlob.value || (await buildFilledDocumentBlob());
|
||
previewBlob.value = blob;
|
||
const fileName = `${contractDetailState.value.contractName || "合同正文"}.docx`;
|
||
await uploadBlobAsLaunchFile(blob, fileName);
|
||
}
|
||
|
||
function validateMainForm() {
|
||
if (creationMode.value === "template" && !templateKey.value)
|
||
return "请选择合同模板";
|
||
|
||
for (const field of launchFieldList.value) {
|
||
if (field.readonly) {
|
||
continue;
|
||
}
|
||
const value = getLaunchFieldValue(field.key);
|
||
if (value === null || value === undefined || value === "") {
|
||
return `请输入${field.label}`;
|
||
}
|
||
}
|
||
|
||
if (
|
||
creationMode.value === "manual" &&
|
||
!contractDetailState.value.contractBodyArrayBuffer
|
||
)
|
||
return "请上传合同正文 Word 文件";
|
||
return "";
|
||
}
|
||
|
||
async function handleSave() {
|
||
const errorMessage = validateMainForm();
|
||
if (errorMessage) {
|
||
message.warning(errorMessage);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ensureTemplateBodyFileUploaded();
|
||
emit("save", {
|
||
creationMode: creationMode.value,
|
||
templateKey: templateKey.value,
|
||
templateName: activeTemplate.value?.templateName || "",
|
||
detailState: copyContractFormData(contractDetailState.value),
|
||
});
|
||
visible.value = false;
|
||
message.success("合同正文配置已保存");
|
||
} catch (error) {
|
||
message.warning(error?.message || "合同正文保存失败");
|
||
}
|
||
}
|
||
|
||
function resetForm() {
|
||
creationMode.value = "template";
|
||
templateKey.value = publishedTemplates.value[0]?.id || "";
|
||
contractDetailState.value = createContractFormData();
|
||
syncManualBodyFileList();
|
||
previewError.value = "";
|
||
previewBlob.value = null;
|
||
nextTick(() => {
|
||
if (visible.value) {
|
||
refreshPreview();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function showModal(initialValues = {}, mode = "create") {
|
||
modalMode.value = mode || "create";
|
||
const isCreate = modalMode.value === "create";
|
||
await loadPublishedTemplates();
|
||
|
||
creationMode.value = isCreate
|
||
? "template"
|
||
: initialValues.creationMode ||
|
||
(initialValues.detailState?.contractBodyArrayBuffer ||
|
||
initialValues.contractBodyArrayBuffer
|
||
? "manual"
|
||
: "template");
|
||
templateKey.value = isCreate
|
||
? publishedTemplates.value[0]?.id || ""
|
||
: initialValues.templateKey || "";
|
||
contractDetailState.value = isCreate
|
||
? createContractFormData()
|
||
: initialValues.detailState
|
||
? copyContractFormData(initialValues.detailState)
|
||
: copyContractFormData(initialValues);
|
||
|
||
syncManualBodyFileList();
|
||
previewBlob.value = null;
|
||
previewError.value = "";
|
||
visible.value = true;
|
||
}
|
||
|
||
defineExpose({
|
||
showModal,
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
clearPreviewTimer();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.contract-template-layout {
|
||
display: grid;
|
||
grid-template-columns: 460px minmax(0, 1fr);
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.config-panel {
|
||
border-right: 1px solid #e5e7eb;
|
||
background: linear-gradient(180deg, #fbfdff 0%, #f6f8fc 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.panel-header,
|
||
.preview-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 20px 24px 16px;
|
||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||
}
|
||
|
||
.panel-header h3,
|
||
.preview-header h3 {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.preview-header p {
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.config-form {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.panel-card {
|
||
margin-bottom: 16px;
|
||
border-radius: 14px;
|
||
border-color: #e8edf5;
|
||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.grid-two {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.template-tip {
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, #eff6ff 0%, #f8fbff 100%);
|
||
border: 1px solid #dbeafe;
|
||
}
|
||
|
||
.template-tip-title {
|
||
color: #1d4ed8;
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.template-tip-desc {
|
||
font-size: 12px;
|
||
color: #5b6b81;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.party-action-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.minor-label {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.minor-desc {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.summary-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.summary-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
background: #f8fbff;
|
||
border: 1px solid #e4edf8;
|
||
}
|
||
|
||
.summary-item span {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.summary-item strong {
|
||
flex: 1;
|
||
text-align: right;
|
||
font-size: 13px;
|
||
color: #0f172a;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.summary-meta-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.meta-box {
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
border: 1px solid #e5edf7;
|
||
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
|
||
}
|
||
|
||
.meta-box-label {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.meta-box-value {
|
||
font-size: 13px;
|
||
color: #0f172a;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.panel-footer {
|
||
padding: 16px 20px 20px;
|
||
border-top: 1px solid rgba(148, 163, 184, 0.16);
|
||
background: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.preview-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
background:
|
||
radial-gradient(
|
||
circle at top left,
|
||
rgba(59, 130, 246, 0.12),
|
||
transparent 28%
|
||
),
|
||
linear-gradient(180deg, #f3f6fb 0%, #eef2f7 100%);
|
||
}
|
||
|
||
.preview-shell {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
padding: 28px 24px 32px;
|
||
}
|
||
|
||
.preview-stage {
|
||
min-height: 100%;
|
||
}
|
||
|
||
.preview-state {
|
||
position: absolute;
|
||
inset: 28px 24px 32px;
|
||
min-height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 14px;
|
||
color: #64748b;
|
||
text-align: center;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.preview-mask {
|
||
background: rgba(243, 246, 251, 0.88);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.preview-error {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1px dashed #fca5a5;
|
||
}
|
||
|
||
.preview-error h4 {
|
||
color: #b91c1c;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.preview-error p {
|
||
max-width: 520px;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.preview-stage :deep(.docx-wrapper) {
|
||
background: transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
.preview-stage :deep(.docx) {
|
||
margin: 0 auto 24px;
|
||
box-shadow:
|
||
0 22px 50px rgba(15, 23, 42, 0.12),
|
||
0 2px 10px rgba(15, 23, 42, 0.08);
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.contract-template-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.config-panel {
|
||
border-right: none;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.grid-two,
|
||
.summary-meta-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.panel-header,
|
||
.preview-header,
|
||
.party-action-row {
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
</style>
|