Files
stamp-ant/src/views/contract/launch/components/ContractPartyPlaceholderModal.vue
T
2026-05-26 10:10:32 +08:00

1762 lines
54 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="维护合同方信息"
width="96vw"
:footer="null"
:mask-closable="false"
:style="{ top: '12px' }"
:body-style="{
padding: 0,
height: 'calc(100vh - 120px)',
overflow: 'hidden',
}"
>
<div class="contract-main-editor">
<div class="editor-header">
<div><h3>合同主信息</h3></div>
<a-space>
<a-tag :color="creationMode === 'manual' ? 'orange' : 'blue'">
{{ creationMode === "manual" ? "手动创建" : "模板创建" }}
</a-tag>
<a-tag v-if="templateName" color="geekblue">
{{ templateName }}
</a-tag>
</a-space>
</div>
<div class="editor-body">
<a-form layout="vertical" :disabled="isViewMode" class="editor-form">
<section class="main-grid">
<div class="left-column">
<div class="section-card">
<div class="section-title-row">
<div class="section-title">合同主信息</div>
</div>
<div class="party-block">
<div class="field-label required">我方</div>
<a-input
:value="detailState.partyState.partyA.companyName"
readonly
disabled
/>
<div class="party-info-box">
<div class="info-line">
<span>我方</span
><strong>{{
detailState.partyState.partyA.companyName || "/"
}}</strong>
</div>
<div class="info-line">
<span>电话</span
><strong>{{
detailState.partyState.partyA.contactPhone || "/"
}}</strong>
</div>
<div class="info-line">
<span>公司地址</span
><strong>{{
detailState.partyState.partyA.registeredAddress || "/"
}}</strong>
</div>
<div class="info-line">
<span>银行/账号</span
><strong>{{
detailState.partyState.partyA.bankAccount || "/"
}}</strong>
</div>
<a-button
class="mini-search-btn"
type="text"
@click="showSearchTip('我方详情')"
>
<SearchOutlined />
</a-button>
</div>
<a-button
class="invoice-btn"
@click="showInvoiceTitles(detailState.partyState.partyA, '我方')"
>
<PlusOutlined />
备选开票抬头选择
</a-button>
</div>
<div class="section-title-row counterparty-toolbar">
<div class="section-title">相对方</div>
<a-button
v-if="!isViewMode"
type="link"
class="text-action-btn"
@click="handleAddCounterparty"
>
添加
</a-button>
</div>
<div
v-for="(party, index) in counterpartyParties"
:key="party.roleKey"
class="party-block"
>
<div class="party-block-header">
<div class="field-label required">{{ party.displayName }}</div>
<a-button
v-if="!isViewMode"
type="link"
danger
class="text-action-btn"
@click="handleRemoveCounterparty(party.roleKey)"
>
删除
</a-button>
</div>
<div class="search-input-row">
<a-select
v-model:value="party.companyName"
:options="contractCompanyOptions"
show-search
:placeholder="`请选择${party.displayName}`"
@change="handleCompanySelect(party.roleKey, $event)"
/>
<a-button
class="icon-btn"
@click="showSearchTip(`${party.displayName}`)"
>
<SearchOutlined />
</a-button>
</div>
<div class="party-info-box">
<div class="info-line">
<span>{{ party.displayName }}</span
><strong>{{
party.companyName || "/"
}}</strong>
</div>
<div class="info-line">
<span>电话</span
><strong>{{ party.contactPhone || "/" }}</strong>
</div>
<div class="info-line">
<span>公司地址</span
><strong>{{ party.registeredAddress || "/" }}</strong>
</div>
<div class="info-line">
<span>银行/账号</span
><strong>{{ party.bankAccount || "/" }}</strong>
</div>
<a-button
class="mini-search-btn"
type="text"
@click="showSearchTip(`${party.displayName}详情`)"
>
<SearchOutlined />
</a-button>
</div>
</div>
<div v-if="!counterpartyParties.length" class="party-empty-state">
暂无相对方请点击右上角添加
</div>
<div class="field-grid field-grid-2">
<a-form-item class="tight-item" label="合同名称" required>
<a-input
v-model:value="detailState.contractName"
placeholder="请输入"
/>
</a-form-item>
<a-form-item class="tight-item" label="合同号" required>
<a-input
v-model:value="detailState.contractCode"
placeholder="请输入"
/>
</a-form-item>
<a-form-item
class="tight-item field-span-2"
label="合同类型"
required
>
<a-select
v-model:value="detailState.contractType"
:options="contractTypeOptions"
placeholder="请选择"
/>
</a-form-item>
</div>
<div class="period-block">
<div class="field-label required">合同起止时间</div>
<a-radio-group v-model:value="detailState.contractPeriodType">
<a-radio value="0">固定期限</a-radio>
<a-radio value="1">无固定期限</a-radio>
</a-radio-group>
<div
class="period-row"
v-if="detailState.contractPeriodType === '0'"
>
<a-date-picker
v-model:value="detailState.effectiveStart"
value-format="YYYY-MM-DD"
placeholder="开始日期"
style="width: 48%"
/>
<a-date-picker
v-model:value="detailState.effectiveEnd"
value-format="YYYY-MM-DD"
placeholder="结束日期"
style="width: 48%"
/>
</div>
<a-input
v-else
v-model:value="detailState.periodExplain"
placeholder="期限说明"
class="period-explain-input"
/>
</div>
<div class="field-grid field-grid-2">
<a-form-item class="tight-item" label="计价方式" required>
<a-select
v-model:value="detailState.valuationMode"
:options="valuationModeOptions"
placeholder="请选择"
/>
</a-form-item>
<a-form-item class="tight-item" label="币种" required>
<a-select
v-model:value="detailState.currencyName"
:options="currencyOptions"
placeholder="请选择"
/>
</a-form-item>
<a-form-item
class="tight-item field-span-2"
label="签订方式"
required
>
<a-select
v-model:value="detailState.sealType"
:options="sealTypeOptions"
placeholder="请选择"
/>
</a-form-item>
<a-form-item
class="tight-item field-span-2"
label="委托类型"
required
>
<a-select
v-model:value="detailState.mandateType"
:options="mandateTypeOptions"
placeholder="委托类型"
/>
</a-form-item>
<a-form-item
class="tight-item field-span-2"
label="货种"
required
>
<a-select
v-model:value="detailState.cargoType"
:options="cargoTypeOptions"
placeholder="货种"
/>
</a-form-item>
<a-form-item
class="tight-item field-span-2"
label="付款方式"
required
>
<a-select
v-model:value="detailState.paymentMethod"
:options="paymentMethodOptions"
placeholder="请选择付款方式"
/>
</a-form-item>
</div>
</div>
</div>
<div class="right-column">
<div class="section-card">
<div class="basic-header">
<div class="basic-title">
<span class="dot"></span>
基本信息
</div>
</div>
<div class="summary-grid">
<div class="summary-line">
<span>合同名称</span
><strong>{{ detailState.contractName || "未填写" }}</strong>
</div>
<div class="summary-line">
<span>我方</span
><strong>{{
detailState.partyState.partyA.companyName || "未填写"
}}</strong>
</div>
<div class="summary-line">
<span>合同编号</span
><strong>{{ detailState.contractCode || "未填写" }}</strong>
</div>
<div class="summary-line">
<span>相对方</span
><strong>{{ counterpartySummaryText }}</strong>
</div>
</div>
<div class="upload-block">
<div class="upload-label">合同正文</div>
<div
class="readonly-upload-box contract-body-entry"
:class="{ 'is-clickable': !isViewMode }"
@click="openBodyConfigModal"
>
<div class="contract-body-entry-name">
{{ contractBodyDisplayText }}
</div>
<div class="contract-body-entry-hint">
{{ contractBodyHint }}
</div>
</div>
</div>
<div class="upload-block">
<div class="upload-label">其他附件</div>
<a-upload-dragger
:file-list="otherAttachmentList"
:before-upload="handleOtherAttachmentUpload"
@remove="handleRemoveOtherAttachment"
:disabled="isViewMode"
:show-upload-list="{ showRemoveIcon: !isViewMode }"
multiple
>
<div class="other-upload-text">其他附件(可上传多个文件)</div>
</a-upload-dragger>
</div>
<a-form-item class="tight-item" label="主要内容">
<a-textarea
v-model:value="detailState.primaryContent"
:rows="2"
placeholder="选填"
/>
</a-form-item>
<a-form-item class="tight-item" label="金额说明" required>
<a-textarea
v-model:value="detailState.amountExplain"
:rows="2"
placeholder="选填"
/>
</a-form-item>
<div class="basic-title contract-attr-title">
<span class="dot"></span>
合同属性
</div>
<div class="field-grid field-grid-2">
<a-form-item class="tight-item" label="计价方式">
<a-input
:value="
getOptionLabel(
valuationModeOptions,
detailState.valuationMode,
)
"
readonly
/>
</a-form-item>
<a-form-item class="tight-item" label="标的额">
<a-input-number
v-model:value="detailState.contractAmount"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
</a-form-item>
<a-form-item class="tight-item" label="币种">
<a-input
:value="
getOptionLabel(currencyOptions, detailState.currencyName)
"
readonly
/>
</a-form-item>
<a-form-item class="tight-item" label="签订方式">
<a-input
:value="
getOptionLabel(sealTypeOptions, detailState.sealType)
"
readonly
/>
</a-form-item>
<a-form-item class="tight-item" label="协助部门">
<a-input
v-model:value="detailState.assistDept"
placeholder="协助部门"
/>
</a-form-item>
<a-form-item class="tight-item" label="合同类型" required>
<a-select
v-model:value="detailState.contractTypeCode"
:options="contractTypeOptions"
placeholder="合同类型"
/>
</a-form-item>
<a-form-item
class="tight-item field-span-2"
label="合同执行人"
required
>
<a-input
v-model:value="detailState.executorAccount"
placeholder="合同执行人手机号"
/>
</a-form-item>
</div>
<div class="boolean-grid">
<div class="boolean-row">
<span>是否涉及港澳台</span>
<a-radio-group
v-model:value="detailState.isInvolveHongKongMacaoTaiwan"
>
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</div>
<div class="boolean-row">
<span>是否公开招标</span>
<a-radio-group v-model:value="detailState.isPublicTender">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</div>
<div class="boolean-row">
<span>签约主体是否涉外</span>
<a-radio-group
v-model:value="detailState.isForeignRelatedSubject"
>
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</div>
<div class="boolean-row">
<span>是否列入当年预算</span>
<a-radio-group
v-model:value="detailState.isIncludedInCurrentYearBudget"
>
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</div>
<div class="boolean-row">
<span>是否共享</span>
<a-radio-group v-model:value="detailState.isShared">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</div>
<div class="boolean-row">
<span>是否重大合同</span>
<a-radio-group v-model:value="detailState.isMajorContract">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
</div>
</div>
<div class="extra-amount-section">
<div class="extra-label">额外金额</div>
<div class="extra-check-row">
<label
v-for="item in extraAmountFieldOptions"
:key="item.key"
class="extra-check-item"
>
<a-checkbox
v-model:checked="
detailState.extraAmounts[`${item.key}Enabled`]
"
>
{{ item.label }}
</a-checkbox>
</label>
</div>
<div class="extra-input-row">
<a-input-number
v-for="item in extraAmountFieldOptions"
:key="`${item.key}_input`"
v-model:value="detailState.extraAmounts[item.key]"
:disabled="!detailState.extraAmounts[`${item.key}Enabled`]"
:min="0"
:controls="false"
:precision="2"
style="width: 24%"
/>
</div>
</div>
<div class="plan-section">
<div class="basic-title contract-attr-title">
<span class="dot"></span>
履行计划
</div>
<div class="plan-toolbar">
<a-button
type="dashed"
size="small"
@click="addPerformancePlan"
>
<PlusOutlined />
新增履行计划
</a-button>
</div>
<a-table
:data-source="detailState.performancePlans"
:pagination="false"
size="small"
:scroll="{ x: 820 }"
row-key="id"
class="plan-table"
>
<a-table-column title="履行事项" key="planMatter">
<template #default="{ record }">
<a-input v-model:value="record.planMatter" />
</template>
</a-table-column>
<a-table-column title="履约条件" key="planWhere">
<template #default="{ record }">
<a-input v-model:value="record.planWhere" />
</template>
</a-table-column>
<a-table-column title="节点日期" key="planTime" width="140">
<template #default="{ record }">
<a-date-picker
v-model:value="record.planTime"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column
title="提醒提前天数"
key="planDay"
width="130"
>
<template #default="{ record }">
<a-input-number
v-model:value="record.planDay"
:min="0"
:controls="false"
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="款项金额" key="planMoney" width="130">
<template #default="{ record }">
<a-input-number
v-model:value="record.planMoney"
:min="0"
:controls="false"
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="备注" key="planDetail">
<template #default="{ record }">
<a-input v-model:value="record.planDetail" />
</template>
</a-table-column>
<a-table-column
title="操作"
key="action"
width="80"
fixed="right"
>
<template #default="{ record }">
<a-button
type="link"
danger
size="small"
@click="removePerformancePlan(record.id)"
>
删除
</a-button>
</template>
</a-table-column>
<template #emptyText>
<div class="empty-plan-text">暂无数据</div>
</template>
</a-table>
</div>
</div>
</div>
</section>
<a-collapse class="detail-collapse">
<a-collapse-panel
v-for="party in displayedParties"
:key="party.roleKey"
:header="`${party.displayName}详细字段维护`"
>
<div class="field-grid field-grid-3">
<a-form-item class="tight-item" label="公司类型">
<a-select
v-model:value="party.companyType"
:options="companyTypeOptions"
/>
</a-form-item>
<a-form-item class="tight-item" label="公司性质">
<a-select
v-model:value="party.companyNature"
:options="companyNatureOptions"
/>
</a-form-item>
<a-form-item class="tight-item" label="组织性质">
<a-select
v-model:value="party.organizationNature"
:options="organizationNatureOptions"
/>
</a-form-item>
<a-form-item class="tight-item" label="组织性质企业二级">
<a-select
v-model:value="party.enterpriseNatureLevel2"
:options="enterpriseNatureLevel2Options"
/>
</a-form-item>
<a-form-item class="tight-item" label="统一社会信用代码">
<a-input v-model:value="party.creditCode" />
</a-form-item>
<a-form-item class="tight-item" label="TIN号">
<a-input v-model:value="party.tinNo" />
</a-form-item>
<a-form-item class="tight-item" label="国家/地区">
<a-select
v-model:value="party.countryCode"
:options="countryOptions"
/>
</a-form-item>
<a-form-item class="tight-item" label="法定代表人">
<a-input v-model:value="party.legalRepresentative" />
</a-form-item>
<a-form-item class="tight-item field-span-3" label="注册地址">
<a-input v-model:value="party.registeredAddress" />
</a-form-item>
<a-form-item class="tight-item" label="注册资本">
<a-input v-model:value="party.registeredCapital" />
</a-form-item>
<a-form-item class="tight-item" label="联系电话">
<a-input v-model:value="party.contactPhone" />
</a-form-item>
<a-form-item class="tight-item" label="电子邮箱">
<a-input v-model:value="party.contactEmail" />
</a-form-item>
<a-form-item class="tight-item field-span-3" label="通讯地址">
<a-input v-model:value="party.contactAddress" />
</a-form-item>
<a-form-item class="tight-item" label="开户行">
<a-input v-model:value="party.bankName" />
</a-form-item>
<a-form-item class="tight-item field-span-2" label="银行账号">
<a-input v-model:value="party.bankAccount" />
</a-form-item>
<a-form-item class="tight-item field-span-3" label="备选开票抬头">
<a-select
v-model:value="party.invoiceTitleOptions"
mode="tags"
:token-separators="[',', '']"
/>
</a-form-item>
</div>
</a-collapse-panel>
</a-collapse>
</a-form>
</div>
<div class="editor-footer">
<a-space>
<a-button v-if="!isViewMode" @click="resetCurrentState">重置</a-button>
<a-button @click="visible = false">{{ isViewMode ? "关闭" : "取消" }}</a-button>
<a-button v-if="!isViewMode" type="primary" @click="handleSave">保存</a-button>
</a-space>
</div>
</div>
<ContractTemplateFillDemoModal
ref="bodyConfigModalRef"
@save="handleBodyConfigSave"
/>
</a-modal>
</template>
<script setup>
import { computed, ref } from "vue";
import { message } from "ant-design-vue";
import { PlusOutlined, SearchOutlined } from "@ant-design/icons-vue";
import ContractTemplateFillDemoModal from "./ContractTemplateFillDemoModal.vue";
// ========================================================================
// 以下为表单模型 + 字典 + 工具函数,直接内联在此文件中。
// 接入后端时,保留这些函数,替换入参/出参为真实接口即可。
// ========================================================================
// ========== 合同方相关字典 ==========
const partyCharacterOptions = [
{ label: "企业", value: "QY" },
{ label: "自然人", value: "ZZR" },
];
const companyTypeOptions = [
{ label: "客户", value: "客户" },
{ label: "供应商", value: "供应商" },
{ label: "合作伙伴", value: "合作伙伴" },
{ label: "客户及供应商", value: "客户及供应商" },
{ label: "其他", value: "其他" },
];
const companyNatureOptions = [
{ label: "境内组织", value: "境内组织" },
{ label: "境内个人", value: "境内个人" },
{ label: "境外组织", value: "境外组织" },
{ label: "境外个人", value: "境外个人" },
];
const organizationNatureOptions = [
{ label: "政府", value: "政府" },
{ label: "企业", value: "企业" },
{ label: "其他组织", value: "其他组织" },
];
const enterpriseNatureLevel2Options = [
{ label: "国有企业", value: "国有企业" },
{ label: "民营企业", value: "民营企业" },
{ label: "外资企业", value: "外资企业" },
{ label: "合资企业", value: "合资企业" },
{ label: "国有全资和控股企业", value: "国有全资和控股企业" },
];
const countryOptions = [
{ label: "中国", value: "GJ156" },
{ label: "中国香港", value: "GJ344" },
{ label: "中国澳门", value: "GJ446" },
{ label: "中国台湾", value: "GJ158" },
{ label: "新加坡", value: "GJ702" },
{ label: "美国", value: "GJ840" },
{ label: "英国", value: "GJ826" },
{ label: "日本", value: "GJ392" },
];
const contractCompanyOptions = [
{ label: "扬州宏富特种材料有限公司", value: "扬州宏富特种材料有限公司" },
{ label: "宁波港船务货运代理有限公司", value: "宁波港船务货运代理有限公司" },
{ label: "宁波浙港供应链科技有限公司", value: "宁波浙港供应链科技有限公司" },
{ label: "上海远洋能源有限公司", value: "上海远洋能源有限公司" },
{ label: "宁波保税区港航设备有限公司", value: "宁波保税区港航设备有限公司" },
{ label: "浙江临港数智科技有限公司", value: "浙江临港数智科技有限公司" },
{ label: "杭州智云实施服务有限公司", value: "杭州智云实施服务有限公司" },
{ label: "浙港物流平台有限公司", value: "浙港物流平台有限公司" },
];
// ========== 合同相关字典 ==========
const contractTypeOptions = [
{ label: "采购合同", value: "采购合同" },
{ label: "销售合同", value: "销售合同" },
{ label: "服务合同", value: "服务合同" },
{ label: "框架合同", value: "框架合同" },
];
const valuationModeOptions = [
{ label: "无金额", value: "0" },
{ label: "固定总价", value: "1" },
{ label: "预估总价", value: "2" },
{ label: "无法预估总价", value: "3" },
{ label: "固定单价", value: "4" },
];
const currencyOptions = [
{ label: "人民币", value: "RMB" },
{ label: "美元", value: "USD" },
{ label: "港币", value: "HKD" },
{ label: "欧元", value: "EUR" },
{ label: "印尼盾", value: "IDR" },
];
const sealTypeOptions = [
{ label: "公章", value: "GZ" },
{ label: "合同专用章", value: "HTZYZ" },
{ label: "公章+法定代表人私章", value: "GZFDDBRSZ" },
{ label: "公章+法定代表人签字", value: "GZFDDBRQZ" },
{ label: "公章+被授权人签字", value: "GZBSQRQZ" },
{ label: "合同专用章+被授权人签字", value: "HTZYZBSQRQZ" },
{ label: "合同专用章+法定代表人签字", value: "HTZYZFDDBRQZ" },
{ label: "其他", value: "QT" },
];
const mandateTypeOptions = [
{ label: "海铁联运委托", value: "海铁联运委托" },
{ label: "一站式物流委托", value: "一站式物流委托" },
{ label: "采购执行委托", value: "采购执行委托" },
{ label: "销售执行委托", value: "销售执行委托" },
];
const cargoTypeOptions = [
{ label: "燃料油", value: "燃料油" },
{ label: "港口设备", value: "港口设备" },
{ label: "软件服务", value: "软件服务" },
{ label: "综合物流", value: "综合物流" },
];
const paymentMethodOptions = [
{
label: "银行转账(30%预付款,70%验收后支付)",
value: "银行转账(30%预付款,70%验收后支付)",
},
{ label: "银行转账(按月结算)", value: "银行转账(按月结算)" },
{ label: "银行转账(一次性结清)", value: "银行转账(一次性结清)" },
];
const extraAmountFieldOptions = [
{ key: "deposit", label: "定金" },
{ key: "pledge", label: "押金" },
{ key: "advance", label: "预付款" },
{ key: "guarantee", label: "保证金" },
];
const SELF_COMPANY_NAME = "浙港物流平台有限公司";
const MAX_COUNTERPARTY_COUNT = 2;
function getOptionLabel(options, value) {
return options.find((item) => item.value === value)?.label || value || "";
}
// ========== 工具函数 ==========
/** 深拷贝合同方状态 */
function copyPartyState(input) {
const source = input || {};
const sourcePartyA = source.partyA || source.parties?.A || {};
const sourcePartyB = source.partyB || source.parties?.B || {};
const sourcePartyC = source.partyC || source.parties?.C || {};
return {
isThreePartyContract: Boolean(source.isThreePartyContract),
partyA: {
roleKey: "A",
roleName: "我方",
displayName: "我方",
...sourcePartyA,
companyName: sourcePartyA.companyName || SELF_COMPANY_NAME,
},
partyB: {
roleKey: "B",
roleName: "相对方",
displayName: "相对方1",
companyName: "",
...sourcePartyB,
},
partyC: {
roleKey: "C",
roleName: "相对方",
displayName: "相对方2",
companyName: "",
...sourcePartyC,
},
};
}
function createEmptyCounterparty(roleKey) {
const blankState = copyPartyState({});
return roleKey === "C" ? blankState.partyC : blankState.partyB;
}
function isPartyFilled(party = {}) {
const ignoreKeys = new Set(["roleKey", "roleName", "displayName"]);
return Object.entries(party).some(([key, value]) => {
if (ignoreKeys.has(key)) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "number") return !Number.isNaN(value);
return String(value ?? "").trim() !== "";
});
}
function buildCounterpartyRoleKeys(partyState) {
const state = copyPartyState(partyState);
const keys = [];
if (isPartyFilled(state.partyB)) keys.push("B");
if (isPartyFilled(state.partyC)) keys.push("C");
return keys;
}
function normalizePartyState(partyState) {
const state = copyPartyState(partyState);
if (!isPartyFilled(state.partyB) && isPartyFilled(state.partyC)) {
state.partyB = {
...createEmptyCounterparty("B"),
...state.partyC,
roleKey: "B",
roleName: "相对方",
displayName: "相对方1",
};
state.partyC = createEmptyCounterparty("C");
}
state.partyA.companyName = state.partyA.companyName || SELF_COMPANY_NAME;
state.isThreePartyContract =
isPartyFilled(state.partyB) && isPartyFilled(state.partyC);
return state;
}
function getPartyByRoleKey(partyState, roleKey) {
if (roleKey === "A") return partyState.partyA;
if (roleKey === "C") return partyState.partyC;
return partyState.partyB;
}
/** 创建一条空的履行计划 */
function createPerformancePlan() {
return {
id: `plan_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
planMatter: "",
planWhere: "",
planTime: "",
planDay: 0,
planMoney: "",
planDetail: "",
};
}
/** 创建空白的合同表单数据 */
function createContractFormData() {
return {
contractName: "",
contractCode: "",
contractType: "",
contractTypeCode: "",
contractAmount: null,
effectiveStart: "",
effectiveEnd: "",
contractPeriodType: "0",
periodExplain: "",
paymentMethod: "",
valuationMode: "",
currencyName: "",
sealType: "",
mandateType: "",
cargoType: "",
primaryContent: "",
amountExplain: "",
assistDept: "",
executorAccount: "",
contractBodyFileName: "",
contractBodyArrayBuffer: null,
otherAttachmentList: [],
partyState: {
isThreePartyContract: false,
partyA: { companyName: SELF_COMPANY_NAME },
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,
},
performancePlans: [],
signDate: "",
};
}
/** 深拷贝合同表单数据 */
function copyContractFormData(data = {}) {
const form = createContractFormData();
const source = data || {};
Object.keys(form).forEach((key) => {
if (key === "extraAmounts") {
form.extraAmounts = {
...form.extraAmounts,
...(source.extraAmounts || {}),
};
} else if (key === "performancePlans") {
form.performancePlans = (source.performancePlans || []).map((item) => ({
...createPerformancePlan(),
...item,
}));
} else if (key === "partyState") {
form.partyState = normalizePartyState(source.partyState);
} else if (key === "otherAttachmentList") {
form.otherAttachmentList = [
...(source.otherAttachmentList || source.attachmentList || []),
];
} else if (key === "contractBodyArrayBuffer") {
form.contractBodyArrayBuffer = source.contractBodyArrayBuffer
? source.contractBodyArrayBuffer instanceof ArrayBuffer
? source.contractBodyArrayBuffer.slice(0)
: null
: null;
} else {
form[key] = source[key] ?? form[key];
}
});
return form;
}
const emit = defineEmits(["save"]);
const visible = ref(false);
const modalMode = ref("create");
const creationMode = ref("template");
const templateKey = ref("");
const templateName = ref("");
const detailState = ref(createContractFormData());
const otherAttachmentList = ref([]);
const bodyConfigModalRef = ref(null);
const counterpartyRoleKeys = ref([]);
const companyProfileMap = {
扬州宏富特种材料有限公司: {
contactPhone: "13800000001",
registeredAddress: "江苏省扬州市邗江区江阳中路88号",
bankAccount: "622202100000000001",
bankName: "中国工商银行扬州分行",
contactName: "王经理",
legalRepresentative: "张三",
creditCode: "91321000MA1X111111",
},
宁波港船务货运代理有限公司: {
contactPhone: "15757460062",
registeredAddress: "宁波市北仑区明州西路188号",
bankAccount: "39011600192100006088",
bankName: "中国工商银行股份有限公司宁波市支行",
contactName: "周经理",
legalRepresentative: "李四",
creditCode: "91330201MA2ABCDE9X",
},
宁波浙港供应链科技有限公司: {
contactPhone: "13900000088",
registeredAddress: "宁波市江北区长兴路689号",
bankAccount: "621700000000000088",
bankName: "中国银行宁波分行",
contactName: "陈经理",
legalRepresentative: "陈总",
creditCode: "91330200MA2SUPPLY01",
},
上海远洋能源有限公司: {
contactPhone: "13600000015",
registeredAddress: "上海市浦东新区临港大道188号",
bankAccount: "621483000000000015",
bankName: "招商银行上海分行",
contactName: "吴经理",
legalRepresentative: "孙先生",
creditCode: "91310115MAOCEAN001",
},
};
const isViewMode = computed(() => modalMode.value === "view");
const counterpartyParties = computed(() =>
counterpartyRoleKeys.value.map((roleKey) =>
getPartyByRoleKey(detailState.value.partyState, roleKey),
),
);
const displayedParties = computed(() => [
detailState.value.partyState.partyA,
...counterpartyParties.value,
]);
const counterpartySummaryText = computed(() =>
counterpartyParties.value.length
? counterpartyParties.value
.map((party) => party.companyName || party.displayName)
.join("")
: "未填写",
);
const contractBodyDisplayText = computed(() =>
detailState.value.contractBodyFileName ||
(creationMode.value === "manual"
? "点击上传合同正文 Word 文件"
: templateName.value || "点击配置合同正文"),
);
const contractBodyHint = computed(() =>
creationMode.value === "manual"
? "当前为手动创建,支持上传并预览 .docx 正文"
: templateName.value
? `当前模板:${templateName.value},点击继续维护模板信息和预览`
: "当前为模板创建,点击维护模板信息和正文预览",
);
function syncUploadLists() {
otherAttachmentList.value = (detailState.value.otherAttachmentList || []).map(
(item, index) => ({
uid: item.uid || `other_${index + 1}`,
name: item.name,
size: item.size,
status: item.status || "done",
}),
);
}
function syncCounterpartyState() {
detailState.value.partyState = normalizePartyState(detailState.value.partyState);
counterpartyRoleKeys.value = buildCounterpartyRoleKeys(
detailState.value.partyState,
);
detailState.value.partyState.isThreePartyContract =
counterpartyRoleKeys.value.length > 1;
refreshPartyDisplayMeta();
}
function refreshPartyDisplayMeta() {
detailState.value.partyState.partyA.roleKey = "A";
detailState.value.partyState.partyA.roleName = "我方";
detailState.value.partyState.partyA.displayName = "我方";
counterpartyRoleKeys.value.forEach((roleKey, index) => {
const party = getPartyByRoleKey(detailState.value.partyState, roleKey);
party.roleKey = roleKey;
party.roleName = "相对方";
party.displayName = `相对方${index + 1}`;
});
}
function resetCurrentState() {
creationMode.value = "template";
templateKey.value = "";
templateName.value = "";
detailState.value = createContractFormData();
syncCounterpartyState();
syncUploadLists();
}
function showSearchTip(title) {
message.info(
`${title}查询接口当前未接入,这里先保留与原型一致的搜索入口样式。`,
);
}
function showInvoiceTitles(party, roleName) {
const titles = party.invoiceTitleOptions || [];
message.info(
titles.length
? `${roleName}备选开票抬头:${titles.join("")}`
: `${roleName}暂无备选开票抬头`,
);
}
function handleCompanySelect(roleKey, companyName) {
const party =
roleKey === "A"
? detailState.value.partyState.partyA
: roleKey === "B"
? detailState.value.partyState.partyB
: detailState.value.partyState.partyC;
const profile = companyProfileMap[companyName];
if (!party || !profile) return;
Object.assign(party, {
companyName,
contactPhone: party.contactPhone || profile.contactPhone,
registeredAddress: party.registeredAddress || profile.registeredAddress,
bankAccount: party.bankAccount || profile.bankAccount,
bankName: party.bankName || profile.bankName,
contactName: party.contactName || profile.contactName,
legalRepresentative:
party.legalRepresentative || profile.legalRepresentative,
creditCode: party.creditCode || profile.creditCode,
});
}
function handleAddCounterparty() {
if (counterpartyRoleKeys.value.length >= MAX_COUNTERPARTY_COUNT) {
message.warning(`最多支持添加 ${MAX_COUNTERPARTY_COUNT} 个相对方`);
return;
}
const nextRoleKey = counterpartyRoleKeys.value.includes("B") ? "C" : "B";
detailState.value.partyState[nextRoleKey === "C" ? "partyC" : "partyB"] =
createEmptyCounterparty(nextRoleKey);
counterpartyRoleKeys.value = [...counterpartyRoleKeys.value, nextRoleKey];
detailState.value.partyState.isThreePartyContract =
counterpartyRoleKeys.value.length > 1;
refreshPartyDisplayMeta();
}
function handleRemoveCounterparty(roleKey) {
if (roleKey === "B" && counterpartyRoleKeys.value.includes("C")) {
detailState.value.partyState.partyB = {
...createEmptyCounterparty("B"),
...detailState.value.partyState.partyC,
roleKey: "B",
roleName: "相对方",
displayName: "相对方1",
};
detailState.value.partyState.partyC = createEmptyCounterparty("C");
counterpartyRoleKeys.value = ["B"];
} else {
detailState.value.partyState[roleKey === "C" ? "partyC" : "partyB"] =
createEmptyCounterparty(roleKey);
counterpartyRoleKeys.value = counterpartyRoleKeys.value.filter(
(key) => key !== roleKey,
);
}
detailState.value.partyState.isThreePartyContract =
counterpartyRoleKeys.value.length > 1;
refreshPartyDisplayMeta();
}
function handleOtherAttachmentUpload(file) {
const item = {
uid: `other_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
name: file.name,
size: file.size,
status: "done",
};
detailState.value.otherAttachmentList.push(item);
otherAttachmentList.value.push(item);
message.success(`已添加附件:${file.name}`);
return false;
}
function handleRemoveOtherAttachment(file) {
detailState.value.otherAttachmentList =
detailState.value.otherAttachmentList.filter(
(item) => item.uid !== file.uid && item.name !== file.name,
);
otherAttachmentList.value = otherAttachmentList.value.filter(
(item) => item.uid !== file.uid && item.name !== file.name,
);
}
function openBodyConfigModal() {
bodyConfigModalRef.value?.showModal(
{
detailState: copyContractFormData(detailState.value),
creationMode: creationMode.value,
templateKey: templateKey.value,
templateName: templateName.value,
},
isViewMode.value ? "view" : "edit",
);
}
function handleBodyConfigSave(payload = {}) {
creationMode.value = payload.creationMode || "template";
templateKey.value = payload.templateKey || "";
templateName.value = payload.templateName || "";
detailState.value = copyContractFormData(payload.detailState);
syncCounterpartyState();
syncUploadLists();
}
function addPerformancePlan() {
detailState.value.performancePlans.push(createPerformancePlan());
}
function removePerformancePlan(id) {
detailState.value.performancePlans =
detailState.value.performancePlans.filter((item) => item.id !== id);
}
function validateState() {
if (!detailState.value.partyState.partyA.companyName?.trim())
return "请确认我方信息";
if (!counterpartyRoleKeys.value.length) return "请至少添加一个相对方";
if (
counterpartyParties.value.some((party) => !party.companyName?.trim())
)
return "请选择相对方";
if (!detailState.value.contractName?.trim()) return "请填写合同名称";
if (!detailState.value.contractCode?.trim()) return "请填写合同号";
if (!detailState.value.contractType) return "请选择合同类型";
if (
detailState.value.contractPeriodType === "0" &&
(!detailState.value.effectiveStart || !detailState.value.effectiveEnd)
)
return "请选择固定期限起止日期";
if (
detailState.value.contractPeriodType === "1" &&
!detailState.value.periodExplain?.trim()
)
return "请填写期限说明";
if (!detailState.value.valuationMode) return "请选择计价方式";
if (!detailState.value.currencyName) return "请选择币种";
if (!detailState.value.sealType) return "请选择签订方式";
if (!detailState.value.mandateType) return "请选择委托类型";
if (!detailState.value.cargoType) return "请选择货种";
if (!detailState.value.contractTypeCode) return "请选择合同属性中的合同类型";
if (!detailState.value.executorAccount?.trim()) return "请填写合同执行人";
return "";
}
function handleSave() {
const errorMessage = validateState();
if (errorMessage) {
message.warning(errorMessage);
return;
}
syncCounterpartyState();
detailState.value.contractAmount =
detailState.value.contractAmount === null ||
detailState.value.contractAmount === undefined ||
detailState.value.contractAmount === ""
? null
: Number(detailState.value.contractAmount);
emit("save", {
detailState: copyContractFormData(detailState.value),
creationMode: creationMode.value,
templateKey: templateKey.value,
templateName: templateName.value,
});
visible.value = false;
message.success("合同方信息已保存");
}
function showModal(context = {}, mode = "create") {
modalMode.value = mode || "create";
const source = context.detailState ||
(Object.keys(context || {}).length ? context : createContractFormData());
creationMode.value =
context.creationMode ||
(source.contractBodyArrayBuffer ? "manual" : "template");
templateKey.value = context.templateKey || "";
templateName.value = context.templateName || "";
detailState.value = copyContractFormData(source);
syncCounterpartyState();
syncUploadLists();
visible.value = true;
}
defineExpose({
showModal,
});
</script>
<style scoped>
.contract-main-editor {
display: flex;
flex-direction: column;
height: 100%;
background: #f6f8fb;
}
.editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 24px 14px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.editor-header h3 {
font-size: 20px;
font-weight: 700;
color: #0f172a;
margin-bottom: 6px;
}
.editor-body {
flex: 1;
overflow-y: auto;
padding: 18px 20px;
}
.editor-form {
height: 100%;
}
.main-grid {
display: grid;
grid-template-columns: 470px minmax(0, 1fr);
gap: 18px;
}
.section-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px 18px;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.counterparty-toolbar {
margin-top: 18px;
margin-bottom: 10px;
}
.section-title,
.basic-title {
font-size: 16px;
font-weight: 700;
color: #111827;
}
.basic-header {
border-bottom: 1px solid #eef2f7;
padding-bottom: 10px;
margin-bottom: 14px;
}
.basic-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
background: #2563eb;
border-radius: 2px;
}
.party-block + .party-block {
margin-top: 14px;
}
.party-block-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.field-label {
font-size: 13px;
color: #334155;
margin-bottom: 6px;
}
.required::before {
content: "*";
color: #ef4444;
margin-right: 4px;
}
.search-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
gap: 6px;
margin-bottom: 8px;
}
.icon-btn,
.mini-search-btn {
display: inline-flex;
align-items: center;
justify-content: center;
color: #2563eb;
}
.text-action-btn {
padding-inline: 0;
}
.party-info-box {
position: relative;
background: #fafbff;
border: 1px solid #edf2f7;
border-radius: 6px;
padding: 10px 38px 10px 12px;
margin-bottom: 8px;
}
.info-line {
display: flex;
gap: 10px;
font-size: 12px;
line-height: 1.8;
}
.info-line span {
width: 62px;
flex-shrink: 0;
color: #475569;
}
.info-line strong {
color: #0f172a;
font-weight: 500;
}
.mini-search-btn {
position: absolute;
top: 8px;
right: 8px;
}
.invoice-btn {
width: 100%;
justify-content: flex-start;
}
.party-empty-state {
padding: 10px 12px;
border: 1px dashed #dbe3ee;
border-radius: 8px;
background: #f8fbff;
color: #64748b;
font-size: 13px;
}
.field-grid {
display: grid;
gap: 10px 14px;
}
.field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.field-span-2 {
grid-column: span 2;
}
.field-span-3 {
grid-column: span 3;
}
.tight-item {
margin-bottom: 4px;
}
.period-block {
margin-top: 10px;
margin-bottom: 12px;
}
.period-row {
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.period-explain-input {
margin-top: 8px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
margin-bottom: 16px;
}
.summary-line {
display: flex;
gap: 10px;
font-size: 13px;
}
.summary-line span {
width: 62px;
flex-shrink: 0;
color: #475569;
}
.summary-line strong {
color: #0f172a;
font-weight: 600;
}
.upload-block {
margin-bottom: 14px;
}
.upload-label {
font-size: 13px;
color: #334155;
margin-bottom: 6px;
}
.readonly-upload-box {
min-height: 44px;
display: flex;
align-items: flex-start;
justify-content: center;
flex-direction: column;
padding: 10px 12px;
border: 1px dashed #dbe3ee;
border-radius: 8px;
background: #f8fbff;
color: #475569;
font-size: 13px;
}
.contract-body-entry {
gap: 4px;
transition: all 0.2s ease;
}
.contract-body-entry.is-clickable {
cursor: pointer;
}
.contract-body-entry.is-clickable:hover {
border-color: #91caff;
background: #eef6ff;
}
.contract-body-entry-name {
color: #0f172a;
font-weight: 600;
word-break: break-all;
}
.contract-body-entry-hint {
color: #64748b;
line-height: 1.6;
}
.other-upload-text {
font-size: 13px;
color: #475569;
}
.contract-attr-title {
margin-top: 14px;
margin-bottom: 10px;
}
.boolean-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
margin-top: 6px;
}
.boolean-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 32px;
font-size: 13px;
color: #334155;
}
.extra-amount-section {
margin-top: 16px;
}
.extra-label {
font-size: 13px;
font-weight: 600;
color: #334155;
margin-bottom: 8px;
}
.extra-check-row,
.extra-input-row {
display: flex;
justify-content: space-between;
gap: 10px;
}
.extra-check-item {
width: 24%;
font-size: 13px;
}
.plan-section {
margin-top: 18px;
}
.plan-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.plan-table :deep(.ant-table-cell) {
vertical-align: top;
}
.empty-plan-text {
padding: 12px 0;
color: #94a3b8;
}
.detail-collapse {
margin-top: 16px;
background: transparent;
}
.editor-footer {
display: flex;
justify-content: flex-end;
padding: 14px 20px 18px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
@media (max-width: 1280px) {
.main-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.field-grid-2,
.field-grid-3,
.summary-grid,
.boolean-grid {
grid-template-columns: 1fr;
}
.field-span-2,
.field-span-3 {
grid-column: span 1;
}
.extra-check-row,
.extra-input-row {
flex-direction: column;
}
.extra-check-item,
.extra-input-row :deep(.ant-input-number) {
width: 100% !important;
}
}
</style>