feat: 新增商品统计组件和优化数据处理逻辑

- 引入商品排行和商品概况组件,展示商品相关统计信息
- 更新商品统计 API,支持时间范围查询和数据格式化
- 优化数据加载逻辑,提升用户体验
- 添加日期范围选择器,增强统计数据的灵活性
pull/175/head
lrl 2025-07-17 09:53:04 +08:00
parent 73a73ac312
commit 4620ede9b9
9 changed files with 587 additions and 29 deletions

View File

@ -2,6 +2,8 @@ import type { PageParam, PageResult } from '@vben/request';
import type { MallDataComparisonResp } from './common'; import type { MallDataComparisonResp } from './common';
import { formatDate2 } from '@vben/utils';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export namespace MallProductStatisticsApi { export namespace MallProductStatisticsApi {
@ -38,26 +40,58 @@ export namespace MallProductStatisticsApi {
/** 浏览转化率 */ /** 浏览转化率 */
browseConvertPercent: number; browseConvertPercent: number;
} }
/** 会员分析 Request */
export interface ProductStatisticsReq {
times: Date[];
}
} }
/** 获得商品统计分析 */ /** 获得商品统计分析 */
export function getProductStatisticsAnalyse(params: PageParam) { export function getProductStatisticsAnalyse(
params: MallProductStatisticsApi.ProductStatisticsReq,
) {
return requestClient.get< return requestClient.get<
MallDataComparisonResp<MallProductStatisticsApi.ProductStatistics> MallDataComparisonResp<MallProductStatisticsApi.ProductStatistics>
>('/statistics/product/analyse', { params }); >('/statistics/product/analyse', {
params: {
times: [
formatDate2(params.times[0] || new Date()),
formatDate2(params.times[1] || new Date()),
],
},
});
} }
/** 获得商品状况明细 */ /** 获得商品状况明细 */
export function getProductStatisticsList(params: PageParam) { export function getProductStatisticsList(
params: MallProductStatisticsApi.ProductStatisticsReq,
) {
return requestClient.get<MallProductStatisticsApi.ProductStatistics[]>( return requestClient.get<MallProductStatisticsApi.ProductStatistics[]>(
'/statistics/product/list', '/statistics/product/list',
{ params }, {
params: {
times: [
formatDate2(params.times[0] || new Date()),
formatDate2(params.times[1] || new Date()),
],
},
},
); );
} }
/** 导出获得商品状况明细 Excel */ /** 导出获得商品状况明细 Excel */
export function exportProductStatisticsExcel(params: PageParam) { export function exportProductStatisticsExcel(
return requestClient.download('/statistics/product/export-excel', { params }); params: MallProductStatisticsApi.ProductStatisticsReq,
) {
return requestClient.download('/statistics/product/export-excel', {
params: {
times: [
formatDate2(params.times[0] || new Date()),
formatDate2(params.times[1] || new Date()),
],
},
});
} }
/** 获得商品排行榜分页 */ /** 获得商品排行榜分页 */

View File

@ -0,0 +1,153 @@
<script lang="ts" setup>
import type { MallProductStatisticsApi } from '#/api/mall/statistics/product';
import { onMounted, reactive, ref } from 'vue';
import { AnalysisChartCard } from '@vben/common-ui';
import { buildSortingField, floatToFixed2 } from '@vben/utils';
import * as ProductStatisticsApi from '#/api/mall/statistics/product';
import ShortcutDateRangePicker from '#/views/mall/home/components/shortcut-date-range-picker.vue';
/** 商品排行 */
defineOptions({ name: 'ProductRank' });
// 访-
const formatConvertRate = (row: MallProductStatisticsApi.ProductStatistics) => {
return `${row.browseConvertPercent}%`;
};
const handleSortChange = (params: any) => {
queryParams.sortingFields = [buildSortingField(params)];
getSpuList();
};
const handleDateRangeChange = (times: any[]) => {
queryParams.times = times as [];
getSpuList();
};
const shortcutDateRangePicker = ref();
//
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
times: [],
sortingFields: {},
});
const loading = ref(false); //
const total = ref(0); //
const list = ref<MallProductStatisticsApi.ProductStatistics[]>([]); //
/** 查询商品列表 */
const getSpuList = async () => {
loading.value = true;
try {
const data =
await ProductStatisticsApi.getProductStatisticsRankPage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
//
// @ts-ignore
const fenToYuanFormat = (_, __, cellValue: any, ___) => {
return `${floatToFixed2(cellValue)}`;
};
/** 初始化 */
onMounted(async () => {
await getSpuList();
});
</script>
<template>
<AnalysisChartCard title="商品排行">
<template #header-suffix>
<ShortcutDateRangePicker
ref="shortcutDateRangePicker"
@change="handleDateRangeChange"
/>
</template>
<!-- 排行列表 -->
<el-table v-loading="loading" :data="list" @sort-change="handleSortChange">
<el-table-column label="商品 ID" prop="spuId" min-width="70" />
<el-table-column label="商品图片" align="center" prop="picUrl" width="80">
<template #default="{ row }">
<el-image
:src="row.picUrl"
:preview-src-list="[row.picUrl]"
class="h-30px w-30px"
preview-teleported
/>
</template>
</el-table-column>
<el-table-column
label="商品名称"
prop="name"
min-width="200"
:show-overflow-tooltip="true"
/>
<el-table-column
label="浏览量"
prop="browseCount"
min-width="90"
sortable="custom"
/>
<el-table-column
label="访客数"
prop="browseUserCount"
min-width="90"
sortable="custom"
/>
<el-table-column
label="加购件数"
prop="cartCount"
min-width="105"
sortable="custom"
/>
<el-table-column
label="下单件数"
prop="orderCount"
min-width="105"
sortable="custom"
/>
<el-table-column
label="支付件数"
prop="orderPayCount"
min-width="105"
sortable="custom"
/>
<el-table-column
label="支付金额"
prop="orderPayPrice"
min-width="105"
sortable="custom"
:formatter="fenToYuanFormat"
/>
<el-table-column
label="收藏数"
prop="favoriteCount"
min-width="90"
sortable="custom"
/>
<el-table-column
label="访客-支付转化率(%)"
prop="browseConvertPercent"
min-width="180"
sortable="custom"
:formatter="formatConvertRate"
/>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getSpuList"
/>
</AnalysisChartCard>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,300 @@
<script lang="ts" setup>
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { MallDataComparisonResp } from '#/api/mall/statistics/common';
import type { MallProductStatisticsApi } from '#/api/mall/statistics/product';
import { reactive, ref } from 'vue';
import { AnalysisChartCard, AnalysisOverview, confirm } from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
SvgDownloadIcon,
SvgEyeIcon,
} from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
downloadFileFromBlobPart,
fenToYuan,
formatDate,
isSameDay,
} from '@vben/utils';
import dayjs from 'dayjs';
import * as ProductStatisticsApi from '#/api/mall/statistics/product';
import ShortcutDateRangePicker from '#/views/mall/home/components/shortcut-date-range-picker.vue';
/** 商品概况 */
defineOptions({ name: 'ProductSummary' });
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const trendLoading = ref(true); //
const exportLoading = ref(false); //
const trendSummary =
ref<MallDataComparisonResp<MallProductStatisticsApi.ProductStatistics>>(); //
const shortcutDateRangePicker = ref();
/** 折线图配置 */
const lineChartOptions = reactive({
dataset: {
dimensions: [
'time',
'browseCount',
'browseUserCount',
'orderPayPrice',
'afterSaleRefundPrice',
],
source: [] as MallProductStatisticsApi.ProductStatistics[],
},
grid: {
left: 20,
right: 20,
bottom: 20,
top: 80,
containLabel: true,
},
legend: {
top: 50,
},
series: [
{
name: '商品浏览量',
type: 'line',
smooth: true,
itemStyle: { color: '#B37FEB' },
},
{
name: '商品访客数',
type: 'line',
smooth: true,
itemStyle: { color: '#FFAB2B' },
},
{
name: '支付金额',
type: 'bar',
smooth: true,
yAxisIndex: 1,
itemStyle: { color: '#1890FF' },
},
{
name: '退款金额',
type: 'bar',
smooth: true,
yAxisIndex: 1,
itemStyle: { color: '#00C050' },
},
],
toolbox: {
feature: {
//
dataZoom: {
yAxisIndex: false, // Y
},
brush: {
type: ['lineX', 'clear'] as const, //
},
saveAsImage: { show: true, name: '商品状况' }, //
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
padding: [5, 10],
},
xAxis: {
type: 'category' as const,
boundaryGap: true,
axisTick: {
show: false,
},
},
yAxis: [
{
type: 'value' as const,
name: '金额',
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
color: '#7F8B9C',
},
splitLine: {
show: true,
lineStyle: {
color: '#F5F7F9',
},
},
},
{
type: 'value' as const,
name: '数量',
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
color: '#7F8B9C',
},
splitLine: {
show: true,
lineStyle: {
color: '#F5F7F9',
},
},
},
],
});
/** 处理商品状况查询 */
const getProductTrendData = async () => {
trendLoading.value = true;
// 1. : , 线,
const times = shortcutDateRangePicker.value.times;
if (isSameDay(times[0], times[1])) {
//
times[0] = formatDate(dayjs(times[0]).subtract(1, 'd').toDate());
}
//
await Promise.all([getProductTrendSummary(), getProductStatisticsList()]);
renderEcharts(lineChartOptions as unknown as echarts.EChartsOption);
loadOverview();
trendLoading.value = false;
};
/** 查询商品状况数据统计 */
const getProductTrendSummary = async () => {
const times = shortcutDateRangePicker.value.times;
trendSummary.value = await ProductStatisticsApi.getProductStatisticsAnalyse({
times,
});
};
/** 查询商品状况数据列表 */
const getProductStatisticsList = async () => {
//
const times = shortcutDateRangePicker.value.times;
const list: MallProductStatisticsApi.ProductStatistics[] =
await ProductStatisticsApi.getProductStatisticsList({ times });
//
for (const item of list) {
item.orderPayPrice = Number(fenToYuan(item.orderPayPrice));
item.afterSaleRefundPrice = Number(fenToYuan(item.afterSaleRefundPrice));
}
// Echarts
if (lineChartOptions.dataset && lineChartOptions.dataset.source) {
lineChartOptions.dataset.source = list;
}
};
/** 导出按钮操作 */
const handleExport = async () => {
try {
//
await confirm('确定要导出商品状况吗?');
//
exportLoading.value = true;
const times = shortcutDateRangePicker.value.times;
const data = await ProductStatisticsApi.exportProductStatisticsExcel({
times,
});
downloadFileFromBlobPart({ fileName: '商品状况.xls', source: data });
} finally {
exportLoading.value = false;
}
};
const overviewItems = ref<AnalysisOverviewItem[]>();
const loadOverview = () => {
overviewItems.value = [
{
icon: SvgEyeIcon,
title: '商品浏览量',
totalTitle: '昨日数据',
totalValue: trendSummary.value?.reference?.browseCount,
value: trendSummary?.value?.browseUserCount || 0,
tooltip:
'在选定条件下,所有商品详情页被访问的次数,一个人在统计时间内访问多次记为多次',
},
{
icon: SvgCakeIcon,
title: '商品访客数',
totalTitle: '昨日数据',
totalValue: trendSummary.value?.reference?.browseUserCount || 0,
value: trendSummary?.value?.browseUserCount || 0,
tooltip:
'在选定条件下,访问任何商品详情页的人数,一个人在统计时间范围内访问多次只记为一个',
},
{
icon: SvgDownloadIcon,
title: '支付件数',
totalTitle: '昨日数据',
totalValue: trendSummary.value?.reference?.orderPayCount || 0,
value: trendSummary?.value?.orderPayCount || 0,
},
{
icon: SvgBellIcon,
title: '支付金额',
totalTitle: '昨日数据',
totalValue: trendSummary.value?.reference?.afterSaleCount || 0,
value: trendSummary?.value?.orderPayPrice || 0,
},
{
icon: SvgBellIcon,
title: '退款件数',
totalTitle: '昨日数据',
totalValue: trendSummary.value?.reference?.afterSaleCount || 0,
value: trendSummary?.value?.afterSaleCount || 0,
},
{
icon: SvgBellIcon,
title: '退款金额',
totalTitle: '昨日数据',
totalValue: trendSummary.value?.reference?.afterSaleRefundPrice || 0,
value: trendSummary?.value?.afterSaleRefundPrice || 0,
},
];
};
</script>
<template>
<AnalysisChartCard title="商品概况">
<template #header-suffix>
<!-- 查询条件 -->
<ShortcutDateRangePicker
ref="shortcutDateRangePicker"
@change="getProductTrendData"
>
<el-button
class="ml-4"
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['statistics:product:export']"
>
<Icon icon="ep:download" class="mr-1" />导出
</el-button>
</ShortcutDateRangePicker>
</template>
<!-- 统计值 -->
<AnalysisOverview
v-model:model-value="overviewItems"
:columns-number="6"
class="mt-5 md:mr-4 md:mt-0 md:w-full"
/>
<!-- 折线图 -->
<el-skeleton :loading="trendLoading" animated>
<EchartsUI ref="chartRef" height="500px" />
</el-skeleton>
</AnalysisChartCard>
</template>
<style lang="scss" scoped></style>

View File

@ -1,7 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui'; import { DocAlert, Page } from '@vben/common-ui';
import { ElButton } from 'element-plus'; import ProductRank from './components/product-rank.vue';
import ProductSummary from './components/product-summary.vue';
</script> </script>
<template> <template>
@ -10,25 +11,7 @@ import { ElButton } from 'element-plus';
title="【统计】会员、商品、交易统计" title="【统计】会员、商品、交易统计"
url="https://doc.iocoder.cn/mall/statistics/" url="https://doc.iocoder.cn/mall/statistics/"
/> />
<ElButton <ProductSummary class="md:w-3/3 mt-5 md:mr-4 md:mt-0" />
danger <ProductRank class="md:w-3/3 mt-5 md:mr-4 md:mt-0" />
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</ElButton>
<br />
<ElButton
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/product/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/product/index
代码pull request 贡献给我们
</ElButton>
</Page> </Page>
</template> </template>

View File

@ -22,3 +22,18 @@ export { default as cloneDeep } from 'lodash.clonedeep';
export { default as get } from 'lodash.get'; export { default as get } from 'lodash.get';
export { default as isEqual } from 'lodash.isequal'; export { default as isEqual } from 'lodash.isequal';
export { default as set } from 'lodash.set'; export { default as set } from 'lodash.set';
/**
*
* @param prop
* @param order
*/
export const buildSortingField = ({
prop,
order,
}: {
order: 'ascending' | 'descending';
prop: string;
}) => {
return { field: prop, order: order === 'ascending' ? 'asc' : 'desc' };
};

View File

@ -9,6 +9,10 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
VbenCountToAnimator, VbenCountToAnimator,
VbenIcon, VbenIcon,
} from '@vben-core/shadcn-ui'; } from '@vben-core/shadcn-ui';
@ -16,6 +20,7 @@ import {
interface Props { interface Props {
items?: AnalysisOverviewItem[]; items?: AnalysisOverviewItem[];
modelValue?: AnalysisOverviewItem[]; modelValue?: AnalysisOverviewItem[];
columnsNumber?: number;
} }
defineOptions({ defineOptions({
@ -25,6 +30,7 @@ defineOptions({
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
items: () => [], items: () => [],
modelValue: () => [], modelValue: () => [],
columnsNumber: 4,
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@ -33,14 +39,45 @@ const itemsData = computed({
get: () => (props.modelValue?.length ? props.modelValue : props.items), get: () => (props.modelValue?.length ? props.modelValue : props.items),
set: (value) => emit('update:modelValue', value), 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> </script>
<template> <template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2" :class="gridColumnsClass">
<template v-for="item in itemsData" :key="item.title"> <template v-for="item in itemsData" :key="item.title">
<Card :title="item.title" class="w-full"> <Card :title="item.title" class="w-full">
<CardHeader> <CardHeader>
<CardTitle class="text-xl">{{ item.title }}</CardTitle> <CardTitle class="text-xl">
<div class="flex items-center">
<span>{{ item.title }}</span>
<span v-if="item.tooltip" class="ml-1 inline-block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<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>
</TooltipTrigger>
<TooltipContent>{{ item.tooltip }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</div>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="flex items-center justify-between"> <CardContent class="flex items-center justify-between">

View File

@ -6,6 +6,7 @@ interface AnalysisOverviewItem {
totalTitle?: string; totalTitle?: string;
totalValue?: number; totalValue?: number;
value: number; value: number;
tooltip?: string;
} }
interface WorkbenchProjectItem { interface WorkbenchProjectItem {

View File

@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<!-- 样式定义 -->
<style>
.eye-outline { fill: #0D47A1; }
.eye-white { fill: #BBDEFB; }
.eye-iris { fill: #2196F3; }
.eye-pupil { fill: #000000; }
.eye-highlight { fill: #FFFFFF; }
.eye-shadow { fill: #1565C0; }
</style>
<!-- 眼睛外轮廓 -->
<path class="eye-outline" d="M200,250c-80,0-160-60-200-100c40-40,120-100,200-100s160,60,200,100C360,190,280,250,200,250z"/>
<!-- 眼睛白色部分 -->
<path class="eye-white" d="M200,70c70,0,140,50,180,80c-40,30-110,80-180,80s-140-50-180-80C60,120,130,70,200,70z"/>
<!-- 眼睑阴影 -->
<path class="eye-shadow" d="M200,90c-60,0-120,40-160,60c40,20,100,60,160,60s120-40,160-60C320,130,260,90,200,90z"/>
<!-- 虹膜 -->
<circle class="eye-iris" cx="200" cy="150" r="60"/>
<!-- 瞳孔 - 确保是明显的黑色圆形 -->
<circle class="eye-pupil" cx="200" cy="150" r="25"/>
<!-- 高光 -->
<circle class="eye-highlight" cx="180" cy="130" r="12"/>
<!-- 装饰线条 -->
<path class="eye-highlight" d="M100,110c10-5,30-15,40-20c3-1,2-5-1-4c-10,5-30,15-40,20C96,107,97,111,100,110z"/>
<path class="eye-highlight" d="M300,190c2-5,5-10,10-15c10-10,20-20,30-25c2-1,0-5-2-4c-15,10-30,30-40,45C297,193,299,195,300,190z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download');
const SvgCardIcon = createIconifyIcon('svg:card'); const SvgCardIcon = createIconifyIcon('svg:card');
const SvgBellIcon = createIconifyIcon('svg:bell'); const SvgBellIcon = createIconifyIcon('svg:bell');
const SvgCakeIcon = createIconifyIcon('svg:cake'); const SvgCakeIcon = createIconifyIcon('svg:cake');
const SvgEyeIcon = createIconifyIcon('svg:eye');
const SvgAntdvLogoIcon = createIconifyIcon('svg:antdv-logo'); const SvgAntdvLogoIcon = createIconifyIcon('svg:antdv-logo');
/** AI */ /** AI */
@ -44,6 +45,7 @@ export {
SvgCakeIcon, SvgCakeIcon,
SvgCardIcon, SvgCardIcon,
SvgDownloadIcon, SvgDownloadIcon,
SvgEyeIcon,
SvgGptIcon, SvgGptIcon,
SvgMockIcon, SvgMockIcon,
SvgWalletIcon, SvgWalletIcon,