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

This commit is contained in:
YunaiV
2025-10-18 19:47:36 +08:00
parent fffe060ab1
commit ad6ba25b3e
11 changed files with 1012 additions and 143 deletions

View File

@@ -1,159 +1,45 @@
<script lang="ts" setup>
import type {
AnalysisOverviewItem,
WorkbenchProjectItem,
WorkbenchQuickNavItem,
} from '@vben/common-ui';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
AnalysisOverview,
DocAlert,
Page,
WorkbenchQuickNav,
} from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
SvgCardIcon,
SvgDownloadIcon,
} from '@vben/icons';
import { isString, openWindow } from '@vben/utils';
import { Col, Row } from 'ant-design-vue';
import { getUserCountComparison } from '#/api/mall/statistics/member';
import { getOrderComparison } from '#/api/mall/statistics/trade';
import { DocAlert, Page } from '@vben/common-ui';
import { fenToYuan } from '@vben/utils';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
import ComparisonCard from './modules/comparison-card.vue';
import MemberFunnelCard from './modules/member-funnel-card.vue';
import MemberStatisticsCard from './modules/member-statistics-card.vue';
import MemberTerminalCard from './modules/member-terminal-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 orderComparison = ref<any>(); // 交易对照数据
const userComparison = ref<any>(); // 用户对照数据
/** 查询交易对照卡片数据 */
const getOrder = async () => {
orderComparison.value = await getOrderComparison();
const getOrderComparison = async () => {
orderComparison.value = await TradeStatisticsApi.getOrderComparison();
};
/** 查询会员用户数量对照卡片数据 */
const getUserCount = async () => {
userComparison.value = await getUserCountComparison();
const getUserCountComparison = async () => {
userComparison.value = await MemberStatisticsApi.getUserCountComparison();
};
/** 初始化 */
onMounted(async () => {
loading.value = true;
await Promise.all([getOrder(), getUserCount()]);
await Promise.all([getOrderComparison(), getUserCountComparison()]);
loading.value = false;
});
const overviewItems: AnalysisOverviewItem[] = [
{
icon: SvgCardIcon,
title: '今日销售额',
totalTitle: '昨日数据',
totalValue: orderComparison.value?.reference?.orderPayPrice || 0,
value: orderComparison.value?.orderPayPrice || 0,
},
{
icon: SvgCakeIcon,
title: '今日用户访问量',
totalTitle: '总访问量',
totalValue: userComparison.value?.reference?.visitUserCount || 0,
value: userComparison.value?.visitUserCount || 0,
},
{
icon: SvgDownloadIcon,
title: '今日订单量',
totalTitle: '总订单量',
totalValue: orderComparison.value?.orderPayCount || 0,
value: orderComparison.value?.reference?.orderPayCount || 0,
},
{
icon: SvgBellIcon,
title: '今日会员注册量',
totalTitle: '总会员注册量',
totalValue: userComparison.value?.registerUserCount || 0,
value: userComparison.value?.reference?.registerUserCount || 0,
},
];
// 同样,这里的 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 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>
@@ -164,14 +50,71 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
url="https://doc.iocoder.cn/mall/build/"
/>
</template>
<AnalysisOverview :items="overviewItems" />
<div class="mt-5 w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
title="快捷导航"
@click="navTo"
/>
<div class="flex flex-col gap-4">
<!-- 数据对照 -->
<Row :gutter="16">
<Col :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="销售额"
prefix="¥"
:decimals="2"
:value="fenToYuan(orderComparison?.value?.orderPayPrice || 0)"
:reference="fenToYuan(orderComparison?.reference?.orderPayPrice || 0)"
/>
</Col>
<Col :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="用户访问量"
:value="userComparison?.value?.visitUserCount || 0"
:reference="userComparison?.reference?.visitUserCount || 0"
/>
</Col>
<Col :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="订单量"
:value="orderComparison?.value?.orderPayCount || 0"
:reference="orderComparison?.reference?.orderPayCount || 0"
/>
</Col>
<Col :md="6" :sm="12" :xs="24">
<ComparisonCard
tag="今日"
title="新增用户"
:value="userComparison?.value?.registerUserCount || 0"
:reference="userComparison?.reference?.registerUserCount || 0"
/>
</Col>
</Row>
<!-- 快捷入口和运营数据 -->
<Row :gutter="16">
<Col :md="12" :xs="24">
<ShortcutCard />
</Col>
<Col :md="12" :xs="24">
<OperationDataCard />
</Col>
</Row>
<!-- 会员概览和会员终端 -->
<Row :gutter="16">
<Col :md="18" :sm="24" :xs="24">
<MemberFunnelCard />
</Col>
<Col :md="6" :sm="24" :xs="24">
<MemberTerminalCard />
</Col>
</Row>
<!-- 交易量趋势 -->
<TradeTrendCard />
<!-- 会员统计 -->
<MemberStatisticsCard />
</div>
</Page>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Card, Tag } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
import { erpCalculatePercentage } from '@vben/utils';
/** 交易对照卡片 */
defineOptions({ name: 'ComparisonCard' });
interface Props {
title: string;
tag?: string;
prefix?: string;
value: number | string;
reference: number | string;
decimals?: number;
}
const props = withDefaults(defineProps<Props>(), {
tag: '',
prefix: '',
decimals: 0,
});
// 计算环比
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>
<Card :bordered="false" class="comparison-card">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-gray-500">
<span>{{ title }}</span>
<Tag v-if="tag">{{ tag }}</Tag>
</div>
<div class="flex items-baseline justify-between">
<div class="text-3xl font-semibold">
{{ prefix }}{{ formattedValue }}
</div>
<span :class="percent > 0 ? 'text-red-500' : 'text-green-500'">
{{ 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>
</Card>
</template>
<style lang="less" scoped>
.comparison-card {
height: 100%;
}
</style>

View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { ref } from 'vue';
import { Card, DatePicker } from 'ant-design-vue';
import { erpCalculatePercentage, fenToYuan } from '@vben/utils';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
/** 会员概览卡片 */
defineOptions({ name: 'MemberFunnelCard' });
const loading = ref(false);
const analyseData = ref<any>();
/** 查询会员概览数据列表 */
const handleTimeRangeChange = async (times: [Dayjs, Dayjs]) => {
if (!times || times.length !== 2) return;
loading.value = true;
try {
// 查询数据
analyseData.value = await MemberStatisticsApi.getMemberAnalyse({ times });
} finally {
loading.value = false;
}
};
/** 计算环比增长率 */
const calculateRelativeRate = (value?: number, reference?: number) => {
if (!reference || reference === 0) return 0;
return (((value || 0) - reference) / reference) * 100;
};
</script>
<template>
<Card :bordered="false" :loading="loading">
<template #title>
<div class="flex items-center justify-between">
<span>会员概览</span>
<DatePicker.RangePicker @change="handleTimeRangeChange" />
</div>
</template>
<div class="min-w-[900px] py-7">
<div class="relative flex h-24">
<div class="flex h-full w-[75%] 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="mt-2 text-sm">
环比增长率{{
calculateRelativeRate(
analyseData?.comparison?.value?.registerUserCount,
analyseData?.comparison?.reference?.registerUserCount,
).toFixed(2)
}}%
</div>
</div>
</div>
<div
class="trapezoid1 -ml-[154px] mt-1.5 flex h-full w-[308px] flex-col items-center justify-center bg-blue-500 text-sm text-white"
>
<span class="text-2xl font-bold">{{
analyseData?.visitUserCount || 0
}}</span>
<span>访客</span>
</div>
</div>
<div class="relative flex h-24">
<div class="flex h-full w-[75%] 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="mt-2 text-sm">
环比增长率{{
calculateRelativeRate(
analyseData?.comparison?.value?.visitUserCount,
analyseData?.comparison?.reference?.visitUserCount,
).toFixed(2)
}}%
</div>
</div>
</div>
<div
class="trapezoid2 -ml-[112px] mt-[6.8px] flex h-[100px] w-[224px] flex-col items-center justify-center bg-cyan-500 text-sm text-white"
>
<span class="text-2xl font-bold">{{
analyseData?.orderUserCount || 0
}}</span>
<span>下单</span>
</div>
</div>
<div class="relative flex h-24">
<div class="flex w-[75%] 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="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="trapezoid3 -ml-[72px] mt-[13px] flex h-[92px] w-[144px] flex-col items-center justify-center bg-slate-500 text-sm text-white"
>
<span class="text-2xl font-bold">{{
analyseData?.payUserCount || 0
}}</span>
<span>成交用户</span>
</div>
</div>
</div>
</Card>
</template>
<style lang="less" scoped>
.trapezoid1 {
transform: perspective(5em) rotateX(-11deg);
}
.trapezoid2 {
transform: perspective(7em) rotateX(-20deg);
}
.trapezoid3 {
transform: perspective(3em) rotateX(-13deg);
}
</style>

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { Card, Spin } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import { getMemberStatisticsChartOptions } from './member-statistics-chart-options';
/** 会员用户统计卡片 */
defineOptions({ name: 'MemberStatisticsCard' });
const loading = ref(false);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const getMemberRegisterCountList = async () => {
loading.value = true;
try {
// 查询最近一月数据
const beginTime = dayjs().subtract(30, 'd').startOf('d');
const endTime = dayjs().endOf('d');
const list = await MemberStatisticsApi.getMemberRegisterCountList(
beginTime,
endTime,
);
// 更新 Echarts 数据
await renderEcharts(getMemberStatisticsChartOptions(list));
} finally {
loading.value = false;
}
};
/** 初始化 */
onMounted(() => {
getMemberRegisterCountList();
});
</script>
<template>
<Card :bordered="false" title="用户统计">
<Spin :spinning="loading">
<EchartsUI ref="chartRef" class="h-[300px] w-full" />
</Spin>
</Card>
</template>

View File

@@ -0,0 +1,59 @@
import dayjs from 'dayjs';
/**
* 会员统计图表配置
*/
export function getMemberStatisticsChartOptions(list: any[]): any {
return {
dataset: {
dimensions: ['date', 'count'],
source: list,
},
grid: {
left: 20,
right: 20,
bottom: 20,
top: 80,
containLabel: true,
},
legend: {
top: 50,
},
series: [{ name: '注册量', type: 'line', smooth: true, areaStyle: {} }],
toolbox: {
feature: {
// 数据区域缩放
dataZoom: {
yAxisIndex: false, // Y轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: { show: true, name: '会员统计' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
padding: [5, 10],
},
xAxis: {
type: 'category',
boundaryGap: false,
axisTick: {
show: false,
},
axisLabel: {
formatter: (date: string) => dayjs(date).format('MM-DD'),
},
},
yAxis: {
axisTick: {
show: false,
},
},
};
}

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { Card, Spin } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { getDictOptions } from '@vben/hooks';
import * as MemberStatisticsApi from '#/api/mall/statistics/member';
import { getTerminalChartOptions } from './member-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');
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>
<Card :bordered="false" title="会员终端">
<Spin :spinning="loading">
<EchartsUI ref="chartRef" class="h-[300px] w-full" />
</Spin>
</Card>
</template>

View File

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

View File

@@ -0,0 +1,119 @@
<script lang="ts" setup>
import { onActivated, onMounted, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { Card } from 'ant-design-vue';
import { CountTo } from '@vben/common-ui';
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();
/** 数据 */
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.0,
prefix: '¥',
decimals: 2,
routerName: 'PayWalletRecharge',
},
});
/** 查询订单数据 */
const getOrderData = async () => {
const orderCount = await TradeStatisticsApi.getOrderCount();
if (orderCount.undelivered != null) {
data.orderUndelivered.value = orderCount.undelivered;
}
if (orderCount.afterSaleApply != null) {
data.orderAfterSaleApply.value = orderCount.afterSaleApply;
}
if (orderCount.pickUp != null) {
data.orderWaitePickUp.value = orderCount.pickUp;
}
if (orderCount.auditingWithdraw != null) {
data.withdrawAuditing.value = orderCount.auditingWithdraw;
}
};
/** 查询商品数据 */
const getProductData = async () => {
const productCount = await ProductSpuApi.getTabsCount();
data.productForSale.value = productCount['0'];
data.productInWarehouse.value = productCount['1'];
data.productAlertStock.value = productCount['3'];
};
/** 查询钱包充值数据 */
const getWalletRechargeData = async () => {
const paySummary = await PayStatisticsApi.getWalletRechargePrice();
data.rechargePrice.value = paySummary.rechargePrice;
};
/**
* 跳转到对应页面
*
* @param routerName 路由页面组件的名称
*/
const handleClick = (routerName: string) => {
router.push({ name: routerName });
};
/** 激活时 */
onActivated(() => {
getOrderData();
getProductData();
getWalletRechargeData();
});
/** 初始化 */
onMounted(() => {
getOrderData();
getProductData();
getWalletRechargeData();
});
</script>
<template>
<Card :bordered="false" title="运营数据">
<div class="flex flex-row flex-wrap items-center gap-8 p-4">
<div
v-for="item in data"
:key="item.name"
class="flex h-20 w-[20%] cursor-pointer flex-col items-center justify-center gap-2"
@click="handleClick(item.routerName)"
>
<CountTo
:decimals="item.decimals || 0"
:end-val="item.value"
:prefix="item.prefix || ''"
class="text-3xl"
/>
<span class="text-center">{{ item.name }}</span>
</div>
</div>
</Card>
</template>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { Card } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
/** 快捷入口卡片 */
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',
},
];
/**
* 跳转到菜单对应页面
*
* @param routerName 路由页面组件的名称
*/
const handleMenuClick = (routerName: string) => {
router.push({ name: routerName });
};
</script>
<template>
<Card :bordered="false" title="快捷入口">
<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>
</Card>
</template>

View File

@@ -0,0 +1,203 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { Dayjs } from 'dayjs';
import { onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { Card, Radio, RadioGroup, Spin } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { fenToYuan } from '@vben/utils';
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
import { getTradeTrendChartOptions } from './trade-trend-chart-options';
/** 交易量趋势 */
defineOptions({ name: 'TradeTrendCard' });
enum TimeRangeTypeEnum {
DAY30 = 1,
WEEK = 7,
MONTH = 30,
YEAR = 365,
}
const timeRangeType = ref(TimeRangeTypeEnum.DAY30); // 日期快捷选择按钮, 默认30天
const loading = ref(false);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// 时间范围 Map
const timeRangeConfig = {
[TimeRangeTypeEnum.DAY30]: {
name: '30天',
seriesCount: 2,
},
[TimeRangeTypeEnum.WEEK]: {
name: '周',
seriesCount: 4,
},
[TimeRangeTypeEnum.MONTH]: {
name: '月',
seriesCount: 4,
},
[TimeRangeTypeEnum.YEAR]: {
name: '年',
seriesCount: 4,
},
};
/** 时间范围类型单选按钮选中 */
const handleTimeRangeTypeChange = async () => {
// 设置时间范围
let beginTime: Dayjs;
let endTime: Dayjs;
switch (timeRangeType.value) {
case TimeRangeTypeEnum.WEEK: {
beginTime = dayjs().startOf('week');
endTime = dayjs().endOf('week');
break;
}
case TimeRangeTypeEnum.MONTH: {
beginTime = dayjs().startOf('month');
endTime = dayjs().endOf('month');
break;
}
case TimeRangeTypeEnum.YEAR: {
beginTime = dayjs().startOf('year');
endTime = dayjs().endOf('year');
break;
}
case TimeRangeTypeEnum.DAY30:
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;
try {
// 查询数据
const list = await TradeStatisticsApi.getOrderCountTrendComparison(
timeRangeType.value,
beginTime,
endTime,
);
// 处理数据
const dates: string[] = [];
const series: any[] = [];
const config = timeRangeConfig[timeRangeType.value];
if (config.seriesCount === 2) {
const orderPayPriceData: number[] = [];
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 {
const refPriceData: number[] = [];
const curPriceData: number[] = [];
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);
}
const timeLabel =
timeRangeType.value === TimeRangeTypeEnum.WEEK
? ['上周', '本周']
: timeRangeType.value === TimeRangeTypeEnum.MONTH
? ['上月', '本月']
: ['去年', '今年'];
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,
},
);
}
await renderEcharts(
getTradeTrendChartOptions(dates, series, timeRangeType.value),
);
} finally {
loading.value = false;
}
};
/** 初始化 */
onMounted(() => {
handleTimeRangeTypeChange();
});
</script>
<template>
<Card :bordered="false">
<template #title>
<div class="flex items-center justify-between">
<span>交易量趋势</span>
<RadioGroup v-model:value="timeRangeType" @change="handleTimeRangeTypeChange">
<Radio
v-for="[key, value] in Object.entries(timeRangeConfig)"
:key="key"
:value="Number(key)"
>
{{ value.name }}
</Radio>
</RadioGroup>
</div>
</template>
<Spin :spinning="loading">
<EchartsUI ref="chartRef" class="h-[300px] w-full" />
</Spin>
</Card>
</template>

View File

@@ -0,0 +1,78 @@
import dayjs from 'dayjs';
/**
* 交易量趋势图表配置
*/
export function getTradeTrendChartOptions(
dates: string[],
series: any[],
timeRangeType: number,
): 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 1: // DAY30
return dayjs(date).format('MM-DD');
case 7: // WEEK
{
const weekDay = dayjs(date).day();
return weekDay === 0 ? '周日' : `${weekDay}`;
}
case 30: // MONTH
return dayjs(date).format('D');
case 365: // YEAR
return dayjs(date).format('M') + '月';
default:
return date;
}
},
},
},
yAxis: {
axisTick: {
show: false,
},
},
};
}