Files
gao-customer-h5/src/components/CustomerSelector.vue
T
2026-08-06 20:53:22 +08:00

295 lines
6.5 KiB
Vue

<template>
<t-popup v-model="popupVisible" placement="bottom">
<div class="customer-selector bg-white">
<div class="title-bar">
<t-button variant="text" size="small" @click="cancelSelection">取消</t-button>
<h3 class="title">选择客户</h3>
<t-button
v-if="isMultiple"
theme="primary"
variant="text"
size="small"
@click="confirmSelection"
>确定</t-button>
<span v-else class="title-placeholder" />
</div>
<div class="search-wrapper">
<t-input
v-model.trim="keyword"
class="search"
borderless
clearable
placeholder="姓名 / 编码 / 手机号"
/>
</div>
<div ref="resultListRef" class="result-list">
<customer-list
:customer-list="customerOptions"
:selected-id-list="draftSelectedIdList"
:selectable="isMultiple"
:empty-text="loading ? '正在加载客户' : '未找到匹配客户'"
@select="toggleCustomer"
/>
<div class="load-state" v-text="loadStateText" />
</div>
</div>
</t-popup>
</template>
<script setup lang="ts">
import { Scroller } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import CustomerList from "@/components/CustomerList.vue";
import type { Customer } from "@/types/Customer";
type SelectionMode = "single" | "multiple";
interface Props {
modelValue: string | string[];
visible: boolean;
selectionMode?: SelectionMode;
}
const props = withDefaults(defineProps<Props>(), {
selectionMode: "single"
});
const emit = defineEmits<{
"update:modelValue": [value: string | 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>();
const draftSelectedIdList = ref<string[]>([]);
const selectedCustomerCache = new Map<string, Customer>();
let searchToken = 0;
let keywordTimer: number | undefined;
const isMultiple = computed(() => props.selectionMode === "multiple");
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.list({
index: pageIndex.value,
size: pageSize,
likesExample: {
code: keyword.value || undefined,
name: keyword.value || undefined,
telephone: keyword.value || undefined
}
});
if (token !== searchToken) {
return;
}
const list = result.list || [];
for (const item of list) {
if (item.id) {
selectedCustomerCache.set(item.id, item);
}
}
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 || 0 === customerOptions.value.length) {
Scroller.removeListener(scrollListenerName);
return;
}
await nextTick();
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
distance: 48,
canLoad: () => props.visible && !loading.value && !noMore.value,
getElement: () => resultListRef.value
});
}
function getModelValueIdList(): string[] {
const value = props.modelValue;
return (Array.isArray(value) ? value : [value]).filter((item): item is string => Boolean(item));
}
function syncDraftSelection(): void {
draftSelectedIdList.value = getModelValueIdList();
}
function toggleCustomer(item: Customer): void {
const id = item.id;
if (!id) {
return;
}
selectedCustomerCache.set(id, item);
if (!isMultiple.value) {
emit("update:modelValue", id);
emit("change", id);
emit("update:visible", false);
return;
}
if (draftSelectedIdList.value.includes(id)) {
draftSelectedIdList.value = draftSelectedIdList.value.filter(itemId => itemId !== id);
return;
}
draftSelectedIdList.value = [...draftSelectedIdList.value, id];
}
function cancelSelection(): void {
syncDraftSelection();
emit("update:visible", false);
}
function confirmSelection(): void {
const value = [...draftSelectedIdList.value];
emit("update:modelValue", value);
emit("change", value as unknown as string);
emit("update:visible", false);
}
watch(() => props.visible, async (value) => {
if (!value) {
syncDraftSelection();
keyword.value = "";
window.clearTimeout(keywordTimer);
Scroller.removeListener(scrollListenerName);
return;
}
syncDraftSelection();
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-bar {
padding: 0 1rem;
display: grid;
grid-template-columns: 4rem 1fr 4rem;
align-items: center;
}
.title {
margin: 0;
text-align: center;
}
.title-placeholder {
height: 1px;
}
.search-wrapper {
z-index: 1;
padding: 1rem 0 .5rem 0;
box-shadow: 0 4px 4px rgba(0, 0, 0, .05);
.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);
}
.load-state {
padding: .75rem;
color: #52607a;
text-align: center;
font-size: .875rem;
}
}
:global(html.theme-dark) {
.customer-selector {
background: rgba(15, 23, 42, .96);
}
.load-state {
color: #94a3b8;
}
}
</style>