feat(shop-zone): 新增专区销量统计功能
- 后端新增专区销量统计相关 VO 并扩展 ShopHomeSection 实体 - 新增批量查询专区销量汇总与商品排行的 Mapper 方法及接口 - Controller 增加获取专区销量统计的接口支持时间范围查询 - 前端接口定义新增专区销量统计类型及请求方法 - 专区管理页面列表新增销量件数、销售额列及销量详情按钮 - 实现专区销量统计弹窗支持时间筛选、汇总展示和排行显示 - 完成前端相关界面和交互设计,保证功能完整可用 - 统计口径基于已支付且未取消/退款订单商品实际成交数据
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
row-key="goodsId"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
|
||||
<template v-else-if="column.key === 'totalAmount'">
|
||||
¥{{ Number((record as ShopGoodsRankItem).totalAmount || 0).toFixed(2) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { ShopGoodsRankItem } from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
const props = defineProps<{ data?: ShopGoodsRankItem[] | null }>();
|
||||
|
||||
const rows = computed<ShopGoodsRankItem[]>(() => props.data ?? []);
|
||||
|
||||
const columns = [
|
||||
{ title: '排名', key: 'index', width: 70 },
|
||||
{ title: '商品名称', dataIndex: 'goodsName', key: 'goodsName' },
|
||||
{
|
||||
title: '销量',
|
||||
dataIndex: 'totalNum',
|
||||
key: 'totalNum',
|
||||
width: 100,
|
||||
sorter: (a: ShopGoodsRankItem, b: ShopGoodsRankItem) => a.totalNum - b.totalNum
|
||||
},
|
||||
{
|
||||
title: '销售额(元)',
|
||||
dataIndex: 'totalAmount',
|
||||
key: 'totalAmount',
|
||||
width: 150,
|
||||
sorter: (a: ShopGoodsRankItem, b: ShopGoodsRankItem) =>
|
||||
a.totalAmount - b.totalAmount
|
||||
}
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-label">{{ label }}</div>
|
||||
<div class="stat-card-value">{{ value }}</div>
|
||||
<div v-if="sub" class="stat-card-sub">{{ sub }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.stat-card-label {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.stat-card-value {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stat-card-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="status-dist">
|
||||
<a-row :gutter="16" class="refund-metrics">
|
||||
<a-col :span="6" v-for="m in metrics" :key="m.label">
|
||||
<div class="metric">
|
||||
<div class="metric-value" :style="{ color: m.color }">{{ m.value }}</div>
|
||||
<div class="metric-label">{{ m.label }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<v-chart class="status-chart" :option="chartOption" autoresize />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { PieChart } from 'echarts/charts';
|
||||
import { TooltipComponent, LegendComponent } from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { ShopOrderStatusDist } from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent]);
|
||||
|
||||
const props = defineProps<{ data?: ShopOrderStatusDist | null }>();
|
||||
|
||||
const dist = computed<ShopOrderStatusDist>(
|
||||
() =>
|
||||
props.data ?? {
|
||||
statusCounts: [],
|
||||
orderCount: 0,
|
||||
paidOrderCount: 0,
|
||||
refundCount: 0,
|
||||
refundAmount: 0,
|
||||
refundRate: 0
|
||||
}
|
||||
);
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '订单总数', value: dist.value.orderCount, color: 'rgba(0,0,0,0.85)' },
|
||||
{ label: '已支付', value: dist.value.paidOrderCount, color: '#00704A' },
|
||||
{
|
||||
label: '退款/售后',
|
||||
value: dist.value.refundCount,
|
||||
color: '#ff4d4f'
|
||||
},
|
||||
{
|
||||
label: '退款金额',
|
||||
value: '¥' + Number(dist.value.refundAmount || 0).toFixed(2),
|
||||
color: '#ff4d4f'
|
||||
},
|
||||
{
|
||||
label: '退款率',
|
||||
value: Number(dist.value.refundRate || 0).toFixed(2) + '%',
|
||||
color: '#fa8c16'
|
||||
}
|
||||
]);
|
||||
|
||||
const chartOption = computed(() => ({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['42%', '68%'],
|
||||
center: ['50%', '45%'],
|
||||
data: (dist.value.statusCounts || []).map((i) => ({
|
||||
name: i.statusName,
|
||||
value: i.count
|
||||
})),
|
||||
label: { formatter: '{b}\n{c}' },
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-dist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.refund-metrics {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.metric {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.status-chart {
|
||||
height: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<v-chart class="trend-chart" :option="chartOption" autoresize />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart, BarChart } from 'echarts/charts';
|
||||
import {
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { ShopOrderTrendItem } from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
BarChart,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
data?: ShopOrderTrendItem[] | null;
|
||||
chartType?: 'line' | 'bar';
|
||||
}>();
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const list = props.data ?? [];
|
||||
const seriesType = props.chartType === 'bar' ? 'bar' : 'line';
|
||||
const periods = list.map((d) => d.period);
|
||||
const gmv = list.map((d) => Number(d.gmvSales) || 0);
|
||||
const paid = list.map((d) => Number(d.paidSales) || 0);
|
||||
const orders = list.map((d) => Number(d.orderCount) || 0);
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['GMV(含未付)', '实收(已付)', '订单数'], bottom: 0 },
|
||||
grid: { left: 60, right: 24, top: 30, bottom: periods.length > 30 ? 70 : 50 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: periods,
|
||||
boundaryGap: seriesType === 'bar'
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额(元)' },
|
||||
{ type: 'value', name: '订单数', splitLine: { show: false } }
|
||||
],
|
||||
dataZoom:
|
||||
periods.length > 30
|
||||
? [{ type: 'inside' }, { type: 'slider', height: 18 }]
|
||||
: undefined,
|
||||
series: [
|
||||
{
|
||||
name: 'GMV(含未付)',
|
||||
type: seriesType,
|
||||
data: gmv,
|
||||
smooth: true,
|
||||
itemStyle: { color: '#91cc75' }
|
||||
},
|
||||
{
|
||||
name: '实收(已付)',
|
||||
type: seriesType,
|
||||
data: paid,
|
||||
smooth: true,
|
||||
itemStyle: { color: '#00704A' }
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: seriesType,
|
||||
yAxisIndex: 1,
|
||||
data: orders,
|
||||
smooth: true,
|
||||
itemStyle: { color: '#5470c6' }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
height: 380px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="statistics-page">
|
||||
<!-- 顶部筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<a-radio-group v-model:value="quick" @change="onQuick">
|
||||
<a-radio-button value="today">今日</a-radio-button>
|
||||
<a-radio-button value="7">近7天</a-radio-button>
|
||||
<a-radio-button value="30">近30天</a-radio-button>
|
||||
<a-radio-button value="month">本月</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:allow-clear="false"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
<a-button type="primary" @click="reload" :loading="loading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeKey" @change="onTabChange">
|
||||
<!-- 经营概览 -->
|
||||
<a-tab-pane key="overview" tab="经营概览">
|
||||
<div v-if="loading && !overviewData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<a-row v-else :gutter="[16, 16]">
|
||||
<a-col
|
||||
:xs="12"
|
||||
:sm="8"
|
||||
:md="6"
|
||||
v-for="c in overviewCards"
|
||||
:key="c.label"
|
||||
>
|
||||
<StatCard :label="c.label" :value="c.value" :sub="c.sub" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 销售趋势 -->
|
||||
<a-tab-pane key="trend" tab="销售趋势">
|
||||
<div class="trend-toolbar">
|
||||
<a-radio-group v-model:value="trendType" @change="loadTrend">
|
||||
<a-radio-button value="day">按日</a-radio-button>
|
||||
<a-radio-button value="week">按周</a-radio-button>
|
||||
<a-radio-button value="month">按月</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-radio-group v-model:value="trendChartType" style="margin-left: 12px">
|
||||
<a-radio-button value="line">折线</a-radio-button>
|
||||
<a-radio-button value="bar">柱状</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div v-if="loading && !trendData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<TrendChart v-else :data="trendData" :chart-type="trendChartType" />
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 商品分析 -->
|
||||
<a-tab-pane key="goods" tab="商品分析">
|
||||
<div v-if="loading && !goodsData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<GoodsRankTable v-else :data="goodsData" />
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 订单与退款 -->
|
||||
<a-tab-pane key="status" tab="订单与退款">
|
||||
<div v-if="loading && !statusData" class="block-loading">
|
||||
<a-spin />
|
||||
</div>
|
||||
<StatusDistChart v-else :data="statusData" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue/es';
|
||||
import { SearchOutlined } from '@ant-design/icons-vue';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import StatCard from './components/StatCard.vue';
|
||||
import TrendChart from './components/TrendChart.vue';
|
||||
import GoodsRankTable from './components/GoodsRankTable.vue';
|
||||
import StatusDistChart from './components/StatusDistChart.vue';
|
||||
import {
|
||||
getShopOrderStatsOverview,
|
||||
getShopOrderStatsTrend,
|
||||
getShopOrderGoodsRank,
|
||||
getShopOrderStatsStatusDist
|
||||
} from '@/api/shop/shopOrderStats';
|
||||
import type {
|
||||
ShopOrderStatsOverview,
|
||||
ShopOrderTrendItem,
|
||||
ShopGoodsRankItem,
|
||||
ShopOrderStatusDist
|
||||
} from '@/api/shop/shopOrderStats/model';
|
||||
|
||||
const fmt = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
// 日期区间,默认近30天
|
||||
const dateRange = ref<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(29, 'day').startOf('day'),
|
||||
dayjs().endOf('day')
|
||||
]);
|
||||
const quick = ref<string>('30');
|
||||
const activeKey = ref<string>('overview');
|
||||
const trendType = ref<'day' | 'week' | 'month'>('day');
|
||||
const trendChartType = ref<'line' | 'bar'>('line');
|
||||
|
||||
const loading = ref(false);
|
||||
const overviewData = ref<ShopOrderStatsOverview | null>(null);
|
||||
const trendData = ref<ShopOrderTrendItem[] | null>(null);
|
||||
const goodsData = ref<ShopGoodsRankItem[] | null>(null);
|
||||
const statusData = ref<ShopOrderStatusDist | null>(null);
|
||||
|
||||
const rangeParams = computed(() => ({
|
||||
start: dateRange.value[0].startOf('day').format(fmt),
|
||||
end: dateRange.value[1].endOf('day').format(fmt)
|
||||
}));
|
||||
|
||||
const overviewCards = computed(() => {
|
||||
const d = overviewData.value;
|
||||
if (!d) return [];
|
||||
const money = (v: number) => '¥' + Number(v || 0).toFixed(2);
|
||||
return [
|
||||
{ label: 'GMV(含未付)', value: money(d.gmvSales), sub: '订单面额求和' },
|
||||
{ label: '实收金额', value: money(d.paidSales), sub: '仅已支付' },
|
||||
{ label: '订单总数', value: d.orderCount, sub: '含未支付' },
|
||||
{ label: '已支付订单数', value: d.paidOrderCount, sub: 'pay_status=1' },
|
||||
{ label: '客单价', value: money(d.customerUnitPrice), sub: '实收/已付单数' },
|
||||
{ label: '退款金额', value: money(d.refundAmount), sub: 'refund_money' },
|
||||
{ label: '新增会员', value: d.newUserCount, sub: '区间新增' },
|
||||
{ label: '使用优惠券', value: d.couponUsedCount, sub: 'coupon_type≠0' }
|
||||
];
|
||||
});
|
||||
|
||||
// 快捷选项(直接用 quick.value,v-model 已先行更新)
|
||||
const onQuick = () => {
|
||||
const v = quick.value;
|
||||
const now = dayjs();
|
||||
if (v === 'today') {
|
||||
dateRange.value = [now.startOf('day'), now.endOf('day')];
|
||||
} else if (v === '7') {
|
||||
dateRange.value = [now.subtract(6, 'day').startOf('day'), now.endOf('day')];
|
||||
} else if (v === '30') {
|
||||
dateRange.value = [
|
||||
now.subtract(29, 'day').startOf('day'),
|
||||
now.endOf('day')
|
||||
];
|
||||
} else if (v === 'month') {
|
||||
dateRange.value = [now.startOf('month'), now.endOf('day')];
|
||||
}
|
||||
reload();
|
||||
};
|
||||
|
||||
const onDateChange = () => {
|
||||
// 手动选区间时取消快捷高亮
|
||||
quick.value = '';
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
if (activeKey.value === 'overview') loadOverview();
|
||||
else if (activeKey.value === 'trend') loadTrend();
|
||||
else if (activeKey.value === 'goods') loadGoods();
|
||||
else if (activeKey.value === 'status') loadStatus();
|
||||
};
|
||||
|
||||
const onTabChange = () => reload();
|
||||
|
||||
const loadOverview = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
overviewData.value = await getShopOrderStatsOverview(rangeParams.value);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载经营概览失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTrend = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
trendData.value = await getShopOrderStatsTrend({
|
||||
...rangeParams.value,
|
||||
type: trendType.value
|
||||
});
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载销售趋势失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadGoods = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
goodsData.value = await getShopOrderGoodsRank({
|
||||
...rangeParams.value,
|
||||
limit: 10
|
||||
});
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载商品排行失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
statusData.value = await getShopOrderStatsStatusDist(rangeParams.value);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载订单分布失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-page {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.trend-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.block-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user