feat:【mall 商城】商城首页的迁移【ele】60%:初始化

This commit is contained in:
YunaiV
2025-10-19 16:41:58 +08:00
parent 5b3749d535
commit 73208aa304
22 changed files with 1121 additions and 1440 deletions

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
interface Props {
title: string;
}
defineOptions({
name: 'AnalysisChartCard',
});
withDefaults(defineProps<Props>(), {});
</script>
<template>
<el-card>
<template #header>
<div class="my--1.5 flex flex-row items-center justify-between">
<div class="text-xl">{{ title }}</div>
<slot name="header-suffix"></slot>
</div>
</template>
<template #default>
<slot></slot>
</template>
</el-card>
</template>

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
import type { AnalysisOverviewIconItem } from './data';
import { computed } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
interface Props {
items?: AnalysisOverviewIconItem[];
modelValue?: AnalysisOverviewIconItem[];
columnsNumber?: number;
}
defineOptions({
name: 'AnalysisOverview',
});
const props = withDefaults(defineProps<Props>(), {
items: () => [],
modelValue: () => [],
columnsNumber: 4,
});
const emit = defineEmits(['update:modelValue']);
const itemsData = computed({
get: () => (props.modelValue?.length ? props.modelValue : props.items),
set: (value) => emit('update:modelValue', value),
});
// 计算动态的grid列数类名
const gridColumnsClass = computed(() => {
const colNum = props.columnsNumber;
return {
'lg:grid-cols-1': colNum === 1,
'lg:grid-cols-2': colNum === 2,
'lg:grid-cols-3': colNum === 3,
'lg:grid-cols-4': colNum === 4,
'lg:grid-cols-5': colNum === 5,
'lg:grid-cols-6': colNum === 6,
};
});
</script>
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2" :class="gridColumnsClass">
<template v-for="item in itemsData" :key="item.title">
<div
class="flex flex-row items-center gap-3 rounded bg-[var(--el-bg-color-overlay)] p-4"
>
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded"
:class="`${item.iconColor} ${item.iconBgColor}`"
>
<IconifyIcon :icon="item.icon" class="text-2xl" />
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1 text-gray-500">
<span class="text-sm">{{ item.title }}</span>
<el-tooltip
:content="item.tooltip"
placement="top-start"
v-if="item.tooltip"
>
<IconifyIcon
icon="ep:warning"
class="flex items-center text-sm"
/>
</el-tooltip>
</div>
<div class="flex flex-row items-baseline gap-2">
<div class="text-3xl">
<CountTo
:prefix="item.prefix"
:end-val="item.value"
:decimals="item.decimals"
/>
</div>
<span
v-if="item.percent !== undefined"
:class="
Number(item.percent) > 0 ? 'text-red-500' : 'text-green-500'
"
class="flex items-center whitespace-nowrap"
>
<span class="text-sm">{{ Math.abs(Number(item.percent)) }}%</span>
<IconifyIcon
:icon="
Number(item.percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'
"
class="ml-0.5 text-sm"
/>
</span>
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -1,174 +0,0 @@
<script setup lang="ts">
import type { AnalysisOverviewItem } from './data';
import { computed } from 'vue';
import { VbenCountToAnimator } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
interface Props {
items?: AnalysisOverviewItem[];
modelValue?: AnalysisOverviewItem[];
columnsNumber?: number;
}
defineOptions({
name: 'AnalysisOverview',
});
const props = withDefaults(defineProps<Props>(), {
items: () => [],
modelValue: () => [],
columnsNumber: 4,
});
const emit = defineEmits(['update:modelValue']);
const itemsData = computed({
get: () => (props.modelValue?.length ? props.modelValue : props.items),
set: (value) => emit('update:modelValue', value),
});
// 计算动态的grid列数类名
const gridColumnsClass = computed(() => {
const colNum = props.columnsNumber;
return {
'lg:grid-cols-1': colNum === 1,
'lg:grid-cols-2': colNum === 2,
'lg:grid-cols-3': colNum === 3,
'lg:grid-cols-4': colNum === 4,
'lg:grid-cols-5': colNum === 5,
'lg:grid-cols-6': colNum === 6,
};
});
// 计算环比增长率
const calculateGrowthRate = (
currentValue: number,
previousValue: number,
): { isPositive: boolean; rate: number } => {
if (previousValue === 0) {
return { rate: currentValue > 0 ? 100 : 0, isPositive: currentValue >= 0 };
}
const rate = ((currentValue - previousValue) / previousValue) * 100;
return { rate: Math.abs(rate), isPositive: rate >= 0 };
};
// 格式化增长率显示
const formatGrowthRate = (rate: number): string => {
return rate.toFixed(1);
};
</script>
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2" :class="gridColumnsClass">
<template v-for="item in itemsData" :key="item.title">
<el-card :title="item.title" class="w-full">
<template #header>
<div class="text-lg font-semibold">
<div class="flex items-center justify-between">
<div class="flex items-center">
<span>{{ item.title }}</span>
<span v-if="item.tooltip" class="ml-1 inline-block">
<el-tooltip>
<template #default>
<div
class="inline-flex h-4 w-4 translate-y-[-3px] items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600"
>
!
</div>
</template>
<template #content>
{{ item.tooltip }}
</template>
</el-tooltip>
</span>
</div>
<el-tag>今日</el-tag>
</div>
</div>
</template>
<template #default>
<!-- 左右布局左边数字右边图标 -->
<div class="flex items-center justify-between">
<!-- 左侧数字显示 -->
<div class="flex-1">
<div class="flex items-baseline">
<!-- prefix 前缀 -->
<span
v-if="item.prefix"
class="mr-1 text-3xl font-medium text-gray-600"
>
{{ item.prefix }}
</span>
<!-- 数字动画 -->
<VbenCountToAnimator
:end-val="item.value"
:start-val="1"
class="text-3xl font-bold text-gray-900"
prefix=""
/>
</div>
</div>
<!-- 右侧环比增长率图标和数值 -->
<div
v-if="item.showGrowthRate && item.totalValue !== undefined"
class="flex items-center space-x-2 rounded-lg bg-gray-50 px-3 py-2"
>
<IconifyIcon
:icon="
calculateGrowthRate(item.value, item.totalValue).isPositive
? 'lucide:trending-up'
: 'lucide:trending-down'
"
class="size-5"
:class="[
calculateGrowthRate(item.value, item.totalValue).isPositive
? 'text-green-500'
: 'text-red-500',
]"
/>
<span
class="text-sm font-semibold"
:class="[
calculateGrowthRate(item.value, item.totalValue).isPositive
? 'text-green-500'
: 'text-red-500',
]"
>
{{
calculateGrowthRate(item.value, item.totalValue).isPositive
? '+'
: '-'
}}{{
formatGrowthRate(
calculateGrowthRate(item.value, item.totalValue).rate,
)
}}%
</span>
</div>
</div>
</template>
<template #footer v-if="item.totalTitle">
<div class="flex items-center justify-between">
<span>{{ item.totalTitle }}</span>
<VbenCountToAnimator
:end-val="item.totalValue"
:start-val="1"
prefix=""
/>
</div>
</template>
</el-card>
</template>
</div>
</template>
<style lang="scss" scoped>
/* 移除 el-card header 的下边框 */
:deep(.el-card__header) {
padding-bottom: 16px;
border-bottom: none !important;
}
</style>

View File

@@ -1,87 +0,0 @@
<script setup lang="ts">
import type { AnalysisOverviewTradeItem } from './data';
import { computed } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
interface Props {
items?: AnalysisOverviewTradeItem[];
modelValue?: AnalysisOverviewTradeItem[];
columnsNumber?: number;
}
defineOptions({
name: 'AnalysisOverview',
});
const props = withDefaults(defineProps<Props>(), {
items: () => [],
modelValue: () => [],
columnsNumber: 4,
});
const emit = defineEmits(['update:modelValue']);
const itemsData = computed({
get: () => (props.modelValue?.length ? props.modelValue : props.items),
set: (value) => emit('update:modelValue', value),
});
// 计算动态的grid列数类名
const gridColumnsClass = computed(() => {
const colNum = props.columnsNumber;
return {
'lg:grid-cols-1': colNum === 1,
'lg:grid-cols-2': colNum === 2,
'lg:grid-cols-3': colNum === 3,
'lg:grid-cols-4': colNum === 4,
'lg:grid-cols-5': colNum === 5,
'lg:grid-cols-6': colNum === 6,
};
});
</script>
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2" :class="gridColumnsClass">
<template v-for="item in itemsData" :key="item.title">
<div class="flex flex-col gap-2 bg-[var(--el-bg-color-overlay)] p-6">
<div class="flex items-center justify-between text-gray-500">
<span>{{ item.title }}</span>
<el-tooltip
:content="item.tooltip"
placement="top-start"
v-if="item.tooltip"
>
<IconifyIcon icon="ep:warning" />
</el-tooltip>
</div>
<div class="mb-4 text-3xl">
<CountTo
:prefix="item.prefix"
:end-val="item.value"
:decimals="item.decimals"
/>
</div>
<div class="flex flex-row gap-1 text-sm">
<span class="text-gray-500">环比</span>
<span
class="flex items-center gap-0.5 whitespace-nowrap"
:class="
Number(item.percent) > 0 ? 'text-red-500' : 'text-green-500'
"
>
<span>{{ Math.abs(Number(item.percent)) }}%</span>
<IconifyIcon
:icon="
Number(item.percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'
"
class="flex-shrink-0 !text-sm"
/>
</span>
</div>
</div>
</template>
</div>
</template>

View File

@@ -1,39 +0,0 @@
export interface WorkbenchQuickDataShowItem {
name: string;
value: number;
prefix: string;
decimals: number;
routerName: string;
}
export interface AnalysisOverviewItem {
title: string;
totalTitle?: string;
totalValue?: number;
value: number;
prefix?: string;
tooltip?: string;
// 环比增长相关字段
showGrowthRate?: boolean; // 是否显示环比增长率默认为false
}
export interface AnalysisOverviewIconItem {
icon: string;
title: string;
value: number;
prefix?: string;
iconBgColor: string;
iconColor: string;
tooltip?: string;
decimals?: number;
percent?: number;
}
export interface AnalysisOverviewTradeItem {
title: string;
value: number;
prefix?: string;
decimals?: number;
percent?: number;
tooltip?: string;
}

View File

@@ -1,162 +0,0 @@
<script lang="ts" setup>
import type { MallMemberStatisticsApi } from '#/api/mall/statistics/member';
import { ref } from 'vue';
import { calculateRelativeRate, fenToYuan } from '@vben/utils';
import dayjs from 'dayjs';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import AnalysisChartCard from './analysis-chart-card.vue';
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="<lg:w-35% <xl:w-55% h-full w-3/4 bg-blue-50">
<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 text-3.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="<lg:w-35% <xl:w-55% flex h-full w-3/4 bg-cyan-50">
<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 flex 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="<lg:w-35% <xl:w-55% flex w-3/4 bg-slate-50">
<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 flex 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 {
z-index: 1;
width: 19.25rem;
margin-top: 0.381rem;
margin-left: -9.625rem;
font-size: 0.875rem;
transform: perspective(5em) rotateX(-11deg);
}
.trapezoid2 {
z-index: 1;
width: 14rem;
height: 6.25rem;
margin-top: 0.425rem;
margin-left: -7rem;
font-size: 0.875rem;
transform: perspective(7em) rotateX(-20deg);
}
.trapezoid3 {
z-index: 1;
width: 9rem;
height: 5.75rem;
margin-top: 0.8125rem;
margin-left: -4.5rem;
font-size: 0.875rem;
transform: perspective(3em) rotateX(-13deg);
}
</style>

View File

@@ -1,101 +0,0 @@
<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

@@ -1,81 +0,0 @@
<script lang="ts" setup>
import type { DictDataType } from '@vben/hooks';
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { MallMemberStatisticsApi } from '#/api/mall/statistics/member';
import { onMounted, reactive, ref } from 'vue';
import { AnalysisChartCard } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
/** 会员终端卡片 */
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 = getDictOptions(DICT_TYPE.TERMINAL, 'number');
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

@@ -1,93 +0,0 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import {
getDateRange,
getDayRange,
getLast1Year,
getLast7Days,
getLast30Days,
} 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: () => getDayRange(new Date(), -1),
},
{
text: '最近7天',
value: () => getLast7Days(),
},
{
text: '本月',
value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')],
},
{
text: '最近30天',
value: () => getLast30Days(),
},
{
text: '最近1年',
value: () => getLast1Year(),
},
];
/** 设置时间范围 */
function setTimes() {
const beginDate = dayjs().subtract(shortcutDays.value, 'd');
const yesterday = dayjs().subtract(1, 'd');
times.value = 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

@@ -1,225 +0,0 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, reactive, ref } from 'vue';
import { AnalysisChartCard } from '@vben/common-ui';
import { TimeRangeTypeEnum } from '@vben/constants';
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';
/** 交易量趋势 */
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;
// legend在4个切换到2个的时候还是显示成4个需要手动配置一下
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

@@ -1,72 +0,0 @@
<script setup lang="ts">
import type { WorkbenchQuickDataShowItem } from './data';
import { computed } from 'vue';
import { CountTo } from '@vben/common-ui';
interface Props {
items?: WorkbenchQuickDataShowItem[];
modelValue?: WorkbenchQuickDataShowItem[];
title: string;
}
defineOptions({
name: 'WorkbenchQuickDataShow',
});
const props = withDefaults(defineProps<Props>(), {
items: () => [],
modelValue: () => [],
});
const emit = defineEmits(['update:modelValue']);
// 使用计算属性实现双向绑定
const itemsData = computed({
get: () => (props.modelValue?.length ? props.modelValue : props.items),
set: (value) => {
emit('update:modelValue', value);
},
});
</script>
<template>
<el-card>
<template #header>
<!-- <CardTitle class="text-lg " >{{ title }}</CardTitle>-->
<div class="text-lg font-semibold">{{ title }}</div>
</template>
<template #default>
<div class="flex flex-wrap p-0">
<div
v-for="(item, index) in itemsData"
:key="item.name"
:class="{
'border-r-0': index % 4 === 3,
'border-b-0': index < 4,
'pb-4': index > 4,
'rounded-bl-xl': index === itemsData.length - 4,
'rounded-br-xl': index === itemsData.length - 1,
}"
class="flex-col-center group w-1/4 cursor-pointer py-9"
>
<div class="mb-2 flex justify-center">
<CountTo
:prefix="item.prefix || ''"
:end-val="Number(item.value)"
:decimals="item.decimals || 0"
class="text-4xl font-normal"
/>
</div>
<span class="truncate text-base text-gray-500">{{ item.name }}</span>
</div>
</div>
</template>
<!-- <CardContent class="flex flex-wrap p-0">-->
<!-- <template>-->
<!-- -->
<!-- </template>-->
<!-- </CardContent>-->
</el-card>
</template>

View File

@@ -1,279 +1,51 @@
<script lang="ts" setup>
import type {
WorkbenchProjectItem,
WorkbenchQuickNavItem,
} from '@vben/common-ui';
import type { AnalysisOverviewItem } from './components/data';
import type { WorkbenchQuickDataShowItem } from '#/views/mall/home/components/data';
import type { DataComparisonRespVO } from '#/api/mall/statistics/common';
import type { MallMemberStatisticsApi } from '#/api/mall/statistics/member';
import type { MallTradeStatisticsApi } from '#/api/mall/statistics/trade';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, WorkbenchQuickNav } from '@vben/common-ui';
import { isString, openWindow } from '@vben/utils';
import { DocAlert, Page } from '@vben/common-ui';
import { fenToYuan } from '@vben/utils';
import { ElCol, ElRow } from 'element-plus';
import { getTabsCount } from '#/api/mall/product/spu';
import { getUserCountComparison } from '#/api/mall/statistics/member';
import { getWalletRechargePrice } from '#/api/mall/statistics/pay';
import { getOrderComparison, getOrderCount } from '#/api/mall/statistics/trade';
import { getOrderComparison } from '#/api/mall/statistics/trade';
import AnalysisOverview from './components/analysis-overview.vue';
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';
import WorkbenchQuickDataShow from './components/workbench-quick-data-show.vue';
import MemberFunnelCard from '../statistics/member/modules/funnel-card.vue';
import MemberTerminalCard from '../statistics/member/modules/terminal-card.vue';
import ComparisonCard from './modules/comparison-card.vue';
import MemberStatisticsCard from './modules/member-statistics-card.vue';
import OperationDataCard from './modules/operation-data-card.vue';
import ShortcutCard from './modules/shortcut-card.vue';
import TradeTrendCard from './modules/trade-trend-card.vue';
/** 商城首页 */
defineOptions({ name: 'MallHome' });
const loading = ref(true); // 加载中
const orderComparison = ref(); // 交易对照数据
const userComparison = ref(); // 用户对照数据
const data = ref({
orderUndelivered: 0,
orderAfterSaleApply: 0,
orderWaitePickUp: 0,
withdrawAuditing: 0,
productForSale: 0,
productInWarehouse: 0,
productAlertStock: 0,
rechargePrice: 0,
});
const dataShow = ref(false);
const orderComparison =
ref<DataComparisonRespVO<MallTradeStatisticsApi.TradeOrderSummaryRespVO>>(); // 交易对照数据
const userComparison =
ref<DataComparisonRespVO<MallMemberStatisticsApi.MemberCountRespVO>>(); // 用户对照数据
/** 查询交易对照卡片数据 */
const getOrder = async () => {
async function loadOrderComparison() {
orderComparison.value = await getOrderComparison();
};
}
/** 查询会员用户数量对照卡片数据 */
const getUserCount = async () => {
async function loadUserCountComparison() {
userComparison.value = await getUserCountComparison();
};
/** 查询订单数据 */
const getOrderData = async () => {
const orderCount = await getOrderCount();
if (orderCount.undelivered) {
data.value.orderUndelivered = orderCount.undelivered;
}
if (orderCount.afterSaleApply) {
data.value.orderAfterSaleApply = orderCount.afterSaleApply;
}
if (orderCount.pickUp) {
data.value.orderWaitePickUp = orderCount.pickUp;
}
if (orderCount.auditingWithdraw) {
data.value.withdrawAuditing = orderCount.auditingWithdraw;
}
};
/** 查询商品数据 */
const getProductData = async () => {
// TODO: @芋艿:这个接口的返回值,是不是用命名字段更好些?
const productCount = await getTabsCount();
data.value.productForSale = productCount['0'] || 0;
data.value.productInWarehouse = productCount['1'] || 0;
data.value.productAlertStock = productCount['3'] || 0;
};
/** 查询钱包充值数据 */
const getWalletRechargeData = async () => {
const paySummary = await getWalletRechargePrice();
data.value.rechargePrice = paySummary.rechargePrice;
};
}
/** 初始化 */
onMounted(async () => {
loading.value = true;
await Promise.all([
getOrder(),
getUserCount(),
getOrderData(),
getProductData(),
getWalletRechargeData(),
]);
await Promise.all([loadOrderComparison(), loadUserCountComparison()]);
loading.value = false;
dataShow.value = true;
loadDataShow();
loadOverview();
});
const overviewItems = ref<AnalysisOverviewItem[]>([]);
const loadOverview = () => {
overviewItems.value = [
{
title: '今日销售额',
totalTitle: '昨日数据',
totalValue: orderComparison.value?.reference?.orderPayPrice || 0,
value: orderComparison.value?.orderPayPrice || 0,
showGrowthRate: true,
},
{
title: '今日用户访问量',
totalTitle: '总访问量',
totalValue: userComparison.value?.reference?.visitUserCount || 0,
value: userComparison.value?.visitUserCount || 0,
showGrowthRate: true,
},
{
title: '今日订单量',
totalTitle: '总订单量',
totalValue: orderComparison.value?.orderPayCount || 0,
value: orderComparison.value?.reference?.orderPayCount || 0,
showGrowthRate: true,
},
{
title: '今日会员注册量',
totalTitle: '总会员注册量',
totalValue: userComparison.value?.registerUserCount || 0,
value: userComparison.value?.reference?.registerUserCount || 0,
showGrowthRate: true,
},
];
};
// 同样,这里的 url 也可以使用以 http 开头的外部链接
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ep:user-filled',
title: '用户管理',
url: 'MemberUser',
},
{
color: '#ff6b6b',
icon: 'fluent-mdl2:product',
title: '商品管理',
url: 'ProductSpu',
},
{
color: '#7c3aed',
icon: 'ep:list',
title: '订单管理',
url: 'TradeOrder',
},
{
color: '#3fb27f',
icon: 'ri:refund-2-line',
title: '售后管理',
url: 'TradeAfterSale',
},
{
color: '#4daf1bc9',
icon: 'fa-solid:project-diagram',
title: '分销管理',
url: 'TradeBrokerageUser',
},
{
color: '#1a73e8',
icon: 'ep:ticket',
title: '优惠券',
url: 'PromotionCoupon',
},
{
color: '#4daf1bc9',
icon: 'fa:group',
title: '拼团活动',
url: 'PromotionBargainActivity',
},
{
color: '#1a73e8',
icon: 'vaadin:money-withdraw',
title: '佣金提现',
url: 'TradeBrokerageWithdraw',
},
{
color: '#1a73e8',
icon: 'vaadin:money-withdraw',
title: '数据统计',
url: 'TradeBrokerageWithdraw',
},
];
const quickDataShowItems = ref<WorkbenchQuickDataShowItem[]>();
const loadDataShow = () => {
quickDataShowItems.value = [
{
name: '待发货订单',
value: data.value.orderUndelivered,
prefix: '',
decimals: 0,
routerName: 'TradeOrder',
},
{
name: '退款中订单',
value: data.value.orderAfterSaleApply,
prefix: '',
decimals: 0,
routerName: 'TradeAfterSale',
},
{
name: '待核销订单',
value: data.value.orderWaitePickUp,
routerName: 'TradeOrder',
prefix: '',
decimals: 0,
},
{
name: '库存预警',
value: data.value.productAlertStock,
routerName: 'ProductSpu',
prefix: '',
decimals: 0,
},
{
name: '上架商品',
value: data.value.productForSale,
routerName: 'ProductSpu',
prefix: '',
decimals: 0,
},
{
name: '仓库商品',
value: data.value.productInWarehouse,
routerName: 'ProductSpu',
prefix: '',
decimals: 0,
},
{
name: '提现待审核',
value: data.value.withdrawAuditing,
routerName: 'TradeBrokerageWithdraw',
prefix: '',
decimals: 0,
},
{
name: '账户充值',
value: data.value.rechargePrice,
prefix: '¥',
decimals: 2,
routerName: 'PayWalletRecharge',
},
];
};
const router = useRouter();
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
});
} else if (isString(nav.url)) {
router.push({ name: nav.url });
} else {
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
}
}
</script>
<template>
@@ -284,35 +56,69 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
url="https://doc.iocoder.cn/mall/build/"
/>
</template>
<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"
class="mt-5 md:mr-4 md:mt-0 md:w-1/2"
title="快捷导航"
@click="navTo"
/>
<WorkbenchQuickDataShow
v-if="dataShow"
v-model:model-value="quickDataShowItems"
title="运营数据"
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 class="flex flex-col gap-4">
<!-- 数据对照 -->
<ElRow :gutter="16">
<ElCol :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="销售额"
prefix="¥"
:decimals="2"
:value="fenToYuan(orderComparison?.value?.orderPayPrice || 0)"
:reference="
fenToYuan(orderComparison?.reference?.orderPayPrice || 0)
"
/>
</ElCol>
<ElCol :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="用户访问量"
:value="userComparison?.value?.visitUserCount || 0"
:reference="userComparison?.reference?.visitUserCount || 0"
/>
</ElCol>
<ElCol :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="订单量"
:value="orderComparison?.value?.orderPayCount || 0"
:reference="orderComparison?.reference?.orderPayCount || 0"
/>
</ElCol>
<ElCol :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="新增用户"
:value="userComparison?.value?.registerUserCount || 0"
:reference="userComparison?.reference?.registerUserCount || 0"
/>
</ElCol>
</ElRow>
<!-- 快捷入口和运营数据 -->
<ElRow :gutter="16">
<ElCol :md="12" :xs="24">
<ShortcutCard />
</ElCol>
<ElCol :md="12" :xs="24">
<OperationDataCard />
</ElCol>
</ElRow>
<!-- 会员概览和会员终端 -->
<ElRow :gutter="16">
<ElCol :md="18" :sm="24" :xs="24">
<MemberFunnelCard />
</ElCol>
<ElCol :md="6" :sm="24" :xs="24">
<MemberTerminalCard />
</ElCol>
</ElRow>
<!-- 交易量趋势 -->
<TradeTrendCard />
<!-- 会员统计 -->
<MemberStatisticsCard />
</div>
</Page>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { ElCard, ElTag } from 'element-plus';
/** 交易对照卡片 */
defineOptions({ name: 'ComparisonCard' });
const props = withDefaults(defineProps<Props>(), {
title: '',
tag: '',
prefix: '',
value: 0,
reference: 0,
decimals: 0,
});
interface Props {
title?: string;
tag?: string;
prefix?: string;
value?: number | string;
reference?: number | string;
decimals?: number;
}
/** 计算环比百分比 */
const percent = computed(() => {
const refValue = Number(props.reference);
const curValue = Number(props.value);
if (!refValue || refValue === 0) return 0;
return ((curValue - refValue) / refValue) * 100;
});
/** 格式化今日数据 */
const formattedValue = computed(() => {
const numValue = Number(props.value);
return numValue.toFixed(props.decimals);
});
/** 格式化昨日数据 */
const formattedReference = computed(() => {
const numValue = Number(props.reference);
return numValue.toFixed(props.decimals);
});
</script>
<template>
<ElCard :border="false" class="h-full">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-gray-500">
<span>{{ title }}</span>
<ElTag v-if="tag">{{ tag }}</ElTag>
</div>
<div class="flex items-baseline justify-between">
<div class="text-3xl">{{ prefix }}{{ formattedValue }}</div>
<span
:class="percent > 0 ? 'text-red-500' : 'text-green-500'"
class="flex items-center gap-0.5"
>
{{ Math.abs(percent).toFixed(2) }}%
<IconifyIcon
:icon="percent > 0 ? 'ep:caret-top' : 'ep:caret-bottom'"
class="text-sm"
/>
</span>
</div>
<div class="mt-2 border-t border-gray-200 pt-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">昨日数据</span>
<span>{{ prefix }}{{ formattedReference }}</span>
</div>
</div>
</div>
</ElCard>
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { ElCard, ElRadio, ElRadioGroup } from 'element-plus';
import dayjs from 'dayjs';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import {
getMemberStatisticsChartOptions,
TimeRangeTypeEnum,
} from './member-statistics-chart-options';
/** 会员用户统计卡片 */
defineOptions({ name: 'MemberStatisticsCard' });
const loading = ref(false);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const timeRangeConfig = {
[TimeRangeTypeEnum.DAY30]: {
name: '30 天',
},
[TimeRangeTypeEnum.WEEK]: {
name: '周',
},
[TimeRangeTypeEnum.MONTH]: {
name: '月',
},
[TimeRangeTypeEnum.YEAR]: {
name: '年',
},
}; // 时间范围 Map
const timeRangeType = ref(TimeRangeTypeEnum.DAY30); // 日期快捷选择按钮, 默认 30 天
/** 时间范围类型单选按钮选中 */
const handleTimeRangeTypeChange = async () => {
// 设置时间范围
let beginTime: Dayjs;
let endTime: Dayjs;
switch (timeRangeType.value) {
case TimeRangeTypeEnum.DAY30: {
beginTime = dayjs().subtract(30, 'day').startOf('d');
endTime = dayjs().endOf('d');
break;
}
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: {
throw new Error(`未知的时间范围类型: ${timeRangeType.value}`);
}
}
// 发送时间范围选中事件
await getMemberRegisterCountList(beginTime, endTime);
};
async function getMemberRegisterCountList(beginTime: Dayjs, endTime: Dayjs) {
loading.value = true;
try {
const list = await MemberStatisticsApi.getMemberRegisterCountList(
beginTime.toDate(),
endTime.toDate(),
);
// 更新 Echarts 数据
await renderEcharts(getMemberStatisticsChartOptions(list));
} finally {
loading.value = false;
}
}
/** 初始化 */
onMounted(() => {
handleTimeRangeTypeChange();
});
</script>
<template>
<ElCard :border="false">
<template #header>
<div class="flex items-center justify-between">
<span>用户统计</span>
<ElRadioGroup
v-model="timeRangeType"
@change="handleTimeRangeTypeChange"
>
<ElRadio
v-for="[key, value] in Object.entries(timeRangeConfig)"
:key="key"
:value="Number(key)"
>
{{ value.name }}
</ElRadio>
</ElRadioGroup>
</div>
</template>
<div v-loading="loading">
<EchartsUI ref="chartRef" class="h-[300px] w-full" />
</div>
</ElCard>
</template>

View File

@@ -0,0 +1,64 @@
import dayjs from 'dayjs';
/** 时间范围类型枚举 */
export enum TimeRangeTypeEnum {
DAY30 = 1,
MONTH = 30,
WEEK = 7,
YEAR = 365,
}
/** 会员统计图表配置 */
export function getMemberStatisticsChartOptions(list: any[]): any {
return {
dataset: {
dimensions: ['date', 'count'],
source: list,
},
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) => dayjs(date).format('MM-DD'),
},
},
yAxis: {
axisTick: {
show: false,
},
},
};
}

View File

@@ -0,0 +1,127 @@
<script lang="ts" setup>
import { onActivated, onMounted, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { CountTo } from '@vben/common-ui';
import { ElCard } from 'element-plus';
import * as ProductSpuApi from '#/api/mall/product/spu';
import * as PayStatisticsApi from '#/api/mall/statistics/pay';
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
/** 运营数据卡片 */
defineOptions({ name: 'OperationDataCard' });
const router = useRouter();
/** 数据项接口 */
interface DataItem {
name: string;
value: number;
routerName: string;
prefix?: string;
decimals?: number;
}
/** 数据 */
const data = reactive({
orderUndelivered: { name: '待发货订单', value: 0, routerName: 'TradeOrder' },
orderAfterSaleApply: {
name: '退款中订单',
value: 0,
routerName: 'TradeAfterSale',
},
orderWaitePickUp: { name: '待核销订单', value: 0, routerName: 'TradeOrder' },
productAlertStock: { name: '库存预警', value: 0, routerName: 'ProductSpu' },
productForSale: { name: '上架商品', value: 0, routerName: 'ProductSpu' },
productInWarehouse: { name: '仓库商品', value: 0, routerName: 'ProductSpu' },
withdrawAuditing: {
name: '提现待审核',
value: 0,
routerName: 'TradeBrokerageWithdraw',
},
rechargePrice: {
name: '账户充值',
value: 0,
prefix: '¥',
decimals: 2,
routerName: 'PayWalletRecharge',
},
});
/** 查询订单数据 */
const getOrderData = async () => {
const orderCount = await TradeStatisticsApi.getOrderCount();
if (orderCount.undelivered) {
data.orderUndelivered.value = orderCount.undelivered;
}
if (orderCount.afterSaleApply) {
data.orderAfterSaleApply.value = orderCount.afterSaleApply;
}
if (orderCount.pickUp) {
data.orderWaitePickUp.value = orderCount.pickUp;
}
if (orderCount.auditingWithdraw) {
data.withdrawAuditing.value = orderCount.auditingWithdraw;
}
};
/** 查询商品数据 */
const getProductData = async () => {
const productCount = await ProductSpuApi.getTabsCount();
data.productForSale.value = productCount['0'] || 0;
data.productInWarehouse.value = productCount['1'] || 0;
data.productAlertStock.value = productCount['3'] || 0;
};
/** 查询钱包充值数据 */
const getWalletRechargeData = async () => {
const paySummary = await PayStatisticsApi.getWalletRechargePrice();
data.rechargePrice.value = paySummary.rechargePrice;
};
/** 跳转到对应页面 */
// TODO @xingyu貌似通过 name 的方式,都无法跳转,找不到路由?
const handleClick = (routerName: string) => {
router.push({ name: routerName });
};
/** 激活时 */
onActivated(() => {
getOrderData();
getProductData();
getWalletRechargeData();
});
/** 初始化 */
onMounted(() => {
getOrderData();
getProductData();
getWalletRechargeData();
});
</script>
<template>
<ElCard :border="false">
<template #header>
<div>运营数据</div>
</template>
<div class="flex flex-row flex-wrap items-center gap-8 p-4">
<div
v-for="(item, key) in data"
:key="key"
class="flex h-20 w-[20%] cursor-pointer flex-col items-center justify-center gap-2"
@click="handleClick(item.routerName)"
>
<CountTo
:decimals="(item as DataItem).decimals ?? 0"
:end-val="item.value"
:prefix="(item as DataItem).prefix ?? ''"
class="text-3xl"
/>
<span class="text-center">{{ item.name }}</span>
</div>
</div>
</ElCard>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { IconifyIcon } from '@vben/icons';
import { ElCard } from 'element-plus';
/** 快捷入口卡片 */
defineOptions({ name: 'ShortcutCard' });
const router = useRouter();
/** 菜单列表 */
const menuList = [
{
name: '用户管理',
icon: 'ep:user-filled',
bgColor: 'bg-red-400',
routerName: 'MemberUser',
},
{
name: '商品管理',
icon: 'fluent-mdl2:product',
bgColor: 'bg-orange-400',
routerName: 'ProductSpu',
},
{
name: '订单管理',
icon: 'ep:list',
bgColor: 'bg-yellow-500',
routerName: 'TradeOrder',
},
{
name: '售后管理',
icon: 'ri:refund-2-line',
bgColor: 'bg-green-600',
routerName: 'TradeAfterSale',
},
{
name: '分销管理',
icon: 'fa-solid:project-diagram',
bgColor: 'bg-cyan-500',
routerName: 'TradeBrokerageUser',
},
{
name: '优惠券',
icon: 'ep:ticket',
bgColor: 'bg-blue-500',
routerName: 'PromotionCoupon',
},
{
name: '拼团活动',
icon: 'fa:group',
bgColor: 'bg-purple-500',
routerName: 'PromotionBargainActivity',
},
{
name: '佣金提现',
icon: 'vaadin:money-withdraw',
bgColor: 'bg-rose-500',
routerName: 'TradeBrokerageWithdraw',
},
];
/** 跳转到菜单对应页面 */
// TODO @xingyu貌似通过 name 的方式,都无法跳转,找不到路由?
function handleMenuClick(routerName: string) {
router.push({ name: routerName });
}
</script>
<template>
<ElCard :border="false">
<template #header>
<div>快捷入口</div>
</template>
<div class="flex flex-row flex-wrap gap-8 p-4">
<div
v-for="menu in menuList"
:key="menu.name"
class="flex h-20 w-[20%] cursor-pointer flex-col items-center justify-center gap-2"
@click="handleMenuClick(menu.routerName)"
>
<div
:class="menu.bgColor"
class="flex h-12 w-12 items-center justify-center rounded text-white"
>
<IconifyIcon :icon="menu.icon" class="text-2xl" />
</div>
<span>{{ menu.name }}</span>
</div>
</div>
</ElCard>
</template>

View File

@@ -0,0 +1,207 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { fenToYuan } from '@vben/utils';
import { ElCard, ElRadio, ElRadioGroup } from 'element-plus';
import dayjs from 'dayjs';
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
import {
getTradeTrendChartOptions,
TimeRangeTypeEnum,
} from './trade-trend-chart-options';
/** 交易量趋势 */
defineOptions({ name: 'TradeTrendCard' });
const loading = ref(false);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const timeRangeConfig = {
[TimeRangeTypeEnum.DAY30]: {
name: '30 天',
seriesCount: 2,
},
[TimeRangeTypeEnum.WEEK]: {
name: '周',
seriesCount: 4,
},
[TimeRangeTypeEnum.MONTH]: {
name: '月',
seriesCount: 4,
},
[TimeRangeTypeEnum.YEAR]: {
name: '年',
seriesCount: 4,
},
}; // 时间范围 Map
const timeRangeType = ref(TimeRangeTypeEnum.DAY30); // 日期快捷选择按钮, 默认 30 天
/** 时间范围类型单选按钮选中 */
const handleTimeRangeTypeChange = async () => {
// 设置时间范围
let beginTime: Dayjs;
let endTime: Dayjs;
switch (timeRangeType.value) {
case TimeRangeTypeEnum.DAY30: {
beginTime = dayjs().subtract(30, 'day').startOf('d');
endTime = dayjs().endOf('d');
break;
}
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: {
throw new Error(`未知的时间范围类型: ${timeRangeType.value}`);
}
}
// 发送时间范围选中事件
await getOrderCountTrendComparison(beginTime, endTime);
};
/** 查询订单数量趋势对照数据 */
async function getOrderCountTrendComparison(beginTime: Dayjs, endTime: Dayjs) {
loading.value = true;
try {
// 1. 查询数据
const list = await TradeStatisticsApi.getOrderCountTrendComparison(
timeRangeType.value,
beginTime.toDate(),
endTime.toDate(),
);
// 2. 处理数据
const dates: string[] = [];
const series: any[] = [];
const config = timeRangeConfig[timeRangeType.value];
// 情况一seriesCount 为 2近 30 天)
if (config.seriesCount === 2) {
const orderPayPriceData: string[] = [];
const orderPayCountData: number[] = [];
for (const item of list) {
dates.push(item.value.date);
orderPayPriceData.push(fenToYuan(item?.value?.orderPayPrice || 0));
orderPayCountData.push(item?.value?.orderPayCount || 0);
}
series.push(
{
name: '订单金额',
type: 'bar',
smooth: true,
data: orderPayPriceData,
},
{
name: '订单数量',
type: 'line',
smooth: true,
data: orderPayCountData,
},
);
} else {
// 情况二seriesCount 为 4
const refPriceData: string[] = [];
const curPriceData: string[] = [];
const refCountData: number[] = [];
const curCountData: number[] = [];
for (const item of list) {
dates.push(item.value.date);
refPriceData.push(fenToYuan(item?.reference?.orderPayPrice || 0));
curPriceData.push(fenToYuan(item?.value?.orderPayPrice || 0));
refCountData.push(item?.reference?.orderPayCount || 0);
curCountData.push(item?.value?.orderPayCount || 0);
}
// 根据时间范围类型确定对照数据的标签文本
let timeLabel: string[];
if (timeRangeType.value === TimeRangeTypeEnum.WEEK) {
timeLabel = ['上周', '本周'];
} else if (timeRangeType.value === TimeRangeTypeEnum.MONTH) {
timeLabel = ['上月', '本月'];
} else {
timeLabel = ['去年', '今年'];
}
series.push(
{
name: `${timeLabel[0]}金额`,
type: 'bar',
smooth: true,
data: refPriceData,
},
{
name: `${timeLabel[1]}金额`,
type: 'bar',
smooth: true,
data: curPriceData,
},
{
name: `${timeLabel[0]}数量`,
type: 'line',
smooth: true,
data: refCountData,
},
{
name: `${timeLabel[1]}数量`,
type: 'line',
smooth: true,
data: curCountData,
},
);
}
// 3. 渲染 Echarts 界面
await renderEcharts(
getTradeTrendChartOptions(dates, series, timeRangeType.value),
);
} finally {
loading.value = false;
}
}
/** 初始化 */
onMounted(() => {
handleTimeRangeTypeChange();
});
</script>
<template>
<ElCard :border="false">
<template #header>
<div class="flex items-center justify-between">
<span>交易量趋势</span>
<ElRadioGroup
v-model="timeRangeType"
@change="handleTimeRangeTypeChange"
>
<ElRadio
v-for="[key, value] in Object.entries(timeRangeConfig)"
:key="key"
:value="Number(key)"
>
{{ value.name }}
</ElRadio>
</ElRadioGroup>
</div>
</template>
<div v-loading="loading">
<EchartsUI ref="chartRef" class="w-full" />
</div>
</ElCard>
</template>

View File

@@ -0,0 +1,86 @@
import dayjs from 'dayjs';
/** 时间范围类型枚举 */
export enum TimeRangeTypeEnum {
DAY30 = 1,
MONTH = 30,
WEEK = 7,
YEAR = 365,
}
/** 交易量趋势图表配置 */
export function getTradeTrendChartOptions(
dates: string[],
series: any[],
timeRangeType: TimeRangeTypeEnum,
): any {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
top: 80,
containLabel: true,
},
legend: {
top: 50,
data: series.map((item) => item.name),
},
series,
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',
inverse: true,
boundaryGap: false,
axisTick: {
show: false,
},
data: dates,
axisLabel: {
formatter: (date: string) => {
switch (timeRangeType) {
case TimeRangeTypeEnum.DAY30: {
return dayjs(date).format('MM-DD');
}
case TimeRangeTypeEnum.MONTH: {
return dayjs(date).format('D');
}
case TimeRangeTypeEnum.WEEK: {
const weekDay = dayjs(date).day();
return weekDay === 0 ? '周日' : `${weekDay}`;
}
case TimeRangeTypeEnum.YEAR: {
return `${dayjs(date).format('M')}`;
}
default: {
return date;
}
}
},
},
},
yAxis: {
axisTick: {
show: false,
},
},
};
}

View File

@@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { onMounted, ref } from 'vue';
import { fenToYuan } from '@vben/utils';
import { ElCard, ElDatePicker } from 'element-plus';
import dayjs from 'dayjs';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import { getRangePickerDefaultProps } from '#/utils/rangePickerProps';
/** 会员概览卡片 */
defineOptions({ name: 'MemberFunnelCard' });
const loading = ref(false);
const analyseData = ref<any>();
const dateRange = ref<[Dayjs, Dayjs]>([
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day'),
]);
const rangePickerProps = getRangePickerDefaultProps();
/** 查询会员概览数据列表 */
async function loadData(times?: [Dayjs, Dayjs]) {
const timesToUse = times || dateRange.value;
if (!timesToUse || timesToUse.length !== 2) {
return;
}
loading.value = true;
try {
analyseData.value = await MemberStatisticsApi.getMemberAnalyse({
times: timesToUse,
});
} finally {
loading.value = false;
}
}
/** 时间范围改变 */
const handleDateRangeChange = () => {
if (dateRange.value && dateRange.value.length === 2) {
loadData(dateRange.value);
}
};
/** 计算环比增长率 */
const calculateRelativeRate = (value?: number, reference?: number) => {
if (!reference || reference === 0) {
return 0;
}
return (((value || 0) - reference) / reference) * 100;
};
/** 初始化 */
onMounted(() => {
loadData();
});
</script>
<template>
<ElCard :border="false">
<template #header>
<div class="flex items-center justify-between">
<span>会员概览</span>
<ElDatePicker
v-model="dateRange"
type="datetimerange"
:shortcuts="rangePickerProps.shortcuts"
:format="rangePickerProps.format"
:value-format="rangePickerProps.valueFormat"
:start-placeholder="rangePickerProps.startPlaceholder"
:end-placeholder="rangePickerProps.endPlaceholder"
:default-time="rangePickerProps.defaultTime"
class="!w-[280px]"
@change="handleDateRangeChange"
/>
</div>
</template>
<div v-loading="loading" class="min-w-[900px] py-4">
<div class="flex h-24">
<div class="flex w-[75%] bg-blue-50">
<div class="ml-[50px] flex flex-col justify-center">
<div class="font-bold">
注册用户数量
{{ analyseData?.comparison?.value?.registerUserCount || 0 }}
</div>
<div class="mt-2 text-sm">
环比增长率
{{
calculateRelativeRate(
analyseData?.comparison?.value?.registerUserCount,
analyseData?.comparison?.reference?.registerUserCount,
).toFixed(2)
}}%
</div>
</div>
</div>
<div
class="-ml-[154px] mt-1.5 flex w-[308px] flex-col items-center justify-center bg-blue-500 text-sm text-white [transform:perspective(5em)_rotateX(-11deg)]"
>
<span class="text-2xl font-bold">
{{ analyseData?.visitUserCount || 0 }}
</span>
<span>访客</span>
</div>
</div>
<div class="flex h-24">
<div class="flex w-[75%] bg-cyan-50">
<div class="ml-[50px] flex flex-col justify-center">
<div class="font-bold">
活跃用户数量
{{ analyseData?.comparison?.value?.visitUserCount || 0 }}
</div>
<div class="mt-2 text-sm">
环比增长率
{{
calculateRelativeRate(
analyseData?.comparison?.value?.visitUserCount,
analyseData?.comparison?.reference?.visitUserCount,
).toFixed(2)
}}%
</div>
</div>
</div>
<div
class="-ml-[112px] mt-[6.8px] flex h-[100px] w-[224px] flex-col items-center justify-center bg-cyan-500 text-sm text-white [transform:perspective(7em)_rotateX(-20deg)]"
>
<span class="text-2xl font-bold">
{{ analyseData?.orderUserCount || 0 }}
</span>
<span>下单</span>
</div>
</div>
<div class="flex h-24">
<div class="flex w-[75%] bg-slate-50">
<div class="ml-[50px] flex flex-row gap-x-16">
<div class="flex flex-col justify-center">
<div class="font-bold">
充值用户数量
{{ analyseData?.comparison?.value?.rechargeUserCount || 0 }}
</div>
<div class="mt-2 text-sm">
环比增长率
{{
calculateRelativeRate(
analyseData?.comparison?.value?.rechargeUserCount,
analyseData?.comparison?.reference?.rechargeUserCount,
).toFixed(2)
}}%
</div>
</div>
<div class="flex flex-col justify-center">
<div class="font-bold">
客单价{{ fenToYuan(analyseData?.atv || 0) }}
</div>
</div>
</div>
</div>
<div
class="-ml-[72px] mt-[13px] flex h-[92px] w-[144px] flex-col items-center justify-center bg-slate-500 text-sm text-white [transform:perspective(3em)_rotateX(-13deg)]"
>
<span class="text-2xl font-bold">
{{ analyseData?.payUserCount || 0 }}
</span>
<span>成交用户</span>
</div>
</div>
</div>
</ElCard>
</template>

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { getDictOptions } from '@vben/hooks';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { ElCard } from 'element-plus';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import { getTerminalChartOptions } from './terminal-chart-options';
/** 会员终端卡片 */
defineOptions({ name: 'MemberTerminalCard' });
const loading = ref(true);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 按照终端,查询会员统计列表 */
const getMemberTerminalStatisticsList = async () => {
loading.value = true;
try {
const list = await MemberStatisticsApi.getMemberTerminalStatisticsList();
const dictDataList = getDictOptions('terminal', 'number');
const chartData = dictDataList.map((dictData: any) => {
const userCount = list.find(
(item: any) => item.terminal === dictData.value,
)?.userCount;
return {
name: dictData.label,
value: userCount || 0,
};
});
await renderEcharts(getTerminalChartOptions(chartData));
} finally {
loading.value = false;
}
};
/** 初始化 */
onMounted(() => {
getMemberTerminalStatisticsList();
});
</script>
<template>
<ElCard :border="false" class="h-full">
<template #header>
<div>会员终端</div>
</template>
<div v-loading="loading">
<EchartsUI ref="chartRef" />
</div>
</ElCard>
</template>

View File

@@ -0,0 +1,27 @@
/** 会员终端统计图配置 */
export function getTerminalChartOptions(data: any[]): any {
return {
tooltip: {
trigger: 'item',
confine: true,
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'right',
},
series: [
{
name: '会员终端',
type: 'pie',
label: {
show: false,
},
labelLine: {
show: false,
},
data,
},
],
};
}