batch customer event

This commit is contained in:
Timi
2026-08-03 17:28:19 +08:00
parent 337700cfa7
commit 386d5e0613
5 changed files with 362 additions and 135 deletions
+162
View File
@@ -0,0 +1,162 @@
<template>
<div class="customer-list">
<t-cell-group v-if="customerList.length">
<t-cell
v-for="(item, index) in customerList"
:key="item.id || item.code || index"
:note="item.code"
hover
@click="handleSelect(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>
<div class="left-icon">
<span v-if="selectable" class="check" :class="{selected: isSelected(item)}">
<t-icon v-if="isSelected(item)" name="check" />
</span>
<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)" />
</div>
</template>
<template v-if="removable" #rightIcon>
<t-button theme="danger" size="extra-small" @click.stop="handleRemove(item)">移除</t-button>
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty-block" v-text="emptyText" />
</div>
</template>
<script setup lang="ts">
import type { Customer } from "@/types/Customer";
import { getCustomerPhotoURL } from "@/utils/customer";
defineOptions({
name: "CustomerList"
});
interface Props {
customerList: Customer[];
selectedIdList?: string[];
selectable?: boolean;
removable?: boolean;
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), {
selectedIdList: () => [],
selectable: false,
removable: false,
emptyText: "暂无客户"
});
const emit = defineEmits<{
select: [customer: Customer];
remove: [customer: Customer];
}>();
const selectedIdSet = computed(() => new Set(props.selectedIdList));
function isSelected(customer: Customer): boolean {
return !!customer.id && selectedIdSet.value.has(customer.id);
}
function handleSelect(customer: Customer): void {
emit("select", customer);
}
function handleRemove(customer: Customer): void {
emit("remove", customer);
}
</script>
<style scoped lang="less">
.customer-list {
.cell-title {
gap: .5rem;
display: flex;
align-items: center;
}
.left-icon {
gap: .5rem;
display: flex;
align-items: center;
}
.check {
width: 1.25rem;
height: 1.25rem;
color: #fff;
display: flex;
flex: none;
border: 1px solid var(--td-component-border);
border-radius: 50%;
align-items: center;
justify-content: center;
&.selected {
border-color: var(--td-brand-color);
background: var(--td-brand-color);
}
}
.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;
}
}
.empty-block {
color: #52607a;
margin: 1rem;
padding: 1rem;
text-align: center;
background: #f8fbff;
border-radius: 1rem;
}
}
:deep(.avatar .t-image__img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
:global(html.theme-dark) {
.customer-list {
.check {
border-color: rgba(148, 163, 184, .4);
}
.empty-block {
color: #e2e8f0;
background: rgba(30, 41, 59, .9);
}
.avatar.fallback {
color: #93c5fd;
background: rgba(30, 64, 175, .32);
}
}
}
</style>
+89 -87
View File
@@ -1,7 +1,18 @@
<template>
<t-popup v-model="popupVisible" placement="bottom">
<div class="customer-selector bg-white">
<h3 class="title">选择客户</h3>
<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"
@@ -11,33 +22,16 @@
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 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 v-else class="empty-block" v-text="loading ? '正在加载客户' : '未找到匹配客户'" />
</div>
</t-popup>
</template>
@@ -45,16 +39,23 @@
<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";
import { getCustomerPhotoURL } from "@/utils/customer";
const props = defineProps<{
modelValue: string;
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];
"update:modelValue": [value: string | string[]];
"update:visible": [value: boolean];
change: [value: string];
}>();
@@ -68,9 +69,12 @@ 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 "正在加载更多";
@@ -116,6 +120,11 @@ async function loadCustomers(): Promise<void> {
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;
@@ -128,7 +137,7 @@ async function loadCustomers(): Promise<void> {
}
async function bindScrollListener(): Promise<void> {
if (!props.visible || customerOptions.value.length <= 0) {
if (!props.visible || 0 === customerOptions.value.length) {
Scroller.removeListener(scrollListenerName);
return;
}
@@ -140,24 +149,58 @@ async function bindScrollListener(): Promise<void> {
});
}
function selectCustomer(item: Customer): void {
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;
}
emit("update:modelValue", id);
emit("change", id);
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();
});
@@ -191,11 +234,22 @@ onUnmounted(() => {
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;
@@ -216,12 +270,6 @@ onUnmounted(() => {
overflow: auto;
min-height: 0;
padding-bottom: env(safe-area-inset-bottom, 0);
.cell-title {
gap: .5rem;
display: flex;
align-items: center;
}
}
.load-state {
@@ -232,59 +280,13 @@ onUnmounted(() => {
}
}
.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>