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