feat:【mall 商城】交易统计、商品统计【antd】10%:初始化

This commit is contained in:
YunaiV
2025-10-19 20:57:57 +08:00
parent 6c7af029a2
commit cb717a9d84
8 changed files with 1284 additions and 49 deletions

View File

@@ -1,32 +1,119 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { DataComparisonRespVO } from '#/api/mall/statistics/common';
import type { MallTradeStatisticsApi } from '#/api/mall/statistics/trade';
import { Button } from 'ant-design-vue';
import { onMounted, ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import { fenToYuan } from '@vben/utils';
import { Col, Row } from 'ant-design-vue';
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
import TradeStatisticCard from './modules/trade-statistic-card.vue';
import TradeTrendCard from './modules/trade-trend-card.vue';
/** 交易统计 */
defineOptions({ name: 'TradeStatistics' });
const loading = ref(true); // 加载中
const summary =
ref<DataComparisonRespVO<MallTradeStatisticsApi.TradeSummary>>(); // 交易统计数据
/** 计算环比百分比 */
const calculateRelativeRate = (value?: number, reference?: number): string => {
const refValue = Number(reference || 0);
const curValue = Number(value || 0);
if (!refValue || refValue === 0) return '0.00';
return (((curValue - refValue) / refValue) * 100).toFixed(2);
};
/** 查询交易统计 */
const getTradeStatisticsSummary = async () => {
summary.value = await TradeStatisticsApi.getTradeStatisticsSummary();
};
/** 初始化 */
onMounted(async () => {
loading.value = true;
await getTradeStatisticsSummary();
loading.value = false;
});
</script>
<template>
<Page>
<DocAlert
title="【统计】会员、商品、交易统计"
url="https://doc.iocoder.cn/mall/statistics/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/trade/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/trade/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【统计】会员、商品、交易统计"
url="https://doc.iocoder.cn/mall/statistics/"
/>
</template>
<div class="flex flex-col gap-4">
<!-- 交易概览卡片 -->
<Row :gutter="16">
<Col :sm="6" :xs="12">
<TradeStatisticCard
tooltip="昨日订单数量"
title="昨日订单数量"
:value="summary?.value?.yesterdayOrderCount || 0"
:percent="
calculateRelativeRate(
summary?.value?.yesterdayOrderCount,
summary?.reference?.yesterdayOrderCount,
)
"
/>
</Col>
<Col :sm="6" :xs="12">
<TradeStatisticCard
tooltip="本月订单数量"
title="本月订单数量"
:value="summary?.value?.monthOrderCount || 0"
:percent="
calculateRelativeRate(
summary?.value?.monthOrderCount,
summary?.reference?.monthOrderCount,
)
"
/>
</Col>
<Col :sm="6" :xs="12">
<TradeStatisticCard
tooltip="昨日支付金额"
title="昨日支付金额"
prefix="¥"
:decimals="2"
:value="Number(fenToYuan(summary?.value?.yesterdayPayPrice || 0))"
:percent="
calculateRelativeRate(
summary?.value?.yesterdayPayPrice,
summary?.reference?.yesterdayPayPrice,
)
"
/>
</Col>
<Col :sm="6" :xs="12">
<TradeStatisticCard
tooltip="本月支付金额"
title="本月支付金额"
prefix="¥"
:decimals="2"
:value="Number(fenToYuan(summary?.value?.monthPayPrice || 0))"
:percent="
calculateRelativeRate(
summary?.value?.monthPayPrice,
summary?.reference?.monthPayPrice,
)
"
/>
</Col>
</Row>
<!-- 交易趋势 -->
<TradeTrendCard />
</div>
</Page>
</template>

View File

@@ -0,0 +1,77 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { VbenCountToAnimator } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Card, Tooltip } from 'ant-design-vue';
/** 交易统计值组件 */
defineOptions({ name: 'TradeStatisticCard' });
const props = withDefaults(defineProps<Props>(), {
tooltip: '',
title: '',
prefix: '',
value: 0,
decimals: 0,
percent: 0,
});
interface Props {
tooltip?: string;
title?: string;
prefix?: string;
value?: number | string;
decimals?: number;
percent?: number | string;
}
/** 计算环比百分比 */
const percentValue = computed(() => {
return Number(props.percent);
});
/** 格式化数值 */
const formattedValue = computed(() => {
return Number(props.value);
});
</script>
<template>
<Card :bordered="false" class="h-full">
<div class="flex flex-col gap-2 p-2">
<div class="flex items-center justify-between text-gray-500">
<span class="text-sm">{{ title }}</span>
<Tooltip v-if="tooltip" :title="tooltip" placement="top">
<IconifyIcon
icon="lucide:circle-alert"
class="size-4 cursor-help text-gray-400 hover:text-gray-600"
/>
</Tooltip>
</div>
<div class="mb-4 text-3xl font-medium">
<VbenCountToAnimator
:prefix="prefix"
:end-val="formattedValue"
:decimals="decimals"
/>
</div>
<div class="flex flex-row gap-1 text-sm">
<span class="text-gray-500">环比</span>
<span
:class="percentValue > 0 ? 'text-red-500' : 'text-green-500'"
class="flex items-center gap-0.5"
>
{{ Math.abs(percentValue).toFixed(2) }}%
<IconifyIcon
:icon="
percentValue > 0 ? 'lucide:trending-up' : 'lucide:trending-down'
"
class="size-3"
/>
</span>
</div>
</div>
</Card>
</template>

View File

@@ -0,0 +1,314 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { DataComparisonRespVO } from '#/api/mall/statistics/common';
import type { MallTradeStatisticsApi } from '#/api/mall/statistics/trade';
import { onMounted, ref } from 'vue';
import { SummaryCard } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { fenToYuan } from '@vben/utils';
import { Button, Card, Col, message, Row, Skeleton } from 'ant-design-vue';
import dayjs from 'dayjs';
import * as TradeStatisticsApi from '#/api/mall/statistics/trade';
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
import { getTradeTrendChartOptions } from './trade-trend-chart-options';
/** 交易趋势 */
defineOptions({ name: 'TradeTrendCard' });
const trendLoading = ref(true); // 交易状态加载中
const exportLoading = ref(false); // 导出的加载中
const trendSummary =
ref<DataComparisonRespVO<MallTradeStatisticsApi.TradeTrendSummary>>(); // 交易状况统计数据
const shortcutDateRangePicker = ref();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** 计算环比百分比 */
const calculateRelativeRate = (value?: number, reference?: number): string => {
const refValue = Number(reference || 0);
const curValue = Number(value || 0);
if (!refValue || refValue === 0) return '0.00';
return (((curValue - refValue) / refValue) * 100).toFixed(2);
};
/** 处理交易状况查询 */
const getTradeTrendData = async (times?: [Dayjs, Dayjs]) => {
trendLoading.value = true;
try {
let queryTimes = times;
if (!queryTimes && shortcutDateRangePicker.value?.times) {
queryTimes = shortcutDateRangePicker.value.times;
}
// 1. 处理时间: 开始与截止在同一天的, 折线图出不来, 需要延长一天
if (queryTimes && isSameDay(queryTimes[0], queryTimes[1])) {
// 前天
queryTimes[0] = dayjs(queryTimes[0]).subtract(1, 'd');
}
// 查询数据
await Promise.all([
getTradeStatisticsAnalyse(queryTimes),
getTradeStatisticsList(queryTimes),
]);
} finally {
trendLoading.value = false;
}
};
/** 判断是否同一天 */
const isSameDay = (date1: Dayjs, date2: Dayjs): boolean => {
return date1.format('YYYY-MM-DD') === date2.format('YYYY-MM-DD');
};
/** 查询交易状况数据统计 */
const getTradeStatisticsAnalyse = async (times?: [Dayjs, Dayjs]) => {
const queryTimes = times
? { times: [times[0].toDate(), times[1].toDate()] }
: undefined;
trendSummary.value = await TradeStatisticsApi.getTradeStatisticsAnalyse(
queryTimes as any,
);
};
/** 查询交易状况数据列表 */
const getTradeStatisticsList = async (times?: [Dayjs, Dayjs]) => {
// 查询数据
const queryTimes = times
? { times: [times[0].toDate(), times[1].toDate()] }
: undefined;
const list: MallTradeStatisticsApi.TradeTrendSummary[] =
await TradeStatisticsApi.getTradeStatisticsList(queryTimes as any);
// 处理数据
const processedList = list.map((item) => ({
...item,
turnoverPrice: Number(fenToYuan(item.turnoverPrice)),
orderPayPrice: Number(fenToYuan(item.orderPayPrice)),
rechargePrice: Number(fenToYuan(item.rechargePrice)),
expensePrice: Number(fenToYuan(item.expensePrice)),
}));
// 更新 Echarts 数据
await renderEcharts(getTradeTrendChartOptions(processedList));
};
/** 导出按钮操作 */
const handleExport = async () => {
try {
// 导出的二次确认
await message.confirm({
content: '确认导出交易状况数据吗?',
okText: '确定',
cancelText: '取消',
});
// 发起导出
exportLoading.value = true;
const times = shortcutDateRangePicker.value?.times;
const queryTimes = times
? { times: [times[0].toDate(), times[1].toDate()] }
: undefined;
const data = await TradeStatisticsApi.exportTradeStatisticsExcel(
queryTimes as any,
);
// 处理下载
const blob = new Blob([data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '交易状况.xlsx';
link.click();
window.URL.revokeObjectURL(url);
} catch {
// 用户取消导出
} finally {
exportLoading.value = false;
}
};
/** 初始化 */
onMounted(async () => {
await getTradeTrendData();
});
</script>
<template>
<Card :bordered="false" title="交易状况" class="h-full">
<template #extra>
<!-- 查询条件 -->
<div class="flex items-center gap-2">
<ShortcutDateRangePicker
ref="shortcutDateRangePicker"
@change="getTradeTrendData"
>
<Button class="ml-4" @click="handleExport" :loading="exportLoading">
<template #icon>
<IconifyIcon icon="lucide:download" />
</template>
导出
</Button>
</ShortcutDateRangePicker>
</div>
</template>
<!-- 统计值 -->
<Row :gutter="16" class="mb-4">
<Col :md="6" :sm="12" :xs="24" class="mb-4">
<SummaryCard
title="营业额"
tooltip="商品支付金额、充值金额"
icon="lucide:banknote"
icon-color="text-blue-500"
icon-bg-color="bg-blue-100"
prefix="¥"
:decimals="2"
:value="Number(fenToYuan(trendSummary?.value?.turnoverPrice || 0))"
:percent="
calculateRelativeRate(
trendSummary?.value?.turnoverPrice,
trendSummary?.reference?.turnoverPrice,
)
"
/>
</Col>
<Col :md="6" :sm="12" :xs="24" class="mb-4">
<SummaryCard
title="商品支付金额"
tooltip="用户购买商品的实际支付金额,包括微信支付、余额支付、支付宝支付、线下支付金额(拼团商品在成团之后计入,线下支付订单在后台确认支付后计入)"
icon="lucide:shopping-cart"
icon-color="text-purple-500"
icon-bg-color="bg-purple-100"
prefix="¥"
:decimals="2"
:value="Number(fenToYuan(trendSummary?.value?.orderPayPrice || 0))"
:percent="
calculateRelativeRate(
trendSummary?.value?.orderPayPrice,
trendSummary?.reference?.orderPayPrice,
)
"
/>
</Col>
<Col :md="6" :sm="12" :xs="24" class="mb-4">
<SummaryCard
title="充值金额"
tooltip="用户成功充值的金额"
icon="lucide:credit-card"
icon-color="text-yellow-500"
icon-bg-color="bg-yellow-100"
prefix="¥"
:decimals="2"
:value="Number(fenToYuan(trendSummary?.value?.rechargePrice || 0))"
:percent="
calculateRelativeRate(
trendSummary?.value?.rechargePrice,
trendSummary?.reference?.rechargePrice,
)
"
/>
</Col>
<Col :md="6" :sm="12" :xs="24" class="mb-4">
<SummaryCard
title="支出金额"
tooltip="余额支付金额、支付佣金金额、商品退款金额"
icon="lucide:trending-down"
icon-color="text-green-500"
icon-bg-color="bg-green-100"
prefix="¥"
:decimals="2"
:value="Number(fenToYuan(trendSummary?.value?.expensePrice || 0))"
:percent="
calculateRelativeRate(
trendSummary?.value?.expensePrice,
trendSummary?.reference?.expensePrice,
)
"
/>
</Col>
<Col :md="6" :sm="12" :xs="24" class="mb-4">
<SummaryCard
title="余额支付金额"
tooltip="用户下单时使用余额实际支付的金额"
icon="lucide:wallet"
icon-color="text-cyan-500"
icon-bg-color="bg-cyan-100"
prefix="¥"
:decimals="2"
:value="Number(fenToYuan(trendSummary?.value?.walletPayPrice || 0))"
:percent="
calculateRelativeRate(
trendSummary?.value?.walletPayPrice,
trendSummary?.reference?.walletPayPrice,
)
"
/>
</Col>
<Col :md="6" :sm="12" :xs="24" class="mb-4">
<SummaryCard
title="支付佣金金额"
tooltip="后台给推广员支付的推广佣金,以实际支付为准"
icon="lucide:gift"
icon-color="text-orange-500"
icon-bg-color="bg-orange-100"
prefix="¥"
:decimals="2"
:value="
Number(
fenToYuan(trendSummary?.value?.brokerageSettlementPrice || 0),
)
"
:percent="
calculateRelativeRate(
trendSummary?.value?.brokerageSettlementPrice,
trendSummary?.reference?.brokerageSettlementPrice,
)
"
/>
</Col>
<Col :md="6" :sm="12" :xs="24" class="mb-4">
<SummaryCard
title="商品退款金额"
tooltip="用户成功退款的商品金额"
icon="lucide:undo-2"
icon-color="text-red-500"
icon-bg-color="bg-red-100"
prefix="¥"
:decimals="2"
:value="
Number(fenToYuan(trendSummary?.value?.afterSaleRefundPrice || 0))
"
:percent="
calculateRelativeRate(
trendSummary?.value?.afterSaleRefundPrice,
trendSummary?.reference?.afterSaleRefundPrice,
)
"
/>
</Col>
</Row>
<!-- 折线图 -->
<Skeleton :loading="trendLoading" :active="true">
<EchartsUI ref="chartRef" class="h-[500px]" />
</Skeleton>
</Card>
</template>

View File

@@ -0,0 +1,111 @@
import type { EChartsOption } from 'echarts';
import type { MallTradeStatisticsApi } from '#/api/mall/statistics/trade';
/** 交易趋势折线图配置 */
export function getTradeTrendChartOptions(
data: MallTradeStatisticsApi.TradeTrendSummary[],
): EChartsOption {
return {
dataset: {
dimensions: [
'time',
'turnoverPrice',
'orderPayPrice',
'rechargePrice',
'expensePrice',
],
source: data,
},
grid: {
left: 20,
right: 20,
bottom: 20,
top: 80,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '营业额',
type: 'line',
smooth: true,
itemStyle: { color: '#1890FF' },
},
{
name: '商品支付金额',
type: 'line',
smooth: true,
itemStyle: { color: '#722ED1' },
},
{
name: '充值金额',
type: 'line',
smooth: true,
itemStyle: { color: '#FAAD14' },
},
{
name: '支出金额',
type: 'line',
smooth: true,
itemStyle: { color: '#52C41A' },
},
],
toolbox: {
feature: {
// 数据区域缩放
dataZoom: {
yAxisIndex: false, // Y轴不缩放
},
brush: {
type: ['lineX', 'clear'], // 区域缩放按钮、还原按钮
},
saveAsImage: {
show: true,
name: '交易状况',
}, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
padding: [5, 10],
formatter(params: any) {
let result = `<div><strong>${params[0].data.time}</strong></div>`;
params.forEach((item: any) => {
result += `<div style="margin: 4px 0;">
<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${item.color};"></span>
${item.seriesName}: ¥${item.data[item.dimensionNames[item.encode.y[0]]]}
</div>`;
});
return result;
},
},
xAxis: {
type: 'category',
boundaryGap: false,
axisTick: {
show: false,
},
},
yAxis: {
type: 'value',
axisTick: {
show: false,
},
axisLabel: {
formatter: '¥{value}',
},
splitLine: {
show: true,
lineStyle: {
color: '#F5F7F9',
},
},
},
};
}