650 lines
17 KiB
Vue
650 lines
17 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
|
||
>
|
||
<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>
|