377 lines
8.5 KiB
Vue
377 lines
8.5 KiB
Vue
<template>
|
||
<bottom-popup
|
||
v-model="popupVisible"
|
||
title="选择客户"
|
||
height="80vh"
|
||
:right-button-text="isMultiple ? '确定' : ''"
|
||
@left-button-click="cancelSelection"
|
||
@right-button-click="confirmSelection"
|
||
>
|
||
<div class="customer-selector">
|
||
<div class="search-wrap">
|
||
<t-input
|
||
v-model.trim="keyword"
|
||
class="search"
|
||
borderless
|
||
clearable
|
||
placeholder="搜索编码、姓名(拼音)、电话"
|
||
/>
|
||
<t-button
|
||
v-if="props.enableScan"
|
||
class="scan"
|
||
size="small"
|
||
theme="primary"
|
||
@click="toggleScanner"
|
||
>
|
||
<template #icon>
|
||
<t-icon name="scan" />
|
||
</template>
|
||
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||
</t-button>
|
||
</div>
|
||
<div v-if="scannerVisible" class="scanner-wrap">
|
||
<code-scanner
|
||
:visible="scannerVisible"
|
||
:status-text="scanStatus"
|
||
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
|
||
@decode="handleDecodedText"
|
||
@status-change="handleScanStatusChange"
|
||
/>
|
||
</div>
|
||
<t-pull-down-refresh
|
||
class="result-list"
|
||
v-model="isRefreshing"
|
||
:refresh-timeout="30000"
|
||
@refresh="refresh"
|
||
@scrolltolower="loadCustomers"
|
||
>
|
||
<customer-list
|
||
:customer-list="customerOptions"
|
||
:selected-id-list="draftSelectedIdList"
|
||
:selectable="isMultiple"
|
||
:empty-text="loading ? '正在加载客户' : '未找到匹配客户'"
|
||
@select="toggleCustomer"
|
||
/>
|
||
</t-pull-down-refresh>
|
||
</div>
|
||
</bottom-popup>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Toolkit } from "timi-web";
|
||
import { BottomPopup } from "timi-tdesign-mobile";
|
||
import CustomerAPI from "@/api/CustomerAPI";
|
||
import CodeScanner from "@/components/CodeScanner.vue";
|
||
import CustomerList from "@/components/CustomerList.vue";
|
||
import { useSettingStore } from "@/store/settingStore";
|
||
import type { Customer } from "@/types/Customer";
|
||
|
||
type SelectionMode = "single" | "multiple";
|
||
|
||
interface Props {
|
||
enableScan?: boolean;
|
||
initialKeyword?: string;
|
||
modelValue: string | string[];
|
||
visible: boolean;
|
||
selectionMode?: SelectionMode;
|
||
storeId?: string;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
enableScan: true,
|
||
initialKeyword: "",
|
||
selectionMode: "single",
|
||
storeId: ""
|
||
});
|
||
|
||
const settingStore = useSettingStore();
|
||
|
||
const emit = defineEmits<{
|
||
"update:modelValue": [value: string | string[]];
|
||
"update:visible": [value: boolean];
|
||
change: [value: string];
|
||
}>();
|
||
|
||
const pageSize = 20;
|
||
const keyword = ref("");
|
||
const loading = ref(false);
|
||
const noMore = ref(false);
|
||
const pageIndex = ref(0);
|
||
const totalCount = ref(0);
|
||
const customerOptions = ref<Customer[]>([]);
|
||
const isRefreshing = ref(false);
|
||
const draftSelectedIdList = ref<string[]>([]);
|
||
const selectedCustomerCache = new Map<string, Customer>();
|
||
let searchToken = 0;
|
||
const scannerVisible = ref(false);
|
||
const scanStatus = ref("点击扫码开始识别");
|
||
let latestDecodedText = "";
|
||
let scanToken = 0;
|
||
|
||
const isMultiple = computed(() => props.selectionMode === "multiple");
|
||
|
||
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 refresh(): Promise<void> {
|
||
try {
|
||
await reloadCustomers();
|
||
} finally {
|
||
isRefreshing.value = false;
|
||
}
|
||
}
|
||
|
||
const reloadByKeyword = Toolkit.debounce(async () => {
|
||
await reloadCustomers();
|
||
}, false);
|
||
|
||
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,
|
||
storeId: props.storeId || undefined,
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 toggleScanner(): void {
|
||
if (!props.enableScan) {
|
||
return;
|
||
}
|
||
scannerVisible.value = !scannerVisible.value;
|
||
if (scannerVisible.value) {
|
||
latestDecodedText = "";
|
||
scanStatus.value = "正在启动扫码预览";
|
||
return;
|
||
}
|
||
scanToken++;
|
||
}
|
||
|
||
async function handleDecodedText(rawText: string): Promise<void> {
|
||
const normalizedText = rawText.trim();
|
||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||
return;
|
||
}
|
||
|
||
latestDecodedText = normalizedText;
|
||
const token = ++scanToken;
|
||
await settingStore.playScanSuccessSound();
|
||
try {
|
||
const customer = await CustomerAPI.findByCode(normalizedText, props.storeId || undefined);
|
||
if (token !== scanToken) {
|
||
return;
|
||
}
|
||
if (!customer?.id) {
|
||
scanStatus.value = `客户 ${normalizedText} 不存在,请先建档。`;
|
||
return;
|
||
}
|
||
|
||
selectedCustomerCache.set(customer.id, customer);
|
||
if (!customerOptions.value.some(item => item.id === customer.id)) {
|
||
customerOptions.value = [customer, ...customerOptions.value];
|
||
}
|
||
if (isMultiple.value) {
|
||
if (!draftSelectedIdList.value.includes(customer.id)) {
|
||
draftSelectedIdList.value = [...draftSelectedIdList.value, customer.id];
|
||
}
|
||
} else {
|
||
emit("update:modelValue", customer.id);
|
||
emit("change", customer.id);
|
||
emit("update:visible", false);
|
||
}
|
||
scanStatus.value = `已识别 ${customer.code || normalizedText},已选中 ${customer.name || "未命名客户"}。`;
|
||
scannerVisible.value = false;
|
||
} catch {
|
||
if (token === scanToken) {
|
||
scanStatus.value = "客户查询失败,请重试。";
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleScanStatusChange(value: string): void {
|
||
scanStatus.value = value;
|
||
}
|
||
|
||
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 = "";
|
||
scannerVisible.value = false;
|
||
latestDecodedText = "";
|
||
scanToken++;
|
||
scanStatus.value = "点击扫码开始识别";
|
||
reloadByKeyword.cancel();
|
||
return;
|
||
}
|
||
syncDraftSelection();
|
||
keyword.value = props.initialKeyword;
|
||
await reloadCustomers();
|
||
});
|
||
|
||
watch(() => props.enableScan, (value) => {
|
||
if (value) {
|
||
return;
|
||
}
|
||
scannerVisible.value = false;
|
||
scanToken++;
|
||
});
|
||
|
||
watch(() => props.storeId, async () => {
|
||
if (!props.visible) {
|
||
return;
|
||
}
|
||
await reloadCustomers();
|
||
});
|
||
|
||
watch(keyword, () => {
|
||
if (!props.visible) {
|
||
return;
|
||
}
|
||
reloadByKeyword();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
reloadByKeyword.cancel();
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.customer-selector {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.search-wrap {
|
||
gap: .5rem;
|
||
display: flex;
|
||
z-index: 1;
|
||
padding: .5rem;
|
||
box-shadow: 0 4px 4px rgba(0, 0, 0, .05);
|
||
align-items: center;
|
||
|
||
.search {
|
||
--td-input-vertical-padding: 8px 14px;
|
||
|
||
flex: 1;
|
||
border: 1px solid var(--td-component-border);
|
||
padding: .25rem .5rem;
|
||
min-width: 0;
|
||
border-radius: .75rem;
|
||
}
|
||
|
||
.scan {
|
||
--td-button-border-radius: .75rem;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.scanner-wrap {
|
||
padding: .5rem 1rem 0;
|
||
}
|
||
|
||
.result-list {
|
||
flex: 1;
|
||
overflow: auto;
|
||
min-height: 0;
|
||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||
}
|
||
}
|
||
|
||
:global(html.theme-dark) {
|
||
.customer-selector {
|
||
background: rgba(15, 23, 42, .96);
|
||
}
|
||
|
||
.center-tips {
|
||
color: #94a3b8;
|
||
}
|
||
}
|
||
</style>
|