diff --git a/apps/web-antd/src/api/customer/index.ts b/apps/web-antd/src/api/customer/index.ts index 993c029..1c53736 100644 --- a/apps/web-antd/src/api/customer/index.ts +++ b/apps/web-antd/src/api/customer/index.ts @@ -168,6 +168,154 @@ export interface CustomerExportDto { totalCount: number; } +/** 客户分析周期筛选值。 */ +export type CustomerAnalysisPeriodFilter = '7d' | '30d' | '90d' | '365d'; + +/** 客户分析趋势点。 */ +export interface CustomerAnalysisTrendPointDto { + label: string; + value: number; +} + +/** 客户分析新老客构成项。 */ +export interface CustomerAnalysisCompositionItemDto { + count: number; + label: string; + percent: number; + segmentCode: string; + tone: string; +} + +/** 客单价分布项。 */ +export interface CustomerAnalysisAmountDistributionItemDto { + count: number; + label: string; + percent: number; + segmentCode: string; +} + +/** RFM 分层单元。 */ +export interface CustomerAnalysisRfmCellDto { + count: number; + label: string; + segmentCode: string; + tone: string; +} + +/** RFM 分层行。 */ +export interface CustomerAnalysisRfmRowDto { + cells: CustomerAnalysisRfmCellDto[]; + label: string; +} + +/** 客户分析 Top 客户。 */ +export interface CustomerAnalysisTopCustomerDto { + averageAmount: number; + customerKey: string; + lastOrderAt: string; + name: string; + orderCount: number; + phoneMasked: string; + rank: number; + tags: CustomerTagDto[]; + totalAmount: number; +} + +/** 客户分析总览。 */ +export interface CustomerAnalysisOverviewDto { + activeCustomers: number; + activeRatePercent: number; + amountDistribution: CustomerAnalysisAmountDistributionItemDto[]; + averageLifetimeValue: number; + composition: CustomerAnalysisCompositionItemDto[]; + growthRatePercent: number; + growthTrend: CustomerAnalysisTrendPointDto[]; + newCustomers: number; + newCustomersDailyAverage: number; + periodCode: CustomerAnalysisPeriodFilter; + periodDays: number; + rfmRows: CustomerAnalysisRfmRowDto[]; + topCustomers: CustomerAnalysisTopCustomerDto[]; + totalCustomers: number; +} + +/** 客群明细分群编码。 */ +export type CustomerAnalysisSegmentCode = + | 'active_new' + | 'active_recent' + | 'all' + | 'amount_0_30' + | 'amount_30_60' + | 'amount_60_100' + | 'amount_100_150' + | 'amount_150_plus' + | 'churn' + | 'dormant' + | 'high_value_top' + | 'repeat_loyal' + | `rfm_r${1 | 2 | 3}c${1 | 2 | 3 | 4}` + | (string & {}); + +/** 客群明细行。 */ +export interface CustomerAnalysisSegmentListItemDto { + avatarColor: string; + avatarText: string; + averageAmount: number; + customerKey: string; + isDimmed: boolean; + isMember: boolean; + lastOrderAt: string; + memberTierName: string; + name: string; + orderCount: number; + phoneMasked: string; + registeredAt: string; + tags: CustomerTagDto[]; + totalAmount: number; +} + +/** 客群明细结果。 */ +export interface CustomerAnalysisSegmentListResultDto { + items: CustomerAnalysisSegmentListItemDto[]; + page: number; + pageSize: number; + segmentCode: string; + segmentDescription: string; + segmentTitle: string; + totalCount: number; +} + +/** 客户分析会员详情。 */ +export interface CustomerMemberDetailDto { + averageAmount: number; + customerKey: string; + lastOrderAt: string; + member: CustomerMemberSummaryDto; + name: string; + phoneMasked: string; + recentOrders: CustomerRecentOrderDto[]; + registeredAt: string; + repurchaseRatePercent: number; + source: string; + tags: CustomerTagDto[]; + totalAmount: number; + totalOrders: number; +} + +/** 客户分析基础查询参数。 */ +export interface CustomerAnalysisBaseQuery { + period?: CustomerAnalysisPeriodFilter; + storeId: string; +} + +/** 客群明细查询参数。 */ +export interface CustomerAnalysisSegmentListQuery extends CustomerAnalysisBaseQuery { + keyword?: string; + page: number; + pageSize: number; + segmentCode: CustomerAnalysisSegmentCode; +} + /** 查询客户列表。 */ export async function getCustomerListApi(params: CustomerListQuery) { return requestClient.get('/customer/list/list', { @@ -208,3 +356,69 @@ export async function exportCustomerCsvApi(params: CustomerListFilterQuery) { params, }); } + +/** 查询客户分析总览。 */ +export async function getCustomerAnalysisOverviewApi( + params: CustomerAnalysisBaseQuery, +) { + return requestClient.get( + '/customer/analysis/overview', + { + params, + }, + ); +} + +/** 查询客群明细分页。 */ +export async function getCustomerAnalysisSegmentListApi( + params: CustomerAnalysisSegmentListQuery, +) { + return requestClient.get( + '/customer/analysis/segment/list', + { + params, + }, + ); +} + +/** 查询客户分析会员详情。 */ +export async function getCustomerMemberDetailApi(params: { + customerKey: string; + storeId: string; +}) { + return requestClient.get( + '/customer/analysis/member/detail', + { + params, + }, + ); +} + +/** 查询客户分析客户详情。 */ +export async function getCustomerAnalysisDetailApi(params: { + customerKey: string; + storeId: string; +}) { + return requestClient.get('/customer/analysis/detail', { + params, + }); +} + +/** 查询客户分析客户画像。 */ +export async function getCustomerAnalysisProfileApi(params: { + customerKey: string; + storeId: string; +}) { + return requestClient.get('/customer/analysis/profile', { + params, + }); +} + +/** 导出客户分析 CSV。 */ +export async function exportCustomerAnalysisCsvApi( + params: CustomerAnalysisBaseQuery, +) { + return requestClient.get('/customer/analysis/export', { + params, + }); +} diff --git a/apps/web-antd/src/views/customer/analysis/components/AmountDistributionCard.vue b/apps/web-antd/src/views/customer/analysis/components/AmountDistributionCard.vue new file mode 100644 index 0000000..448678f --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/AmountDistributionCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/AnalysisStatsGrid.vue b/apps/web-antd/src/views/customer/analysis/components/AnalysisStatsGrid.vue new file mode 100644 index 0000000..76bbfd1 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/AnalysisStatsGrid.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/AnalysisToolbar.vue b/apps/web-antd/src/views/customer/analysis/components/AnalysisToolbar.vue new file mode 100644 index 0000000..d891265 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/AnalysisToolbar.vue @@ -0,0 +1,67 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/CompositionCard.vue b/apps/web-antd/src/views/customer/analysis/components/CompositionCard.vue new file mode 100644 index 0000000..f3a3ee5 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/CompositionCard.vue @@ -0,0 +1,84 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/GrowthTrendCard.vue b/apps/web-antd/src/views/customer/analysis/components/GrowthTrendCard.vue new file mode 100644 index 0000000..13b680e --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/GrowthTrendCard.vue @@ -0,0 +1,49 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/MemberDetailDrawer.vue b/apps/web-antd/src/views/customer/analysis/components/MemberDetailDrawer.vue new file mode 100644 index 0000000..c7fb278 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/MemberDetailDrawer.vue @@ -0,0 +1,182 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/RfmMatrixCard.vue b/apps/web-antd/src/views/customer/analysis/components/RfmMatrixCard.vue new file mode 100644 index 0000000..bc0f680 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/RfmMatrixCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/SegmentDrawer.vue b/apps/web-antd/src/views/customer/analysis/components/SegmentDrawer.vue new file mode 100644 index 0000000..8aefcff --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/SegmentDrawer.vue @@ -0,0 +1,239 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/components/TopCustomerTableCard.vue b/apps/web-antd/src/views/customer/analysis/components/TopCustomerTableCard.vue new file mode 100644 index 0000000..e838169 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/components/TopCustomerTableCard.vue @@ -0,0 +1,141 @@ + + + diff --git a/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/constants.ts b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/constants.ts new file mode 100644 index 0000000..5536976 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/constants.ts @@ -0,0 +1,43 @@ +import type { OptionItem } from '../../types'; + +import type { + CustomerAnalysisOverviewDto, + CustomerAnalysisPeriodFilter, + CustomerAnalysisSegmentCode, +} from '#/api/customer'; + +/** 客户分析查看权限。 */ +export const CUSTOMER_ANALYSIS_VIEW_PERMISSION = + 'tenant:customer:analysis:view'; + +/** 统计周期选项。 */ +export const ANALYSIS_PERIOD_OPTIONS: OptionItem[] = [ + { label: '近7天', value: '7d' }, + { label: '近30天', value: '30d' }, + { label: '近90天', value: '90d' }, + { label: '近1年', value: '365d' }, +]; + +/** 默认统计周期。 */ +export const DEFAULT_PERIOD: CustomerAnalysisPeriodFilter = '30d'; + +/** 默认分群。 */ +export const DEFAULT_SEGMENT_CODE: CustomerAnalysisSegmentCode = 'all'; + +/** 默认总览数据。 */ +export const EMPTY_OVERVIEW: CustomerAnalysisOverviewDto = { + periodCode: DEFAULT_PERIOD, + periodDays: 30, + totalCustomers: 0, + newCustomers: 0, + growthRatePercent: 0, + newCustomersDailyAverage: 0, + activeCustomers: 0, + activeRatePercent: 0, + averageLifetimeValue: 0, + growthTrend: [], + composition: [], + amountDistribution: [], + rfmRows: [], + topCustomers: [], +}; diff --git a/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/data-actions.ts b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/data-actions.ts new file mode 100644 index 0000000..b3756f6 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/data-actions.ts @@ -0,0 +1,94 @@ +import type { Ref } from 'vue'; + +import type { CustomerAnalysisOverviewDto } from '#/api/customer'; +import type { StoreListItemDto } from '#/api/store'; + +import { getCustomerAnalysisOverviewApi } from '#/api/customer'; +import { getStoreListApi } from '#/api/store'; + +import { EMPTY_OVERVIEW } from './constants'; + +interface DataActionOptions { + isOverviewLoading: Ref; + isStoreLoading: Ref; + overview: Ref; + period: Ref<'7d' | '30d' | '90d' | '365d'>; + selectedStoreId: Ref; + stores: Ref; +} + +/** + * 文件职责:客户分析页的数据加载动作。 + */ +export function createDataActions(options: DataActionOptions) { + function resolvePeriodDays(period: '7d' | '30d' | '90d' | '365d'): number { + switch (period) { + case '7d': { + return 7; + } + case '90d': { + return 90; + } + case '365d': { + return 365; + } + default: { + return 30; + } + } + } + + function resetOverview() { + options.overview.value = { + ...EMPTY_OVERVIEW, + periodCode: options.period.value, + periodDays: resolvePeriodDays(options.period.value), + }; + } + + 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 = ''; + resetOverview(); + 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 loadOverview() { + if (!options.selectedStoreId.value) { + resetOverview(); + return; + } + + options.isOverviewLoading.value = true; + try { + options.overview.value = await getCustomerAnalysisOverviewApi({ + storeId: options.selectedStoreId.value, + period: options.period.value, + }); + } finally { + options.isOverviewLoading.value = false; + } + } + + return { + loadOverview, + loadStores, + resetOverview, + }; +} diff --git a/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/drawer-actions.ts b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/drawer-actions.ts new file mode 100644 index 0000000..3fbdedf --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/drawer-actions.ts @@ -0,0 +1,84 @@ +import type { Ref } from 'vue'; + +import type { CustomerDetailDto, CustomerProfileDto } from '#/api/customer'; + +import { + getCustomerAnalysisDetailApi, + getCustomerAnalysisProfileApi, +} from '#/api/customer'; + +interface DrawerActionOptions { + detail: Ref; + isDetailDrawerOpen: Ref; + isDetailLoading: Ref; + isProfileDrawerOpen: Ref; + isProfileLoading: Ref; + profile: Ref; + selectedStoreId: Ref; +} + +/** + * 文件职责:客户分析页客户详情与画像抽屉动作。 + */ +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 getCustomerAnalysisDetailApi({ + 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 getCustomerAnalysisProfileApi({ + storeId: options.selectedStoreId.value, + customerKey, + }); + } finally { + options.isProfileLoading.value = false; + } + } + + return { + openDetail, + openProfile, + setDetailDrawerOpen, + setProfileDrawerOpen, + }; +} diff --git a/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/export-actions.ts b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/export-actions.ts new file mode 100644 index 0000000..202d8d8 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/export-actions.ts @@ -0,0 +1,46 @@ +import type { Ref } from 'vue'; + +import { message } from 'ant-design-vue'; + +import { exportCustomerAnalysisCsvApi } from '#/api/customer'; + +import { downloadBase64File } from './helpers'; + +interface ExportActionOptions { + canExport: Ref; + isExporting: Ref; + period: Ref<'7d' | '30d' | '90d' | '365d'>; + selectedStoreId: Ref; +} + +/** + * 文件职责:客户分析报表导出动作。 + */ +export function createExportActions(options: ExportActionOptions) { + async function handleExport() { + if (!options.selectedStoreId.value) { + return; + } + + if (!options.canExport.value) { + message.warning('暂无导出权限'); + return; + } + + options.isExporting.value = true; + try { + const result = await exportCustomerAnalysisCsvApi({ + storeId: options.selectedStoreId.value, + period: options.period.value, + }); + downloadBase64File(result.fileName, result.fileContentBase64); + message.success('客户分析报表导出成功'); + } finally { + options.isExporting.value = false; + } + } + + return { + handleExport, + }; +} diff --git a/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/helpers.ts b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/helpers.ts new file mode 100644 index 0000000..6ff0654 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/helpers.ts @@ -0,0 +1,90 @@ +export function formatCurrency(value: number) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(Number.isFinite(value) ? value : 0); +} + +export function formatCurrencyWithFraction(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$/, '')}%`; +} + +export function formatSignedPercent(value: number) { + const normalized = Number.isFinite(value) ? value : 0; + const sign = normalized > 0 ? '+' : ''; + return `${sign}${formatPercent(normalized)}`; +} + +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 resolveRfmCellToneClass(tone: string) { + if (tone === 'hot') return 'hot'; + if (tone === 'warm') return 'warm'; + if (tone === 'cool') return 'cool'; + return 'cold'; +} + +export function resolveCompositionToneColor(tone: string) { + if (tone === 'green') return '#52c41a'; + if (tone === 'orange') return '#fa8c16'; + if (tone === 'gray') return '#e5e7eb'; + return '#1677ff'; +} + +export function resolveDistributionColor(index: number) { + const palette = ['#7fb4ff', '#4a95ff', '#1677ff', '#1061d6', '#0a4eaf']; + return palette[Math.max(0, Math.min(index, palette.length - 1))] ?? '#1677ff'; +} + +export function resolveAvatarText(name: string) { + const normalized = String(name || '').trim(); + return normalized ? normalized.slice(0, 1) : '客'; +} + +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); +} diff --git a/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/member-actions.ts b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/member-actions.ts new file mode 100644 index 0000000..aba9055 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/member-actions.ts @@ -0,0 +1,47 @@ +import type { Ref } from 'vue'; + +import type { CustomerMemberDetailDto } from '#/api/customer'; + +import { getCustomerMemberDetailApi } from '#/api/customer'; + +interface MemberActionOptions { + detail: Ref; + isMemberDrawerOpen: Ref; + isMemberLoading: Ref; + selectedStoreId: Ref; +} + +/** + * 文件职责:客户分析页会员详情抽屉动作。 + */ +export function createMemberActions(options: MemberActionOptions) { + function setMemberDrawerOpen(value: boolean) { + options.isMemberDrawerOpen.value = value; + if (!value) { + options.detail.value = null; + } + } + + async function openMember(customerKey: string) { + if (!options.selectedStoreId.value || !customerKey) { + return; + } + + options.isMemberDrawerOpen.value = true; + options.detail.value = null; + options.isMemberLoading.value = true; + try { + options.detail.value = await getCustomerMemberDetailApi({ + storeId: options.selectedStoreId.value, + customerKey, + }); + } finally { + options.isMemberLoading.value = false; + } + } + + return { + openMember, + setMemberDrawerOpen, + }; +} diff --git a/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/segment-actions.ts b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/segment-actions.ts new file mode 100644 index 0000000..173cdcf --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/customer-analysis-page/segment-actions.ts @@ -0,0 +1,99 @@ +import type { Ref } from 'vue'; + +import type { + CustomerAnalysisSegmentCode, + CustomerAnalysisSegmentListResultDto, +} from '#/api/customer'; + +import { getCustomerAnalysisSegmentListApi } from '#/api/customer'; + +interface SegmentPagination { + page: number; + pageSize: number; + total: number; +} + +interface SegmentActionOptions { + currentSegmentCode: Ref; + isSegmentDrawerOpen: Ref; + isSegmentLoading: Ref; + keyword: Ref; + pagination: SegmentPagination; + period: Ref<'7d' | '30d' | '90d' | '365d'>; + result: Ref; + selectedStoreId: Ref; +} + +/** + * 文件职责:客户分析页客群明细抽屉动作。 + */ +export function createSegmentActions(options: SegmentActionOptions) { + function setSegmentDrawerOpen(value: boolean) { + options.isSegmentDrawerOpen.value = value; + if (!value) { + options.result.value = null; + options.keyword.value = ''; + options.pagination.page = 1; + options.pagination.total = 0; + } + } + + function setSegmentKeyword(value: string) { + options.keyword.value = value; + } + + async function loadSegmentData() { + if (!options.selectedStoreId.value) { + options.result.value = null; + options.pagination.total = 0; + return; + } + + options.isSegmentLoading.value = true; + try { + const result = await getCustomerAnalysisSegmentListApi({ + storeId: options.selectedStoreId.value, + period: options.period.value, + segmentCode: options.currentSegmentCode.value, + keyword: options.keyword.value.trim() || undefined, + page: options.pagination.page, + pageSize: options.pagination.pageSize, + }); + + options.result.value = result; + options.pagination.page = result.page; + options.pagination.pageSize = result.pageSize; + options.pagination.total = result.totalCount; + } finally { + options.isSegmentLoading.value = false; + } + } + + async function openSegment(segmentCode: CustomerAnalysisSegmentCode) { + options.currentSegmentCode.value = segmentCode; + options.keyword.value = ''; + options.pagination.page = 1; + options.isSegmentDrawerOpen.value = true; + await loadSegmentData(); + } + + async function handleSegmentSearch() { + options.pagination.page = 1; + await loadSegmentData(); + } + + async function handleSegmentPageChange(page: number, pageSize: number) { + options.pagination.page = page; + options.pagination.pageSize = pageSize; + await loadSegmentData(); + } + + return { + handleSegmentPageChange, + handleSegmentSearch, + loadSegmentData, + openSegment, + setSegmentDrawerOpen, + setSegmentKeyword, + }; +} diff --git a/apps/web-antd/src/views/customer/analysis/composables/useCustomerAnalysisPage.ts b/apps/web-antd/src/views/customer/analysis/composables/useCustomerAnalysisPage.ts new file mode 100644 index 0000000..63e5d58 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/composables/useCustomerAnalysisPage.ts @@ -0,0 +1,244 @@ +import type { + CustomerAnalysisOverviewDto, + CustomerAnalysisPeriodFilter, + CustomerAnalysisSegmentCode, + CustomerAnalysisSegmentListResultDto, + CustomerDetailDto, + CustomerMemberDetailDto, + 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 { createNavigationActions } from '#/views/customer/list/composables/customer-list-page/navigation-actions'; + +import { + CUSTOMER_ANALYSIS_VIEW_PERMISSION, + DEFAULT_PERIOD, + DEFAULT_SEGMENT_CODE, + EMPTY_OVERVIEW, +} from './customer-analysis-page/constants'; +import { createDataActions } from './customer-analysis-page/data-actions'; +import { createDrawerActions } from './customer-analysis-page/drawer-actions'; +import { createExportActions } from './customer-analysis-page/export-actions'; +import { createMemberActions } from './customer-analysis-page/member-actions'; +import { createSegmentActions } from './customer-analysis-page/segment-actions'; + +export function useCustomerAnalysisPage() { + const accessStore = useAccessStore(); + const router = useRouter(); + + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const period = ref(DEFAULT_PERIOD); + const overview = ref({ ...EMPTY_OVERVIEW }); + const isOverviewLoading = ref(false); + + const segmentResult = ref(null); + const isSegmentDrawerOpen = ref(false); + const isSegmentLoading = ref(false); + const segmentKeyword = ref(''); + const currentSegmentCode = + ref(DEFAULT_SEGMENT_CODE); + const segmentPagination = reactive({ + page: 1, + pageSize: 10, + total: 0, + }); + + 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 memberDetail = ref(null); + const isMemberDrawerOpen = ref(false); + const isMemberLoading = 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 canExport = computed(() => + accessCodeSet.value.has(CUSTOMER_ANALYSIS_VIEW_PERMISSION), + ); + + const { loadStores, loadOverview, resetOverview } = createDataActions({ + stores, + selectedStoreId, + period, + overview, + isStoreLoading, + isOverviewLoading, + }); + + const { + openSegment, + loadSegmentData, + setSegmentDrawerOpen, + setSegmentKeyword, + handleSegmentSearch, + handleSegmentPageChange, + } = createSegmentActions({ + selectedStoreId, + period, + currentSegmentCode, + keyword: segmentKeyword, + result: segmentResult, + isSegmentDrawerOpen, + isSegmentLoading, + pagination: segmentPagination, + }); + + const { openMember, setMemberDrawerOpen } = createMemberActions({ + selectedStoreId, + detail: memberDetail, + isMemberDrawerOpen, + isMemberLoading, + }); + + const { openDetail, openProfile, setDetailDrawerOpen, setProfileDrawerOpen } = + createDrawerActions({ + selectedStoreId, + detail, + isDetailDrawerOpen, + isDetailLoading, + profile, + isProfileDrawerOpen, + isProfileLoading, + }); + + const { openProfilePage } = createNavigationActions({ + selectedStoreId, + router, + }); + + const { handleExport } = createExportActions({ + selectedStoreId, + period, + isExporting, + canExport, + }); + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setPeriod(value: string) { + const normalized = (value || + DEFAULT_PERIOD) as CustomerAnalysisPeriodFilter; + period.value = normalized; + } + + async function openSegmentByCode(segmentCode: CustomerAnalysisSegmentCode) { + await openSegment(segmentCode); + } + + async function openTopCustomerDetail(customerKey: string) { + await openDetail(customerKey); + } + + async function openMemberFromDetail(customerKey: string) { + await openMember(customerKey); + } + + watch(selectedStoreId, async (storeId) => { + if (!storeId) { + resetOverview(); + segmentResult.value = null; + segmentPagination.total = 0; + setDetailDrawerOpen(false); + setProfileDrawerOpen(false); + setMemberDrawerOpen(false); + isSegmentDrawerOpen.value = false; + return; + } + + await loadOverview(); + if (isSegmentDrawerOpen.value) { + await loadSegmentData(); + } + }); + + watch(period, async () => { + if (!selectedStoreId.value) { + resetOverview(); + return; + } + + await loadOverview(); + if (isSegmentDrawerOpen.value) { + await loadSegmentData(); + } + }); + + onMounted(() => { + void loadStores(); + }); + + onActivated(() => { + if (stores.value.length === 0 || !selectedStoreId.value) { + void loadStores(); + } + }); + + return { + canExport, + currentSegmentCode, + detail, + handleExport, + handleSegmentPageChange, + handleSegmentSearch, + isDetailDrawerOpen, + isDetailLoading, + isExporting, + isMemberDrawerOpen, + isMemberLoading, + isOverviewLoading, + isProfileDrawerOpen, + isProfileLoading, + isSegmentDrawerOpen, + isSegmentLoading, + isStoreLoading, + memberDetail, + openDetail, + openMember, + openMemberFromDetail, + openProfile, + openProfilePage, + openSegmentByCode, + openTopCustomerDetail, + overview, + period, + profile, + segmentKeyword, + segmentPagination, + segmentResult, + selectedStoreId, + setDetailDrawerOpen, + setMemberDrawerOpen, + setPeriod, + setProfileDrawerOpen, + setSegmentDrawerOpen, + setSegmentKeyword, + setSelectedStoreId, + storeOptions, + }; +} diff --git a/apps/web-antd/src/views/customer/analysis/index.vue b/apps/web-antd/src/views/customer/analysis/index.vue new file mode 100644 index 0000000..e654789 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/index.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/apps/web-antd/src/views/customer/analysis/styles/base.less b/apps/web-antd/src/views/customer/analysis/styles/base.less new file mode 100644 index 0000000..222f890 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/styles/base.less @@ -0,0 +1,35 @@ +.page-customer-analysis { + .ant-card { + border-radius: 10px; + } + + .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; + } + } +} + +.ca-page { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ca-empty { + padding: 48px 16px; + 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/analysis/styles/cards.less b/apps/web-antd/src/views/customer/analysis/styles/cards.less new file mode 100644 index 0000000..6ca4fa9 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/styles/cards.less @@ -0,0 +1,327 @@ +.ca-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.ca-stat { + display: block; + padding: 16px 20px; + text-align: left; + cursor: pointer; + 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); + } + + .label { + margin-bottom: 6px; + font-size: 13px; + color: rgb(0 0 0 / 45%); + } + + .val { + 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; + } + } + + .sub { + margin-top: 4px; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } +} + +.ca-bars { + display: flex; + gap: 6px; + align-items: flex-end; + height: 140px; +} + +.ca-bar-col { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + align-items: center; + min-width: 0; + padding: 0; + cursor: pointer; + background: transparent; + border: none; +} + +.ca-bar-val { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; + color: rgb(0 0 0 / 65%); + text-align: center; + white-space: nowrap; +} + +.ca-bar { + width: 100%; + background: #1677ff; + border-radius: 4px 4px 0 0; + opacity: 0.8; + transition: all 0.2s ease; +} + +.ca-bar-col:hover .ca-bar { + opacity: 1; +} + +.ca-bar-lbl { + font-size: 11px; + color: rgb(0 0 0 / 45%); +} + +.ca-donut-wrap { + display: flex; + gap: 24px; + align-items: center; +} + +.ca-donut { + position: relative; + flex-shrink: 0; + width: 130px; + height: 130px; + border-radius: 50%; +} + +.ca-donut-hole { + position: absolute; + inset: 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #fff; + border-radius: 50%; + + .num { + font-size: 18px; + font-weight: 700; + color: rgb(0 0 0 / 88%); + } + + .lbl { + font-size: 11px; + color: rgb(0 0 0 / 45%); + } +} + +.ca-legend { + flex: 1; + min-width: 0; +} + +.ca-legend-item { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + padding: 6px 0; + text-align: left; + cursor: pointer; + background: transparent; + border: none; +} + +.ca-legend-dot { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 3px; +} + +.ca-legend-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: rgb(0 0 0 / 65%); + white-space: nowrap; +} + +.ca-legend-value { + font-size: 13px; + font-weight: 600; + color: rgb(0 0 0 / 88%); +} + +.ca-dist-row { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + padding: 8px 0; + text-align: left; + cursor: pointer; + background: transparent; + border: none; +} + +.ca-dist-label { + flex-shrink: 0; + width: 90px; + font-size: 13px; + color: rgb(0 0 0 / 65%); +} + +.ca-dist-bar { + flex: 1; + height: 20px; + overflow: hidden; + background: #f0f0f0; + border-radius: 4px; +} + +.ca-dist-bar-inner { + display: inline-flex; + height: 100%; + border-radius: 4px; + transition: all 0.2s ease; +} + +.ca-dist-val { + flex-shrink: 0; + width: 60px; + font-weight: 500; + color: rgb(0 0 0 / 88%); + text-align: right; +} + +.ca-rfm { + display: grid; + grid-template-columns: auto repeat(4, minmax(0, 1fr)); + gap: 2px; + font-size: 12px; +} + +.ca-rfm-header { + padding: 8px; + font-weight: 600; + color: #6b7280; + text-align: center; + background: #f8f9fb; +} + +.ca-rfm-label { + padding: 8px 10px; + font-weight: 600; + color: #6b7280; + white-space: nowrap; + background: #f8f9fb; +} + +.ca-rfm-cell { + padding: 10px 8px; + text-align: center; + cursor: pointer; + border: none; + border-radius: 4px; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.03); + } + + &.hot { + color: #1677ff; + background: rgb(22 119 255 / 18%); + } + + &.warm { + color: #1677ff; + background: rgb(22 119 255 / 10%); + } + + &.cool { + color: #fa8c16; + background: rgb(250 140 22 / 10%); + } + + &.cold { + color: rgb(0 0 0 / 45%); + background: #f8f9fb; + } +} + +.ca-rfm-cell-num { + font-weight: 600; +} + +.ca-rfm-cell-label { + margin-top: 2px; + font-size: 11px; + opacity: 0.75; +} + +.ca-top-card { + overflow: hidden; +} + +.ca-top-segment-btn { + padding-inline: 0; +} + +.ca-top-table { + .ant-table-thead > tr > th { + font-size: 13px; + white-space: nowrap; + background: #f8f9fb; + } + + .ant-table-tbody > tr > td { + vertical-align: middle; + } +} + +.ca-top-rank { + font-weight: 700; + color: #1677ff; +} + +.ca-top-money { + font-weight: 600; +} + +.ca-top-tag-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + + .ant-tag { + margin-inline-end: 0; + } +} + +.ca-top-action { + padding-inline: 0; +} diff --git a/apps/web-antd/src/views/customer/analysis/styles/drawer.less b/apps/web-antd/src/views/customer/analysis/styles/drawer.less new file mode 100644 index 0000000..92aa64d --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/styles/drawer.less @@ -0,0 +1,229 @@ +.ca-segment-head { + margin-bottom: 12px; +} + +.ca-segment-title { + font-size: 16px; + font-weight: 600; + color: rgb(0 0 0 / 88%); +} + +.ca-segment-desc { + margin-top: 3px; + font-size: 13px; + color: rgb(0 0 0 / 45%); +} + +.ca-segment-toolbar { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 12px; + + .ca-segment-search { + width: 260px; + } +} + +.ca-segment-table { + .ant-table-thead > tr > th { + white-space: nowrap; + background: #f8f9fb; + } + + .ant-pagination { + margin: 14px 0 0; + } +} + +.ca-segment-customer { + display: flex; + gap: 10px; + align-items: center; +} + +.ca-segment-avatar { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 14px; + font-weight: 600; + color: #fff; + border-radius: 50%; +} + +.ca-segment-customer-main { + min-width: 0; +} + +.ca-segment-customer-name { + font-weight: 500; + color: rgb(0 0 0 / 88%); +} + +.ca-segment-customer-phone { + margin-top: 2px; + font-size: 12px; + color: rgb(0 0 0 / 45%); +} + +.ca-segment-tag-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + + .ant-tag { + margin-inline-end: 0; + } +} + +.ca-segment-action-wrap { + display: flex; + gap: 4px; + align-items: center; +} + +.ca-segment-action { + padding-inline: 0; +} + +.ca-segment-row-dimmed td { + opacity: 0.55; +} + +.ca-segment-row-dimmed:hover td { + opacity: 0.78; +} + +.ca-member-head { + display: flex; + gap: 14px; + align-items: center; + padding-bottom: 16px; + margin-bottom: 20px; + border-bottom: 1px solid #f5f5f5; +} + +.ca-member-avatar { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + font-size: 18px; + font-weight: 600; + color: #fff; + background: #1677ff; + border-radius: 50%; +} + +.ca-member-head-main { + min-width: 0; +} + +.ca-member-name-wrap { + display: flex; + gap: 8px; + align-items: center; +} + +.ca-member-name { + font-size: 16px; + font-weight: 600; + color: rgb(0 0 0 / 88%); +} + +.ca-member-meta { + margin-top: 3px; + font-size: 12px; + color: rgb(0 0 0 / 45%); +} + +.ca-member-overview { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.ca-member-ov-item { + padding: 12px 8px; + text-align: center; + background: #f8f9fb; + border-radius: 8px; +} + +.ca-member-ov-val { + overflow: hidden; + text-overflow: ellipsis; + font-size: 16px; + font-weight: 700; + color: rgb(0 0 0 / 88%); + white-space: nowrap; +} + +.ca-member-ov-label { + margin-top: 4px; + font-size: 11px; + color: rgb(0 0 0 / 45%); +} + +.ca-member-section { + margin-bottom: 20px; +} + +.ca-member-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; +} + +.ca-member-tag-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + + .ant-tag { + margin-inline-end: 0; + } +} + +.ca-member-empty-text { + font-size: 13px; + color: rgb(0 0 0 / 45%); +} + +.ca-member-order-table { + .ant-table-thead > tr > th { + font-size: 12px; + white-space: nowrap; + background: #f8f9fb; + } +} + +.ca-member-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/analysis/styles/index.less b/apps/web-antd/src/views/customer/analysis/styles/index.less new file mode 100644 index 0000000..7880f5b --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/styles/index.less @@ -0,0 +1,9 @@ +@import './base.less'; +@import './layout.less'; +@import './cards.less'; +@import './drawer.less'; +@import '../../list/styles/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/analysis/styles/layout.less b/apps/web-antd/src/views/customer/analysis/styles/layout.less new file mode 100644 index 0000000..2c3ddde --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/styles/layout.less @@ -0,0 +1,71 @@ +.ca-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%); + + .ca-store-select { + width: 220px; + } + + .ca-period-label { + font-size: 13px; + color: rgb(0 0 0 / 65%); + } + + .ca-period-segment { + .ant-segmented-item { + min-width: 64px; + text-align: center; + } + } + + .ca-export-btn { + margin-left: auto; + } +} + +.ca-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 12px; +} + +.ca-card { + padding: 20px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 6%); +} + +.ca-card-full { + grid-column: 1 / -1; +} + +.ca-card-title-wrap { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.ca-card-title { + padding-left: 10px; + margin-bottom: 12px; + font-size: 15px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-left: 3px solid #1677ff; +} + +.ca-card-title-wrap .ca-card-title { + margin-bottom: 0; +} diff --git a/apps/web-antd/src/views/customer/analysis/styles/responsive.less b/apps/web-antd/src/views/customer/analysis/styles/responsive.less new file mode 100644 index 0000000..6055397 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/styles/responsive.less @@ -0,0 +1,68 @@ +@media (max-width: 1600px) { + .ca-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .ca-member-overview { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 1024px) { + .ca-grid { + grid-template-columns: 1fr; + } + + .ca-card-full { + grid-column: auto; + } + + .ca-toolbar { + .ca-store-select { + width: 100%; + } + + .ca-export-btn { + margin-left: 0; + } + } + + .ca-segment-toolbar { + .ca-segment-search { + width: 100%; + } + } +} + +@media (max-width: 768px) { + .ca-stats { + grid-template-columns: 1fr; + } + + .ca-donut-wrap { + flex-direction: column; + align-items: flex-start; + } + + .ca-member-overview { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .ca-toolbar { + .ca-period-label { + width: 100%; + } + + .ca-period-segment { + width: 100%; + } + + .ca-export-btn { + width: 100%; + } + } + + .ca-segment-toolbar { + flex-wrap: wrap; + } +} diff --git a/apps/web-antd/src/views/customer/analysis/types.ts b/apps/web-antd/src/views/customer/analysis/types.ts new file mode 100644 index 0000000..c1c61f0 --- /dev/null +++ b/apps/web-antd/src/views/customer/analysis/types.ts @@ -0,0 +1,42 @@ +import type { + CustomerAnalysisOverviewDto, + CustomerAnalysisPeriodFilter, + CustomerAnalysisSegmentCode, + CustomerAnalysisSegmentListResultDto, + CustomerDetailDto, + CustomerMemberDetailDto, + CustomerProfileDto, +} from '#/api/customer'; + +export interface OptionItem { + label: string; + value: string; +} + +export interface CustomerAnalysisPagePagination { + page: number; + pageSize: number; + total: number; +} + +export interface CustomerAnalysisPageState { + detail: CustomerDetailDto | null; + isDetailDrawerOpen: boolean; + isDetailLoading: boolean; + isExporting: boolean; + isMemberDrawerOpen: boolean; + isMemberLoading: boolean; + isOverviewLoading: boolean; + isProfileDrawerOpen: boolean; + isProfileLoading: boolean; + isSegmentDrawerOpen: boolean; + isSegmentLoading: boolean; + memberDetail: CustomerMemberDetailDto | null; + overview: CustomerAnalysisOverviewDto; + pagination: CustomerAnalysisPagePagination; + period: CustomerAnalysisPeriodFilter; + profile: CustomerProfileDto | null; + segmentCode: CustomerAnalysisSegmentCode; + segmentKeyword: string; + segmentResult: CustomerAnalysisSegmentListResultDto | null; +} diff --git a/apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue b/apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue index 8ec0f85..0a316f0 100644 --- a/apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue +++ b/apps/web-antd/src/views/customer/list/components/CustomerDetailDrawer.vue @@ -15,12 +15,16 @@ interface Props { detail: CustomerDetailDto | null; loading: boolean; open: boolean; + showMemberAction?: boolean; } -const props = defineProps(); +const props = withDefaults(defineProps(), { + showMemberAction: false, +}); const emit = defineEmits<{ (event: 'close'): void; + (event: 'member', customerKey: string): void; (event: 'profile', customerKey: string): void; (event: 'profilePage', customerKey: string): void; }>(); @@ -60,6 +64,15 @@ function handleOpenProfilePage() { emit('profilePage', customerKey); } + +function handleOpenMember() { + const customerKey = props.detail?.customerKey || ''; + if (!customerKey) { + return; + } + + emit('member', customerKey); +} diff --git a/apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts b/apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts index da1b16c..286f4ed 100644 --- a/apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts +++ b/apps/web-antd/src/views/customer/profile/composables/useCustomerProfilePage.ts @@ -14,6 +14,7 @@ import { export function useCustomerProfilePage() { const route = useRoute(); const router = useRouter(); + const PROFILE_ROUTE_PATH = '/customer/profile'; const stores = ref([]); const selectedStoreId = ref(''); @@ -49,6 +50,10 @@ export function useCustomerProfilePage() { }); async function syncRouteQuery(storeId: string, customerKey: string) { + if (route.path !== PROFILE_ROUTE_PATH) { + return; + } + const currentStoreId = parseRouteQueryValue(route.query.storeId); const currentCustomerKey = parseRouteQueryValue(route.query.customerKey); if (currentStoreId === storeId && currentCustomerKey === customerKey) { @@ -56,7 +61,7 @@ export function useCustomerProfilePage() { } await router.replace({ - path: '/customer/profile', + path: PROFILE_ROUTE_PATH, query: buildRouteQuery( route.query as Record, storeId, @@ -75,6 +80,10 @@ export function useCustomerProfilePage() { } async function loadProfileByRoute() { + if (route.path !== PROFILE_ROUTE_PATH) { + return; + } + if (stores.value.length === 0) { await loadStores(); } @@ -110,15 +119,24 @@ export function useCustomerProfilePage() { watch( () => route.fullPath, () => { + if (route.path !== PROFILE_ROUTE_PATH) { + return; + } void loadProfileByRoute(); }, ); onMounted(() => { - void loadProfileByRoute(); + if (route.path === PROFILE_ROUTE_PATH) { + void loadProfileByRoute(); + } }); onActivated(() => { + if (route.path !== PROFILE_ROUTE_PATH) { + return; + } + if (stores.value.length === 0) { void loadProfileByRoute(); }