首次提交

This commit is contained in:
2026-05-26 10:10:32 +08:00
parent 02e1486d39
commit a6f7ad12da
45 changed files with 27435 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
/**
* 合同发起模块 - API 层
*
* 当前使用 mock 数据模拟接口返回。
* 【接入后端】:直接替换每个函数内部实现为 axios 请求即可,函数签名不改。
*/
// ========== 合同发起列表 mock 数据 ==========
const mockLaunchRecords = [
{
id: 'INIT-202605-001',
// 合同编码
contractCode: 'HT-2026-0008',
// 合同名称
contractName: '智慧园区设备采购合同',
// 合同类型
contractType: '采购合同',
// 合同金额(元)
contractAmount: 368000,
// 有效期起 YYYY-MM-DD
effectiveStart: '2026-05-10',
// 有效期止 YYYY-MM-DD
effectiveEnd: '2027-05-09',
// 付款方式
paymentMethod: '银行转账(30%预付款,70%验收后支付)',
// 所选模板标识
templateKey: 'purchase',
// 所选模板名称
templateName: '采购合同模板',
// 合同标的
contractSubject: '园区门禁一体机、访客终端及部署实施服务',
// 业务备注
businessRemark: '首次发起,等待业务确认后提交法务。',
// 发起人
initiator: '张晓明',
// 所属部门
initiatorDept: '供应链运营部',
// 发起人公司
companyName: '浙港物流平台有限公司',
// 状态: draft/reviewing/rejected/pending_sign/signing/completed
status: 'draft',
// 查询级别: personal/company/admin
queryScope: 'personal',
// 创建时间
createTime: '2026-05-10 09:20:00',
// 更新时间
updateTime: '2026-05-13 14:30:00',
// 附件列表
attachmentList: [
{ uid: 'a1', name: '技术方案V1.pdf', size: 283011, status: 'done' },
{ uid: 'a2', name: '补充协议草案.docx', size: 125880, status: 'done' },
],
// 合同方信息(详见下方 partyState 结构)
partyState: {
isThreePartyContract: false,
partyA: {
companyName: '浙港物流平台有限公司',
contactPhone: '',
registeredAddress: '',
bankAccount: '',
bankName: '',
contactName: '',
legalRepresentative: '',
creditCode: '',
},
partyB: {
companyName: '宁波港船务货运代理有限公司',
companyType: '供应商',
contactPhone: '15757460062',
registeredAddress: '',
bankAccount: '',
bankName: '',
contactName: '周经理',
legalRepresentative: '',
creditCode: '',
},
partyC: { companyName: '' },
},
},
{
id: 'INIT-202605-002',
contractCode: 'XS-2026-0015',
contractName: '保税燃料油销售合同',
contractType: '销售合同',
contractAmount: 1580000,
effectiveStart: '2026-05-01',
effectiveEnd: '2026-12-31',
paymentMethod: '银行转账(按月结算)',
templateKey: 'sales',
templateName: '销售合同模板',
contractSubject: '保税燃料油销售及船舶供油服务',
businessRemark: '已提交法务,等待审批中。',
initiator: '王倩',
initiatorDept: '国际贸易事业部',
companyName: '浙港物流平台有限公司',
status: 'reviewing',
queryScope: 'company',
createTime: '2026-05-08 16:15:00',
updateTime: '2026-05-14 08:30:00',
attachmentList: [
{ uid: 'a3', name: '报价确认函.pdf', size: 90211, status: 'done' },
],
partyState: {
isThreePartyContract: false,
partyA: {
companyName: '宁波浙港供应链科技有限公司',
contactPhone: '13900000088',
contactName: '陈经理',
},
partyB: {
companyName: '上海远洋能源有限公司',
companyType: '客户',
legalRepresentative: '孙先生',
contactName: '吴经理',
contactPhone: '13600000015',
contactEmail: 'sales@oceanenergy.com',
bankName: '招商银行上海分行',
bankAccount: '621483000000000015',
},
partyC: { companyName: '' },
},
},
{
id: 'INIT-202605-003',
contractCode: 'FW-2026-0021',
contractName: '数字化平台运维服务合同',
contractType: '服务合同',
contractAmount: 520000,
effectiveStart: '2026-04-15',
effectiveEnd: '2027-04-14',
paymentMethod: '银行转账(一次性结清)',
templateKey: 'purchase',
templateName: '采购合同模板',
contractSubject: '合同管理板块运维与驻场实施服务',
businessRemark: '法务要求补充服务SLA附件后重新提交。',
initiator: '李娜',
initiatorDept: '数字化办公室',
companyName: '浙港物流平台有限公司',
status: 'rejected',
queryScope: 'company',
createTime: '2026-04-15 10:25:00',
updateTime: '2026-05-07 18:05:00',
attachmentList: [
{ uid: 'a4', name: '实施范围说明.pdf', size: 70123, status: 'done' },
],
partyState: {
isThreePartyContract: false,
partyA: { companyName: '浙港物流平台有限公司' },
partyB: {
companyName: '杭州智云实施服务有限公司',
companyType: '供应商',
contactName: '刘经理',
contactPhone: '13888886666',
contactEmail: 'liu@cloudservice.com',
},
partyC: { companyName: '' },
},
},
{
id: 'INIT-202605-004',
contractCode: 'KJ-2026-0011',
contractName: '港口智能设备年度框架合同',
contractType: '框架合同',
contractAmount: 980000,
effectiveStart: '2026-03-01',
effectiveEnd: '2027-02-28',
paymentMethod: '银行转账(按月结算)',
templateKey: 'purchase',
templateName: '采购合同模板',
contractSubject: '港口智能硬件、集成服务及年度维保合作',
businessRemark: '审批完成,待发起线上签署。',
initiator: '周立',
initiatorDept: '装备采购中心',
companyName: '浙港物流平台有限公司',
status: 'pending_sign',
queryScope: 'admin',
createTime: '2026-03-01 11:12:00',
updateTime: '2026-05-11 09:45:00',
attachmentList: [
{ uid: 'a5', name: '年度采购清单.xlsx', size: 154933, status: 'done' },
],
partyState: {
isThreePartyContract: true,
partyA: { companyName: '浙港物流平台有限公司' },
partyB: {
companyName: '宁波保税区港航设备有限公司',
companyType: '合作伙伴',
contactName: '赵主管',
contactPhone: '13500000021',
},
partyC: {
companyName: '浙江临港数智科技有限公司',
companyType: '合作伙伴',
creditCode: '91330000MAABCDE123',
legalRepresentative: '钱先生',
registeredAddress: '浙江省杭州市滨江区江南大道2888号',
contactName: '何经理',
contactPhone: '13700000078',
contactAddress: '浙江省杭州市滨江区江南大道2888号',
contactEmail: 'hz@example.com',
},
},
},
{
id: 'INIT-202605-005',
contractCode: 'XS-2026-0022',
contractName: '沿海航线供应服务合同',
contractType: '销售合同',
contractAmount: 760000,
effectiveStart: '2026-02-18',
effectiveEnd: '2026-11-30',
paymentMethod: '银行转账(按月结算)',
templateKey: 'sales',
templateName: '销售合同模板',
contractSubject: '沿海航线供应链服务与结算支持',
businessRemark: '甲方已签章,等待乙方签署。',
initiator: '徐晨',
initiatorDept: '客户服务中心',
companyName: '浙港物流平台有限公司',
status: 'signing',
queryScope: 'personal',
createTime: '2026-02-18 13:40:00',
updateTime: '2026-05-12 17:16:00',
attachmentList: [],
partyState: {
isThreePartyContract: false,
partyA: {
companyName: '宁波浙港供应链科技有限公司',
contactPhone: '13900000088',
contactName: '陈经理',
},
partyB: {
companyName: '上海远洋能源有限公司',
companyType: '客户',
legalRepresentative: '孙先生',
contactName: '吴经理',
contactPhone: '13600000015',
contactEmail: 'sales@oceanenergy.com',
bankName: '招商银行上海分行',
bankAccount: '621483000000000015',
},
partyC: { companyName: '' },
},
},
{
id: 'INIT-202605-006',
contractCode: 'CG-2026-0033',
contractName: '港区安防升级采购合同',
contractType: '采购合同',
contractAmount: 2860000,
effectiveStart: '2026-01-10',
effectiveEnd: '2026-12-31',
paymentMethod: '银行转账(30%预付款,70%验收后支付)',
templateKey: 'purchase',
templateName: '采购合同模板',
contractSubject: '港区安防摄像头、门禁和施工实施采购',
businessRemark: '合同已签署完毕并推送法务归档。',
initiator: '高翔',
initiatorDept: '工程保障部',
companyName: '浙港物流平台有限公司',
status: 'completed',
queryScope: 'admin',
createTime: '2026-01-10 08:45:00',
updateTime: '2026-05-09 16:28:00',
attachmentList: [
{ uid: 'a6', name: '验收报告.pdf', size: 188221, status: 'done' },
],
partyState: {
isThreePartyContract: false,
partyA: { companyName: '浙港物流平台有限公司' },
partyB: {
companyName: '宁波港船务货运代理有限公司',
companyType: '供应商',
contactPhone: '15757460062',
contactName: '周经理',
},
partyC: { companyName: '' },
},
},
]
// ========== API 函数(接入后端时替换内部实现) ==========
/** 查询合同发起列表 */
export function queryLaunchList() {
return Promise.resolve(mockLaunchRecords)
}
/** 查询合同发起详情 */
export function getLaunchDetail(id) {
const record = mockLaunchRecords.find((item) => item.id === id) || null
return Promise.resolve(record)
}
/** 保存合同发起 */
export function saveLaunchRecord(record) {
return Promise.resolve(record)
}
/** 提交合同到法务 */
export function submitLaunchRecord(record) {
return Promise.resolve(record)
}
/** 删除合同发起记录 */
export function deleteLaunchRecord(idList) {
return Promise.resolve(idList)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
<template>
<BaseMixedSignModal ref="innerRef" />
</template>
<script setup>
import { ref } from 'vue'
import BaseMixedSignModal from '../../../seal/mixedSignModal.vue'
const innerRef = ref(null)
function showModal(...args) {
return innerRef.value?.showModal(...args)
}
defineExpose({
showModal,
})
</script>
+577
View File
@@ -0,0 +1,577 @@
<template>
<section class="section-container">
<a-card
:bordered="false"
class="query-card"
:body-style="queryCardBodyStyle"
>
<div ref="queryContainer" class="section-query-container">
<a-form ref="formRef" :model="queryParam" layout="inline">
<a-row :gutter="16" style="width: 100%">
<a-col :md="6" :sm="24">
<a-form-item label="合同名称">
<a-input
size="small"
v-model:value="queryParam.contractName"
allow-clear
placeholder="请输入合同名称"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="合同编码">
<a-input
size="small"
v-model:value="queryParam.contractCode"
allow-clear
placeholder="请输入合同编码"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="当前状态">
<a-select
v-model:value="queryParam.status"
allow-clear
placeholder="请选择状态"
:options="contractRecordStatusOptions"
/>
</a-form-item>
</a-col>
<a-col :md="12" :sm="24">
<a-form-item label="发起日期">
<a-range-picker
v-model:value="queryParam.dateRange"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-card>
<a-card
:bordered="false"
class="table-card"
:body-style="tableCardBodyStyle"
>
<vxe-toolbar ref="toolbarRef" custom>
<template #buttons>
<a-space wrap>
<a-button
type="primary"
:icon="h(SearchOutlined)"
@click="handleQuery"
>
查询
</a-button>
<a-button :icon="h(RedoOutlined)" @click="handleReset">
重置
</a-button>
<a-button
type="primary"
ghost
:icon="h(PlusOutlined)"
@click="handleCreate"
>
发起合同
</a-button>
<a-button
danger
:icon="h(DeleteOutlined)"
:disabled="!selectedRowKeys.length"
@click="handleBatchDelete"
>
删除
</a-button>
</a-space>
</template>
</vxe-toolbar>
<vxe-table
ref="tableRef"
border
:height="tableHeight"
:row-config="{ isHover: true, height: 44 }"
:column-config="{ resizable: true, minWidth: 120 }"
:custom-config="{ storage: true }"
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
:checkbox-config="{ range: true }"
:data="tableData"
:loading="loading"
:loading-config="{
icon: 'vxe-icon-indicator roll',
text: '正在拼命加载中...',
}"
@checkbox-range-change="selectChangeEvent"
@checkbox-change="selectChangeEvent"
@checkbox-all="selectChangeEvent"
>
<vxe-column type="checkbox" width="56" />
<vxe-column type="seq" title="序号" width="68" />
<vxe-column
field="contractCode"
title="合同编码"
width="150"
show-overflow="title"
/>
<vxe-column
field="contractName"
title="合同名称"
width="220"
show-overflow="title"
/>
<vxe-column
field="contractType"
title="合同类型"
width="110"
show-overflow="title"
/>
<vxe-column
field="templateName"
title="所选模板"
width="150"
show-overflow="title"
/>
<vxe-column
field="counterpartySummary"
title="合同相对方"
width="260"
show-overflow="title"
/>
<vxe-column
field="status"
title="状态"
width="120"
show-overflow="title"
>
<template #default="{ row }">
<a-tag :color="getStatusColor(row.status)">
{{ getStatusLabel(row.status) }}
</a-tag>
</template>
</vxe-column>
<vxe-column
field="initiator"
title="发起人"
width="100"
show-overflow="title"
/>
<vxe-column
field="initiatorDept"
title="所属部门"
width="140"
show-overflow="title"
/>
<vxe-column
field="createTime"
title="发起时间"
width="170"
show-overflow="title"
/>
<vxe-column
field="updateTime"
title="最后更新时间"
width="170"
show-overflow="title"
/>
<vxe-column title="操作" width="260" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<a @click="handleView(row)">查看</a>
<a v-if="canEdit(row)" @click="handleEdit(row)">编辑</a>
<a v-if="canOnlineSign(row)" @click="handleOnlineSign(row)">
{{ row.status === "completed" ? "查看签署" : "在线签订" }}
</a>
<a v-if="canDelete(row)" @click="handleDelete(row)">删除</a>
</div>
</template>
</vxe-column>
</vxe-table>
<a-pagination
id="pageWrapper"
ref="pageWrapperRef"
v-model:pageSize="queryParam.pageSize"
v-model:current="queryParam.pageNo"
class="table-pagination"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
:total="total"
:show-total="(value) => `${value}`"
@change="onShowSizeChange"
@show-size-change="onShowSizeChange"
/>
</a-card>
<ContractPartyPlaceholderModal ref="contractModalRef" />
<MixedSignModal ref="mixedSignModalRef" />
</section>
</template>
<script setup>
import { h, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import {
DeleteOutlined,
PlusOutlined,
RedoOutlined,
SearchOutlined,
} from "@ant-design/icons-vue";
import { message, Modal } from "ant-design-vue";
import ContractPartyPlaceholderModal from "./components/ContractPartyPlaceholderModal.vue";
import MixedSignModal from "./components/mixedSignModal.vue";
import { deleteLaunchRecord, queryLaunchList } from "./api.js";
// 页面内字典(后续可由后端接口替换)
const contractRecordStatusOptions = [
{ label: "草稿", value: "draft" },
{ label: "审批中", value: "reviewing" },
{ label: "审批驳回", value: "rejected" },
{ label: "待签署", value: "pending_sign" },
{ label: "签署中", value: "signing" },
{ label: "已完成", value: "completed" },
]
const formRef = ref(null);
const queryContainer = ref(null);
const pageWrapperRef = ref(null);
const tableRef = ref(null);
const toolbarRef = ref(null);
const contractModalRef = ref(null);
const mixedSignModalRef = ref(null);
const total = ref(0);
const loading = ref(false);
const tableHeight = ref(420);
const tableData = ref([]);
const selectedRows = ref([]);
const selectedRowKeys = ref([]);
const allRecords = ref([]);
const queryParam = ref({
pageNo: 1,
pageSize: 10,
contractName: undefined,
contractCode: undefined,
contractType: undefined,
status: undefined,
queryScope: undefined,
templateKey: undefined,
dateRange: [],
});
const queryCardBodyStyle = {
padding: "20px 24px 6px",
};
const tableCardBodyStyle = {
display: "flex",
flexFlow: "column nowrap",
minHeight: "0",
padding: "8px 12px 6px",
};
const templateQueryOptions = [
{ label: "采购合同模板", value: "purchase" },
{ label: "销售合同模板", value: "sales" },
];
const statusLabelMap = {
draft: "草稿",
reviewing: "审批中",
rejected: "审批驳回",
pending_sign: "待签署",
signing: "签署中",
completed: "已完成",
};
const statusColorMap = {
draft: "default",
reviewing: "processing",
rejected: "error",
pending_sign: "warning",
signing: "purple",
completed: "success",
};
const scopeLabelMap = {
personal: "个人级",
company: "公司级",
admin: "管理员级",
};
function connectToolbar() {
const table = tableRef.value;
const toolbar = toolbarRef.value;
if (table && toolbar && table.connect) {
table.connect(toolbar);
}
}
function getStatusLabel(status) {
return statusLabelMap[status] || status;
}
function getStatusColor(status) {
return statusColorMap[status] || "default";
}
function getScopeLabel(scope) {
return scopeLabelMap[scope] || scope;
}
function canEdit(row) {
return ["draft", "rejected"].includes(row.status);
}
function canDelete(row) {
return ["draft", "rejected"].includes(row.status);
}
function canOnlineSign(row) {
return ["pending_sign", "signing", "completed"].includes(row.status);
}
function selectChangeEvent({ records }) {
selectedRows.value = records;
selectedRowKeys.value = records.map((item) => item.id);
}
function filterRecords(records) {
return records.filter((item) => {
const matchName =
!queryParam.value.contractName ||
item.contractName.includes(queryParam.value.contractName);
const matchCode =
!queryParam.value.contractCode ||
item.contractCode.includes(queryParam.value.contractCode);
const matchType =
!queryParam.value.contractType ||
item.contractType === queryParam.value.contractType;
const matchStatus =
!queryParam.value.status || item.status === queryParam.value.status;
const matchScope =
!queryParam.value.queryScope ||
item.queryScope === queryParam.value.queryScope;
const matchTemplate =
!queryParam.value.templateKey ||
item.templateKey === queryParam.value.templateKey;
const matchDate = (() => {
const range = queryParam.value.dateRange || [];
if (!range.length) {
return true;
}
const day = item.createTime.slice(0, 10);
return day >= range[0] && day <= range[1];
})();
return (
matchName &&
matchCode &&
matchType &&
matchStatus &&
matchScope &&
matchTemplate &&
matchDate
);
});
}
function getList() {
loading.value = true;
queryLaunchList().then((records) => {
// TODO: 这里后续直接使用合同发起列表接口返回值
allRecords.value = records;
const filtered = filterRecords(allRecords.value);
const start = (queryParam.value.pageNo - 1) * queryParam.value.pageSize;
const end = start + queryParam.value.pageSize;
total.value = filtered.length;
tableData.value = filtered.slice(start, end);
loading.value = false;
nextTick(() => {
connectToolbar();
syncTableHeight();
});
});
}
function handleQuery() {
queryParam.value.pageNo = 1;
getList();
}
function handleReset() {
formRef.value?.resetFields?.();
Object.assign(queryParam.value, {
pageNo: 1,
pageSize: 10,
contractName: undefined,
contractCode: undefined,
contractType: undefined,
status: undefined,
queryScope: undefined,
templateKey: undefined,
dateRange: [],
});
getList();
}
function onShowSizeChange(current, pageSize) {
queryParam.value.pageNo = current;
queryParam.value.pageSize = pageSize;
getList();
}
function handleCreate() {
contractModalRef.value?.showModal({}, "create");
}
function handleView(row) {
contractModalRef.value?.showModal(row, "view");
}
function handleEdit(row) {
contractModalRef.value?.showModal(row, "edit");
}
function handleOnlineSign(row) {
if (!row.sealContractId && !row.signContractId && !row.contractId) {
message.info(
"当前未接入签署详情接口将进入在线签署弹窗并保留合同业务信息你可以继续上传文件后发起签署",
);
}
mixedSignModalRef.value?.showModal(row);
}
function deleteRowsByIds(ids) {
// TODO: 这里后续替换为删除接口后再刷新列表
allRecords.value = allRecords.value.filter((item) => !ids.includes(item.id));
selectedRows.value = [];
selectedRowKeys.value = [];
getList();
}
function handleDelete(row) {
Modal.confirm({
title: `确认删除合同【${row.contractName}】吗?`,
content: "当前仅删除本地 mock 数据便于后续替换真实接口",
okText: "删除",
cancelText: "取消",
okType: "danger",
onOk: () => {
deleteLaunchRecord([row.id]).then(() => {
deleteRowsByIds([row.id]);
message.success("已删除发起记录");
});
},
});
}
function handleBatchDelete() {
const undeletable = selectedRows.value.filter((item) => !canDelete(item));
if (undeletable.length) {
message.warning("仅草稿或审批驳回状态支持删除请重新选择");
return;
}
Modal.confirm({
title: `确认删除选中的 ${selectedRowKeys.value.length} 条记录吗?`,
content: "当前仅演示本地数据删除后续接入真实接口即可替换",
okText: "删除",
cancelText: "取消",
okType: "danger",
onOk: () => {
deleteLaunchRecord(selectedRowKeys.value).then(() => {
deleteRowsByIds(selectedRowKeys.value);
message.success("批量删除成功");
});
},
});
}
function syncTableHeight() {
nextTick(() => {
const viewportHeight = window.innerHeight;
const queryHeight = queryContainer.value?.offsetHeight || 0;
const pageHeight = pageWrapperRef.value?.$el?.offsetHeight || 56;
tableHeight.value = Math.max(
320,
viewportHeight - queryHeight - pageHeight - 320,
);
});
}
onMounted(() => {
handleQuery();
nextTick(() => {
connectToolbar();
syncTableHeight();
});
window.addEventListener("resize", syncTableHeight);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", syncTableHeight);
});
</script>
<style scoped>
.section-container {
height: calc(100vh - 116px);
display: flex;
flex-direction: column;
gap: 12px;
}
.query-card {
flex: 0 0 auto;
}
.table-card {
flex: 1 1 auto;
min-height: 0;
}
.section-query-container :deep(.ant-form-item) {
display: flex;
width: 100%;
margin-bottom: 14px;
}
.section-query-container :deep(.ant-form-item .ant-form-item-control) {
flex: 1;
}
.table-actions {
display: flex;
align-items: center;
gap: 12px;
}
.table-pagination {
display: flex;
justify-content: flex-end;
padding: 12px 4px 2px;
}
@media (max-width: 960px) {
.section-container {
height: auto;
}
}
</style>
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
<template>
<section class="section-container">
<a-card
:bordered="false"
class="query-card"
:body-style="queryCardBodyStyle"
>
<div ref="queryContainer" class="section-query-container">
<a-form
ref="formRef"
:model="queryParam"
layout="inline"
>
<a-row :gutter="16" style="width: 100%">
<a-col :md="8" :sm="24">
<a-form-item label="模板名称">
<a-input
v-model:value="queryParam.templateName"
allow-clear
placeholder="请输入模板名称"
/>
</a-form-item>
</a-col>
<a-col :md="8" :sm="24">
<a-form-item label="合同类型">
<a-select
v-model:value="queryParam.contractType"
allow-clear
placeholder="请选择合同类型"
:options="contractTypeOptions"
/>
</a-form-item>
</a-col>
<a-col :md="8" :sm="24">
<a-form-item label="模板状态">
<a-select
v-model:value="queryParam.status"
allow-clear
placeholder="请选择模板状态"
:options="templateStatusOptions"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-card>
<a-card
:bordered="false"
class="table-card"
:body-style="tableCardBodyStyle"
>
<vxe-toolbar ref="toolbarRef" custom>
<template #buttons>
<a-space wrap>
<a-button type="primary" :icon="h(SearchOutlined)" @click="handleQuery">
查询
</a-button>
<a-button :icon="h(RedoOutlined)" @click="handleReset">
重置
</a-button>
<a-button type="primary" ghost :icon="h(PlusOutlined)" @click="handleCreate">
新增模板
</a-button>
</a-space>
</template>
</vxe-toolbar>
<vxe-table
ref="tableRef"
border
:height="tableHeight"
:row-config="{ isHover: true, height: 44 }"
:column-config="{ resizable: true, minWidth: 120 }"
:custom-config="{ storage: true }"
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
:data="tableData"
:loading="loading"
:loading-config="{ icon: 'vxe-icon-indicator roll', text: '正在加载模板数据...' }"
>
<vxe-column type="seq" title="序号" width="68" />
<vxe-column field="templateCode" title="模板编码" width="150" show-overflow="title" />
<vxe-column field="templateName" title="模板名称" width="180" show-overflow="title" />
<vxe-column field="contractType" title="合同类型" width="120" show-overflow="title" />
<vxe-column field="source" title="模板来源" width="110" show-overflow="title" />
<vxe-column field="version" title="版本" width="90" />
<vxe-column field="status" title="状态" width="110">
<template #default="{ row }">
<a-tag :color="getStatusColor(row.status)">
{{ getStatusLabel(row.status) }}
</a-tag>
</template>
</vxe-column>
<vxe-column field="variableCount" title="变量数" width="90" />
<vxe-column field="publishScopeText" title="发布范围" width="220" show-overflow="title" />
<vxe-column field="templateFileName" title="模板文件" width="220" show-overflow="title" />
<vxe-column field="updatedBy" title="最后修改人" width="120" show-overflow="title" />
<vxe-column field="updatedTime" title="最后修改时间" width="170" show-overflow="title" />
<vxe-column field="description" title="说明" width="260" show-overflow="title" />
<vxe-column title="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<a @click="handleView(row)">查看</a>
<a @click="handleEdit(row)">编辑</a>
<a @click="toggleStatus(row)">
{{ row.status === 'published' ? '停用' : '发布' }}
</a>
</div>
</template>
</vxe-column>
</vxe-table>
<a-pagination
ref="pageWrapperRef"
v-model:pageSize="queryParam.pageSize"
v-model:current="queryParam.pageNo"
class="table-pagination"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
:total="total"
:show-total="(value) => `${value}`"
@change="onShowSizeChange"
@show-size-change="onShowSizeChange"
/>
</a-card>
<ContractTemplateManageModal
ref="templateModalRef"
@submit="handleTemplateSubmit"
/>
</section>
</template>
<script setup>
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { PlusOutlined, RedoOutlined, SearchOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import ContractTemplateManageModal from './components/ContractTemplateManageModal.vue'
import {
changeManifestStatus,
getManifestDetail,
queryManifestList,
saveManifest,
} from '@/api/manifest.js'
function isSuccessResponse(res) {
return String(res?.code || '') === '0000'
}
function getPageResult(res) {
return res?.result || {}
}
function getDataResult(res) {
return res?.result || null
}
function getResponseMessage(res, fallback = '操作失败') {
return res?.message || res?.msg || fallback
}
// 页面内字典(后续可由后端接口替换)
const contractTypeOptions = [
{ label: '买卖合同', value: '买卖合同' },
{ label: '采购合同', value: '采购合同' },
{ label: '销售合同', value: '销售合同' },
{ label: '服务合同', value: '服务合同' },
{ label: '框架合同', value: '框架合同' },
{ label: '供用电、水、气、热力合同', value: '供用电、水、气、热力合同' },
{ label: '赠与合同', value: '赠与合同' },
{ label: '借款合同', value: '借款合同' },
{ label: '保证合同', value: '保证合同' },
{ label: '租赁合同', value: '租赁合同' },
{ label: '融资租赁合同', value: '融资租赁合同' },
{ label: '保理合同', value: '保理合同' },
{ label: '承揽合同', value: '承揽合同' },
{ label: '建设工程合同', value: '建设工程合同' },
{ label: '运输合同', value: '运输合同' },
{ label: '技术合同', value: '技术合同' },
{ label: '保管合同', value: '保管合同' },
{ label: '仓储合同', value: '仓储合同' },
{ label: '委托合同', value: '委托合同' },
{ label: '物业服务合同', value: '物业服务合同' },
{ label: '行纪合同', value: '行纪合同' },
{ label: '中介合同', value: '中介合同' },
{ label: '合伙合同', value: '合伙合同' },
{ label: '用工合同', value: '用工合同' },
{ label: '业务合同', value: '业务合同' },
{ label: '其他合同', value: '其他合同' },
]
const templateStatusOptions = [
{ label: '已发布', value: 'published' },
{ label: '草稿', value: 'draft' },
{ label: '已停用', value: 'disabled' },
]
const formRef = ref(null)
const queryContainer = ref(null)
const pageWrapperRef = ref(null)
const tableRef = ref(null)
const toolbarRef = ref(null)
const templateModalRef = ref(null)
const total = ref(0)
const loading = ref(false)
const tableHeight = ref(420)
const tableData = ref([])
const queryParam = ref({
pageNo: 1,
pageSize: 10,
templateName: undefined,
contractType: undefined,
status: undefined,
})
const queryCardBodyStyle = {
padding: '20px 24px 6px',
}
const tableCardBodyStyle = {
display: 'flex',
flexFlow: 'column nowrap',
minHeight: '0',
padding: '8px 12px 6px',
}
const templateStatusLabelMap = {
published: '已发布',
draft: '草稿',
disabled: '已停用',
}
const templateStatusColorMap = {
published: 'success',
draft: 'gold',
disabled: 'default',
}
function connectToolbar() {
const table = tableRef.value
const toolbar = toolbarRef.value
if (table && toolbar && table.connect) {
table.connect(toolbar)
}
}
function getStatusLabel(status) {
return templateStatusLabelMap[status] || status
}
function getStatusColor(status) {
return templateStatusColorMap[status] || 'default'
}
function getList() {
loading.value = true
queryManifestList(queryParam.value)
.then((res) => {
if (!isSuccessResponse(res)) {
tableData.value = []
total.value = 0
message.error(getResponseMessage(res, '模板列表查询失败'))
return
}
const pageResult = getPageResult(res)
tableData.value = pageResult.records || []
total.value = pageResult.total || 0
})
.finally(() => {
loading.value = false
nextTick(() => {
connectToolbar()
syncTableHeight()
})
})
}
function handleQuery() {
queryParam.value.pageNo = 1
getList()
}
function handleReset() {
formRef.value?.resetFields?.()
Object.assign(queryParam.value, {
pageNo: 1,
pageSize: 10,
templateName: undefined,
contractType: undefined,
status: undefined,
})
getList()
}
function onShowSizeChange(current, pageSize) {
queryParam.value.pageNo = current
queryParam.value.pageSize = pageSize
getList()
}
function handleCreate() {
templateModalRef.value?.showModal({}, 'create')
}
function handleEdit(row) {
getManifestDetail(row.id).then((res) => {
if (!isSuccessResponse(res) || !getDataResult(res)) {
message.error(getResponseMessage(res, '获取模板详情失败'))
return
}
templateModalRef.value?.showModal(getDataResult(res), 'edit')
})
}
function handleView(row) {
getManifestDetail(row.id).then((res) => {
if (!isSuccessResponse(res) || !getDataResult(res)) {
message.error(getResponseMessage(res, '获取模板详情失败'))
return
}
templateModalRef.value?.showModal(getDataResult(res), 'view')
})
}
function handleTemplateSubmit({ mode, record, successMessage }) {
saveManifest(record, mode).then((res) => {
if (!isSuccessResponse(res)) {
message.error(getResponseMessage(res, '模板保存失败'))
return
}
message.success(successMessage || '保存成功')
getList()
})
}
function toggleStatus(row) {
const nextStatus = row.status === 'published' ? 'disabled' : 'published'
changeManifestStatus(row.id, nextStatus).then((res) => {
if (!isSuccessResponse(res)) {
message.error(getResponseMessage(res, '状态修改失败'))
return
}
message.success(`模板已${nextStatus === 'disabled' ? '停用' : '发布'}${row.templateName}`)
getList()
})
}
function syncTableHeight() {
nextTick(() => {
const viewportHeight = window.innerHeight
const queryHeight = queryContainer.value?.offsetHeight || 0
const pageHeight = pageWrapperRef.value?.$el?.offsetHeight || 56
tableHeight.value = Math.max(320, viewportHeight - queryHeight - pageHeight - 320)
})
}
onMounted(() => {
handleQuery()
nextTick(() => {
connectToolbar()
syncTableHeight()
})
window.addEventListener('resize', syncTableHeight)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', syncTableHeight)
})
</script>
<style scoped>
.section-container {
height: calc(100vh - 116px);
display: flex;
flex-direction: column;
gap: 12px;
}
.query-card {
flex: 0 0 auto;
}
.table-card {
flex: 1 1 auto;
min-height: 0;
}
.section-query-container :deep(.ant-form-item) {
display: flex;
width: 100%;
margin-bottom: 14px;
}
.section-query-container :deep(.ant-form-item .ant-form-item-control) {
flex: 1;
}
.table-actions {
display: flex;
align-items: center;
gap: 12px;
}
.table-pagination {
display: flex;
justify-content: flex-end;
padding: 12px 4px 2px;
}
@media (max-width: 960px) {
.section-container {
height: auto;
}
}
</style>
@@ -0,0 +1,628 @@
<template>
<a-modal
v-model:open="visible"
:title="modalTitle"
width="1100px"
:mask-closable="false"
:footer="null"
:body-style="{
padding: 0,
maxHeight: 'calc(100vh - 180px)',
overflow: 'hidden',
}"
centered
>
<div class="partner-modal-layout">
<a-form layout="vertical" :disabled="isViewMode" class="partner-form">
<a-card size="small" title="基础信息" class="section-card">
<a-alert
type="info"
show-icon
class="section-alert"
message="填写说明"
description="境内组织需要填写统一社会信用代码、法定代表人、注册地址、注册资本;境外组织需要填写 TIN 号、国家、注册地址、注册资本;组织类型联系人必填。"
/>
<div class="grid-two">
<a-form-item label="相对方编码">
<a-input :value="formState.oppositeCode || '保存后自动生成'" readonly />
</a-form-item>
<a-form-item label="法务相对方ID">
<a-input
:value="formState.oppositeId || '未同步前为空'"
readonly
/>
</a-form-item>
<a-form-item label="公司名称" required>
<a-input
v-model:value="formState.customerNameC"
placeholder="请输入公司名称"
/>
</a-form-item>
<a-form-item label="公司类型" required>
<a-select
v-model:value="formState.relativeType"
:options="optionSets.relativeTypeOptions"
placeholder="请选择公司类型"
/>
</a-form-item>
<a-form-item label="公司性质" required>
<a-select
v-model:value="formState.oppositeCharacter"
:options="optionSets.oppositeCharacterOptions"
placeholder="请选择公司性质"
@change="handleOppositeCharacterChange"
/>
</a-form-item>
<a-form-item label="组织性质" required>
<a-select
v-model:value="formState.natureOfEnterpriseOrg"
:options="optionSets.natureOfEnterpriseOrgOptions"
placeholder="请选择组织性质"
@change="handleNatureOfEnterpriseOrgChange"
/>
</a-form-item>
<a-form-item
v-if="showEnterpriseNatureLev"
label="组织性质企业二级"
required
>
<a-select
v-model:value="formState.natureOfEnterpriseOrgLev"
:options="optionSets.natureOfEnterpriseOrgLevOptions"
placeholder="请选择组织性质企业二级"
/>
</a-form-item>
<a-form-item
v-if="showGatFlag"
label="是否涉港澳台"
>
<a-radio-group v-model:value="formState.gatFlag">
<a-radio value="Y"></a-radio>
<a-radio value="N"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="是否集团内" required>
<a-radio-group v-model:value="formState.groupFlag">
<a-radio value="Y"></a-radio>
<a-radio value="N"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
v-if="showCustomerTaxno"
label="统一社会信用代码"
required
>
<a-input
v-model:value="formState.customerTaxno"
placeholder="请输入统一社会信用代码"
/>
</a-form-item>
<a-form-item
v-if="showCertificateFields"
label="自然人证件类型"
:required="isDomesticPerson"
>
<a-select
v-model:value="formState.certificateType"
:options="optionSets.certificateTypeOptions"
placeholder="请选择证件类型"
/>
</a-form-item>
<a-form-item
v-if="showCertificateFields"
label="自然人证件号码"
:required="isDomesticPerson"
>
<a-input
v-model:value="formState.certificateNum"
placeholder="请输入证件号码"
/>
</a-form-item>
<a-form-item v-if="showTinCode" label="TIN号" required>
<a-input v-model:value="formState.tinCode" placeholder="请输入TIN号" />
</a-form-item>
<a-form-item v-if="showCountry" label="国家" required>
<a-select
v-model:value="formState.country"
:options="optionSets.countryOptions"
placeholder="请选择国家"
show-search
option-filter-prop="label"
/>
</a-form-item>
<a-form-item
v-if="showLegalPersonName"
label="法定代表人或负责人"
required
>
<a-input
v-model:value="formState.legalPersonName"
placeholder="请输入法定代表人或负责人"
/>
</a-form-item>
<a-form-item
v-if="showCustomerRegaddr"
class="field-span-2"
label="注册地址"
required
>
<a-input
v-model:value="formState.customerRegaddr"
placeholder="请输入注册地址"
/>
</a-form-item>
<a-form-item
v-if="showRegCapital"
label="注册资本"
required
>
<a-input
v-model:value="formState.regCapital"
placeholder="请输入注册资本"
/>
</a-form-item>
<a-form-item label="邮箱地址">
<a-input
v-model:value="formState.emailAddress"
placeholder="请输入邮箱地址"
/>
</a-form-item>
</div>
</a-card>
<a-card size="small" class="section-card">
<template #title>
<div class="card-title-row">
<span>联系人信息</span>
<a-tag v-if="needContacts" color="orange">组织类型必填</a-tag>
<a-tag v-else color="default">个人类型可选</a-tag>
</div>
</template>
<div class="contact-toolbar" v-if="!isViewMode">
<a-button type="dashed" @click="addContact">新增联系人</a-button>
</div>
<div v-if="!formState.contacts.length" class="empty-contact">
暂无联系人信息
</div>
<div
v-for="(contact, index) in formState.contacts"
:key="contact.uid"
class="contact-card"
>
<div class="contact-card-header">
<strong>联系人{{ index + 1 }}</strong>
<a-button
v-if="!isViewMode"
type="link"
danger
@click="removeContact(contact.uid)"
>
删除
</a-button>
</div>
<div class="grid-two">
<a-form-item label="姓名" :required="needContacts">
<a-input v-model:value="contact.contactName" placeholder="请输入姓名" />
</a-form-item>
<a-form-item label="联系电话" :required="needContacts">
<a-input
v-model:value="contact.contactPhone"
placeholder="请输入联系电话"
/>
</a-form-item>
<a-form-item class="field-span-2" label="通讯地址" :required="needContacts">
<a-input
v-model:value="contact.postalAddress"
placeholder="请输入通讯地址"
/>
</a-form-item>
<a-form-item class="field-span-2" label="电子邮箱" :required="needContacts">
<a-input
v-model:value="contact.email"
placeholder="请输入电子邮箱"
/>
</a-form-item>
</div>
</div>
</a-card>
<a-card size="small" title="同步信息" class="section-card">
<div class="grid-two">
<a-form-item label="同步状态">
<a-input :value="syncStatusLabel" readonly />
</a-form-item>
<a-form-item label="创建时间">
<a-input :value="formState.createTime || '-'" readonly />
</a-form-item>
<a-form-item label="同步时间">
<a-input :value="formState.syncTime || '-'" readonly />
</a-form-item>
<a-form-item label="同步说明">
<a-input :value="formState.syncMessage || '-'" readonly />
</a-form-item>
</div>
</a-card>
</a-form>
<div class="modal-footer">
<a-space>
<a-button v-if="!isViewMode" @click="resetForm">重置</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>
</a-modal>
</template>
<script setup>
import { computed, ref } from "vue";
import { message } from "ant-design-vue";
const props = defineProps({
optionSets: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["save"]);
function createContact() {
return {
uid: `contact_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
id: null,
partnerRecordId: null,
contactName: "",
contactPhone: "",
postalAddress: "",
email: "",
sortOrder: 1,
delFlag: "0",
remark: "",
};
}
function createFormData() {
return {
customerSysid: null,
oppositeCode: "",
oppositeId: "",
customerNameC: "",
relativeType: undefined,
oppositeCharacter: undefined,
natureOfEnterpriseOrg: undefined,
natureOfEnterpriseOrgLev: undefined,
gatFlag: "N",
groupFlag: "N",
customerTaxno: "",
certificateType: undefined,
certificateNum: "",
tinCode: "",
country: undefined,
countryName: "",
legalPersonName: "",
customerRegaddr: "",
registeredCapital: undefined,
regCapital: "",
oppositeStatus: "EFFECTUAL",
isSgat: "0",
isJtn: "0",
isDeleted: "N",
comments: "",
remark: "",
emailAddress: "",
contacts: [],
syncStatus: "unsynced",
createTime: "",
syncTime: "",
syncMessage: "",
};
}
function copyFormData(data = {}) {
const source = data || {};
return {
...createFormData(),
...source,
contacts: Array.isArray(source.contacts)
? source.contacts.map((item) => ({
...createContact(),
...item,
}))
: [],
};
}
function isBlankContact(contact = {}) {
return !contact.contactName?.trim() &&
!contact.contactPhone?.trim() &&
!contact.postalAddress?.trim() &&
!contact.email?.trim();
}
const visible = ref(false);
const modalMode = ref("create");
const originalState = ref(createFormData());
const formState = ref(createFormData());
const isViewMode = computed(() => modalMode.value === "view");
const modalTitle = computed(() => {
if (modalMode.value === "view") return "查看相对方";
if (modalMode.value === "edit") return "编辑相对方";
return "新增相对方";
});
const isDomesticOrg = computed(() => formState.value.oppositeCharacter === "1");
const isDomesticPerson = computed(() => formState.value.oppositeCharacter === "2");
const isForeignOrg = computed(() => formState.value.oppositeCharacter === "3");
const isForeignPerson = computed(() => formState.value.oppositeCharacter === "4");
const showGatFlag = computed(() =>
["1", "2"].includes(formState.value.oppositeCharacter),
);
const showEnterpriseNatureLev = computed(
() => formState.value.natureOfEnterpriseOrg === "QY",
);
const showCustomerTaxno = computed(() => isDomesticOrg.value);
const showCertificateFields = computed(
() => isDomesticPerson.value || isForeignPerson.value,
);
const showTinCode = computed(() => isForeignOrg.value);
const showCountry = computed(
() => isForeignOrg.value || isForeignPerson.value,
);
const showLegalPersonName = computed(() => isDomesticOrg.value);
const showCustomerRegaddr = computed(() => isDomesticOrg.value || isForeignOrg.value);
const showRegCapital = computed(() => isDomesticOrg.value || isForeignOrg.value);
const needContacts = computed(() => isDomesticOrg.value || isForeignOrg.value);
const syncStatusLabel = computed(() => {
const list = props.optionSets.syncStatusOptions || [];
return (
list.find((item) => item.value === formState.value.syncStatus)?.label ||
formState.value.syncStatus ||
"未同步"
);
});
function addContact() {
formState.value.contacts.push(createContact());
}
function removeContact(uid) {
formState.value.contacts = formState.value.contacts.filter(
(item) => item.uid !== uid,
);
if (!formState.value.contacts.length) {
formState.value.contacts = needContacts.value ? [createContact()] : [];
}
}
function handleOppositeCharacterChange() {
formState.value.customerTaxno = "";
formState.value.certificateType = undefined;
formState.value.certificateNum = "";
formState.value.tinCode = "";
formState.value.country = undefined;
formState.value.countryName = "";
formState.value.legalPersonName = "";
formState.value.customerRegaddr = "";
formState.value.registeredCapital = undefined;
formState.value.regCapital = "";
formState.value.gatFlag = "N";
if (needContacts.value && !formState.value.contacts.length) {
formState.value.contacts = [createContact()];
}
if (!needContacts.value) {
formState.value.contacts = formState.value.contacts.filter(
(item) => !isBlankContact(item),
);
}
}
function handleNatureOfEnterpriseOrgChange() {
if (formState.value.natureOfEnterpriseOrg !== "QY") {
formState.value.natureOfEnterpriseOrgLev = undefined;
}
}
function validateContacts() {
if (!needContacts.value) {
return "";
}
if (!formState.value.contacts.length) {
return "请至少维护一个联系人";
}
for (const contact of formState.value.contacts) {
if (!contact.contactName?.trim()) return "请填写联系人姓名";
if (!contact.contactPhone?.trim()) return "请填写联系人联系电话";
if (!contact.postalAddress?.trim()) return "请填写联系人通讯地址";
if (!contact.email?.trim()) return "请填写联系人电子邮箱";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email.trim())) {
return "联系人电子邮箱格式不正确";
}
}
return "";
}
function validateForm() {
if (!formState.value.customerNameC?.trim()) return "请填写公司名称";
if (!formState.value.relativeType) return "请选择公司类型";
if (!formState.value.oppositeCharacter) return "请选择公司性质";
if (!formState.value.natureOfEnterpriseOrg) return "请选择组织性质";
if (showEnterpriseNatureLev.value && !formState.value.natureOfEnterpriseOrgLev)
return "请选择组织性质企业二级";
if (!formState.value.groupFlag) return "请选择是否集团内";
if (isDomesticOrg.value && !formState.value.customerTaxno?.trim())
return "请填写统一社会信用代码";
if (isDomesticPerson.value && !formState.value.certificateType)
return "请选择自然人证件类型";
if (isDomesticPerson.value && !formState.value.certificateNum?.trim())
return "请填写自然人证件号码";
if (isForeignOrg.value && !formState.value.tinCode?.trim()) return "请填写TIN号";
if (isForeignOrg.value && !formState.value.country) return "请选择国家";
if (isForeignPerson.value && !formState.value.country) return "请选择国家";
if (showLegalPersonName.value && !formState.value.legalPersonName?.trim())
return "请填写法定代表人或负责人";
if (showCustomerRegaddr.value && !formState.value.customerRegaddr?.trim())
return "请填写注册地址";
if (showRegCapital.value && !formState.value.regCapital?.trim())
return "请填写注册资本";
return validateContacts();
}
function resetForm() {
formState.value = copyFormData(originalState.value);
}
function normalizeFormBeforeSave() {
formState.value.customerNameC = formState.value.customerNameC?.trim?.() || "";
formState.value.customerTaxno = formState.value.customerTaxno?.trim?.() || "";
formState.value.certificateNum = formState.value.certificateNum?.trim?.() || "";
formState.value.tinCode = formState.value.tinCode?.trim?.() || "";
formState.value.legalPersonName = formState.value.legalPersonName?.trim?.() || "";
formState.value.customerRegaddr = formState.value.customerRegaddr?.trim?.() || "";
formState.value.regCapital = formState.value.regCapital?.trim?.() || "";
formState.value.comments = formState.value.comments?.trim?.() || "";
formState.value.remark = formState.value.remark?.trim?.() || "";
formState.value.emailAddress = formState.value.emailAddress?.trim?.() || "";
formState.value.countryName =
(props.optionSets.countryOptions || []).find(
(item) => item.value === formState.value.country,
)?.label || "";
formState.value.isSgat = formState.value.gatFlag === "Y" ? "1" : "0";
formState.value.isJtn = formState.value.groupFlag === "Y" ? "1" : "0";
formState.value.contacts = (formState.value.contacts || []).map((item) => ({
...item,
contactName: item.contactName?.trim?.() || "",
contactPhone: item.contactPhone?.trim?.() || "",
postalAddress: item.postalAddress?.trim?.() || "",
email: item.email?.trim?.() || "",
remark: item.remark?.trim?.() || "",
}));
}
function handleSave() {
normalizeFormBeforeSave();
const errorMessage = validateForm();
if (errorMessage) {
message.warning(errorMessage);
return;
}
const payload = copyFormData(formState.value);
payload.contacts = needContacts.value
? payload.contacts
: payload.contacts.filter((item) => !isBlankContact(item));
emit("save", payload);
visible.value = false;
}
function showModal(record = {}, mode = "create") {
modalMode.value = mode || "create";
const source =
modalMode.value === "create" ? createFormData() : copyFormData(record);
originalState.value = copyFormData(source);
formState.value = copyFormData(source);
visible.value = true;
}
defineExpose({
showModal,
});
</script>
<style scoped>
.partner-modal-layout {
display: flex;
flex-direction: column;
max-height: calc(100vh - 180px);
}
.partner-form {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 0;
}
.section-card {
border-radius: 10px;
}
.section-alert {
margin-bottom: 14px;
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
}
.field-span-2 {
grid-column: span 2;
}
.card-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.contact-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.contact-card {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px;
background: #fafcff;
}
.contact-card + .contact-card {
margin-top: 12px;
}
.contact-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.empty-contact {
padding: 24px 0;
text-align: center;
color: #94a3b8;
}
.modal-footer {
display: flex;
justify-content: flex-end;
flex: 0 0 auto;
padding: 12px 20px 20px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 900px) {
.grid-two {
grid-template-columns: 1fr;
}
.field-span-2 {
grid-column: span 1;
}
}
</style>
+579
View File
@@ -0,0 +1,579 @@
<template>
<section class="section-container">
<a-card
:bordered="false"
class="query-card"
:body-style="queryCardBodyStyle"
>
<div ref="queryContainer" class="section-query-container">
<a-form ref="formRef" :model="queryParam" layout="inline">
<a-row :gutter="16" style="width: 100%">
<a-col :md="6" :sm="24">
<a-form-item label="公司名称">
<a-input
v-model:value="queryParam.customerNameC"
allow-clear
placeholder="请输入公司名称"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="公司类型">
<a-select
v-model:value="queryParam.relativeType"
:options="optionSets.relativeTypeOptions"
allow-clear
placeholder="请选择公司类型"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="公司性质">
<a-select
v-model:value="queryParam.oppositeCharacter"
:options="optionSets.oppositeCharacterOptions"
allow-clear
placeholder="请选择公司性质"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="是否集团内">
<a-select
v-model:value="queryParam.groupFlag"
:options="optionSets.booleanOptions"
allow-clear
placeholder="请选择"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="同步状态">
<a-select
v-model:value="queryParam.syncStatus"
:options="optionSets.syncStatusOptions"
allow-clear
placeholder="请选择同步状态"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-card>
<a-card
:bordered="false"
class="table-card"
:body-style="tableCardBodyStyle"
>
<vxe-toolbar ref="toolbarRef" custom>
<template #buttons>
<a-space wrap>
<a-button
type="primary"
:icon="h(SearchOutlined)"
@click="handleQuery"
>
查询
</a-button>
<a-button :icon="h(RedoOutlined)" @click="handleReset">
重置
</a-button>
<a-button
type="primary"
ghost
:icon="h(PlusOutlined)"
@click="handleCreate"
>
新增相对方
</a-button>
<a-button
danger
:icon="h(DeleteOutlined)"
:disabled="!selectedRowKeys.length"
@click="handleBatchDelete"
>
批量删除
</a-button>
</a-space>
</template>
</vxe-toolbar>
<vxe-table
ref="tableRef"
border
:height="tableHeight"
:row-config="{ isHover: true, height: 44 }"
:column-config="{ resizable: true, minWidth: 120 }"
:custom-config="{ storage: true }"
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
:checkbox-config="{ range: true }"
:data="tableData"
:loading="loading"
:loading-config="{
icon: 'vxe-icon-indicator roll',
text: '正在拼命加载中...',
}"
@checkbox-range-change="selectChangeEvent"
@checkbox-change="selectChangeEvent"
@checkbox-all="selectChangeEvent"
>
<vxe-column type="checkbox" width="56" />
<vxe-column type="seq" title="序号" width="68" />
<vxe-column
field="oppositeCode"
title="相对方编码"
width="170"
show-overflow="title"
/>
<vxe-column
field="oppositeId"
title="法务相对方ID"
width="150"
show-overflow="title"
>
<template #default="{ row }">
{{ row.oppositeId || "未同步" }}
</template>
</vxe-column>
<vxe-column
field="customerNameC"
title="公司名称"
width="220"
show-overflow="title"
/>
<vxe-column field="relativeType" title="公司类型" width="140">
<template #default="{ row }">
{{ getLabel(optionSets.relativeTypeOptions, row.relativeType) }}
</template>
</vxe-column>
<vxe-column field="oppositeCharacter" title="公司性质" width="140">
<template #default="{ row }">
{{ getLabel(optionSets.oppositeCharacterOptions, row.oppositeCharacter) }}
</template>
</vxe-column>
<vxe-column field="natureOfEnterpriseOrg" title="组织性质" width="140">
<template #default="{ row }">
{{ getLabel(optionSets.natureOfEnterpriseOrgOptions, row.natureOfEnterpriseOrg) }}
</template>
</vxe-column>
<vxe-column field="groupFlag" title="是否集团内" width="110">
<template #default="{ row }">
{{ row.groupFlag === "Y" ? "是" : "否" }}
</template>
</vxe-column>
<vxe-column field="gatFlag" title="涉港澳台" width="110">
<template #default="{ row }">
{{ row.gatFlag === "Y" ? "是" : "否" }}
</template>
</vxe-column>
<vxe-column field="identityNo" title="信用代码 / 证件号" width="220" show-overflow="title">
<template #default="{ row }">
{{ row.customerTaxno || row.certificateNum || row.tinCode || "-" }}
</template>
</vxe-column>
<vxe-column field="country" title="国家" width="120">
<template #default="{ row }">
{{ getLabel(optionSets.countryOptions, row.country) || "-" }}
</template>
</vxe-column>
<vxe-column field="contactSummary" title="联系人信息" width="220" show-overflow="title">
<template #default="{ row }">
{{ getContactSummary(row.contacts) }}
</template>
</vxe-column>
<vxe-column field="syncStatus" title="同步状态" width="120">
<template #default="{ row }">
<a-tag :color="getSyncStatusColor(row.syncStatus)">
{{ getLabel(optionSets.syncStatusOptions, row.syncStatus) }}
</a-tag>
</template>
</vxe-column>
<vxe-column
field="createTime"
title="创建时间"
width="170"
show-overflow="title"
/>
<vxe-column title="操作" width="240" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<a @click="handleView(row)">查看</a>
<a @click="handleEdit(row)">编辑</a>
<a @click="handleMockSync(row)">模拟同步</a>
<a @click="handleDelete(row)">删除</a>
</div>
</template>
</vxe-column>
</vxe-table>
<a-pagination
id="pageWrapper"
ref="pageWrapperRef"
v-model:pageSize="queryParam.pageSize"
v-model:current="queryParam.pageNo"
class="table-pagination"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
:total="total"
:show-total="(value) => `${value}`"
@change="onShowSizeChange"
@show-size-change="onShowSizeChange"
/>
</a-card>
<PartnerModal
ref="partnerModalRef"
:option-sets="optionSets"
@save="handleModalSave"
/>
</section>
</template>
<script setup>
import { h, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import {
DeleteOutlined,
PlusOutlined,
RedoOutlined,
SearchOutlined,
} from "@ant-design/icons-vue";
import { message, Modal } from "ant-design-vue";
import PartnerModal from "./components/PartnerModal.vue";
import {queryPartnerList,getPartnerInfo,createPartner,updatePartner,deletePartnerByIds,mockSyncPartner} from '@/api/partner.js'
function isSuccessResponse(res) {
return String(res?.code || "") === "0000";
}
function getPageResult(res) {
return res?.result || {};
}
function getDataResult(res) {
return res?.result || null;
}
const optionSets = {
relativeTypeOptions: [
{ label: "客户", value: "client" },
{ label: "供应商", value: "supplier" },
{ label: "合作伙伴", value: "Partner" },
{ label: "客户及供应商", value: "clientAndSup" },
{ label: "其他", value: "other" },
],
oppositeCharacterOptions: [
{ label: "境内组织", value: "1" },
{ label: "境内个人", value: "2" },
{ label: "境外组织", value: "3" },
{ label: "境外个人", value: "4" },
],
natureOfEnterpriseOrgOptions: [
{ label: "政府", value: "ZY" },
{ label: "企业", value: "QY" },
{ label: "其他组织", value: "QT" },
],
natureOfEnterpriseOrgLevOptions: [
{ label: "国有企业", value: "GNQY" },
{ label: "民营企业", value: "MYQY" },
{ label: "外资企业", value: "WZQY" },
{ label: "合资企业", value: "HZQY" },
{ label: "国有全资和控股企业", value: "GYQZHKGQY" },
{ label: "其他", value: "QT" },
],
certificateTypeOptions: [
{ label: "身份证", value: "idCard" },
{ label: "护照", value: "passport" },
{ label: "其他", value: "other" },
],
countryOptions: [
{ label: "中国", value: "GJ156" },
{ label: "日本", value: "GJ392" },
{ label: "芬兰", value: "GJ246" },
{ label: "美国", value: "GJ840" },
{ label: "加拿大", value: "GJ124" },
{ label: "英国", value: "GJ826" },
],
booleanOptions: [
{ label: "", value: "Y" },
{ label: "", value: "N" },
],
syncStatusOptions: [
{ label: "待同步", value: "unsynced" },
{ label: "同步成功", value: "synced" },
{ label: "同步失败", value: "failed" },
],
};
function copyRecord(record) {
return {
...record,
contacts: (record.contacts || []).map((item) => ({ ...item })),
};
}
function getLabel(options, value) {
return options.find((item) => item.value === value)?.label || value || "";
}
function getContactSummary(contacts = []) {
if (!contacts.length) return "暂无";
const first = contacts[0];
return `${first.contactName || "-"} / ${first.contactPhone || "-"}`;
}
const formRef = ref(null);
const queryContainer = ref(null);
const pageWrapperRef = ref(null);
const tableRef = ref(null);
const toolbarRef = ref(null);
const partnerModalRef = ref(null);
const total = ref(0);
const loading = ref(false);
const tableHeight = ref(420);
const tableData = ref([]);
const selectedRows = ref([]);
const selectedRowKeys = ref([]);
const currentModalMode = ref("create");
const queryParam = ref({
pageNo: 1,
pageSize: 10,
customerNameC: undefined,
relativeType: undefined,
oppositeCharacter: undefined,
groupFlag: undefined,
syncStatus: undefined,
});
const queryCardBodyStyle = {
padding: "20px 24px 6px",
};
const tableCardBodyStyle = {
display: "flex",
flexFlow: "column nowrap",
minHeight: "0",
padding: "8px 12px 6px",
};
function connectToolbar() {
const table = tableRef.value;
const toolbar = toolbarRef.value;
if (table && toolbar && table.connect) {
table.connect(toolbar);
}
}
function getSyncStatusColor(status) {
if (status === "synced") return "success";
if (status === "failed") return "error";
return "default";
}
function selectChangeEvent({ records }) {
selectedRows.value = records;
selectedRowKeys.value = records.map((item) => item.customerSysid);
}
function getList() {
loading.value = true;
queryPartnerList(queryParam.value)
.then((res) => {
if (!isSuccessResponse(res)) {
tableData.value = [];
total.value = 0;
return;
}
const pageResult = getPageResult(res);
tableData.value = (pageResult.records || []).map((item) => copyRecord(item));
total.value = pageResult.total || 0;
})
.finally(() => {
loading.value = false;
nextTick(() => {
connectToolbar();
syncTableHeight();
});
});
}
function handleQuery() {
queryParam.value.pageNo = 1;
getList();
}
function handleReset() {
formRef.value?.resetFields?.();
Object.assign(queryParam.value, {
pageNo: 1,
pageSize: 10,
customerNameC: undefined,
relativeType: undefined,
oppositeCharacter: undefined,
groupFlag: undefined,
syncStatus: undefined,
});
getList();
}
function onShowSizeChange(current, pageSize) {
queryParam.value.pageNo = current;
queryParam.value.pageSize = pageSize;
getList();
}
function handleCreate() {
currentModalMode.value = "create";
partnerModalRef.value?.showModal({}, "create");
}
function handleView(row) {
currentModalMode.value = "view";
getPartnerInfo(row.customerSysid).then((res) => {
partnerModalRef.value?.showModal(copyRecord(getDataResult(res) || row), "view");
});
}
function handleEdit(row) {
currentModalMode.value = "edit";
getPartnerInfo(row.customerSysid).then((res) => {
partnerModalRef.value?.showModal(copyRecord(getDataResult(res) || row), "edit");
});
}
function handleMockSync(row) {
mockSyncPartner(row.customerSysid).then(() => {
message.success(`已模拟同步:${row.customerNameC}`);
getList();
});
}
function handleDelete(row) {
Modal.confirm({
title: `确认删除【${row.customerNameC}】吗?`,
content: "删除后不可恢复请确认是否继续",
okText: "删除",
cancelText: "取消",
okType: "danger",
onOk: () => {
deletePartnerByIds([row.customerSysid]).then(() => {
selectedRows.value = [];
selectedRowKeys.value = [];
message.success("删除成功");
getList();
});
},
});
}
function handleBatchDelete() {
Modal.confirm({
title: `确认删除选中的 ${selectedRowKeys.value.length} 条记录吗?`,
content: "删除后不可恢复请确认是否继续",
okText: "删除",
cancelText: "取消",
okType: "danger",
onOk: () => {
deletePartnerByIds(selectedRowKeys.value).then(() => {
selectedRows.value = [];
selectedRowKeys.value = [];
message.success("批量删除成功");
getList();
});
},
});
}
function handleModalSave(payload) {
if (currentModalMode.value === "create") {
createPartner(payload).then(() => {
message.success("新增成功");
getList();
});
} else {
updatePartner(payload).then(() => {
message.success("保存成功");
getList();
});
}
}
function syncTableHeight() {
nextTick(() => {
const viewportHeight = window.innerHeight;
const queryHeight = queryContainer.value?.offsetHeight || 0;
const pageHeight = pageWrapperRef.value?.$el?.offsetHeight || 56;
tableHeight.value = Math.max(
320,
viewportHeight - queryHeight - pageHeight - 320,
);
});
}
onMounted(() => {
handleQuery();
nextTick(() => {
connectToolbar();
syncTableHeight();
});
window.addEventListener("resize", syncTableHeight);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", syncTableHeight);
});
</script>
<style scoped>
.section-container {
height: calc(100vh - 116px);
display: flex;
flex-direction: column;
gap: 12px;
}
.query-card {
flex: 0 0 auto;
}
.table-card {
flex: 1 1 auto;
min-height: 0;
}
.section-query-container :deep(.ant-form-item) {
display: flex;
width: 100%;
margin-bottom: 14px;
}
.section-query-container :deep(.ant-form-item .ant-form-item-control) {
flex: 1;
}
.table-actions {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.table-actions a {
white-space: nowrap;
}
.table-pagination {
display: flex;
justify-content: flex-end;
padding: 12px 4px 2px;
}
@media (max-width: 960px) {
.section-container {
height: auto;
}
}
</style>