Files
gao-customer-h5/src/components/CustomerSelector.vue
T
2026-08-03 01:27:46 +08:00

291 lines
6.1 KiB
Vue

<template>
<t-popup v-model="popupVisible" placement="bottom">
<div class="customer-selector bg-white">
<h3 class="title">选择客户</h3>
<div class="search-wrapper">
<t-input
v-model.trim="keyword"
class="search"
borderless
clearable
placeholder="姓名 / 编码 / 手机号"
/>
</div>
<div v-if="customerOptions.length" ref="resultListRef" class="result-list">
<t-cell-group>
<t-cell
v-for="(item, index) in customerOptions"
:key="item.id || item.code || index"
:note="item.code"
hover
@click="selectCustomer(item)"
>
<template #title>
<div class="cell-title">
<span v-text="item.name || item.code || '未命名客户'" />
<gender-icon :value="item.gender" />
</div>
</template>
<template #description>
<telephone-link :value="item.telephone" placeholder="未填写电话" />
</template>
<template #leftIcon>
<t-image v-if="getCustomerPhotoURL(item)" :src="getCustomerPhotoURL(item)" :alt="item.name" fit="cover" class="avatar" />
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
</template>
</t-cell>
</t-cell-group>
<div class="load-state" v-text="loadStateText" />
</div>
<div v-else class="empty-block" v-text="loading ? '正在加载客户' : '未找到匹配客户'" />
</div>
</t-popup>
</template>
<script setup lang="ts">
import { Scroller } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import type { Customer } from "@/types/Customer";
import { getCustomerPhotoURL } from "@/utils/customer";
const props = defineProps<{
modelValue: string;
visible: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
"update:visible": [value: boolean];
change: [value: string];
}>();
const pageSize = 20;
const scrollListenerName = "CustomerSelector";
const keyword = ref("");
const loading = ref(false);
const noMore = ref(false);
const pageIndex = ref(0);
const totalCount = ref(0);
const customerOptions = ref<Customer[]>([]);
const resultListRef = ref<HTMLElement>();
let searchToken = 0;
let keywordTimer: number | undefined;
const loadStateText = computed(() => {
if (loading.value) {
return "正在加载更多";
}
return noMore.value ? `共 ${totalCount.value} 位客户` : "上拉加载更多";
});
const popupVisible = computed({
get() {
return props.visible;
},
set(value: boolean) {
emit("update:visible", value);
}
});
async function reloadCustomers(): Promise<void> {
pageIndex.value = 0;
totalCount.value = 0;
customerOptions.value = [];
noMore.value = false;
await loadCustomers();
}
async function loadCustomers(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
const token = searchToken + 1;
searchToken = token;
loading.value = true;
try {
const result = await CustomerAPI.searchPage({
index: pageIndex.value,
size: pageSize,
equalsExample: {
keyword: keyword.value || undefined
}
});
if (token !== searchToken) {
return;
}
const list = result.list || [];
customerOptions.value = customerOptions.value.concat(list);
totalCount.value = result.total || 0;
pageIndex.value += 1;
noMore.value = totalCount.value <= customerOptions.value.length || list.length < pageSize;
} finally {
if (token === searchToken) {
loading.value = false;
}
}
}
async function bindScrollListener(): Promise<void> {
if (!props.visible || customerOptions.value.length <= 0) {
Scroller.removeListener(scrollListenerName);
return;
}
await nextTick();
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
distance: 48,
canLoad: () => props.visible && !loading.value && !noMore.value,
getElement: () => resultListRef.value
});
}
function selectCustomer(item: Customer): void {
const id = item.id;
if (!id) {
return;
}
emit("update:modelValue", id);
emit("change", id);
emit("update:visible", false);
}
watch(() => props.visible, async (value) => {
if (!value) {
keyword.value = "";
window.clearTimeout(keywordTimer);
Scroller.removeListener(scrollListenerName);
return;
}
await reloadCustomers();
await bindScrollListener();
});
watch(keyword, async () => {
if (!props.visible) {
return;
}
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await reloadCustomers();
await bindScrollListener();
}, 300);
});
watch(() => customerOptions.value.length, async () => {
await bindScrollListener();
});
onUnmounted(() => {
window.clearTimeout(keywordTimer);
Scroller.removeListener(scrollListenerName);
});
</script>
<style scoped lang="less">
.customer-selector {
display: flex;
height: 80vh;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
flex-direction: column;
.title {
margin: 0;
text-align: center;
}
.search-wrapper {
z-index: 1;
padding: 1rem 0 .5rem 0;
box-shadow: var(--td-shadow-1);
.search {
--td-input-vertical-padding: 8px;
flex: 0;
margin: 0 1rem;
border: 1px solid var(--td-component-border);
border-radius: .875rem;
}
}
.result-list {
flex: 1;
overflow: auto;
min-height: 0;
padding-bottom: env(safe-area-inset-bottom, 0);
.cell-title {
gap: .5rem;
display: flex;
align-items: center;
}
}
.load-state {
padding: .75rem;
color: #52607a;
text-align: center;
font-size: .875rem;
}
}
.avatar {
width: 3.5rem;
height: 3.5rem;
border-radius: .5rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1.25rem;
background: #dbeafe;
align-items: center;
font-weight: 600;
justify-content: center;
}
}
:deep(.avatar .t-image__img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.empty-block {
color: #52607a;
margin: 1rem;
padding: 1rem;
text-align: center;
background: #f8fbff;
border-radius: 1rem;
}
:global(html.theme-dark) {
.customer-selector {
background: rgba(15, 23, 42, .96);
}
.empty-block {
color: #e2e8f0;
background: rgba(30, 41, 59, .9);
border-color: rgba(148, 163, 184, .16);
}
.load-state {
color: #94a3b8;
}
.avatar {
&.fallback {
color: #93c5fd;
background: rgba(30, 64, 175, .32);
}
}
}
</style>