新增合同管理相关功能

This commit is contained in:
2026-09-11 18:22:22 +08:00
parent 840673bda5
commit 184c604bee
17 changed files with 2944 additions and 0 deletions
@@ -0,0 +1,236 @@
<template>
<div class="party-select" :class="{ own: locked }">
<div class="party-head">
<span class="party-role">{{ roleLabel }}</span>
<el-tag v-if="locked" size="small" type="primary" effect="light">本公司</el-tag>
<el-tag v-else-if="party?.ref_name" size="small" type="info" effect="plain">
{{ party.ref_type === 2 ? "供应商" : "客户" }}
</el-tag>
<span v-if="locked" class="party-company">{{ tenantName }}</span>
</div>
<!-- 关联主体:当前租户方锁定,其余模糊搜索客户库 / 供应商库 -->
<template v-if="!locked">
<div class="party-row">
<el-select
v-if="allowChooseRefType"
v-model="party.ref_type"
class="ref-type-select"
size="default"
@change="handleRefTypeChange"
>
<el-option label="客户" :value="1" />
<el-option label="供应商" :value="2" />
</el-select>
<el-select
v-model="party.ref_id"
class="ref-select"
filterable
remote
clearable
reserve-keyword
:loading="searching"
:remote-method="handleSearch"
:placeholder="`搜索${refTypeText}名称`"
@change="handlePick"
@clear="handleClear"
@visible-change="handleDropdownOpen"
>
<el-option
v-for="item in options"
:key="item.id"
:label="item.label"
:value="item.id"
>
<span class="option-name">{{ item.label }}</span>
<span class="option-sub">{{ item.sub }}</span>
</el-option>
<template #footer>
<div class="select-footer">
<el-button link type="primary" :icon="Plus" @click="emit('create')">
新建{{ refTypeText }}
</el-button>
</div>
</template>
</el-select>
<el-tooltip :content="`没有找到?点击新建${refTypeText}`" placement="top">
<el-button :icon="Plus" @click="emit('create')" />
</el-tooltip>
</div>
<div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div>
</template>
<!-- 签约人 -->
<div class="party-row signer-row">
<el-input
v-model="party.signer_name"
placeholder="签约人姓名"
maxlength="30"
/>
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
import { useAuthStore } from "@/stores/auth";
const props = defineProps({
/** 参与方对象:{ role, ref_type, ref_id, ref_name, signer_name, signer_phone } */
modelValue: { type: Object, required: true },
/** 甲方 / 乙方 / 丙方 / 丁方 */
roleLabel: { type: String, default: "" },
/** 是否为当前租户方(锁定为本公司) */
locked: { type: Boolean, default: false },
/** 丙/丁方允许在客户 / 供应商库之间切换来源 */
allowChooseRefType: { type: Boolean, default: false },
});
const emit = defineEmits(["create"]);
const authStore = useAuthStore();
const tenantName = computed(() => authStore.user?.tenant_name || "本公司");
/** 参与方对象由父组件持有,这里直接读写其字段 */
const party = computed(() => props.modelValue);
const searching = ref(false);
const options = ref([]);
const refTypeText = computed(() =>
Number(party.value.ref_type) === 2 ? "供应商" : "客户"
);
/** 初次打开按已有名称兜底展示,避免回显成 id */
const ensureOption = () => {
if (party.value?.ref_id && party.value?.ref_name) {
const exists = options.value.some((i) => String(i.id) === String(party.value.ref_id));
if (!exists) {
options.value = [
{ id: party.value.ref_id, label: party.value.ref_name, sub: "" },
...options.value,
];
}
}
};
watch(
() => party.value?.ref_id,
() => ensureOption(),
{ immediate: true }
);
const remoteSearch = async (keyword) => {
searching.value = true;
try {
const api =
Number(party.value.ref_type) === 2 ? getCrmSupplierList : getCrmCustomerList;
const res = await api({ keyword, page: 1, pageSize: 50 });
const list = res?.data?.list || [];
options.value = list.map((item) => ({
id: item.id,
label: item.customer_name || item.supplier_name || item.name || `#${item.id}`,
sub: item.contact_person || item.industry || "",
}));
ensureOption();
} catch {
options.value = [];
} finally {
searching.value = false;
}
};
const handleSearch = (keyword) => remoteSearch(keyword || "");
/** 下拉首次展开时加载全量候选 */
const handleDropdownOpen = (visible) => {
if (visible && options.value.length === 0) remoteSearch("");
};
const handlePick = (id) => {
const hit = options.value.find((i) => String(i.id) === String(id));
party.value.ref_name = hit?.label || "";
};
const handleClear = () => {
party.value.ref_id = null;
party.value.ref_name = "";
options.value = [];
};
/** 丙/丁方切换客户 / 供应商来源时清空已选 */
const handleRefTypeChange = () => {
party.value.ref_id = null;
party.value.ref_name = "";
options.value = [];
};
</script>
<style lang="less" scoped>
.party-select {
padding: 14px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
transition: border-color 0.2s, background 0.2s;
/* 本公司方:主色浅底突出显示 */
&.own {
border-color: var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
}
.party-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.party-role {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.party-company {
margin-left: auto;
font-size: 13px;
color: var(--el-color-primary);
font-weight: 500;
}
}
.party-row {
display: flex;
gap: 8px;
&.signer-row {
margin-top: 8px;
}
.ref-type-select {
width: 96px;
flex-shrink: 0;
}
.ref-select {
flex: 1;
}
}
.picked-name {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.select-footer {
text-align: center;
}
}
</style>