feat(shop-zone): 新增专区销量统计功能

- 后端新增专区销量统计相关 VO 并扩展 ShopHomeSection 实体
- 新增批量查询专区销量汇总与商品排行的 Mapper 方法及接口
- Controller 增加获取专区销量统计的接口支持时间范围查询
- 前端接口定义新增专区销量统计类型及请求方法
- 专区管理页面列表新增销量件数、销售额列及销量详情按钮
- 实现专区销量统计弹窗支持时间筛选、汇总展示和排行显示
- 完成前端相关界面和交互设计,保证功能完整可用
- 统计口径基于已支付且未取消/退款订单商品实际成交数据
This commit is contained in:
2026-08-17 12:40:08 +08:00
parent fe97f49afe
commit ced8c7cff7
15 changed files with 1082 additions and 448 deletions
@@ -0,0 +1,182 @@
<template>
<a-modal
:visible="visible"
title="专区销量统计"
:footer="null"
width="760"
:destroy-on-close="true"
@update:visible="(v: boolean) => emit('update:visible', v)"
>
<div>
<!-- 时间范围筛选 -->
<a-space style="margin-bottom: 16px; flex-wrap: wrap">
<a-radio-group v-model:value="rangeType" @change="reload">
<a-radio-button value="all">全部</a-radio-button>
<a-radio-button value="today">今日</a-radio-button>
<a-radio-button value="7d">近7天</a-radio-button>
<a-radio-button value="30d">近30天</a-radio-button>
<a-radio-button value="custom">自定义</a-radio-button>
</a-radio-group>
<a-range-picker
v-if="rangeType === 'custom'"
v-model:value="customRange"
show-time
style="width: 380px"
@change="reload"
/>
</a-space>
<a-spin :spinning="loading">
<!-- 汇总卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="12">
<a-card :bordered="false" style="background: #fafafa">
<div style="color: #999; font-size: 13px">销量件数</div>
<div style="font-size: 24px; font-weight: 600; margin-top: 4px">
{{ stats?.salesNum != null ? stats.salesNum : 0 }}
</div>
</a-card>
</a-col>
<a-col :span="12">
<a-card :bordered="false" style="background: #fafafa">
<div style="color: #999; font-size: 13px">销售额</div>
<div style="font-size: 24px; font-weight: 600; margin-top: 4px">
¥{{
stats?.salesAmount != null
? Number(stats.salesAmount).toFixed(2)
: '0.00'
}}
</div>
</a-card>
</a-col>
</a-row>
<!-- 商品销量排行 -->
<div style="color: #999; font-size: 13px; margin-bottom: 8px">
商品销量排行TOP 50
</div>
<a-table
:dataSource="stats?.goodsRank || []"
:columns="rankColumns"
row-key="goodsId"
size="small"
:pagination="false"
:scroll="{ y: 360 }"
>
<template #bodyCell="{ column, text, index }">
<template v-if="column.key === 'idx'">
{{ index + 1 }}
</template>
<template v-if="column.key === 'salesNum'">
{{ text != null ? text : 0 }}
</template>
<template v-if="column.key === 'salesAmount'">
¥{{ text != null ? Number(text).toFixed(2) : '0.00' }}
</template>
</template>
</a-table>
</a-spin>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import dayjs, { type Dayjs } from 'dayjs';
import { getSectionSalesStats } from '@/api/shop/shopZone';
import type { SectionSalesStatsVO } from '@/api/shop/shopZone/model';
const props = defineProps<{
visible: boolean;
sectionId: number;
}>();
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void;
}>();
const loading = ref(false);
const stats = ref<SectionSalesStatsVO | null>(null);
const rangeType = ref<'today' | '7d' | '30d' | 'all' | 'custom'>('all');
const customRange = ref<[Dayjs, Dayjs] | null>(null);
const rankColumns = [
{ title: '#', key: 'idx', align: 'center', width: 60 },
{ title: '商品名称', dataIndex: 'goodsName', key: 'goodsName' },
{ title: '销量件数', dataIndex: 'salesNum', key: 'salesNum', align: 'center', width: 100 },
{ title: '销售额', dataIndex: 'salesAmount', key: 'salesAmount', align: 'center', width: 120 }
] as any[];
function fmt(d: Date): string {
const p = (n: number) => (n < 10 ? '0' + n : '' + n);
return (
d.getFullYear() +
'-' +
p(d.getMonth() + 1) +
'-' +
p(d.getDate()) +
' ' +
p(d.getHours()) +
':' +
p(d.getMinutes()) +
':' +
p(d.getSeconds())
);
}
function buildParams(): { start?: string; end?: string } {
const now = new Date();
if (rangeType.value === 'today') {
const s = new Date(now);
s.setHours(0, 0, 0, 0);
return { start: fmt(s), end: fmt(now) };
}
if (rangeType.value === '7d') {
const s = new Date(now);
s.setDate(s.getDate() - 7);
s.setHours(0, 0, 0, 0);
return { start: fmt(s), end: fmt(now) };
}
if (rangeType.value === '30d') {
const s = new Date(now);
s.setDate(s.getDate() - 30);
s.setHours(0, 0, 0, 0);
return { start: fmt(s), end: fmt(now) };
}
if (rangeType.value === 'custom' && customRange.value) {
return {
start: fmt(customRange.value[0].toDate()),
end: fmt(customRange.value[1].toDate())
};
}
return {};
}
function reload() {
if (!props.sectionId) {
return;
}
loading.value = true;
getSectionSalesStats(props.sectionId, buildParams())
.then((res) => {
stats.value = res;
})
.catch((e) => {
message.error(e?.message || '统计失败');
})
.finally(() => {
loading.value = false;
});
}
// 打开弹窗 / 切换时间范围时自动加载
watch(
() => [props.visible, props.sectionId, rangeType.value, customRange.value],
() => {
if (props.visible && props.sectionId) {
reload();
}
}
);
</script>