From 4fe8bbdba731a51bebc74aec75c7bc345282d526 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Tue, 3 Mar 2026 14:41:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(@vben/web-antd):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=94=BB=E5=83=8F=E9=A1=B5=E9=9D=A2=E4=B8=8E=E4=BA=8C=E7=BA=A7?= =?UTF-8?q?=E6=8A=BD=E5=B1=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/customer/index.ts | 210 +++++++ .../list/components/CustomerDetailDrawer.vue | 232 ++++++++ .../list/components/CustomerFilterBar.vue | 125 ++++ .../list/components/CustomerProfileDrawer.vue | 35 ++ .../list/components/CustomerStatsBar.vue | 56 ++ .../list/components/CustomerTableCard.vue | 174 ++++++ .../customer-list-page/constants.ts | 60 ++ .../customer-list-page/data-actions.ts | 113 ++++ .../customer-list-page/drawer-actions.ts | 79 +++ .../customer-list-page/export-actions.ts | 45 ++ .../customer-list-page/filter-actions.ts | 63 ++ .../composables/customer-list-page/helpers.ts | 105 ++++ .../customer-list-page/navigation-actions.ts | 33 ++ .../list/composables/useCustomerListPage.ts | 183 ++++++ .../src/views/customer/list/index.vue | 96 ++++ .../src/views/customer/list/styles/base.less | 11 + .../views/customer/list/styles/drawer.less | 537 ++++++++++++++++++ .../src/views/customer/list/styles/index.less | 8 + .../views/customer/list/styles/layout.less | 112 ++++ .../customer/list/styles/responsive.less | 78 +++ .../src/views/customer/list/styles/table.less | 107 ++++ .../web-antd/src/views/customer/list/types.ts | 61 ++ .../components/CustomerProfileHeader.vue | 50 ++ .../components/CustomerProfileKpiGrid.vue | 41 ++ .../components/CustomerProfilePanel.vue | 134 +++++ .../CustomerProfileRecentOrdersTable.vue | 78 +++ .../customer-profile-page/constants.ts | 23 + .../customer-profile-page/data-actions.ts | 93 +++ .../customer-profile-page/helpers.ts | 109 ++++ .../composables/useCustomerProfilePage.ts | 132 +++++ .../src/views/customer/profile/index.vue | 29 + .../views/customer/profile/styles/base.less | 5 + .../views/customer/profile/styles/card.less | 264 +++++++++ .../views/customer/profile/styles/index.less | 5 + .../views/customer/profile/styles/layout.less | 17 + .../customer/profile/styles/responsive.less | 36 ++ .../views/customer/profile/styles/table.less | 27 + .../src/views/customer/profile/types.ts | 25 + 38 files changed, 3591 insertions(+) create mode 100644 apps/web-antd/src/api/customer/index.ts create mode 100644 apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue create mode 100644 apps/web-antd/src/views/customer/list/components/CustomerFilterBar.vue create mode 100644 apps/web-antd/src/views/customer/list/components/CustomerProfileDrawer.vue create mode 100644 apps/web-antd/src/views/customer/list/components/CustomerStatsBar.vue create mode 100644 apps/web-antd/src/views/customer/list/components/CustomerTableCard.vue create mode 100644 apps/web-antd/src/views/customer/list/composables/customer-list-page/constants.ts create mode 100644 apps/web-antd/src/views/customer/list/composables/customer-list-page/data-actions.ts create mode 100644 apps/web-antd/src/views/customer/list/composables/customer-list-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/customer/list/composables/customer-list-page/export-actions.ts create mode 100644 apps/web-antd/src/views/customer/list/composables/customer-list-page/filter-actions.ts create mode 100644 apps/web-antd/src/views/customer/list/composables/customer-list-page/helpers.ts create mode 100644 apps/web-antd/src/views/customer/list/composables/customer-list-page/navigation-actions.ts create mode 100644 apps/web-antd/src/views/customer/list/composables/useCustomerListPage.ts create mode 100644 apps/web-antd/src/views/customer/list/index.vue create mode 100644 apps/web-antd/src/views/customer/list/styles/base.less create mode 100644 apps/web-antd/src/views/customer/list/styles/drawer.less create mode 100644 apps/web-antd/src/views/customer/list/styles/index.less create mode 100644 apps/web-antd/src/views/customer/list/styles/layout.less create mode 100644 apps/web-antd/src/views/customer/list/styles/responsive.less create mode 100644 apps/web-antd/src/views/customer/list/styles/table.less create mode 100644 apps/web-antd/src/views/customer/list/types.ts create mode 100644 apps/web-antd/src/views/customer/profile/components/CustomerProfileHeader.vue create mode 100644 apps/web-antd/src/views/customer/profile/components/CustomerProfileKpiGrid.vue create mode 100644 apps/web-antd/src/views/customer/profile/components/CustomerProfilePanel.vue create mode 100644 apps/web-antd/src/views/customer/profile/components/CustomerProfileRecentOrdersTable.vue create mode 100644 apps/web-antd/src/views/customer/profile/composables/customer-profile-page/constants.ts create mode 100644 apps/web-antd/src/views/customer/profile/composables/customer-profile-page/data-actions.ts create mode 100644 apps/web-antd/src/views/customer/profile/composables/customer-profile-page/helpers.ts create mode 100644 apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts create mode 100644 apps/web-antd/src/views/customer/profile/index.vue create mode 100644 apps/web-antd/src/views/customer/profile/styles/base.less create mode 100644 apps/web-antd/src/views/customer/profile/styles/card.less create mode 100644 apps/web-antd/src/views/customer/profile/styles/index.less create mode 100644 apps/web-antd/src/views/customer/profile/styles/layout.less create mode 100644 apps/web-antd/src/views/customer/profile/styles/responsive.less create mode 100644 apps/web-antd/src/views/customer/profile/styles/table.less create mode 100644 apps/web-antd/src/views/customer/profile/types.ts diff --git a/apps/web-antd/src/api/customer/index.ts b/apps/web-antd/src/api/customer/index.ts new file mode 100644 index 0000000..993c029 --- /dev/null +++ b/apps/web-antd/src/api/customer/index.ts @@ -0,0 +1,210 @@ +/** + * 文件职责:客户管理列表与画像 API 契约定义。 + */ +import { requestClient } from '#/api/request'; + +/** 客户标签筛选值。 */ +export type CustomerTagFilter = + | 'active' + | 'all' + | 'churn' + | 'dormant' + | 'high_value' + | 'new_customer'; + +/** 客户下单次数筛选值。 */ +export type CustomerOrderCountRangeFilter = + | 'all' + | 'once' + | 'six_to_ten' + | 'ten_plus' + | 'two_to_five'; + +/** 客户注册周期筛选值。 */ +export type CustomerRegisterPeriodFilter = '7d' | '30d' | '90d' | 'all'; + +/** 客户列表筛选参数。 */ +export interface CustomerListFilterQuery { + keyword?: string; + orderCountRange?: CustomerOrderCountRangeFilter; + registerPeriod?: CustomerRegisterPeriodFilter; + storeId: string; + tag?: CustomerTagFilter; +} + +/** 客户列表分页参数。 */ +export interface CustomerListQuery extends CustomerListFilterQuery { + page: number; + pageSize: number; +} + +/** 客户标签。 */ +export interface CustomerTagDto { + code: string; + label: string; + tone: string; +} + +/** 客户列表行。 */ +export interface CustomerListItemDto { + avatarColor: string; + avatarText: string; + averageAmount: number; + customerKey: string; + isDimmed: boolean; + lastOrderAt: string; + name: string; + orderCount: number; + orderCountBarPercent: number; + phoneMasked: string; + tags: CustomerTagDto[]; + totalAmount: number; +} + +/** 客户列表结果。 */ +export interface CustomerListResultDto { + items: CustomerListItemDto[]; + page: number; + pageSize: number; + total: number; +} + +/** 客户列表统计。 */ +export interface CustomerListStatsDto { + activeCustomers: number; + averageAmountLast30Days: number; + monthlyGrowthRatePercent: number; + monthlyNewCustomers: number; + totalCustomers: number; +} + +/** 客户消费偏好。 */ +export interface CustomerPreferenceDto { + averageDeliveryDistance: string; + preferredCategories: string[]; + preferredDelivery: string; + preferredOrderPeaks: string; + preferredPaymentMethod: string; +} + +/** 客户常购商品。 */ +export interface CustomerTopProductDto { + count: number; + productName: string; + proportionPercent: number; + rank: number; +} + +/** 客户趋势点。 */ +export interface CustomerTrendPointDto { + amount: number; + label: string; +} + +/** 客户最近订单。 */ +export interface CustomerRecentOrderDto { + amount: number; + deliveryType: string; + itemsSummary: string; + orderNo: string; + orderedAt: string; + status: string; +} + +/** 客户会员摘要。 */ +export interface CustomerMemberSummaryDto { + growthValue: number; + isMember: boolean; + joinedAt: string; + pointsBalance: number; + tierName: string; +} + +/** 客户详情(一级抽屉)。 */ +export interface CustomerDetailDto { + averageAmount: number; + customerKey: string; + firstOrderAt: string; + member: CustomerMemberSummaryDto; + name: string; + phoneMasked: string; + preference: CustomerPreferenceDto; + recentOrders: CustomerRecentOrderDto[]; + registeredAt: string; + repurchaseRatePercent: number; + source: string; + tags: CustomerTagDto[]; + topProducts: CustomerTopProductDto[]; + totalAmount: number; + totalOrders: number; + trend: CustomerTrendPointDto[]; +} + +/** 客户画像(一级抽屉内二级抽屉)。 */ +export interface CustomerProfileDto { + averageAmount: number; + averageOrderIntervalDays: number; + customerKey: string; + firstOrderAt: string; + member: CustomerMemberSummaryDto; + name: string; + phoneMasked: string; + preference: CustomerPreferenceDto; + recentOrders: CustomerRecentOrderDto[]; + registeredAt: string; + repurchaseRatePercent: number; + source: string; + tags: CustomerTagDto[]; + topProducts: CustomerTopProductDto[]; + totalAmount: number; + totalOrders: number; + trend: CustomerTrendPointDto[]; +} + +/** 客户导出回执。 */ +export interface CustomerExportDto { + fileContentBase64: string; + fileName: string; + totalCount: number; +} + +/** 查询客户列表。 */ +export async function getCustomerListApi(params: CustomerListQuery) { + return requestClient.get('/customer/list/list', { + params, + }); +} + +/** 查询客户列表统计。 */ +export async function getCustomerListStatsApi(params: CustomerListFilterQuery) { + return requestClient.get('/customer/list/stats', { + params, + }); +} + +/** 查询客户详情。 */ +export async function getCustomerDetailApi(params: { + customerKey: string; + storeId: string; +}) { + return requestClient.get('/customer/list/detail', { + params, + }); +} + +/** 查询客户画像。 */ +export async function getCustomerProfileApi(params: { + customerKey: string; + storeId: string; +}) { + return requestClient.get('/customer/list/profile', { + params, + }); +} + +/** 导出客户列表 CSV。 */ +export async function exportCustomerCsvApi(params: CustomerListFilterQuery) { + return requestClient.get('/customer/list/export', { + params, + }); +} diff --git a/apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue b/apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue new file mode 100644 index 0000000..8ec0f85 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue @@ -0,0 +1,232 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/components/CustomerFilterBar.vue b/apps/web-antd/src/views/customer/list/components/CustomerFilterBar.vue new file mode 100644 index 0000000..b7a5738 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/components/CustomerFilterBar.vue @@ -0,0 +1,125 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/components/CustomerProfileDrawer.vue b/apps/web-antd/src/views/customer/list/components/CustomerProfileDrawer.vue new file mode 100644 index 0000000..2a70486 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/components/CustomerProfileDrawer.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/components/CustomerStatsBar.vue b/apps/web-antd/src/views/customer/list/components/CustomerStatsBar.vue new file mode 100644 index 0000000..466ac88 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/components/CustomerStatsBar.vue @@ -0,0 +1,56 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/components/CustomerTableCard.vue b/apps/web-antd/src/views/customer/list/components/CustomerTableCard.vue new file mode 100644 index 0000000..1b80ab2 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/components/CustomerTableCard.vue @@ -0,0 +1,174 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/composables/customer-list-page/constants.ts b/apps/web-antd/src/views/customer/list/composables/customer-list-page/constants.ts new file mode 100644 index 0000000..dafd002 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/customer-list-page/constants.ts @@ -0,0 +1,60 @@ +import type { OptionItem } from '../../types'; + +import type { CustomerListStatsDto } from '#/api/customer'; +import type { CustomerFilterState } from '#/views/customer/list/types'; + +/** + * 文件职责:客户列表页常量与默认值。 + */ + +/** 客户列表查看权限。 */ +export const CUSTOMER_LIST_VIEW_PERMISSION = 'tenant:customer:list:view'; + +/** 客户列表管理权限(导出)。 */ +export const CUSTOMER_LIST_MANAGE_PERMISSION = 'tenant:customer:list:manage'; + +/** 客户标签筛选项。 */ +export const CUSTOMER_TAG_OPTIONS: OptionItem[] = [ + { label: '全部标签', value: 'all' }, + { label: '高价值', value: 'high_value' }, + { label: '活跃客户', value: 'active' }, + { label: '沉睡客户', value: 'dormant' }, + { label: '流失客户', value: 'churn' }, + { label: '新客户', value: 'new_customer' }, +]; + +/** 下单次数筛选项。 */ +export const CUSTOMER_ORDER_COUNT_OPTIONS: OptionItem[] = [ + { label: '全部次数', value: 'all' }, + { label: '1次', value: 'once' }, + { label: '2-5次', value: 'two_to_five' }, + { label: '6-10次', value: 'six_to_ten' }, + { label: '10次以上', value: 'ten_plus' }, +]; + +/** 注册周期筛选项。 */ +export const CUSTOMER_REGISTER_PERIOD_OPTIONS: OptionItem[] = [ + { label: '全部时间', value: 'all' }, + { label: '最近7天', value: '7d' }, + { label: '最近30天', value: '30d' }, + { label: '最近90天', value: '90d' }, +]; + +/** 默认筛选项。 */ +export function createDefaultFilters(): CustomerFilterState { + return { + keyword: '', + tag: 'all', + orderCountRange: 'all', + registerPeriod: 'all', + }; +} + +/** 默认统计值。 */ +export const DEFAULT_STATS: CustomerListStatsDto = { + totalCustomers: 0, + monthlyNewCustomers: 0, + monthlyGrowthRatePercent: 0, + activeCustomers: 0, + averageAmountLast30Days: 0, +}; diff --git a/apps/web-antd/src/views/customer/list/composables/customer-list-page/data-actions.ts b/apps/web-antd/src/views/customer/list/composables/customer-list-page/data-actions.ts new file mode 100644 index 0000000..b434976 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/customer-list-page/data-actions.ts @@ -0,0 +1,113 @@ +import type { CustomerFilterState, CustomerPaginationState } from '../../types'; + +import type { CustomerListItemDto, CustomerListStatsDto } from '#/api/customer'; +import type { StoreListItemDto } from '#/api/store'; + +import { getCustomerListApi, getCustomerListStatsApi } from '#/api/customer'; +import { getStoreListApi } from '#/api/store'; + +import { DEFAULT_STATS } from './constants'; +import { buildFilterPayload, buildQueryPayload } from './helpers'; + +interface DataActionOptions { + filters: CustomerFilterState; + isListLoading: { value: boolean }; + isStatsLoading: { value: boolean }; + isStoreLoading: { value: boolean }; + pagination: CustomerPaginationState; + rows: { value: CustomerListItemDto[] }; + selectedStoreId: { value: string }; + stats: CustomerListStatsDto; + stores: { value: StoreListItemDto[] }; +} + +/** + * 文件职责:客户列表页数据加载动作。 + */ +export function createDataActions(options: DataActionOptions) { + function resetStats() { + options.stats.totalCustomers = DEFAULT_STATS.totalCustomers; + options.stats.monthlyNewCustomers = DEFAULT_STATS.monthlyNewCustomers; + options.stats.monthlyGrowthRatePercent = + DEFAULT_STATS.monthlyGrowthRatePercent; + options.stats.activeCustomers = DEFAULT_STATS.activeCustomers; + options.stats.averageAmountLast30Days = + DEFAULT_STATS.averageAmountLast30Days; + } + + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ page: 1, pageSize: 200 }); + options.stores.value = result.items; + + if (result.items.length === 0) { + options.selectedStoreId.value = ''; + options.rows.value = []; + options.pagination.total = 0; + resetStats(); + return; + } + + const matched = result.items.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!matched) { + options.selectedStoreId.value = result.items[0]?.id ?? ''; + } + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadPageData() { + if (!options.selectedStoreId.value) { + options.rows.value = []; + options.pagination.total = 0; + resetStats(); + return; + } + + const queryPayload = buildQueryPayload( + options.selectedStoreId.value, + options.filters, + options.pagination.page, + options.pagination.pageSize, + ); + const filterPayload = buildFilterPayload( + options.selectedStoreId.value, + options.filters, + ); + + options.isListLoading.value = true; + options.isStatsLoading.value = true; + try { + const [listResult, statsResult] = await Promise.all([ + getCustomerListApi(queryPayload), + getCustomerListStatsApi(filterPayload), + ]); + + options.rows.value = listResult.items; + options.pagination.total = listResult.total; + options.pagination.page = listResult.page; + options.pagination.pageSize = listResult.pageSize; + + options.stats.totalCustomers = statsResult.totalCustomers; + options.stats.monthlyNewCustomers = statsResult.monthlyNewCustomers; + options.stats.monthlyGrowthRatePercent = + statsResult.monthlyGrowthRatePercent; + options.stats.activeCustomers = statsResult.activeCustomers; + options.stats.averageAmountLast30Days = + statsResult.averageAmountLast30Days; + } finally { + options.isListLoading.value = false; + options.isStatsLoading.value = false; + } + } + + return { + loadPageData, + loadStores, + resetStats, + }; +} diff --git a/apps/web-antd/src/views/customer/list/composables/customer-list-page/drawer-actions.ts b/apps/web-antd/src/views/customer/list/composables/customer-list-page/drawer-actions.ts new file mode 100644 index 0000000..508c0ef --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/customer-list-page/drawer-actions.ts @@ -0,0 +1,79 @@ +import type { CustomerDetailDto, CustomerProfileDto } from '#/api/customer'; + +import { getCustomerDetailApi, getCustomerProfileApi } from '#/api/customer'; + +interface DrawerActionOptions { + detail: { value: CustomerDetailDto | null }; + isDetailDrawerOpen: { value: boolean }; + isDetailLoading: { value: boolean }; + isProfileDrawerOpen: { value: boolean }; + isProfileLoading: { value: boolean }; + profile: { value: CustomerProfileDto | null }; + selectedStoreId: { value: string }; +} + +/** + * 文件职责:客户详情与画像抽屉动作。 + */ +export function createDrawerActions(options: DrawerActionOptions) { + function setDetailDrawerOpen(value: boolean) { + options.isDetailDrawerOpen.value = value; + if (!value) { + options.detail.value = null; + options.isProfileDrawerOpen.value = false; + options.profile.value = null; + } + } + + function setProfileDrawerOpen(value: boolean) { + options.isProfileDrawerOpen.value = value; + if (!value) { + options.profile.value = null; + } + } + + async function openDetail(customerKey: string) { + if (!options.selectedStoreId.value || !customerKey) { + return; + } + + options.isDetailDrawerOpen.value = true; + options.detail.value = null; + options.isProfileDrawerOpen.value = false; + options.profile.value = null; + options.isDetailLoading.value = true; + try { + options.detail.value = await getCustomerDetailApi({ + storeId: options.selectedStoreId.value, + customerKey, + }); + } finally { + options.isDetailLoading.value = false; + } + } + + async function openProfile(customerKey: string) { + if (!options.selectedStoreId.value || !customerKey) { + return; + } + + options.isProfileDrawerOpen.value = true; + options.profile.value = null; + options.isProfileLoading.value = true; + try { + options.profile.value = await getCustomerProfileApi({ + storeId: options.selectedStoreId.value, + customerKey, + }); + } finally { + options.isProfileLoading.value = false; + } + } + + return { + openDetail, + openProfile, + setDetailDrawerOpen, + setProfileDrawerOpen, + }; +} diff --git a/apps/web-antd/src/views/customer/list/composables/customer-list-page/export-actions.ts b/apps/web-antd/src/views/customer/list/composables/customer-list-page/export-actions.ts new file mode 100644 index 0000000..3b67933 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/customer-list-page/export-actions.ts @@ -0,0 +1,45 @@ +import type { CustomerFilterState } from '../../types'; + +import { message } from 'ant-design-vue'; + +import { exportCustomerCsvApi } from '#/api/customer'; + +import { buildFilterPayload, downloadBase64File } from './helpers'; + +interface ExportActionOptions { + canManage: { value: boolean }; + filters: CustomerFilterState; + isExporting: { value: boolean }; + selectedStoreId: { value: string }; +} + +/** + * 文件职责:客户列表导出动作。 + */ +export function createExportActions(options: ExportActionOptions) { + async function handleExport() { + if (!options.selectedStoreId.value) { + return; + } + + if (!options.canManage.value) { + message.warning('暂无导出权限'); + return; + } + + options.isExporting.value = true; + try { + const result = await exportCustomerCsvApi( + buildFilterPayload(options.selectedStoreId.value, options.filters), + ); + downloadBase64File(result.fileName, result.fileContentBase64); + message.success(`导出成功,共 ${result.totalCount} 条记录`); + } finally { + options.isExporting.value = false; + } + } + + return { + handleExport, + }; +} diff --git a/apps/web-antd/src/views/customer/list/composables/customer-list-page/filter-actions.ts b/apps/web-antd/src/views/customer/list/composables/customer-list-page/filter-actions.ts new file mode 100644 index 0000000..cd305b4 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/customer-list-page/filter-actions.ts @@ -0,0 +1,63 @@ +import type { CustomerFilterState, CustomerPaginationState } from '../../types'; + +import { createDefaultFilters } from './constants'; + +interface FilterActionOptions { + filters: CustomerFilterState; + loadPageData: () => Promise; + pagination: CustomerPaginationState; +} + +/** + * 文件职责:客户列表筛选与分页动作。 + */ +export function createFilterActions(options: FilterActionOptions) { + function setTag(value: string) { + options.filters.tag = (value || 'all') as CustomerFilterState['tag']; + } + + function setOrderCountRange(value: string) { + options.filters.orderCountRange = (value || + 'all') as CustomerFilterState['orderCountRange']; + } + + function setRegisterPeriod(value: string) { + options.filters.registerPeriod = (value || + 'all') as CustomerFilterState['registerPeriod']; + } + + function setKeyword(value: string) { + options.filters.keyword = value; + } + + async function handleSearch() { + options.pagination.page = 1; + await options.loadPageData(); + } + + async function handleReset() { + const defaults = createDefaultFilters(); + options.filters.keyword = defaults.keyword; + options.filters.tag = defaults.tag; + options.filters.orderCountRange = defaults.orderCountRange; + options.filters.registerPeriod = defaults.registerPeriod; + options.pagination.page = 1; + await options.loadPageData(); + } + + async function handlePageChange(page: number, pageSize: number) { + options.pagination.page = page; + options.pagination.pageSize = pageSize; + await options.loadPageData(); + } + + return { + handlePageChange, + handleReset, + handleSearch, + setKeyword, + setOrderCountRange, + setRegisterPeriod, + setTag, + }; +} diff --git a/apps/web-antd/src/views/customer/list/composables/customer-list-page/helpers.ts b/apps/web-antd/src/views/customer/list/composables/customer-list-page/helpers.ts new file mode 100644 index 0000000..cb51575 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/customer-list-page/helpers.ts @@ -0,0 +1,105 @@ +import type { + CustomerFilterQueryPayload, + CustomerFilterState, + CustomerListQueryPayload, +} from '../../types'; + +import type { + CustomerOrderCountRangeFilter, + CustomerRegisterPeriodFilter, + CustomerTagFilter, +} from '#/api/customer'; + +function normalizeTag(tag: CustomerTagFilter): CustomerTagFilter | undefined { + return tag === 'all' ? undefined : tag; +} + +function normalizeOrderCountRange( + orderCountRange: CustomerOrderCountRangeFilter, +): CustomerOrderCountRangeFilter | undefined { + return orderCountRange === 'all' ? undefined : orderCountRange; +} + +function normalizeRegisterPeriod( + registerPeriod: CustomerRegisterPeriodFilter, +): CustomerRegisterPeriodFilter | undefined { + return registerPeriod === 'all' ? undefined : registerPeriod; +} + +export function buildFilterPayload( + storeId: string, + filters: CustomerFilterState, +): CustomerFilterQueryPayload { + return { + storeId, + keyword: filters.keyword.trim() || undefined, + tag: normalizeTag(filters.tag), + orderCountRange: normalizeOrderCountRange(filters.orderCountRange), + registerPeriod: normalizeRegisterPeriod(filters.registerPeriod), + }; +} + +export function buildQueryPayload( + storeId: string, + filters: CustomerFilterState, + page: number, + pageSize: number, +): CustomerListQueryPayload { + return { + ...buildFilterPayload(storeId, filters), + page, + pageSize, + }; +} + +export function formatCurrency(value: number) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number.isFinite(value) ? value : 0); +} + +export function formatInteger(value: number) { + return new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 0, + }).format(Number.isFinite(value) ? value : 0); +} + +export function formatPercent(value: number) { + if (!Number.isFinite(value)) { + return '0%'; + } + return `${value.toFixed(1).replace(/\.0$/, '')}%`; +} + +function decodeBase64ToBlob(base64: string) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.codePointAt(index) ?? 0; + } + return new Blob([bytes], { type: 'text/csv;charset=utf-8;' }); +} + +export function downloadBase64File( + fileName: string, + fileContentBase64: string, +) { + const blob = decodeBase64ToBlob(fileContentBase64); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function resolveTagColor(tone: string) { + if (tone === 'orange') return 'orange'; + if (tone === 'green') return 'green'; + if (tone === 'gray') return 'default'; + if (tone === 'red') return 'red'; + return 'blue'; +} diff --git a/apps/web-antd/src/views/customer/list/composables/customer-list-page/navigation-actions.ts b/apps/web-antd/src/views/customer/list/composables/customer-list-page/navigation-actions.ts new file mode 100644 index 0000000..675835d --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/customer-list-page/navigation-actions.ts @@ -0,0 +1,33 @@ +import type { Router } from 'vue-router'; + +interface NavigationActionOptions { + router: Router; + selectedStoreId: { value: string }; +} + +/** + * 文件职责:客户列表页导航动作。 + */ +export function createNavigationActions(options: NavigationActionOptions) { + function openProfilePage(customerKey: string) { + if (!customerKey) { + return; + } + + const query: Record = { + customerKey, + }; + if (options.selectedStoreId.value) { + query.storeId = options.selectedStoreId.value; + } + + void options.router.push({ + path: '/customer/profile', + query, + }); + } + + return { + openProfilePage, + }; +} diff --git a/apps/web-antd/src/views/customer/list/composables/useCustomerListPage.ts b/apps/web-antd/src/views/customer/list/composables/useCustomerListPage.ts new file mode 100644 index 0000000..580a9bb --- /dev/null +++ b/apps/web-antd/src/views/customer/list/composables/useCustomerListPage.ts @@ -0,0 +1,183 @@ +import type { + CustomerDetailDto, + CustomerListItemDto, + CustomerProfileDto, +} from '#/api/customer'; +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:客户列表页面状态与动作编排。 + */ +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; +import { useRouter } from 'vue-router'; + +import { useAccessStore } from '@vben/stores'; + +import { + createDefaultFilters, + CUSTOMER_LIST_MANAGE_PERMISSION, + DEFAULT_STATS, +} from './customer-list-page/constants'; +import { createDataActions } from './customer-list-page/data-actions'; +import { createDrawerActions } from './customer-list-page/drawer-actions'; +import { createExportActions } from './customer-list-page/export-actions'; +import { createFilterActions } from './customer-list-page/filter-actions'; +import { createNavigationActions } from './customer-list-page/navigation-actions'; + +export function useCustomerListPage() { + const accessStore = useAccessStore(); + const router = useRouter(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const filters = reactive(createDefaultFilters()); + const rows = ref([]); + const pagination = reactive({ + page: 1, + pageSize: 10, + total: 0, + }); + + const stats = reactive({ ...DEFAULT_STATS }); + const isListLoading = ref(false); + const isStatsLoading = ref(false); + + const detail = ref(null); + const isDetailDrawerOpen = ref(false); + const isDetailLoading = ref(false); + + const profile = ref(null); + const isProfileDrawerOpen = ref(false); + const isProfileLoading = ref(false); + + const isExporting = ref(false); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(CUSTOMER_LIST_MANAGE_PERMISSION), + ); + + const { loadPageData, loadStores, resetStats } = createDataActions({ + stores, + selectedStoreId, + filters, + rows, + pagination, + stats, + isStoreLoading, + isListLoading, + isStatsLoading, + }); + + const { + handlePageChange, + handleReset, + handleSearch, + setKeyword, + setOrderCountRange, + setRegisterPeriod, + setTag, + } = createFilterActions({ + filters, + pagination, + loadPageData, + }); + + const { openDetail, openProfile, setDetailDrawerOpen, setProfileDrawerOpen } = + createDrawerActions({ + selectedStoreId, + detail, + isDetailDrawerOpen, + isDetailLoading, + profile, + isProfileDrawerOpen, + isProfileLoading, + }); + + const { handleExport } = createExportActions({ + selectedStoreId, + filters, + isExporting, + canManage, + }); + + const { openProfilePage } = createNavigationActions({ + selectedStoreId, + router, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + watch(selectedStoreId, async (storeId) => { + if (!storeId) { + rows.value = []; + pagination.total = 0; + detail.value = null; + profile.value = null; + isDetailDrawerOpen.value = false; + isProfileDrawerOpen.value = false; + resetStats(); + return; + } + + pagination.page = 1; + await loadPageData(); + }); + + onMounted(() => { + void loadStores(); + }); + + onActivated(() => { + if (stores.value.length === 0 || !selectedStoreId.value) { + void loadStores(); + } + }); + + return { + canManage, + detail, + filters, + handleExport, + handlePageChange, + handleReset, + handleSearch, + isDetailDrawerOpen, + isDetailLoading, + isExporting, + isListLoading, + isProfileDrawerOpen, + isProfileLoading, + isStatsLoading, + isStoreLoading, + openDetail, + openProfile, + openProfilePage, + pagination, + profile, + rows, + selectedStoreId, + setDetailDrawerOpen, + setKeyword, + setOrderCountRange, + setProfileDrawerOpen, + setRegisterPeriod, + setSelectedStoreId, + setTag, + stats, + storeOptions, + }; +} diff --git a/apps/web-antd/src/views/customer/list/index.vue b/apps/web-antd/src/views/customer/list/index.vue new file mode 100644 index 0000000..3d64323 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/index.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/apps/web-antd/src/views/customer/list/styles/base.less b/apps/web-antd/src/views/customer/list/styles/base.less new file mode 100644 index 0000000..c13a7fa --- /dev/null +++ b/apps/web-antd/src/views/customer/list/styles/base.less @@ -0,0 +1,11 @@ +.page-customer-list { + .ant-card { + border-radius: 10px; + } +} + +.cl-page { + display: flex; + flex-direction: column; + gap: 12px; +} diff --git a/apps/web-antd/src/views/customer/list/styles/drawer.less b/apps/web-antd/src/views/customer/list/styles/drawer.less new file mode 100644 index 0000000..4cfc0f2 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/styles/drawer.less @@ -0,0 +1,537 @@ +.page-customer-list { + .ant-drawer { + .ant-drawer-header { + padding: 14px 18px; + border-bottom: 1px solid #f0f0f0; + } + + .ant-drawer-body { + padding: 16px 20px; + } + + .ant-drawer-footer { + padding: 12px 20px; + border-top: 1px solid #f0f0f0; + } + } +} + +.cl-detail-head { + display: flex; + gap: 14px; + align-items: center; + margin-bottom: 20px; +} + +.cl-detail-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + font-size: 20px; + font-weight: 600; + color: #fff; + background: #1677ff; + border-radius: 50%; +} + +.cl-detail-title-wrap { + min-width: 0; +} + +.cl-detail-name { + font-size: 16px; + font-weight: 600; + color: rgb(0 0 0 / 88%); +} + +.cl-detail-meta { + margin-top: 2px; + font-size: 13px; + color: rgb(0 0 0 / 45%); +} + +.cl-detail-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-left: auto; + + .ant-tag { + margin-inline-end: 0; + } +} + +.cl-overview-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 22px; +} + +.cl-overview-item { + padding: 12px; + text-align: center; + background: #f8f9fb; + border-radius: 8px; + + .label { + font-size: 12px; + color: rgb(0 0 0 / 45%); + } + + .value { + margin-top: 4px; + font-size: 20px; + font-weight: 700; + color: rgb(0 0 0 / 88%); + + &.primary { + color: #1677ff; + } + + &.success { + color: #52c41a; + } + } +} + +.cl-drawer-section { + margin-bottom: 22px; +} + +.cl-drawer-section-title { + padding-left: 10px; + margin-bottom: 12px; + font-size: 15px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-left: 3px solid #1677ff; +} + +.cl-preference-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; + + .ant-tag { + margin-inline-end: 0; + } + + .cl-empty-text { + font-size: 13px; + color: rgb(0 0 0 / 45%); + } +} + +.cl-preference-list { + font-size: 13px; +} + +.cl-preference-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + color: rgb(0 0 0 / 65%); + + span:last-child { + max-width: 62%; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: rgb(0 0 0 / 88%); + text-align: right; + white-space: nowrap; + } +} + +.cl-top-product-list { + min-height: 42px; +} + +.cl-top-product-item { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 0; + font-size: 13px; + border-bottom: 1px solid #f3f4f6; + + &:last-child { + border-bottom: none; + } + + .rank { + width: 20px; + color: rgb(0 0 0 / 45%); + text-align: center; + } + + .name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: rgb(0 0 0 / 88%); + white-space: nowrap; + } + + .count { + color: rgb(0 0 0 / 65%); + } +} + +.cl-trend-bar-group { + display: flex; + gap: 8px; + align-items: flex-end; + height: 116px; +} + +.cl-trend-bar-item { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + align-items: center; + min-width: 0; + + .amount { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 10px; + color: rgb(0 0 0 / 45%); + text-align: center; + white-space: nowrap; + } + + .bar { + width: 100%; + background: #1677ff; + border-radius: 4px 4px 0 0; + opacity: 0.78; + } + + .month { + font-size: 11px; + color: rgb(0 0 0 / 45%); + } +} + +.cl-recent-orders { + font-size: 13px; +} + +.cl-recent-order-item { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid #f3f4f6; + + &:last-child { + border-bottom: none; + } + + .left { + flex: 1; + min-width: 0; + } + + .summary { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: rgb(0 0 0 / 88%); + white-space: nowrap; + } + + .meta { + margin-top: 2px; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } + + .amount { + flex-shrink: 0; + font-weight: 600; + color: rgb(0 0 0 / 88%); + white-space: nowrap; + } +} + +.cl-detail-footer { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.cl-profile-head { + display: flex; + gap: 16px; + align-items: center; + padding: 4px 0 16px; + margin-bottom: 16px; + border-bottom: 1px solid #f0f0f0; +} + +.cl-profile-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + font-size: 22px; + font-weight: 700; + color: #fff; + background: #3b82f6; + border-radius: 50%; +} + +.cl-profile-title-wrap { + min-width: 0; +} + +.cl-profile-name { + font-size: 18px; + font-weight: 700; + color: rgb(0 0 0 / 88%); +} + +.cl-profile-meta { + margin-top: 4px; + font-size: 13px; + color: rgb(0 0 0 / 45%); +} + +.cl-profile-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-left: auto; + + .ant-tag { + margin-inline-end: 0; + } +} + +.cl-profile-kpis { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.cl-profile-kpi { + padding: 14px 10px; + text-align: center; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + + .num { + overflow: hidden; + text-overflow: ellipsis; + font-size: 20px; + font-weight: 700; + color: rgb(0 0 0 / 88%); + white-space: nowrap; + + &.blue { + color: #1677ff; + } + + &.success { + color: #52c41a; + } + } + + .lbl { + margin-top: 4px; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } +} + +.cl-profile-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.cl-profile-card { + padding: 18px 20px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + + &.full { + grid-column: 1 / -1; + } +} + +.cl-profile-card-title { + padding-left: 10px; + margin-bottom: 14px; + font-size: 15px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-left: 3px solid #1677ff; +} + +.cl-profile-pref-row { + display: flex; + gap: 10px; + justify-content: space-between; + padding: 10px 0; + font-size: 13px; + border-bottom: 1px solid #f3f4f6; + + &:last-child { + border-bottom: none; + } + + .label { + color: rgb(0 0 0 / 65%); + } + + .value { + max-width: 66%; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: rgb(0 0 0 / 88%); + text-align: right; + white-space: nowrap; + } +} + +.cl-profile-top-list { + min-height: 48px; +} + +.cl-profile-top-item { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 0; + font-size: 13px; + border-bottom: 1px solid #f3f4f6; + + &:last-child { + border-bottom: none; + } + + .name { + width: 110px; + overflow: hidden; + text-overflow: ellipsis; + color: rgb(0 0 0 / 88%); + white-space: nowrap; + } + + .bar { + display: inline-flex; + flex: 1; + height: 6px; + overflow: hidden; + background: #f0f0f0; + border-radius: 3px; + } + + .bar-inner { + display: inline-flex; + height: 100%; + background: #1677ff; + border-radius: 3px; + } + + .count { + width: 42px; + color: rgb(0 0 0 / 65%); + text-align: right; + } +} + +.cl-profile-top-rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + font-size: 11px; + font-weight: 700; + border-radius: 50%; + + &.gold { + color: #d97706; + background: #fef3c7; + } + + &.silver { + color: #6b7280; + background: #f3f4f6; + } +} + +.cl-profile-trend-bars { + display: flex; + gap: 8px; + align-items: flex-end; + height: 126px; +} + +.cl-profile-trend-bar-col { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + align-items: center; + min-width: 0; + + .bar-val { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 10px; + color: rgb(0 0 0 / 45%); + text-align: center; + white-space: nowrap; + } + + .bar { + width: 100%; + background: #1677ff; + border-radius: 4px 4px 0 0; + opacity: 0.8; + } + + .bar-lbl { + font-size: 11px; + color: rgb(0 0 0 / 45%); + } +} + +.cl-profile-order-status { + font-weight: 600; + + &.success { + color: #52c41a; + } + + &.danger { + color: #ff4d4f; + } + + &.processing { + color: #1677ff; + } + + &.default { + color: rgb(0 0 0 / 65%); + } +} diff --git a/apps/web-antd/src/views/customer/list/styles/index.less b/apps/web-antd/src/views/customer/list/styles/index.less new file mode 100644 index 0000000..2b938e8 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/styles/index.less @@ -0,0 +1,8 @@ +@import './base.less'; +@import './layout.less'; +@import './table.less'; +@import './drawer.less'; +@import '../../profile/styles/card.less'; +@import '../../profile/styles/table.less'; +@import '../../profile/styles/responsive.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/customer/list/styles/layout.less b/apps/web-antd/src/views/customer/list/styles/layout.less new file mode 100644 index 0000000..771d899 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/styles/layout.less @@ -0,0 +1,112 @@ +.cl-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 12px 14px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + + .cl-store-select { + width: 220px; + } + + .cl-tag-select { + width: 140px; + } + + .cl-order-count-select { + width: 140px; + } + + .cl-register-period-select { + width: 140px; + } + + .cl-search-input { + width: 220px; + } + + .cl-toolbar-right { + margin-left: auto; + } + + .cl-search-icon { + width: 14px; + height: 14px; + color: rgb(0 0 0 / 45%); + } + + .cl-query-btn, + .cl-reset-btn, + .cl-export-btn { + display: inline-flex; + gap: 4px; + align-items: center; + height: 32px; + } + + .ant-select-selector, + .ant-input, + .ant-input-affix-wrapper { + height: 32px; + font-size: 13px; + } + + .ant-input-affix-wrapper .ant-input { + height: 100%; + } +} + +.cl-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.cl-stat-card { + padding: 16px 20px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 6px 14px rgb(15 23 42 / 10%); + transform: translateY(-1px); + } + + .cl-stat-label { + margin-bottom: 6px; + font-size: 13px; + color: rgb(0 0 0 / 45%); + } + + .cl-stat-value { + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: rgb(0 0 0 / 88%); + + &.blue { + color: #1677ff; + } + + &.green { + color: #52c41a; + } + + &.orange { + color: #fa8c16; + } + } + + .cl-stat-sub { + margin-top: 4px; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } +} diff --git a/apps/web-antd/src/views/customer/list/styles/responsive.less b/apps/web-antd/src/views/customer/list/styles/responsive.less new file mode 100644 index 0000000..5388330 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/styles/responsive.less @@ -0,0 +1,78 @@ +@media (max-width: 1600px) { + .cl-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .cl-profile-kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 1024px) { + .cl-profile-grid { + grid-template-columns: 1fr; + } + + .cl-profile-card.full { + grid-column: auto; + } +} + +@media (max-width: 768px) { + .cl-toolbar { + padding: 14px 12px; + + .cl-store-select, + .cl-tag-select, + .cl-order-count-select, + .cl-register-period-select, + .cl-search-input { + width: 100%; + } + + .cl-query-btn, + .cl-reset-btn { + flex: 1; + } + + .cl-toolbar-right { + width: 100%; + margin-left: 0; + } + + .cl-export-btn { + justify-content: center; + width: 100%; + } + } + + .cl-stats { + grid-template-columns: 1fr; + } + + .cl-overview-grid { + grid-template-columns: 1fr; + } + + .cl-detail-head { + flex-wrap: wrap; + } + + .cl-detail-tags { + width: 100%; + margin-left: 0; + } + + .cl-profile-head { + flex-wrap: wrap; + } + + .cl-profile-tags { + width: 100%; + margin-left: 0; + } + + .cl-profile-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/apps/web-antd/src/views/customer/list/styles/table.less b/apps/web-antd/src/views/customer/list/styles/table.less new file mode 100644 index 0000000..a9d8761 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/styles/table.less @@ -0,0 +1,107 @@ +.cl-table-card { + overflow: hidden; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + + .ant-table-wrapper { + .ant-table-thead > tr > th { + font-size: 13px; + white-space: nowrap; + background: #f8f9fb; + } + + .ant-table-tbody > tr > td { + vertical-align: middle; + } + } + + .ant-pagination { + margin: 14px 16px; + } +} + +.cl-customer-cell { + display: flex; + gap: 10px; + align-items: center; +} + +.cl-avatar { + display: inline-flex; + flex-shrink: 0; + gap: 0; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 14px; + font-weight: 600; + color: #fff; + border-radius: 50%; +} + +.cl-customer-main { + min-width: 0; +} + +.cl-customer-name { + font-weight: 500; + color: rgb(0 0 0 / 88%); +} + +.cl-customer-phone { + margin-top: 2px; + font-size: 12px; + color: rgb(0 0 0 / 45%); +} + +.cl-order-count-cell { + display: flex; + gap: 8px; + align-items: center; +} + +.cl-order-count-value { + min-width: 20px; + font-weight: 600; +} + +.cl-order-count-bar { + display: inline-flex; + flex: 1; + max-width: 90px; + height: 6px; + background: #1677ff; + border-radius: 3px; + opacity: 0.72; +} + +.cl-amount, +.cl-average-amount { + font-weight: 600; + white-space: nowrap; +} + +.cl-tag-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + + .ant-tag { + margin-inline-end: 0; + } +} + +.cl-detail-action { + padding-inline: 0; +} + +.cl-row-dimmed td { + opacity: 0.55; +} + +.cl-row-dimmed:hover td { + opacity: 0.78; +} diff --git a/apps/web-antd/src/views/customer/list/types.ts b/apps/web-antd/src/views/customer/list/types.ts new file mode 100644 index 0000000..1637c4c --- /dev/null +++ b/apps/web-antd/src/views/customer/list/types.ts @@ -0,0 +1,61 @@ +import type { + CustomerDetailDto, + CustomerListItemDto, + CustomerListStatsDto, + CustomerOrderCountRangeFilter, + CustomerProfileDto, + CustomerRegisterPeriodFilter, + CustomerTagFilter, +} from '#/api/customer'; + +export interface CustomerFilterState { + keyword: string; + orderCountRange: CustomerOrderCountRangeFilter; + registerPeriod: CustomerRegisterPeriodFilter; + tag: CustomerTagFilter; +} + +export interface CustomerPaginationState { + page: number; + pageSize: number; + total: number; +} + +export interface CustomerListPageState { + detail: CustomerDetailDto | null; + filters: CustomerFilterState; + isDetailDrawerOpen: boolean; + isDetailLoading: boolean; + isExporting: boolean; + isListLoading: boolean; + isProfileDrawerOpen: boolean; + isProfileLoading: boolean; + isStatsLoading: boolean; + pagination: CustomerPaginationState; + profile: CustomerProfileDto | null; + rows: CustomerListItemDto[]; + stats: CustomerListStatsDto; +} + +export interface OptionItem { + label: string; + value: string; +} + +export interface CustomerListQueryPayload { + keyword?: string; + orderCountRange?: CustomerOrderCountRangeFilter; + page: number; + pageSize: number; + registerPeriod?: CustomerRegisterPeriodFilter; + storeId: string; + tag?: CustomerTagFilter; +} + +export interface CustomerFilterQueryPayload { + keyword?: string; + orderCountRange?: CustomerOrderCountRangeFilter; + registerPeriod?: CustomerRegisterPeriodFilter; + storeId: string; + tag?: CustomerTagFilter; +} diff --git a/apps/web-antd/src/views/customer/profile/components/CustomerProfileHeader.vue b/apps/web-antd/src/views/customer/profile/components/CustomerProfileHeader.vue new file mode 100644 index 0000000..a4b8651 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/components/CustomerProfileHeader.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/web-antd/src/views/customer/profile/components/CustomerProfileKpiGrid.vue b/apps/web-antd/src/views/customer/profile/components/CustomerProfileKpiGrid.vue new file mode 100644 index 0000000..d89cddf --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/components/CustomerProfileKpiGrid.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/web-antd/src/views/customer/profile/components/CustomerProfilePanel.vue b/apps/web-antd/src/views/customer/profile/components/CustomerProfilePanel.vue new file mode 100644 index 0000000..257f5b1 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/components/CustomerProfilePanel.vue @@ -0,0 +1,134 @@ + + + diff --git a/apps/web-antd/src/views/customer/profile/components/CustomerProfileRecentOrdersTable.vue b/apps/web-antd/src/views/customer/profile/components/CustomerProfileRecentOrdersTable.vue new file mode 100644 index 0000000..6704e07 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/components/CustomerProfileRecentOrdersTable.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/constants.ts b/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/constants.ts new file mode 100644 index 0000000..89201c1 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/constants.ts @@ -0,0 +1,23 @@ +import type { CustomerProfileListFilters } from '../../types'; + +/** 默认客户查询页码。 */ +export const PROFILE_DEFAULT_LIST_PAGE = 1; + +/** 默认客户查询条数。 */ +export const PROFILE_DEFAULT_LIST_PAGE_SIZE = 1; + +/** 门店下拉加载条数。 */ +export const PROFILE_STORE_QUERY_PAGE_SIZE = 200; + +/** 客户画像查看权限码。 */ +export const CUSTOMER_PROFILE_VIEW_PERMISSION = 'tenant:customer:profile:view'; + +/** 默认客户筛选值。 */ +export function createDefaultListFilters(): CustomerProfileListFilters { + return { + keyword: '', + orderCountRange: 'all', + registerPeriod: 'all', + tag: 'all', + }; +} diff --git a/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/data-actions.ts b/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/data-actions.ts new file mode 100644 index 0000000..e4e4d94 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/data-actions.ts @@ -0,0 +1,93 @@ +import type { Ref } from 'vue'; + +import type { CustomerProfileListFilters } from '../../types'; + +import type { CustomerProfileDto, CustomerTagFilter } from '#/api/customer'; +import type { StoreListItemDto } from '#/api/store'; + +import { getCustomerListApi, getCustomerProfileApi } from '#/api/customer'; +import { getStoreListApi } from '#/api/store'; + +import { + PROFILE_DEFAULT_LIST_PAGE, + PROFILE_DEFAULT_LIST_PAGE_SIZE, + PROFILE_STORE_QUERY_PAGE_SIZE, +} from './constants'; +import { + normalizeOrderCountRangeFilter, + normalizeRegisterPeriodFilter, + normalizeTagFilter, +} from './helpers'; + +interface CreateDataActionsOptions { + filters: CustomerProfileListFilters; + isProfileLoading: Ref; + isStoreLoading: Ref; + profile: Ref; + stores: Ref; +} + +/** + * 文件职责:客户画像页数据加载动作。 + */ +export function createDataActions(options: CreateDataActionsOptions) { + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: PROFILE_STORE_QUERY_PAGE_SIZE, + }); + options.stores.value = result.items; + return result.items; + } finally { + options.isStoreLoading.value = false; + } + } + + async function pickDefaultCustomerKey(storeId: string) { + if (!storeId) { + return ''; + } + + const result = await getCustomerListApi({ + storeId, + page: PROFILE_DEFAULT_LIST_PAGE, + pageSize: PROFILE_DEFAULT_LIST_PAGE_SIZE, + keyword: options.filters.keyword.trim() || undefined, + tag: normalizeTagFilter(options.filters.tag as CustomerTagFilter), + orderCountRange: normalizeOrderCountRangeFilter( + options.filters.orderCountRange, + ), + registerPeriod: normalizeRegisterPeriodFilter( + options.filters.registerPeriod, + ), + }); + return result.items[0]?.customerKey || ''; + } + + async function loadProfile(storeId: string, customerKey: string) { + if (!storeId || !customerKey) { + options.profile.value = null; + return null; + } + + options.isProfileLoading.value = true; + try { + const result = await getCustomerProfileApi({ + storeId, + customerKey, + }); + options.profile.value = result; + return result; + } finally { + options.isProfileLoading.value = false; + } + } + + return { + loadProfile, + loadStores, + pickDefaultCustomerKey, + }; +} diff --git a/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/helpers.ts b/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/helpers.ts new file mode 100644 index 0000000..40ffdaf --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/composables/customer-profile-page/helpers.ts @@ -0,0 +1,109 @@ +import type { + CustomerOrderCountRangeFilter, + CustomerRegisterPeriodFilter, + CustomerTagFilter, +} from '#/api/customer'; + +function toStringValue(value: unknown) { + if (Array.isArray(value)) { + return String(value[0] || ''); + } + if (typeof value === 'number' || typeof value === 'string') { + return String(value); + } + return ''; +} + +export function parseRouteQueryValue(value: unknown) { + return toStringValue(value).trim(); +} + +export function normalizeTagFilter(tag: CustomerTagFilter) { + return tag === 'all' ? undefined : tag; +} + +export function normalizeOrderCountRangeFilter( + range: CustomerOrderCountRangeFilter, +) { + return range === 'all' ? undefined : range; +} + +export function normalizeRegisterPeriodFilter( + period: CustomerRegisterPeriodFilter, +) { + return period === 'all' ? undefined : period; +} + +export function buildRouteQuery( + currentQuery: Record, + storeId: string, + customerKey: string, +) { + const nextQuery: Record = {}; + for (const [key, value] of Object.entries(currentQuery)) { + if (key === 'storeId' || key === 'customerKey') { + continue; + } + const normalized = parseRouteQueryValue(value); + if (!normalized) { + continue; + } + nextQuery[key] = normalized; + } + if (storeId) { + nextQuery.storeId = storeId; + } + if (customerKey) { + nextQuery.customerKey = customerKey; + } + return nextQuery; +} + +export function formatCurrency(value: number) { + return new Intl.NumberFormat('zh-CN', { + currency: 'CNY', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + style: 'currency', + }).format(Number.isFinite(value) ? value : 0); +} + +export function formatInteger(value: number) { + return new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 0, + }).format(Number.isFinite(value) ? value : 0); +} + +export function formatPercent(value: number) { + if (!Number.isFinite(value)) { + return '0%'; + } + return `${value.toFixed(1).replace(/\.0$/, '')}%`; +} + +export function resolveAvatarText(name: string) { + const normalized = String(name || '').trim(); + return normalized ? normalized.slice(0, 1) : '客'; +} + +export function resolveTagColor(tone: string) { + if (tone === 'orange') return 'orange'; + if (tone === 'green') return 'green'; + if (tone === 'gray') return 'default'; + if (tone === 'red') return 'red'; + return 'blue'; +} + +export function resolveDeliveryTagColor(deliveryType: string) { + if (deliveryType.includes('外卖')) return 'blue'; + if (deliveryType.includes('自提')) return 'green'; + if (deliveryType.includes('堂食')) return 'orange'; + return 'default'; +} + +export function resolveOrderStatusClass(status: string) { + if (status.includes('完成')) return 'success'; + if (status.includes('取消') || status.includes('流失')) return 'danger'; + if (status.includes('配送') || status.includes('制作')) return 'processing'; + return 'default'; +} diff --git a/apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts b/apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts new file mode 100644 index 0000000..0feb62f --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts @@ -0,0 +1,132 @@ +import { computed, onActivated, onMounted, ref, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; + +import type { CustomerProfileDto } from '#/api/customer'; +import type { StoreListItemDto } from '#/api/store'; + +import { createDefaultListFilters } from './customer-profile-page/constants'; +import { createDataActions } from './customer-profile-page/data-actions'; +import { + buildRouteQuery, + parseRouteQueryValue, +} from './customer-profile-page/helpers'; + +export function useCustomerProfilePage() { + const route = useRoute(); + const router = useRouter(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const activeCustomerKey = ref(''); + const profile = ref(null); + + const isStoreLoading = ref(false); + const isProfileLoading = ref(false); + + const filters = createDefaultListFilters(); + + const { loadStores, pickDefaultCustomerKey, loadProfile } = createDataActions({ + stores, + profile, + filters, + isStoreLoading, + isProfileLoading, + }); + + const emptyDescription = computed(() => { + if (isStoreLoading.value || isProfileLoading.value) { + return ''; + } + if (stores.value.length === 0) { + return '暂无门店,请先创建门店'; + } + if (!activeCustomerKey.value) { + return '当前门店暂无客户'; + } + return '暂无画像'; + }); + + async function syncRouteQuery(storeId: string, customerKey: string) { + const currentStoreId = parseRouteQueryValue(route.query.storeId); + const currentCustomerKey = parseRouteQueryValue(route.query.customerKey); + if (currentStoreId === storeId && currentCustomerKey === customerKey) { + return; + } + + await router.replace({ + path: '/customer/profile', + query: buildRouteQuery( + route.query as Record, + storeId, + customerKey, + ), + }); + } + + function resolveStoreId(routeStoreId: string) { + if (!routeStoreId) { + return stores.value[0]?.id || ''; + } + + const matched = stores.value.find((item) => item.id === routeStoreId); + return matched?.id || stores.value[0]?.id || ''; + } + + async function loadProfileByRoute() { + if (stores.value.length === 0) { + await loadStores(); + } + if (stores.value.length === 0) { + selectedStoreId.value = ''; + activeCustomerKey.value = ''; + profile.value = null; + return; + } + + const routeStoreId = parseRouteQueryValue(route.query.storeId); + const routeCustomerKey = parseRouteQueryValue(route.query.customerKey); + + const nextStoreId = resolveStoreId(routeStoreId); + selectedStoreId.value = nextStoreId; + + let nextCustomerKey = routeCustomerKey; + if (!nextCustomerKey) { + nextCustomerKey = await pickDefaultCustomerKey(nextStoreId); + } + + activeCustomerKey.value = nextCustomerKey; + if (!nextCustomerKey) { + profile.value = null; + await syncRouteQuery(nextStoreId, ''); + return; + } + + await loadProfile(nextStoreId, nextCustomerKey); + await syncRouteQuery(nextStoreId, nextCustomerKey); + } + + watch( + () => route.fullPath, + () => { + void loadProfileByRoute(); + }, + ); + + onMounted(() => { + void loadProfileByRoute(); + }); + + onActivated(() => { + if (stores.value.length === 0) { + void loadProfileByRoute(); + } + }); + + return { + activeCustomerKey, + emptyDescription, + isProfileLoading, + isStoreLoading, + profile, + }; +} diff --git a/apps/web-antd/src/views/customer/profile/index.vue b/apps/web-antd/src/views/customer/profile/index.vue new file mode 100644 index 0000000..3c79fbf --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/index.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/apps/web-antd/src/views/customer/profile/styles/base.less b/apps/web-antd/src/views/customer/profile/styles/base.less new file mode 100644 index 0000000..f0672d1 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/styles/base.less @@ -0,0 +1,5 @@ +.page-customer-profile { + .ant-card { + border-radius: 10px; + } +} diff --git a/apps/web-antd/src/views/customer/profile/styles/card.less b/apps/web-antd/src/views/customer/profile/styles/card.less new file mode 100644 index 0000000..cd10bee --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/styles/card.less @@ -0,0 +1,264 @@ +.cp-panel { + .cp-header { + display: flex; + gap: 16px; + align-items: center; + padding: 20px; + margin-bottom: 16px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + } + + .cp-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + font-size: 22px; + font-weight: 700; + color: #fff; + background: #3b82f6; + border-radius: 50%; + } + + .cp-header-main { + min-width: 0; + } + + .cp-name { + font-size: 18px; + font-weight: 700; + color: rgb(0 0 0 / 88%); + } + + .cp-meta { + margin-top: 4px; + font-size: 13px; + color: rgb(0 0 0 / 45%); + } + + .cp-member-meta { + margin-top: 2px; + font-size: 12px; + color: rgb(0 0 0 / 65%); + } + + .cp-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-left: auto; + + .ant-tag { + margin-inline-end: 0; + } + } + + .cp-kpis { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; + } + + .cp-kpi { + padding: 16px 12px; + text-align: center; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 6px 14px rgb(15 23 42 / 10%); + transform: translateY(-1px); + } + + .num { + overflow: hidden; + text-overflow: ellipsis; + font-size: 20px; + font-weight: 700; + color: rgb(0 0 0 / 88%); + white-space: nowrap; + + &.blue { + color: #1677ff; + } + + &.success { + color: #52c41a; + } + } + + .lbl { + margin-top: 4px; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } + } + + .cp-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .cp-card { + padding: 20px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); + + &.full { + grid-column: 1 / -1; + } + } + + .cp-card-title { + padding-left: 10px; + margin-bottom: 16px; + font-size: 15px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-left: 3px solid #1677ff; + } + + .cp-pref-row { + display: flex; + gap: 10px; + justify-content: space-between; + padding: 10px 0; + font-size: 13px; + border-bottom: 1px solid #f3f4f6; + + &:last-child { + border-bottom: none; + } + + .label { + color: rgb(0 0 0 / 65%); + } + + .value { + max-width: 66%; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: rgb(0 0 0 / 88%); + text-align: right; + white-space: nowrap; + } + } + + .cp-top-list { + min-height: 42px; + } + + .cp-top-item { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 0; + font-size: 13px; + border-bottom: 1px solid #f3f4f6; + + &:last-child { + border-bottom: none; + } + + .name { + width: 110px; + overflow: hidden; + text-overflow: ellipsis; + color: rgb(0 0 0 / 88%); + white-space: nowrap; + } + + .bar { + display: inline-flex; + flex: 1; + height: 6px; + overflow: hidden; + background: #f0f0f0; + border-radius: 3px; + } + + .bar-inner { + display: inline-flex; + height: 100%; + background: #1677ff; + border-radius: 3px; + } + + .count { + width: 42px; + color: rgb(0 0 0 / 65%); + text-align: right; + } + } + + .cp-top-rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + font-size: 11px; + font-weight: 700; + border-radius: 50%; + + &.gold { + color: #d97706; + background: #fef3c7; + } + + &.silver { + color: #6b7280; + background: #f3f4f6; + } + } + + .cp-trend-bars { + display: flex; + gap: 8px; + align-items: flex-end; + height: 126px; + } + + .cp-trend-bar-col { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + align-items: center; + min-width: 0; + + .bar-val { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 10px; + color: rgb(0 0 0 / 45%); + text-align: center; + white-space: nowrap; + } + + .bar { + width: 100%; + background: #1677ff; + border-radius: 4px 4px 0 0; + opacity: 0.8; + } + + .bar-lbl { + font-size: 11px; + color: rgb(0 0 0 / 45%); + } + } +} diff --git a/apps/web-antd/src/views/customer/profile/styles/index.less b/apps/web-antd/src/views/customer/profile/styles/index.less new file mode 100644 index 0000000..dbb9d91 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/styles/index.less @@ -0,0 +1,5 @@ +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './table.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/customer/profile/styles/layout.less b/apps/web-antd/src/views/customer/profile/styles/layout.less new file mode 100644 index 0000000..fa99230 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/styles/layout.less @@ -0,0 +1,17 @@ +.cp-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.cp-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 420px; + padding: 24px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); +} diff --git a/apps/web-antd/src/views/customer/profile/styles/responsive.less b/apps/web-antd/src/views/customer/profile/styles/responsive.less new file mode 100644 index 0000000..5ca3e79 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/styles/responsive.less @@ -0,0 +1,36 @@ +@media (max-width: 1600px) { + .cp-panel { + .cp-kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } +} + +@media (max-width: 1024px) { + .cp-panel { + .cp-grid { + grid-template-columns: 1fr; + } + + .cp-card.full { + grid-column: auto; + } + } +} + +@media (max-width: 768px) { + .cp-panel { + .cp-header { + flex-wrap: wrap; + } + + .cp-tags { + width: 100%; + margin-left: 0; + } + + .cp-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } +} diff --git a/apps/web-antd/src/views/customer/profile/styles/table.less b/apps/web-antd/src/views/customer/profile/styles/table.less new file mode 100644 index 0000000..483bc0e --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/styles/table.less @@ -0,0 +1,27 @@ +.cp-order-table { + .ant-table-thead > tr > th { + font-size: 13px; + white-space: nowrap; + background: #f8f9fb; + } +} + +.cp-order-status { + font-weight: 600; + + &.success { + color: #52c41a; + } + + &.danger { + color: #ff4d4f; + } + + &.processing { + color: #1677ff; + } + + &.default { + color: rgb(0 0 0 / 65%); + } +} diff --git a/apps/web-antd/src/views/customer/profile/types.ts b/apps/web-antd/src/views/customer/profile/types.ts new file mode 100644 index 0000000..ff19632 --- /dev/null +++ b/apps/web-antd/src/views/customer/profile/types.ts @@ -0,0 +1,25 @@ +import type { + CustomerOrderCountRangeFilter, + CustomerProfileDto, + CustomerRegisterPeriodFilter, + CustomerTagFilter, +} from '#/api/customer'; +import type { StoreListItemDto } from '#/api/store'; + +/** 客户画像页筛选状态(用于默认客户定位)。 */ +export interface CustomerProfileListFilters { + keyword: string; + orderCountRange: CustomerOrderCountRangeFilter; + registerPeriod: CustomerRegisterPeriodFilter; + tag: CustomerTagFilter; +} + +/** 客户画像页状态。 */ +export interface CustomerProfilePageState { + activeCustomerKey: string; + isProfileLoading: boolean; + isStoreLoading: boolean; + profile: CustomerProfileDto | null; + selectedStoreId: string; + stores: StoreListItemDto[]; +}