Files
yunzerwebsiteallinone/backend/src/views/apps/crm/contract/components/PartySelect.vue
T
2026-09-13 22:31:42 +08:00

343 lines
10 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>
<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">{{ party?.ref_name || 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-select
v-model="party.signer_name"
filterable
allow-create
default-first-option
clearable
:loading="signerLoading"
:placeholder="contactPlaceholder"
@change="handleSignerPick"
>
<el-option
v-for="c in contacts"
:key="c.id"
:label="c.contact_name"
:value="c.contact_name"
>
<span class="option-name">{{ c.contact_name }}</span>
<span class="option-sub">
{{ [c.position || c.department, signerPhoneOf(c)].filter(Boolean).join(" · ") }}
</span>
</el-option>
</el-select>
<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 { listContacts } from "@/api/contact";
import { erpOrganizationApi } from "@/api/organization";
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([]);
/* ------------------------------ 签约人:人员联动 ------------------------------ */
/** 是否为本公司方:锁定方,或 ref_type=0(本公司) */
const isOwnCompany = computed(() => props.locked || Number(party.value?.ref_type) === 0);
const contacts = ref([]);
const contactLoading = ref(false);
/** 本公司人员:来自租户组织架构(与组织架构模块同源) */
const ownEmployees = ref([]);
const ownLoading = ref(false);
/** 签约人下拉加载态:本公司方取员工加载态,其余取联系人加载态 */
const signerLoading = computed(() => (isOwnCompany.value ? ownLoading.value : contactLoading.value));
/** 联系人电话:手机优先,其次座机 */
const signerPhoneOf = (c) => c?.mobile || c?.phone || "";
const contactPlaceholder = computed(() => {
if (isOwnCompany.value) return "选择本公司人员(也可输入)";
return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商";
});
/** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */
async function fetchOwnEmployees() {
ownLoading.value = true;
try {
const res = await erpOrganizationApi.getEmployeeList({ status: 1 });
const list = Array.isArray(res) ? res : res?.data || [];
ownEmployees.value = (Array.isArray(list) ? list : []).map((e) => ({
id: `emp_${e.id}`,
contact_name: e.name || e.account || "",
position: e.position || "",
department: e.department_name || "",
mobile: e.phone || "",
phone: "",
}));
} catch {
ownEmployees.value = [];
} finally {
ownLoading.value = false;
}
}
/** 拉取签约人候选:本公司方取组织架构人员,其余取关联企业联系人(客户 / 供应商) */
async function fetchContacts() {
if (isOwnCompany.value) {
if (!ownEmployees.value.length) await fetchOwnEmployees();
contacts.value = ownEmployees.value;
return;
}
const refID = party.value?.ref_id;
if (!refID) {
contacts.value = [];
return;
}
contactLoading.value = true;
try {
const res = await listContacts({
related_type: Number(party.value.ref_type) === 2 ? 2 : 1,
related_id: refID,
page: 1,
pageSize: 100,
});
contacts.value = res?.data?.list || [];
} catch (e) {
contacts.value = [];
} finally {
contactLoading.value = false;
}
}
/** 选择签约人:自动带出该联系人的联系电话 */
function handleSignerPick(name) {
const hit = contacts.value.find((c) => c.contact_name === name);
if (hit) party.value.signer_phone = signerPhoneOf(hit);
}
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();
// 关联企业变化时同步刷新其联系人,供签约人下拉选择
fetchContacts();
},
{ 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 = "";
party.value.signer_name = "";
party.value.signer_phone = "";
options.value = [];
contacts.value = [];
};
/** 丙/丁方切换客户 / 供应商来源时清空已选 */
const handleRefTypeChange = () => {
party.value.ref_id = null;
party.value.ref_name = "";
party.value.signer_name = "";
party.value.signer_phone = "";
options.value = [];
contacts.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>