343 lines
10 KiB
Vue
343 lines
10 KiB
Vue
<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>
|