feat:【mall 商城】商城首页的迁移【ele】60%:初始化
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
78
apps/web-ele/src/views/mall/home/modules/comparison-card.vue
Normal file
78
apps/web-ele/src/views/mall/home/modules/comparison-card.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
127
apps/web-ele/src/views/mall/home/modules/operation-data-card.vue
Normal file
127
apps/web-ele/src/views/mall/home/modules/operation-data-card.vue
Normal 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>
|
||||
94
apps/web-ele/src/views/mall/home/modules/shortcut-card.vue
Normal file
94
apps/web-ele/src/views/mall/home/modules/shortcut-card.vue
Normal 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>
|
||||
207
apps/web-ele/src/views/mall/home/modules/trade-trend-card.vue
Normal file
207
apps/web-ele/src/views/mall/home/modules/trade-trend-card.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user