Files
gao-customer-h5/src/pages/customer/CustomerList.vue
T
2026-08-31 11:27:01 +08:00

401 lines
9.7 KiB
Vue

<template>
<div v-can="permissionCodes.customer.read" class="customer-list">
<div class="filter bg-white">
<t-input
v-model.trim="keyword"
class="search"
placeholder="搜索编码、姓名(拼音)、电话"
borderless
/>
<t-button
v-if="canReadTag"
class="tag-filter"
theme="primary"
variant="outline"
@click="tagSelectorVisible = true"
>
<template #icon>
<t-icon name="tag" />
</template>
<span v-text="tagFilterText" />
</t-button>
</div>
<div v-if="canReadTag && selectedTagList.length" class="selected-tags bg-white">
<t-tag
v-for="item in selectedTagList"
:key="item.id"
theme="primary"
variant="light-outline"
:content="getTagName(item)"
/>
<t-button size="extra-small" variant="text" @click="clearTagFilter">清空</t-button>
</div>
<t-pull-down-refresh
class="list-wrapper"
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<t-cell-group class="items" theme="card">
<t-swipe-cell
v-entrance-transition="index * 30"
v-for="(item, index) in pageResult.list"
:key="item.id || item.code || index"
class="item"
>
<customer-cell
class="cell"
:customer="item"
arrow
@click="toDetail(item)"
>
<template #description>
<telephone-link :value="item.telephone" />
</template>
</customer-cell>
<template v-if="canDeleteCustomer" #right>
<t-button
class="delete"
theme="danger"
:loading="isRemoving && removingCustomerId === item.id"
@click.stop="openRemoveDialog(item)"
>
删除
</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<div v-if="!pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载客户' : '暂无客户数据'" />
<div v-if="pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
</t-pull-down-refresh>
<tag-selector
v-model="selectedTagIdList"
v-model:visible="tagSelectorVisible"
@change="handleTagFilterChanged"
/>
<customer-remove-dialog
v-model:visible="removeDialogVisible"
:customer="removeTarget"
:loading="isRemoving"
@confirm="removeCustomer"
/>
</div>
<nav-bar-action-menu v-if="canShowActionMenu" :actions="actionList" />
</template>
<script setup lang="ts">
import { Message } from "@/utils/message";
import { Time, Toolkit, WebToolkit } from "timi-web";
import { type Page, type PageResult, useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import CustomerRemoveDialog from "@/pages/customer/component/CustomerRemoveDialog.vue";
import TagSelector from "@/components/TagSelector.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { NavBarAction } from "@/types/NavBarAction";
import type { Tag } from "@/types/Tag";
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "CustomerList"
});
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const navBarStore = useNavBarStore();
const isRefreshing = ref(false);
const isExporting = ref(false);
const isRemoving = ref(false);
const removeDialogVisible = ref(false);
const removeTarget = ref<Customer>();
const removingCustomerId = ref("");
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const canReadTag = computed(() => userStore.hasPermissions(corePermissionCodes.tag.read));
const canCreateCustomer = computed(() => userStore.hasPermissions(permissionCodes.customer.create));
const canDeleteCustomer = computed(() => userStore.hasPermissions(permissionCodes.customer.delete));
const canExportCustomer = computed(() => userStore.hasPermissions(permissionCodes.customer.export));
const canShowActionMenu = computed(() => canCreateCustomer.value || canExportCustomer.value);
const actionList = computed<NavBarAction[]>(() => {
const list: NavBarAction[] = [];
if (canCreateCustomer.value) {
list.push({
action: toCreate,
theme: "primary",
icon: "add",
key: "create",
text: "新增客户"
});
}
if (canExportCustomer.value) {
list.push({
action: doExport,
icon: "download",
key: "export",
loading: isExporting.value,
text: "导出 Excel"
});
}
return list;
});
// ---------- 标签筛选 ----------
const selectedTagList = ref<Tag[]>([]);
const selectedTagIdList = ref<string[]>([]);
const tagSelectorVisible = ref(false);
const tagFilterText = computed(() => selectedTagList.value.length ? `${selectedTagList.value.length} 个标签` : "标签");
function getTagName(tag: Tag): string {
return tag.name || tag.zhCN || "未命名标签";
}
async function handleTagFilterChanged(tagList: Tag[]): Promise<void> {
selectedTagList.value = tagList;
selectedTagIdList.value = tagList.map(item => item.id || "").filter(Boolean);
await refresh();
}
async function clearTagFilter(): Promise<void> {
selectedTagList.value = [];
selectedTagIdList.value = [];
await refresh();
}
// ---------- 分页 ----------
const keyword = ref();
const isLoading = ref(false);
const isNoMore = ref(false);
let keywordTimer: number | undefined;
const pageResult = reactive<PageResult<Customer>>({
total: 0,
list: []
});
const page = reactive<Page<Customer>>({
index: 0,
size: 15
});
const fetch = Toolkit.debounce(async () => {
const result = await CustomerAPI.list({
...page,
keyword: keyword.value,
storeId: currentStoreStore.currentStoreId,
tagIdList: selectedTagIdList.value
});
pageResult.total = result.total;
pageResult.list = pageResult.list.concat(result.list);
page.index += 1;
isNoMore.value = (result.list || []).length < page.size || pageResult.total <= pageResult.list.length;
});
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
page.size = 15;
pageResult.total = 0;
pageResult.list = [];
fetch();
} finally {
isRefreshing.value = false;
}
}
async function loadData(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
await fetch();
}
onMounted(loadData);
onMounted(() => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canShowActionMenu.value);
});
watch(keyword, async () => {
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await refresh();
}, 300);
});
onUnmounted(() => {
navBarStore.clearRight();
window.clearTimeout(keywordTimer);
});
// ---------- 导航 ----------
async function toCreate(): Promise<void> {
await router.push({
path: "/management/customers/create",
query: isGlobalManager.value && currentStoreStore.currentStoreId ? { storeId: currentStoreStore.currentStoreId } : undefined
});
}
async function toDetail(customer: Customer): Promise<void> {
if (!customer.id) {
return;
}
await router.push(`/management/customers/${customer.id}`);
}
// ---------- 删除客户 ----------
function openRemoveDialog(customer: Customer): void {
if (!customer.id || isRemoving.value) {
return;
}
removeTarget.value = customer;
removeDialogVisible.value = true;
}
async function removeCustomer(customer: Customer): Promise<void> {
const customerId = customer.id;
if (!customerId || isRemoving.value) {
return;
}
isRemoving.value = true;
removingCustomerId.value = customerId;
removeDialogVisible.value = false;
removeTarget.value = undefined;
try {
await CustomerAPI.remove(customerId);
pageResult.list = pageResult.list.filter(item => item.id !== customerId);
pageResult.total = Math.max(0, pageResult.total - 1);
Message.success("客户已删除");
} finally {
isRemoving.value = false;
removingCustomerId.value = "";
}
}
// ---------- 导出 ----------
async function doExport(): Promise<void> {
if (isExporting.value) {
return;
}
isExporting.value = true;
try {
const blob = await CustomerAPI.exportXSSF(page);
WebToolkit.downloadBlob(blob, `客户列表-${Time.formatDate(new Date())}.xlsx`);
Message.success("导出已开始");
} finally {
isExporting.value = false;
}
}
</script>
<style scoped lang="less">
.customer-list {
padding: var(--app-nav-height) 0 1rem 0;
.filter {
gap: .5rem;
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 10;
padding: .5rem;
display: flex;
position: sticky;
box-shadow: var(--td-shadow-1);
align-items: center;
.search {
width: 100%;
border: 1px solid var(--td-component-border);
padding: .5rem 1rem;
border-radius: 1rem;
}
.tag-filter {
--td-button-border-radius: .875rem;
flex: 0 0 auto;
}
}
.selected-tags {
gap: .5rem;
z-index: 9;
display: flex;
padding: .5rem 1rem;
overflow: auto;
position: sticky;
top: calc(var(--app-nav-height) + 3.5rem);
box-shadow: var(--td-shadow-1);
:deep(.t-tag) {
flex: 0 0 auto;
}
}
.list-wrapper {
padding: 1rem 0;
.delete {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
}
}
:global(html.theme-dark) {
.customer-list {
.filter-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);
}
}
.stats {
.stat {
background: rgba(30, 41, 59, .9);
border-color: rgba(148, 163, 184, .16);
.label {
color: #94a3b8;
}
.value {
color: #e2e8f0;
}
}
}
}
}
</style>