feat: 更新日期格式化方法,新增多个统计卡片组件

- 将日期格式化方法从 formatDate 更新为 formatDate2,提升日期处理的灵活性
- 新增会员概览、用户统计、会员终端和交易量趋势等统计卡片组件
- 在商城首页引入新组件以展示关键会员和交易数据
- 优化数据获取逻辑,提升用户体验
pull/175/head
lrl 2025-07-16 11:01:27 +08:00
parent 5edccd3efe
commit 8c2f982ab6
11 changed files with 710 additions and 8 deletions

View File

@ -1,6 +1,6 @@
import type { MallDataComparisonResp } from './common';
import { formatDate } from '@vben/utils';
import { formatDate2 } from '@vben/utils';
import { requestClient } from '#/api/request';
@ -84,7 +84,10 @@ export function getMemberAnalyse(params: MallMemberStatisticsApi.AnalyseReq) {
'/statistics/member/analyse',
{
params: {
times: [formatDate(params.times[0]), formatDate(params.times[1])],
times: [
formatDate2(params.times[0] || new Date()),
formatDate2(params.times[1] || new Date()),
],
},
},
);
@ -124,7 +127,7 @@ export function getMemberRegisterCountList(beginTime: Date, endTime: Date) {
'/statistics/member/register-count-list',
{
params: {
times: [formatDate(beginTime), formatDate(endTime)],
times: [formatDate2(beginTime), formatDate2(endTime)],
},
},
);

View File

@ -1,6 +1,6 @@
import type { MallDataComparisonResp } from './common';
import { formatDate } from '@vben/utils';
import { formatDate, formatDate2 } from '@vben/utils';
import { requestClient } from '#/api/request';
@ -128,8 +128,8 @@ export function getOrderCountTrendComparison(
>('/statistics/trade/order-count-trend', {
params: {
type,
beginTime: formatDate(beginTime),
endTime: formatDate(endTime),
beginTime: formatDate2(beginTime),
endTime: formatDate2(endTime),
},
});
}

View File

@ -0,0 +1,167 @@
<script lang="ts" setup>
import type { MallMemberStatisticsApi } from '#/api/mall/statistics/member';
import { ref } from 'vue';
import { AnalysisChartCard } from '@vben/common-ui';
import { calculateRelativeRate, fenToYuan } from '@vben/utils';
import dayjs from 'dayjs';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import ShortcutDateRangePicker from './shortcut-date-range-picker.vue';
/** 会员概览卡片 */
defineOptions({ name: 'MemberFunnelCard' });
const loading = ref(true); //
const analyseData = ref<MallMemberStatisticsApi.Analyse>(); //
/** 查询会员概览数据列表 */
const handleTimeRangeChange = async (
times: [dayjs.ConfigType, dayjs.ConfigType],
) => {
loading.value = true;
//
analyseData.value = await MemberStatisticsApi.getMemberAnalyse({
times: [dayjs(times[0]).toDate(), dayjs(times[1]).toDate()],
});
loading.value = false;
};
</script>
<template>
<AnalysisChartCard title="会员概览">
<template #header-suffix>
<!-- 查询条件 -->
<ShortcutDateRangePicker @change="handleTimeRangeChange" />
</template>
<template #default>
<div class="min-w-225 py-1.75" v-loading="loading">
<div class="relative flex h-24">
<div
class="w-75% <lg:w-35% <xl:w-55% h-full bg-blue-50"
style="width: 75%"
>
<div class="ml-15 flex h-full flex-col justify-center">
<div class="font-bold">
注册用户数量{{
analyseData?.comparison?.value?.registerUserCount || 0
}}
</div>
<div class="text-3.5 mt-2">
环比增长率{{
calculateRelativeRate(
analyseData?.comparison?.value?.registerUserCount,
analyseData?.comparison?.reference?.registerUserCount,
)
}}%
</div>
</div>
</div>
<div
class="trapezoid1 ml--38.5 w-77 text-3.5 mt-1.5 flex h-full flex-col items-center justify-center bg-blue-500 text-white"
>
<span class="text-6 font-bold">{{
analyseData?.visitUserCount || 0
}}</span>
<span>访客</span>
</div>
</div>
<div class="relative flex h-24">
<div
class="w-75% <lg:w-35% <xl:w-55% flex h-full bg-cyan-50"
style="width: 75%"
>
<div class="ml-15 flex h-full flex-col justify-center">
<div class="font-bold">
活跃用户数量{{
analyseData?.comparison?.value?.visitUserCount || 0
}}
</div>
<div class="text-3.5 mt-2">
环比增长率{{
calculateRelativeRate(
analyseData?.comparison?.value?.visitUserCount,
analyseData?.comparison?.reference?.visitUserCount,
)
}}%
</div>
</div>
</div>
<div
class="trapezoid2 mt-1.7 h-25 text-3.5 ml--28 flex w-56 flex-col items-center justify-center bg-cyan-500 text-white"
>
<span class="text-6 font-bold">{{
analyseData?.orderUserCount || 0
}}</span>
<span>下单</span>
</div>
</div>
<div class="relative flex h-24">
<div
class="w-75% <lg:w-35% <xl:w-55% flex bg-slate-50"
style="width: 75%"
>
<div class="ml-15 flex h-full flex-row gap-x-16">
<div class="flex flex-col justify-center">
<div class="font-bold">
充值用户数量{{
analyseData?.comparison?.value?.rechargeUserCount || 0
}}
</div>
<div class="text-3.5 mt-2">
环比增长率{{
calculateRelativeRate(
analyseData?.comparison?.value?.rechargeUserCount,
analyseData?.comparison?.reference?.rechargeUserCount,
)
}}%
</div>
</div>
<div class="flex flex-col justify-center">
<div class="font-bold">
客单价{{ fenToYuan(analyseData?.atv || 0) }}
</div>
</div>
</div>
</div>
<div
class="trapezoid3 ml--18 mt-3.25 h-23 text-3.5 flex w-36 flex-col items-center justify-center bg-slate-500 text-white"
>
<span class="text-6 font-bold">{{
analyseData?.payUserCount || 0
}}</span>
<span>成交用户</span>
</div>
</div>
</div>
</template>
</AnalysisChartCard>
</template>
<style lang="scss" scoped>
.trapezoid1 {
transform: perspective(5em) rotateX(-11deg);
font-size: 0.875rem;
width: 19.25rem;
margin-left: -9.625rem;
}
.trapezoid2 {
transform: perspective(7em) rotateX(-20deg);
font-size: 0.875rem;
width: 14rem;
margin-left: -7rem;
height: 6.25rem;
margin-top: 0.425rem;
}
.trapezoid3 {
transform: perspective(3em) rotateX(-13deg);
font-size: 0.875rem;
width: 9rem;
height: 5.75rem;
margin-top: 0.8125rem;
margin-left: -4.5rem;
}
</style>

View File

@ -0,0 +1,101 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, reactive, ref } from 'vue';
import { AnalysisChartCard } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { formatDate } from '@vben/utils';
import dayjs from 'dayjs';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
/** 会员用户统计卡片 */
defineOptions({ name: 'MemberStatisticsCard' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(true); //
/** 折线图配置 */
const lineChartOptions = reactive({
dataset: {
dimensions: ['date', 'count'],
source: [] as any[],
},
grid: {
left: 20,
right: 20,
bottom: 20,
top: 80,
containLabel: true,
},
legend: {
top: 50,
},
series: [{ name: '注册量', type: 'line', smooth: true, areaStyle: {} }],
toolbox: {
feature: {
//
dataZoom: {
yAxisIndex: false, // Y
},
brush: {
type: ['lineX', 'clear'], //
},
saveAsImage: { show: true, name: '会员统计' }, //
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
padding: [5, 10],
},
xAxis: {
type: 'category',
boundaryGap: false,
axisTick: {
show: false,
},
axisLabel: {
formatter: (date: string) => formatDate(date, 'MM-DD'),
},
},
yAxis: {
axisTick: {
show: false,
},
},
});
const getMemberRegisterCountList = async () => {
loading.value = true;
//
const beginTime = dayjs().subtract(30, 'd').startOf('d');
const endTime = dayjs().endOf('d');
const list = await MemberStatisticsApi.getMemberRegisterCountList(
beginTime.toDate(),
endTime.toDate(),
);
// Echarts
if (lineChartOptions.dataset && lineChartOptions.dataset.source) {
lineChartOptions.dataset.source = list;
}
loading.value = false;
};
/** 初始化 */
onMounted(async () => {
await getMemberRegisterCountList();
renderEcharts(lineChartOptions as any);
});
</script>
<template>
<AnalysisChartCard title="用户统计">
<!-- 折线图 -->
<EchartsUI ref="chartRef" />
</AnalysisChartCard>
</template>

View File

@ -0,0 +1,80 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { MallMemberStatisticsApi } from '#/api/mall/statistics/member';
import type { DictDataType } from '#/utils/dict';
import { onMounted, reactive, ref } from 'vue';
import { AnalysisChartCard } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import { DICT_TYPE, getIntDictOptions } from '#/utils/dict';
/** 会员终端卡片 */
defineOptions({ name: 'MemberTerminalCard' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(true); //
/** 会员终端统计图配置 */
const terminalChartOptions = reactive({
tooltip: {
trigger: 'item' as const,
confine: true,
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
orient: 'vertical' as const,
left: 'right' as const,
},
roseType: 'area',
series: [
{
name: '会员终端',
type: 'pie' as const,
label: {
show: false,
},
labelLine: {
show: false,
},
data: [] as { name: string; value: number }[],
},
],
});
/** 按照终端,查询会员统计列表 */
const getMemberTerminalStatisticsList = async () => {
loading.value = true;
const list = await MemberStatisticsApi.getMemberTerminalStatisticsList();
const dictDataList = getIntDictOptions(DICT_TYPE.TERMINAL);
if (terminalChartOptions.series && terminalChartOptions.series.length > 0) {
(terminalChartOptions.series[0] as any).data = dictDataList.map(
(dictData: DictDataType) => {
const userCount = list.find(
(item: MallMemberStatisticsApi.TerminalStatistics) =>
item.terminal === dictData.value,
)?.userCount;
return {
name: dictData.label,
value: userCount || 0,
};
},
);
}
loading.value = false;
};
/** 初始化 */
onMounted(async () => {
await getMemberTerminalStatisticsList();
renderEcharts(terminalChartOptions);
});
</script>
<template>
<AnalysisChartCard title="会员终端">
<EchartsUI ref="chartRef" />
</AnalysisChartCard>
</template>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as DateUtil from '@vben/utils';
import dayjs from 'dayjs';
/** 快捷日期范围选择组件 */
defineOptions({ name: 'ShortcutDateRangePicker' });
/** 触发事件:时间范围选中 */
const emits = defineEmits<{
(e: 'change', times: [dayjs.ConfigType, dayjs.ConfigType]): void;
}>();
const shortcutDays = ref(7); // , 7
const times = ref<[string, string]>(['', '']); //
defineExpose({ times }); //
/** 日期快捷选择 */
const shortcuts = [
{
text: '昨天',
value: () => DateUtil.getDayRange(new Date(), -1),
},
{
text: '最近7天',
value: () => DateUtil.getLast7Days(),
},
{
text: '本月',
value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')],
},
{
text: '最近30天',
value: () => DateUtil.getLast30Days(),
},
{
text: '最近1年',
value: () => DateUtil.getLast1Year(),
},
];
/** 设置时间范围 */
function setTimes() {
const beginDate = dayjs().subtract(shortcutDays.value, 'd');
const yesterday = dayjs().subtract(1, 'd');
times.value = DateUtil.getDateRange(beginDate, yesterday);
}
/** 快捷日期单选按钮选中 */
const handleShortcutDaysChange = async () => {
//
setTimes();
//
await emitDateRangePicker();
};
/** 触发时间范围选中事件 */
const emitDateRangePicker = async () => {
emits('change', times.value);
};
/** 初始化 */
onMounted(() => {
handleShortcutDaysChange();
});
</script>
<template>
<div class="flex flex-row items-center gap-2">
<el-radio-group v-model="shortcutDays" @change="handleShortcutDaysChange">
<el-radio-button :value="1">昨天</el-radio-button>
<el-radio-button :value="7">最近7天</el-radio-button>
<el-radio-button :value="30">最近30天</el-radio-button>
</el-radio-group>
<el-date-picker
v-model="times"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
:shortcuts="shortcuts"
class="!w-240px"
@change="emitDateRangePicker"
/>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,226 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, reactive, ref } from 'vue';
import { AnalysisChartCard } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { fenToYuan, formatDate } from '@vben/utils';
import dayjs, { Dayjs } from 'dayjs';
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
import { TimeRangeTypeEnum } from '../data';
/** 交易量趋势 */
defineOptions({ name: 'TradeTrendCard' });
const timeRangeType = ref(TimeRangeTypeEnum.DAY30); // , 30
const loading = ref(true); //
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// Map
const timeRange = new Map()
.set(TimeRangeTypeEnum.DAY30, {
name: '30天',
series: [
{ name: '订单金额', type: 'bar', smooth: true, data: [] },
{ name: '订单数量', type: 'line', smooth: true, data: [] },
],
})
.set(TimeRangeTypeEnum.WEEK, {
name: '周',
series: [
{ name: '上周金额', type: 'bar', smooth: true, data: [] },
{ name: '本周金额', type: 'bar', smooth: true, data: [] },
{ name: '上周数量', type: 'line', smooth: true, data: [] },
{ name: '本周数量', type: 'line', smooth: true, data: [] },
],
})
.set(TimeRangeTypeEnum.MONTH, {
name: '月',
series: [
{ name: '上月金额', type: 'bar', smooth: true, data: [] },
{ name: '本月金额', type: 'bar', smooth: true, data: [] },
{ name: '上月数量', type: 'line', smooth: true, data: [] },
{ name: '本月数量', type: 'line', smooth: true, data: [] },
],
})
.set(TimeRangeTypeEnum.YEAR, {
name: '年',
series: [
{ name: '去年金额', type: 'bar', smooth: true, data: [] },
{ name: '今年金额', type: 'bar', smooth: true, data: [] },
{ name: '去年数量', type: 'line', smooth: true, data: [] },
{ name: '今年数量', type: 'line', smooth: true, data: [] },
],
});
/** 图表配置 */
const eChartOptions = reactive({
grid: {
left: 20,
right: 20,
bottom: 20,
top: 80,
containLabel: true,
},
legend: {
top: 50,
data: [] as string[],
},
series: [] as any[],
toolbox: {
feature: {
//
dataZoom: {
yAxisIndex: false, // Y
},
brush: {
type: ['lineX', 'clear'], //
},
saveAsImage: { show: true, name: '订单量趋势' }, //
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
padding: [5, 10],
},
xAxis: {
type: 'category' as const,
inverse: true,
boundaryGap: false,
axisTick: {
show: false,
},
data: [] as string[],
axisLabel: {
formatter: (date: string) => {
switch (timeRangeType.value) {
case TimeRangeTypeEnum.DAY30: {
return formatDate(date, 'MM-DD');
}
case TimeRangeTypeEnum.MONTH: {
return formatDate(date, 'D');
}
case TimeRangeTypeEnum.WEEK: {
let weekDay = formatDate(date, 'ddd');
if (weekDay === '0') weekDay = '日';
return `${weekDay}`;
}
case TimeRangeTypeEnum.YEAR: {
return `${formatDate(date, 'M')}`;
}
default: {
return date;
}
}
},
},
},
yAxis: {
axisTick: {
show: false,
},
},
});
/** 时间范围类型单选按钮选中 */
const handleTimeRangeTypeChange = async () => {
//
let beginTime: Dayjs;
let endTime: Dayjs;
switch (timeRangeType.value) {
case TimeRangeTypeEnum.MONTH: {
beginTime = dayjs().startOf('month');
endTime = dayjs().endOf('month');
break;
}
case TimeRangeTypeEnum.WEEK: {
beginTime = dayjs().startOf('week');
endTime = dayjs().endOf('week');
break;
}
case TimeRangeTypeEnum.YEAR: {
beginTime = dayjs().startOf('year');
endTime = dayjs().endOf('year');
break;
}
default: {
beginTime = dayjs().subtract(30, 'day').startOf('d');
endTime = dayjs().endOf('d');
break;
}
}
//
await getOrderCountTrendComparison(beginTime, endTime);
};
/** 查询订单数量趋势对照数据 */
const getOrderCountTrendComparison = async (
beginTime: dayjs.ConfigType,
endTime: dayjs.ConfigType,
) => {
loading.value = true;
//
const list = await TradeStatisticsApi.getOrderCountTrendComparison(
timeRangeType.value,
dayjs(beginTime).toDate(),
dayjs(endTime).toDate(),
);
//
const dates: string[] = [];
const series = [...timeRange.get(timeRangeType.value).series];
for (const item of list) {
dates.push(item.value.date);
if (series.length === 2) {
series[0].data.push(fenToYuan(item?.value?.orderPayPrice || 0)); //
series[1].data.push(item?.value?.orderPayCount || 0); //
} else {
series[0].data.push(fenToYuan(item?.reference?.orderPayPrice || 0)); //
series[1].data.push(fenToYuan(item?.value?.orderPayPrice || 0)); //
series[2].data.push(item?.reference?.orderPayCount || 0); //
series[3].data.push(item?.value?.orderPayCount || 0); //
}
}
eChartOptions.xAxis!.data = dates;
eChartOptions.series = series;
// legend424
eChartOptions.legend.data = series.map((item) => item.name);
loading.value = false;
};
/** 初始化 */
onMounted(async () => {
await handleTimeRangeTypeChange();
renderEcharts(eChartOptions as any);
});
</script>
<template>
<AnalysisChartCard title="交易量趋势">
<template #header-suffix>
<div class="flex flex-row items-center justify-between">
<!-- 查询条件 -->
<div class="flex flex-row items-center gap-2">
<el-radio-group
v-model="timeRangeType"
@change="handleTimeRangeTypeChange"
>
<el-radio-button
v-for="[key, value] in timeRange.entries()"
:key="key"
:value="key"
>
{{ value.name }}
</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<!-- 折线图 -->
<EchartsUI ref="chartRef" />
</AnalysisChartCard>
</template>

View File

@ -0,0 +1,6 @@
export enum TimeRangeTypeEnum {
DAY30 = 1,
MONTH = 30,
WEEK = 7,
YEAR = 365,
} // 日期类型

View File

@ -29,6 +29,11 @@ import { getUserCountComparison } from '#/api/mall/statistics/member';
import { getWalletRechargePrice } from '#/api/mall/statistics/pay';
import { getOrderComparison, getOrderCount } from '#/api/mall/statistics/trade';
import MemberFunnelCard from './components/member-funnel-card.vue';
import MemberStatisticsCard from './components/member-statistics-card.vue';
import MemberTerminalCard from './components/member-terminal-card.vue';
import TradeTrendCard from './components/trade-trend-card.vue';
/** 商城首页 */
defineOptions({ name: 'MallHome' });
@ -287,7 +292,12 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
url="https://doc.iocoder.cn/mall/build/"
/>
</template>
<AnalysisOverview v-model:model-value="overviewItems" />
<div class="mt-5 w-full md:flex">
<AnalysisOverview
v-model:model-value="overviewItems"
class="mt-5 md:mr-4 md:mt-0 md:w-full"
/>
</div>
<div class="mt-5 w-full md:flex">
<WorkbenchQuickNav
:items="quickNavItems"
@ -302,5 +312,15 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
class="mt-5 md:mr-4 md:mt-0 md:w-1/2"
/>
</div>
<div class="mb-4 mt-5 w-full md:flex">
<MemberFunnelCard class="mt-5 md:mr-4 md:mt-0 md:w-2/3" />
<MemberTerminalCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" />
</div>
<div class="mb-4 mt-5 w-full md:flex">
<TradeTrendCard class="mt-5 md:mr-4 md:mt-0 md:w-full" />
</div>
<div class="mb-4 mt-5 w-full md:flex">
<MemberStatisticsCard class="mt-5 md:mr-4 md:mt-0 md:w-full" />
</div>
</Page>
</template>

View File

@ -26,6 +26,15 @@ export function formatDateTime(time: Date | number | string | undefined) {
return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
}
export function formatDate2(date: Date, format?: string): string {
// 日期不存在,则返回空
if (!date) {
return '';
}
// 日期存在,则进行格式化
return date ? dayjs(date).format(format ?? 'YYYY-MM-DD HH:mm:ss') : '';
}
export function isDate(value: any): value is Date {
return value instanceof Date;
}

View File

@ -15,7 +15,10 @@ withDefaults(defineProps<Props>(), {});
<template>
<Card>
<CardHeader>
<CardTitle class="text-xl">{{ title }}</CardTitle>
<div class="my--1.5 flex flex-row items-center justify-between">
<CardTitle class="text-xl">{{ title }}</CardTitle>
<slot name="header-suffix"></slot>
</div>
</CardHeader>
<CardContent>
<slot></slot>