Files
gao-customer-h5/src/pages/management/CustomerList.vue
T
2026-08-01 18:01:23 +08:00

375 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div ref="pageRef" v-can="gaoPermissionCodes.customer.read" class="customer-list">
<div class="search-bar">
<t-input
v-model.trim="keyword"
class="text-input"
placeholder="搜索编码、姓名、电话"
clearable
borderless
/>
</div>
<section class="total">
<span class="gray">客户总数:</span>
<span class="bold" v-text="String(totalCount)" />
</section>
<section class="items">
<div
class="item"
v-for="item in customerList"
:key="item.id || item.code"
@click="goDetail(item)"
>
<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 class="content">
<div class="line">
<div class="name">
<h4 class="value" v-text="item.name" />
<gender-icon :value="item.gender" size="1.125rem" />
</div>
<span class="meta gray" v-text="item.code" />
</div>
<div class="line">
<div class="gray">
<template v-if="item.telephone">
<t-icon name="call" />
<span class="word-space" v-text="item.telephone" />
</template>
</div>
<t-tag
size="small"
variant="outline"
theme="success"
:content="`${item.invitedCount} 位邀请`"
/>
</div>
</div>
</div>
<div v-if="!customerList.length" class="empty-block" v-text="loading ? '正在加载客户' : '暂无客户数据'" />
<div v-if="customerList.length" class="load-state" v-text="loading ? '正在加载更多' : noMore ? '已经到底了' : '上拉加载更多'" />
</section>
<div class="bottom-actions">
<t-button
v-can="gaoPermissionCodes.customer.read"
theme="primary"
variant="outline"
class="export bg-white"
:loading="exporting"
:disabled="exporting"
@click="exportCustomers"
>
<template #icon>
<t-icon name="download" />
</template>
导出
</t-button>
<t-button
v-can="gaoPermissionCodes.customer.create"
theme="primary"
class="create"
@click="router.push('/management/customers/create')"
>
<template #icon>
<t-icon name="add" />
</template>
添加
</t-button>
</div>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { Scroller, Time, Toolkit } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import GenderIcon from "@/components/GenderIcon.vue";
import type { Customer, CustomerSearchReq } from "@/types/Customer";
import { getCustomerPhotoURL } from "@/utils/customer";
import { gaoPermissionCodes } from "@/utils/gaoPermission";
defineOptions({
name: "CustomerList"
});
const scrollListenerName = "CustomerList";
const router = useRouter();
const pageSize = 20;
const keyword = ref("");
const loading = ref(false);
const exporting = ref(false);
const noMore = ref(false);
const pageIndex = ref(0);
const totalCount = ref(0);
const customerList = ref<Customer[]>([]);
const pageRef = ref<HTMLElement>();
let loadToken = 0;
let keywordTimer: number | undefined;
function getPageScrollElement(): HTMLElement | null {
return pageRef.value?.closest(".pages") || null;
}
async function reloadCustomers(): Promise<void> {
pageIndex.value = 0;
totalCount.value = 0;
customerList.value = [];
noMore.value = false;
await loadCustomers();
}
async function loadCustomers(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
const token = loadToken + 1;
loadToken = token;
loading.value = true;
try {
const result = await CustomerAPI.searchPage({
index: pageIndex.value,
size: pageSize,
equalsExample: buildSearchReq()
});
if (token !== loadToken) {
return;
}
customerList.value = customerList.value.concat(result.list || []);
totalCount.value = result.total || 0;
pageIndex.value += 1;
noMore.value = customerList.value.length >= totalCount.value || (result.list || []).length < pageSize;
} finally {
if (token === loadToken) {
loading.value = false;
}
}
}
async function exportCustomers(): Promise<void> {
if (exporting.value) {
return;
}
exporting.value = true;
try {
const blob = await CustomerAPI.exportExcel(buildSearchReq());
Toolkit.downloadBlob(blob, `客户列表-${Time.formatDate(new Date())}.xlsx`);
showToast("导出已开始");
} finally {
exporting.value = false;
}
}
async function goDetail(customer: Customer): Promise<void> {
if (!customer.id) {
showToast("客户 ID 缺失,无法打开详情", "warning");
return;
}
await router.push(`/management/customers/${customer.id}`);
}
function buildSearchReq(): CustomerSearchReq {
return {
keyword: keyword.value || undefined
};
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
message,
theme
});
}
watch(keyword, async () => {
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await reloadCustomers();
}, 300);
});
onMounted(async () => {
await reloadCustomers();
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
canLoad: () => !loading.value && !noMore.value,
getElement: getPageScrollElement
});
});
onUnmounted(() => {
window.clearTimeout(keywordTimer);
Scroller.removeListener(scrollListenerName);
});
</script>
<style scoped lang="less">
.customer-list {
padding: calc(var(--app-nav-height)) 0 5.5rem 0;
.search-bar {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 10;
padding: .75rem 1rem;
position: sticky;
background: #F5F5F8;
box-shadow: var(--td-shadow-1);
.text-input {
width: 100%;
border: 1px solid rgba(148, 163, 184, .28);
padding: .5rem 1rem;
background: #f8fbff;
border-radius: 1rem;
}
}
.total {
display: flex;
margin-top: 1rem;
justify-content: center;
}
.items {
gap: .5rem;
padding: 1rem;
display: grid;
.item {
gap: .75rem;
padding: .5rem;
display: flex;
text-align: left;
background: #f7faff;
align-items: center;
border: 1px solid var(--td-border-level-1-color);
border-radius: .875rem;
justify-content: space-between;
.avatar {
flex: none;
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;
}
}
.content {
gap: .75rem;
flex: 1;
display: flex;
min-width: 0;
flex-direction: column;
justify-content: space-between;
.meta {
font-size: .875rem;
}
.line {
display: flex;
align-items: center;
justify-content: space-between;
}
.name {
gap: .375rem;
display: flex;
align-items: center;
.value {
margin: 0;
}
}
}
}
}
.empty-block,
.load-state {
padding: 1rem;
color: #52607a;
text-align: center;
background: #f8fbff;
border-radius: 1rem;
}
.bottom-actions {
gap: .75rem;
left: 1rem;
right: 1rem;
display: flex;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
.export {
flex: 1;
}
.create {
flex: 2;
}
}
}
:global(html.theme-dark) {
.customer-list {
.search-bar {
background: #10161f;
.text-input {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
.items {
.load-state,
.item,
.empty-block {
color: #e2e8f0;
background: rgba(30, 41, 59, .9);
border-color: rgba(148, 163, 184, .16);
}
.avatar {
&.fallback {
color: #93c5fd;
background: rgba(30, 64, 175, .32);
}
}
}
.stats {
.stat {
background: rgba(30, 41, 59, .9);
border-color: rgba(148, 163, 184, .16);
.label {
color: #94a3b8;
}
.value {
color: #e2e8f0;
}
}
}
}
}
</style>