feat(customer): implement customer analysis page and drawer flow

This commit is contained in:
2026-03-03 16:45:22 +08:00
parent ccf7d403de
commit d450526204
28 changed files with 2880 additions and 3 deletions

View File

@@ -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: [],
};

View File

@@ -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<boolean>;
isStoreLoading: Ref<boolean>;
overview: Ref<CustomerAnalysisOverviewDto>;
period: Ref<'7d' | '30d' | '90d' | '365d'>;
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
}
/**
* 文件职责:客户分析页的数据加载动作。
*/
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,
};
}

View File

@@ -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<CustomerDetailDto | null>;
isDetailDrawerOpen: Ref<boolean>;
isDetailLoading: Ref<boolean>;
isProfileDrawerOpen: Ref<boolean>;
isProfileLoading: Ref<boolean>;
profile: Ref<CustomerProfileDto | null>;
selectedStoreId: Ref<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 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,
};
}

View File

@@ -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<boolean>;
isExporting: Ref<boolean>;
period: Ref<'7d' | '30d' | '90d' | '365d'>;
selectedStoreId: Ref<string>;
}
/**
* 文件职责:客户分析报表导出动作。
*/
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,
};
}

View File

@@ -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);
}

View File

@@ -0,0 +1,47 @@
import type { Ref } from 'vue';
import type { CustomerMemberDetailDto } from '#/api/customer';
import { getCustomerMemberDetailApi } from '#/api/customer';
interface MemberActionOptions {
detail: Ref<CustomerMemberDetailDto | null>;
isMemberDrawerOpen: Ref<boolean>;
isMemberLoading: Ref<boolean>;
selectedStoreId: Ref<string>;
}
/**
* 文件职责:客户分析页会员详情抽屉动作。
*/
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,
};
}

View File

@@ -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<CustomerAnalysisSegmentCode>;
isSegmentDrawerOpen: Ref<boolean>;
isSegmentLoading: Ref<boolean>;
keyword: Ref<string>;
pagination: SegmentPagination;
period: Ref<'7d' | '30d' | '90d' | '365d'>;
result: Ref<CustomerAnalysisSegmentListResultDto | null>;
selectedStoreId: Ref<string>;
}
/**
* 文件职责:客户分析页客群明细抽屉动作。
*/
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,
};
}

View File

@@ -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<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const period = ref<CustomerAnalysisPeriodFilter>(DEFAULT_PERIOD);
const overview = ref<CustomerAnalysisOverviewDto>({ ...EMPTY_OVERVIEW });
const isOverviewLoading = ref(false);
const segmentResult = ref<CustomerAnalysisSegmentListResultDto | null>(null);
const isSegmentDrawerOpen = ref(false);
const isSegmentLoading = ref(false);
const segmentKeyword = ref('');
const currentSegmentCode =
ref<CustomerAnalysisSegmentCode>(DEFAULT_SEGMENT_CODE);
const segmentPagination = reactive({
page: 1,
pageSize: 10,
total: 0,
});
const detail = ref<CustomerDetailDto | null>(null);
const isDetailDrawerOpen = ref(false);
const isDetailLoading = ref(false);
const profile = ref<CustomerProfileDto | null>(null);
const isProfileDrawerOpen = ref(false);
const isProfileLoading = ref(false);
const memberDetail = ref<CustomerMemberDetailDto | null>(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,
};
}