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>
|
<script lang="ts" setup>
|
||||||
import type {
|
import type { DataComparisonRespVO } from '#/api/mall/statistics/common';
|
||||||
WorkbenchProjectItem,
|
import type { MallMemberStatisticsApi } from '#/api/mall/statistics/member';
|
||||||
WorkbenchQuickNavItem,
|
import type { MallTradeStatisticsApi } from '#/api/mall/statistics/trade';
|
||||||
} from '@vben/common-ui';
|
|
||||||
|
|
||||||
import type { AnalysisOverviewItem } from './components/data';
|
|
||||||
|
|
||||||
import type { WorkbenchQuickDataShowItem } from '#/views/mall/home/components/data';
|
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
import { DocAlert, Page, WorkbenchQuickNav } from '@vben/common-ui';
|
import { DocAlert, Page } from '@vben/common-ui';
|
||||||
import { isString, openWindow } from '@vben/utils';
|
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 { getUserCountComparison } from '#/api/mall/statistics/member';
|
||||||
import { getWalletRechargePrice } from '#/api/mall/statistics/pay';
|
import { getOrderComparison } from '#/api/mall/statistics/trade';
|
||||||
import { getOrderComparison, getOrderCount } from '#/api/mall/statistics/trade';
|
|
||||||
|
|
||||||
import AnalysisOverview from './components/analysis-overview.vue';
|
import MemberFunnelCard from '../statistics/member/modules/funnel-card.vue';
|
||||||
import MemberFunnelCard from './components/member-funnel-card.vue';
|
import MemberTerminalCard from '../statistics/member/modules/terminal-card.vue';
|
||||||
import MemberStatisticsCard from './components/member-statistics-card.vue';
|
import ComparisonCard from './modules/comparison-card.vue';
|
||||||
import MemberTerminalCard from './components/member-terminal-card.vue';
|
import MemberStatisticsCard from './modules/member-statistics-card.vue';
|
||||||
import TradeTrendCard from './components/trade-trend-card.vue';
|
import OperationDataCard from './modules/operation-data-card.vue';
|
||||||
import WorkbenchQuickDataShow from './components/workbench-quick-data-show.vue';
|
import ShortcutCard from './modules/shortcut-card.vue';
|
||||||
|
import TradeTrendCard from './modules/trade-trend-card.vue';
|
||||||
|
|
||||||
/** 商城首页 */
|
/** 商城首页 */
|
||||||
defineOptions({ name: 'MallHome' });
|
defineOptions({ name: 'MallHome' });
|
||||||
|
|
||||||
const loading = ref(true); // 加载中
|
const loading = ref(true); // 加载中
|
||||||
const orderComparison = ref(); // 交易对照数据
|
const orderComparison =
|
||||||
const userComparison = ref(); // 用户对照数据
|
ref<DataComparisonRespVO<MallTradeStatisticsApi.TradeOrderSummaryRespVO>>(); // 交易对照数据
|
||||||
const data = ref({
|
const userComparison =
|
||||||
orderUndelivered: 0,
|
ref<DataComparisonRespVO<MallMemberStatisticsApi.MemberCountRespVO>>(); // 用户对照数据
|
||||||
orderAfterSaleApply: 0,
|
|
||||||
orderWaitePickUp: 0,
|
|
||||||
withdrawAuditing: 0,
|
|
||||||
productForSale: 0,
|
|
||||||
productInWarehouse: 0,
|
|
||||||
productAlertStock: 0,
|
|
||||||
rechargePrice: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataShow = ref(false);
|
|
||||||
|
|
||||||
/** 查询交易对照卡片数据 */
|
/** 查询交易对照卡片数据 */
|
||||||
const getOrder = async () => {
|
async function loadOrderComparison() {
|
||||||
orderComparison.value = await getOrderComparison();
|
orderComparison.value = await getOrderComparison();
|
||||||
};
|
}
|
||||||
|
|
||||||
/** 查询会员用户数量对照卡片数据 */
|
/** 查询会员用户数量对照卡片数据 */
|
||||||
const getUserCount = async () => {
|
async function loadUserCountComparison() {
|
||||||
userComparison.value = await getUserCountComparison();
|
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 () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
await Promise.all([
|
await Promise.all([loadOrderComparison(), loadUserCountComparison()]);
|
||||||
getOrder(),
|
|
||||||
getUserCount(),
|
|
||||||
getOrderData(),
|
|
||||||
getProductData(),
|
|
||||||
getWalletRechargeData(),
|
|
||||||
]);
|
|
||||||
loading.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -284,35 +56,69 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
|||||||
url="https://doc.iocoder.cn/mall/build/"
|
url="https://doc.iocoder.cn/mall/build/"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div class="mt-5 w-full md:flex">
|
|
||||||
<AnalysisOverview
|
<div class="flex flex-col gap-4">
|
||||||
v-model:model-value="overviewItems"
|
<!-- 数据对照 -->
|
||||||
class="mt-5 md:mr-4 md:mt-0 md:w-full"
|
<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)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ElCol>
|
||||||
<div class="mt-5 w-full md:flex">
|
<ElCol :md="6" :sm="12" :xs="24">
|
||||||
<WorkbenchQuickNav
|
<ComparisonCard
|
||||||
:items="quickNavItems"
|
tag="今日"
|
||||||
class="mt-5 md:mr-4 md:mt-0 md:w-1/2"
|
title="用户访问量"
|
||||||
title="快捷导航"
|
:value="userComparison?.value?.visitUserCount || 0"
|
||||||
@click="navTo"
|
:reference="userComparison?.reference?.visitUserCount || 0"
|
||||||
/>
|
/>
|
||||||
<WorkbenchQuickDataShow
|
</ElCol>
|
||||||
v-if="dataShow"
|
<ElCol :md="6" :sm="12" :xs="24">
|
||||||
v-model:model-value="quickDataShowItems"
|
<ComparisonCard
|
||||||
title="运营数据"
|
tag="今日"
|
||||||
class="mt-5 md:mr-4 md:mt-0 md:w-1/2"
|
title="订单量"
|
||||||
|
:value="orderComparison?.value?.orderPayCount || 0"
|
||||||
|
:reference="orderComparison?.reference?.orderPayCount || 0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ElCol>
|
||||||
<div class="mb-4 mt-5 w-full md:flex">
|
<ElCol :md="6" :sm="12" :xs="24">
|
||||||
<MemberFunnelCard class="mt-5 md:mr-4 md:mt-0 md:w-2/3" />
|
<ComparisonCard
|
||||||
<MemberTerminalCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" />
|
tag="今日"
|
||||||
</div>
|
title="新增用户"
|
||||||
<div class="mb-4 mt-5 w-full md:flex">
|
:value="userComparison?.value?.registerUserCount || 0"
|
||||||
<TradeTrendCard class="mt-5 md:mr-4 md:mt-0 md:w-full" />
|
:reference="userComparison?.reference?.registerUserCount || 0"
|
||||||
</div>
|
/>
|
||||||
<div class="mb-4 mt-5 w-full md:flex">
|
</ElCol>
|
||||||
<MemberStatisticsCard class="mt-5 md:mr-4 md:mt-0 md:w-full" />
|
</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>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</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