Files
gao-customer-h5/src/pages/customer/CustomerList.vue
T
2026-09-22 00:36:31 +08:00

650 lines
17 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 v-can="permissionCodes.customer.read" class="customer-list">
<div class="filter bg-white">
<t-input
v-model.trim="keyword"
class="search"
placeholder="搜索编码、姓名(拼音)、电话"
borderless
>
<template #suffix-icon>
<t-button
size="extra-small"
theme="primary"
variant="outline"
@click.stop="toggleScanner"
>
<template #icon>
<t-icon name="scan" />
</template>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</template>
</t-input>
<t-dropdown-menu ref="filterMenu" class="menus">
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="selectDateFilter"
/>
<t-dropdown-item
v-if="canReadTag"
:label="tagFilterText"
@open="handleTagFilterOpen"
/>
</t-dropdown-menu>
</div>
<div v-if="scannerVisible" class="scanner-wrapper">
<code-scanner
:visible="scannerVisible"
:status-text="scanStatus"
active-text="正在识别客户编码,请将条码或二维码移入取景框"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</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="fix-last-cell-line"
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<div class="list-wrap">
<t-cell-group class="summary" theme="card">
<t-cell
v-if="dateFilterType !== 'all'"
title="开始时间"
:note="dateRange[0] || '-'"
arrow
@click="openStartTime"
/>
<t-cell
v-if="dateFilterType !== 'all'"
title="结束时间"
:note="dateRange[1] || '-'"
arrow
@click="openEndTime"
/>
<t-cell v-if="keyword" title="关键词" :note="keyword" />
<t-cell v-if="selectedTagList.length" title="标签" :note="tagFilterText" />
<t-cell title="数据量" :note="`共 ${pageResult.total} 位客户`" />
</t-cell-group>
<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="center-tips" v-text="isLoading ? '正在加载客户' : '暂无客户数据'" />
<div v-if="pageResult.list.length" class="center-tips" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
</div>
</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" />
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-if="startVisible"
v-model="startDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
:default-value="startDraft"
@cancel="cancelStartTime"
@confirm="confirmStartTime"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-if="endVisible"
v-model="endDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="结束时间"
:default-value="endDraft"
@cancel="cancelEndTime"
@confirm="confirmEndTime"
/>
</t-popup>
</template>
<script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue";
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 CodeScanner from "@/components/CodeScanner.vue";
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 { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { NavBarAction } from "@/types/NavBarAction";
import type { Tag } from "@/types/Tag";
import { buildPresetDateRange, parseDateTime } from "@/utils/date";
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "CustomerList"
});
type DateFilterType = "today" | "yesterday" | "thisWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const settingStore = useSettingStore();
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 scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别");
let latestDecodedText = "";
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "全部", value: "all" },
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "本周", value: "thisWeek" },
{ label: "本月", value: "thisMonth" },
{ label: "上个月", value: "lastMonth" },
{ label: "自定义范围", value: "custom" }
];
const dateFilterType = ref<DateFilterType>("all");
const dateRange = ref<[string, string]>(buildPresetDateRange("all"));
const startDraft = ref("");
const endDraft = ref("");
const startVisible = ref(false);
const endVisible = ref(false);
const filterMenu = ref<{ toggle?: (index?: number) => void }>();
const dateFilterLabel = computed(() => `录入时间:${dateOptions.find(item => item.value === dateFilterType.value)?.label || "全部"}`);
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
scanStatus.value = "正在启动扫码预览";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
await settingStore.playScanSuccessSound();
try {
const customer = await CustomerAPI.findByCode(normalizedText, currentStoreStore.currentStoreId || undefined);
if (!customer?.id) {
scanStatus.value = `客户 ${normalizedText} 不存在,请先建档。`;
return;
}
keyword.value = customer.code || normalizedText;
scannerVisible.value = false;
scanStatus.value = `已识别 ${customer.code || normalizedText}`;
} catch {
scanStatus.value = "客户查询失败,请重试。";
}
}
function handleScanStatusChange(value: string): void {
scanStatus.value = 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;
});
// ---------- 录入时间筛选 ----------
async function selectDateFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetDateRange(dateFilterType.value);
await refresh();
}
}
function openStartTime(): void {
startDraft.value = dateRange.value[0] || `${Time.formatDate(new Date())} 00:00`;
startVisible.value = true;
}
function openEndTime(): void {
endDraft.value = dateRange.value[1] || `${Time.formatDate(new Date())} 23:59`;
endVisible.value = true;
}
function cancelStartTime(): void {
startVisible.value = false;
startDraft.value = dateRange.value[0];
}
function cancelEndTime(): void {
endVisible.value = false;
endDraft.value = dateRange.value[1];
}
async function confirmStartTime(value?: string | number): Promise<void> {
startVisible.value = false;
dateRange.value = [String(value || startDraft.value), dateRange.value[1]];
await confirmCustomRange();
}
async function confirmEndTime(value?: string | number): Promise<void> {
endVisible.value = false;
dateRange.value = [dateRange.value[0], String(value || endDraft.value)];
await confirmCustomRange();
}
async function confirmCustomRange(): Promise<void> {
if (dateRange.value[0] && dateRange.value[1] && dateRange.value[1] < dateRange.value[0]) {
dateRange.value = [dateRange.value[1], dateRange.value[0]];
}
dateFilterType.value = "custom";
await refresh();
}
function handleTagFilterOpen(): void {
tagSelectorVisible.value = true;
filterMenu.value?.toggle?.();
}
// ---------- 标签筛选 ----------
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 [beginCreatedAt, endCreatedAt] = buildCreatedAtRange();
const result = await CustomerAPI.list({
...page,
keyword: keyword.value,
storeId: currentStoreStore.currentStoreId,
tagIdList: selectedTagIdList.value,
beginCreatedAt,
endCreatedAt
});
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);
scannerVisible.value = false;
});
// ---------- 导航 ----------
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;
}
}
function buildCreatedAtRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [undefined, undefined];
}
return [
dateRange.value[0] ? Time.toTimestamp(parseDateTime(dateRange.value[0], false)) : undefined,
dateRange.value[1] ? Time.toTimestamp(parseDateTime(dateRange.value[1], true)) : undefined
];
}
</script>
<style scoped lang="less">
.customer-list {
padding: var(--app-nav-height) 0 1rem 0;
.filter {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 10;
display: flex;
padding: .75rem 1rem;
position: sticky;
background: #f5f5f8;
box-shadow: var(--td-shadow-1);
flex-direction: column;
.search {
--td-button-border-radius: .75rem;
--td-input-vertical-padding: .25rem 1rem;
border: 1px solid rgba(148, 163, 184, .28);
z-index: 2;
background: #f8fbff;
padding-right: .3rem;
border-radius: 1rem;
}
.menus {
--td-spacer-1: 6px;
width: 100%;
overflow: hidden;
margin-top: -1rem;
padding-top: .8rem;
background: #fff;
border: 1px solid rgba(148, 163, 184, .18);
border-radius: 0 0 1rem 1rem;
}
}
.scanner-wrapper {
padding: .5rem 1rem 0;
}
.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-wrap {
gap: 1rem;
display: flex;
flex-direction: column;
padding: 1rem 0;
.summary {
overflow: hidden;
:deep(.t-cell__title) {
flex: 0 0 4.5rem;
}
:deep(.t-cell__note) {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.delete {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
}
}
:global(html.theme-dark) {
.customer-list {
.filter {
background: #10161f;
.search {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.menus {
background: #172033;
border-color: rgba(148, 163, 184, .16);
}
}
.summary {
color: #e2e8f0;
background: rgba(30, 41, 59, .9);
border-color: rgba(148, 163, 184, .16);
}
.items {
.center-tips,
.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>