feat(shop-zone): 新增专区销量统计功能
- 后端新增专区销量统计相关 VO 并扩展 ShopHomeSection 实体 - 新增批量查询专区销量汇总与商品排行的 Mapper 方法及接口 - Controller 增加获取专区销量统计的接口支持时间范围查询 - 前端接口定义新增专区销量统计类型及请求方法 - 专区管理页面列表新增销量件数、销售额列及销量详情按钮 - 实现专区销量统计弹窗支持时间筛选、汇总展示和排行显示 - 完成前端相关界面和交互设计,保证功能完整可用 - 统计口径基于已支付且未取消/退款订单商品实际成交数据
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user