Files
guilixu-admin/src/views/special/zone/components/SectionSalesStatsModal.vue
T
gxwebsoft ced8c7cff7 feat(shop-zone): 新增专区销量统计功能
- 后端新增专区销量统计相关 VO 并扩展 ShopHomeSection 实体
- 新增批量查询专区销量汇总与商品排行的 Mapper 方法及接口
- Controller 增加获取专区销量统计的接口支持时间范围查询
- 前端接口定义新增专区销量统计类型及请求方法
- 专区管理页面列表新增销量件数、销售额列及销量详情按钮
- 实现专区销量统计弹窗支持时间筛选、汇总展示和排行显示
- 完成前端相关界面和交互设计,保证功能完整可用
- 统计口径基于已支付且未取消/退款订单商品实际成交数据
2026-08-17 12:40:08 +08:00

183 lines
5.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>