Files
stamp-ant/src/views/contract/launch/components/ContractTemplateFillDemoModal.vue
T
2026-05-29 15:26:25 +08:00

1630 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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>