423 lines
9.5 KiB
Vue
423 lines
9.5 KiB
Vue
<template>
|
|
<div v-can="permissionCodes.customer.read" class="customer-list">
|
|
<div class="filter bg-white">
|
|
<t-dropdown-menu v-if="isGlobalManager" class="store">
|
|
<t-dropdown-item
|
|
v-model="selectedStoreId"
|
|
:label="storeFilterLabel"
|
|
:options="storeOptions"
|
|
@change="selectStoreFilter"
|
|
/>
|
|
</t-dropdown-menu>
|
|
<t-input
|
|
v-model.trim="keyword"
|
|
class="search"
|
|
placeholder="搜索编码、姓名、电话"
|
|
clearable
|
|
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-cell
|
|
class="item"
|
|
v-for="(item, index) in pageResult.list"
|
|
:key="item.id || item.code || index"
|
|
arrow
|
|
@click="router.push(`/management/customers/${item.id}`)"
|
|
>
|
|
<template #left-icon>
|
|
<t-image
|
|
v-if="getCustomerPhotoURL(item)"
|
|
class="avatar"
|
|
:src="getCustomerPhotoURL(item)"
|
|
:alt="item.name"
|
|
fit="cover"
|
|
/>
|
|
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
|
|
</template>
|
|
<template #title>
|
|
<div class="header">
|
|
<div class="left">
|
|
<div class="text-bold" v-text="item.name" />
|
|
<gender-icon :value="item.gender" size="1.125rem" />
|
|
</div>
|
|
<span class="gray" v-text="item.code || ''" />
|
|
</div>
|
|
</template>
|
|
<template #description>
|
|
<telephone-link :value="item.telephone" />
|
|
</template>
|
|
</t-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>
|
|
<div class="bottom-actions">
|
|
<t-button
|
|
v-can="permissionCodes.customer.export"
|
|
theme="primary"
|
|
variant="outline"
|
|
class="export bg-white"
|
|
:loading="isExporting"
|
|
:disabled="isExporting"
|
|
@click="doExport"
|
|
>
|
|
<template #icon>
|
|
<t-icon name="download" />
|
|
</template>
|
|
导出
|
|
</t-button>
|
|
<t-button
|
|
v-can="permissionCodes.customer.create"
|
|
theme="primary"
|
|
class="create"
|
|
@click="toCreate"
|
|
>
|
|
<template #icon>
|
|
<t-icon name="add" />
|
|
</template>
|
|
添加
|
|
</t-button>
|
|
</div>
|
|
<tag-selector
|
|
v-model="selectedTagIdList"
|
|
v-model:visible="tagSelectorVisible"
|
|
@change="handleTagFilterChanged"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Toast } from "tdesign-mobile-vue";
|
|
import { type DropdownValue } from "tdesign-mobile-vue";
|
|
import { type Page, type PageResult, Time, Toolkit, useUserStore } from "timi-web";
|
|
import CustomerAPI from "@/api/CustomerAPI";
|
|
import GenderIcon from "@/components/GenderIcon.vue";
|
|
import TagSelector from "@/components/TagSelector.vue";
|
|
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
|
import type { Customer } from "@/types/Customer";
|
|
import type { Tag } from "@/types/Tag";
|
|
import { getCustomerPhotoURL } from "@/utils/customer";
|
|
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
|
|
|
defineOptions({
|
|
name: "CustomerList"
|
|
});
|
|
|
|
const router = useRouter();
|
|
const userStore = useUserStore();
|
|
const currentStoreStore = useCurrentStoreStore();
|
|
const isRefreshing = ref(false);
|
|
const isExporting = ref(false);
|
|
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
|
const canReadTag = computed(() => userStore.hasPermissions(corePermissionCodes.tag.read));
|
|
|
|
// ---------- 门店筛选 ----------
|
|
|
|
const selectedStoreId = ref(currentStoreStore.currentStoreId);
|
|
const storeOptions = computed(() => currentStoreStore.storeList
|
|
.map(item => ({
|
|
label: item.name || item.code || "未命名门店",
|
|
value: item.id || ""
|
|
}))
|
|
.filter(item => item.value));
|
|
const storeFilterLabel = computed(() => storeOptions.value.find(item => item.value === selectedStoreId.value)?.label || "选择门店");
|
|
|
|
async function selectStoreFilter(value: DropdownValue): Promise<void> {
|
|
if (Array.isArray(value)) {
|
|
return;
|
|
}
|
|
|
|
const storeId = String(value);
|
|
selectedStoreId.value = storeId;
|
|
currentStoreStore.selectStore(storeId);
|
|
await refresh();
|
|
}
|
|
|
|
// ---------- 标签筛选 ----------
|
|
|
|
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: selectedStoreId.value,
|
|
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();
|
|
selectedStoreId.value = currentStoreStore.currentStoreId;
|
|
} else {
|
|
await currentStoreStore.loadLoginStore();
|
|
}
|
|
await fetch();
|
|
}
|
|
|
|
onMounted(loadData);
|
|
|
|
watch(keyword, async () => {
|
|
window.clearTimeout(keywordTimer);
|
|
keywordTimer = window.setTimeout(async () => {
|
|
await refresh();
|
|
}, 300);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.clearTimeout(keywordTimer);
|
|
});
|
|
|
|
// ---------- 导航 ----------
|
|
|
|
async function toCreate(): Promise<void> {
|
|
await router.push({
|
|
path: "/management/customers/create",
|
|
query: isGlobalManager.value && selectedStoreId.value ? { storeId: selectedStoreId.value } : undefined
|
|
});
|
|
}
|
|
|
|
// ---------- 导出 ----------
|
|
|
|
async function doExport(): Promise<void> {
|
|
if (isExporting.value) {
|
|
return;
|
|
}
|
|
isExporting.value = true;
|
|
try {
|
|
const blob = await CustomerAPI.exportXSSF(page);
|
|
Toolkit.downloadBlob(blob, `客户列表-${Time.formatDate(new Date())}.xlsx`);
|
|
Toast.success("导出已开始");
|
|
} finally {
|
|
isExporting.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.customer-list {
|
|
padding: calc(var(--app-nav-height)) 0 5.5rem 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;
|
|
|
|
.store {
|
|
|
|
&:after {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.search {
|
|
width: 100%;
|
|
border: 1px solid rgba(148, 163, 184, .28);
|
|
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 4rem 0;
|
|
|
|
.items {
|
|
|
|
.item {
|
|
padding: 10px;
|
|
|
|
.avatar {
|
|
width: 3.5rem;
|
|
height: 3.5rem;
|
|
border-radius: .5rem;
|
|
}
|
|
|
|
.header {
|
|
gap: .5rem;
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
|
|
.left {
|
|
gap: .5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.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 {
|
|
|
|
.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>
|