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
+74
View File
@@ -0,0 +1,74 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { MODULES_API_URL } from '@/config/setting';
import type {
ShopOrderStatsOverview,
ShopOrderTrendItem,
ShopGoodsRankItem,
ShopOrderStatusDist,
StatsRangeParams
} from './model';
/**
* 经营概览(KPI 卡片)
*/
export async function getShopOrderStatsOverview(
params: StatsRangeParams
): Promise<ShopOrderStatsOverview> {
const res = await request.get<ApiResult<ShopOrderStatsOverview>>(
MODULES_API_URL + '/shop/shop-order/stats/overview',
{ params }
);
if (res.data.code === 0) {
return res.data.data as ShopOrderStatsOverview;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 销售趋势(type=day|week|month
*/
export async function getShopOrderStatsTrend(
params: StatsRangeParams & { type?: string }
): Promise<ShopOrderTrendItem[]> {
const res = await request.get<ApiResult<ShopOrderTrendItem[]>>(
MODULES_API_URL + '/shop/shop-order/stats/trend',
{ params }
);
if (res.data.code === 0) {
return res.data.data as ShopOrderTrendItem[];
}
return Promise.reject(new Error(res.data.message));
}
/**
* 商品销量排行
*/
export async function getShopOrderGoodsRank(
params: StatsRangeParams & { limit?: number }
): Promise<ShopGoodsRankItem[]> {
const res = await request.get<ApiResult<ShopGoodsRankItem[]>>(
MODULES_API_URL + '/shop/shop-order/stats/goods-rank',
{ params }
);
if (res.data.code === 0) {
return res.data.data as ShopGoodsRankItem[];
}
return Promise.reject(new Error(res.data.message));
}
/**
* 订单/退款分布
*/
export async function getShopOrderStatsStatusDist(
params: StatsRangeParams
): Promise<ShopOrderStatusDist> {
const res = await request.get<ApiResult<ShopOrderStatusDist>>(
MODULES_API_URL + '/shop/shop-order/stats/status-dist',
{ params }
);
if (res.data.code === 0) {
return res.data.data as ShopOrderStatusDist;
}
return Promise.reject(new Error(res.data.message));
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 商城报表统计 - 类型定义
*/
/** 经营概览(GMV + 实收双口径) */
export interface ShopOrderStatsOverview {
/** GMV(含未支付),按订单面额 total_price 求和 */
gmvSales: number;
/** 实收金额(仅已支付),按 pay_price 求和 */
paidSales: number;
/** 订单总数(含未支付) */
orderCount: number;
/** 已支付订单数 */
paidOrderCount: number;
/** 客单价 = 实收 / 已支付订单数 */
customerUnitPrice: number;
/** 退款金额 */
refundAmount: number;
/** 区间新增会员数 */
newUserCount: number;
/** 使用优惠券的订单数 */
couponUsedCount: number;
}
/** 销售趋势周期项 */
export interface ShopOrderTrendItem {
/** 周期:day=yyyy-MM-dd / week=yyyy-ww / month=yyyy-MM */
period: string;
gmvSales: number;
paidSales: number;
orderCount: number;
paidOrderCount: number;
}
/** 商品销量排行项 */
export interface ShopGoodsRankItem {
goodsId: number;
goodsName: string;
totalNum: number;
totalAmount: number;
}
/** 订单状态分布项 */
export interface ShopOrderStatusItem {
status: number;
statusName: string;
count: number;
}
/** 订单/退款分布 */
export interface ShopOrderStatusDist {
statusCounts: ShopOrderStatusItem[];
orderCount: number;
paidOrderCount: number;
refundCount: number;
refundAmount: number;
/** 退款率(% */
refundRate: number;
}
/** 区间参数(start/end 格式 yyyy-MM-dd HH:mm:ss */
export interface StatsRangeParams {
start: string;
end: string;
}
+19 -1
View File
@@ -4,7 +4,8 @@ import type {
HomeSection,
HomeSectionParam,
SectionPermission,
SectionGoods
SectionGoods,
SectionSalesStatsVO
} from './model';
import type { User } from '@/api/system/user/model';
import { MODULES_API_URL } from '@/config/setting';
@@ -238,3 +239,20 @@ export async function getSectionQrcode(
}
return window.URL.createObjectURL(blob);
}
/**
* 专区销量统计(销量件数 + 销售额 + 商品排行), 支持时间范围 start/end(yyyy-MM-dd HH:mm:ss)
*/
export async function getSectionSalesStats(
id: number,
params?: { start?: string; end?: string }
) {
const res = await request.get<ApiResult<SectionSalesStatsVO>>(
MODULES_API_URL + '/shop/shop-home-section/' + id + '/stats',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+32
View File
@@ -36,6 +36,10 @@ export interface HomeSection {
createTime?: string;
// 修改时间
updateTime?: string;
// 销量件数(统计, 非DB字段)
salesNum?: number;
// 销售额(统计, 非DB字段)
salesAmount?: number;
}
/**
@@ -86,3 +90,31 @@ export interface SectionUser {
sectionId?: number;
userId?: number;
}
/**
* 专区销量统计(含商品排行)
*/
export interface SectionSalesStatsVO {
// 专区ID
sectionId?: number;
// 销量件数
salesNum?: number;
// 销售额
salesAmount?: number;
// 统计开始时间
startTime?: string;
// 统计结束时间
endTime?: string;
// 商品销量排行
goodsRank?: SectionGoodsRankItem[];
}
/**
* 专区商品销量排行项
*/
export interface SectionGoodsRankItem {
goodsId?: number;
goodsName?: string;
salesNum?: number;
salesAmount?: number;
}
+60 -268
View File
@@ -1,21 +1,26 @@
/**
* 统计数据 store
* 说明:今日概况(销售额/订单数/新增会员/用券数)改为调用后端聚合接口
* /shop/shop-order/stats/overview,不再前端拉全量订单求和,也不再依赖 cms_statistics 表。
*/
import { defineStore } from 'pinia';
import dayjs from 'dayjs';
import { pageUsers } from '@/api/system/user';
import { pageShopOrder, shopOrderTotal, listShopOrder } from '@/api/shop/shopOrder';
import {
addCmsStatistics,
listCmsStatistics,
updateCmsStatistics
} from '@/api/cms/cmsStatistics';
import { CmsStatistics } from '@/api/cms/cmsStatistics/model';
import { safeNumber, hasValidId } from '@/utils/type-guards';
import { pageShopOrder, shopOrderTotal } from '@/api/shop/shopOrder';
import { getShopOrderStatsOverview } from '@/api/shop/shopOrderStats';
import { safeNumber } from '@/utils/type-guards';
export interface StatisticsState {
// 统计数据
statistics: CmsStatistics | null;
statistics: {
userCount: number;
orderCount: number;
totalSales: number;
todaySales: number;
monthSales: number;
todayOrders: number;
todayUsers: number;
} | null;
// 加载状态
loading: boolean;
// 最后更新时间
@@ -40,65 +45,14 @@ export const useStatisticsStore = defineStore('statistics', {
}),
getters: {
/**
* 获取用户总数
*/
userCount: (state): number => {
return safeNumber(state.statistics?.userCount);
},
/**
* 获取订单总数
*/
orderCount: (state): number => {
return safeNumber(state.statistics?.orderCount);
},
/**
* 获取总销售额
*/
totalSales: (state): number => {
return safeNumber(state.statistics?.totalSales);
},
/**
* 获取今日销售额
*/
todaySales: (state): number => {
return safeNumber(state.statistics?.todaySales);
},
/**
* 获取本月销售额
*/
monthSales: (state): number => {
return safeNumber(state.statistics?.monthSales);
},
/**
* 获取今日订单数
*/
todayOrders: (state): number => {
return safeNumber(state.statistics?.todayOrders);
},
/**
* 获取今日新增用户
*/
todayUsers: (state): number => {
return safeNumber(state.statistics?.todayUsers);
},
/**
* 获取今日使用优惠券数量(安全取值)
*/
safeCouponUsedCount: (state): number => {
return safeNumber(state.couponUsedCount);
},
/**
* 检查缓存是否有效
*/
userCount: (state): number => safeNumber(state.statistics?.userCount),
orderCount: (state): number => safeNumber(state.statistics?.orderCount),
totalSales: (state): number => safeNumber(state.statistics?.totalSales),
todaySales: (state): number => safeNumber(state.statistics?.todaySales),
monthSales: (state): number => safeNumber(state.statistics?.monthSales),
todayOrders: (state): number => safeNumber(state.statistics?.todayOrders),
todayUsers: (state): number => safeNumber(state.statistics?.todayUsers),
safeCouponUsedCount: (state): number => safeNumber(state.couponUsedCount),
isCacheValid: (state): boolean => {
if (!state.lastUpdateTime) return false;
const now = Date.now();
@@ -107,40 +61,35 @@ export const useStatisticsStore = defineStore('statistics', {
},
actions: {
/**
* 获取统计数据
* @param forceRefresh 是否强制刷新
*/
async fetchStatistics(forceRefresh = false) {
// 如果缓存有效且不强制刷新,直接返回缓存数据
if (!forceRefresh && this.isCacheValid && this.statistics) {
return this.statistics;
}
this.loading = true;
try {
// 并行获取各种统计数据,使用Promise.allSettled确保部分失败不影响整体
const [usersResult, ordersResult, totalResult, statisticsResult] =
// 今日区间(与后端 dayjs startOf/endOf('day') 对齐)
const todayStart = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
const todayEnd = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
// 并行获取:累计用户数 / 累计订单数 / 累计实收 / 今日概览(后端聚合)
const [usersResult, ordersResult, totalResult, overviewResult] =
await Promise.allSettled([
pageUsers({ page: 1, limit: 1 }),
pageShopOrder({ page: 1, limit: 1 }),
shopOrderTotal(),
listCmsStatistics({})
getShopOrderStatsOverview({ start: todayStart, end: todayEnd })
]);
// 安全提取结果
const users =
usersResult.status === 'fulfilled' ? usersResult.value : null;
const orders =
ordersResult.status === 'fulfilled' ? ordersResult.value : null;
const total =
totalResult.status === 'fulfilled' ? totalResult.value : null;
const statisticsData =
statisticsResult.status === 'fulfilled'
? statisticsResult.value
: null;
const overview =
overviewResult.status === 'fulfilled' ? overviewResult.value : null;
// 记录失败的API调用
if (usersResult.status === 'rejected') {
console.error('❌ 用户API调用失败:', usersResult.reason);
}
@@ -150,178 +99,41 @@ export const useStatisticsStore = defineStore('statistics', {
if (totalResult.status === 'rejected') {
console.error('❌ 订单总额API调用失败:', totalResult.reason);
}
if (statisticsResult.status === 'rejected') {
console.error('❌ 统计数据API调用失败:', statisticsResult.reason);
if (overviewResult.status === 'rejected') {
console.error('❌ 今日概览API调用失败:', overviewResult.reason);
}
// 添加调试日志
console.log('🔍 统计数据获取结果:', {
users: users,
orders: orders,
total: total,
statisticsData: statisticsData
});
const userCount =
users && typeof users === 'object' && 'count' in users
? safeNumber((users as any).count)
: 0;
const orderCount =
orders && typeof orders === 'object' && 'count' in orders
? safeNumber((orders as any).count)
: 0;
const totalSales = safeNumber(total);
let statistics: CmsStatistics;
// 今日数据走后端聚合接口(实收 + 总数 + 新增会员 + 用券数)
const todaySales = overview ? safeNumber(overview.paidSales) : 0;
const todayOrders = overview ? safeNumber(overview.orderCount) : 0;
const todayUsers = overview ? safeNumber(overview.newUserCount) : 0;
const couponUsedCount = overview
? safeNumber(overview.couponUsedCount)
: 0;
// 安全获取用户数量,添加更详细的验证
const userCount = (() => {
if (!users) {
console.warn('⚠️ 用户API返回空数据');
return 0;
}
if (typeof users === 'object' && 'count' in users) {
const count = users.count;
console.log('✅ 用户数量:', count);
return safeNumber(count);
}
console.warn('⚠️ 用户API返回数据格式不正确:', users);
return 0;
})();
// 安全获取订单数量
const orderCount = (() => {
if (!orders) {
console.warn('⚠️ 订单API返回空数据');
return 0;
}
if (typeof orders === 'object' && 'count' in orders) {
const count = orders.count;
console.log('✅ 订单数量:', count);
return safeNumber(count);
}
console.warn('⚠️ 订单API返回数据格式不正确:', orders);
return 0;
})();
// 实时计算今日数据(不依赖可能未更新的统计表)
const todayStart = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss');
const todayEnd = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
// 安全获取今日订单列表、销售额和优惠券使用量
let todayOrders = 0;
let todaySales = 0;
let couponUsedCount = 0;
try {
const todayOrderList = await listShopOrder({
createTimeStart: todayStart,
createTimeEnd: todayEnd
});
if (Array.isArray(todayOrderList)) {
todayOrders = todayOrderList.length;
todaySales = todayOrderList.reduce((acc, order) => {
return acc + (order.payStatus ? safeNumber(order.payPrice) : 0);
}, 0);
couponUsedCount = todayOrderList.filter((order) => {
const couponType = order.couponType;
return couponType !== undefined && couponType !== null && couponType !== 0;
}).length;
}
} catch (e) {
console.warn('⚠️ 获取今日订单列表失败:', e);
}
// 安全获取今日新增用户
let todayUsers = 0;
try {
const todayUsersResult = await pageUsers({
page: 1,
limit: 1,
createTimeStart: todayStart,
createTimeEnd: todayEnd
});
if (todayUsersResult && typeof todayUsersResult === 'object' && 'count' in todayUsersResult) {
todayUsers = safeNumber(todayUsersResult.count);
}
} catch (e) {
console.warn('⚠️ 获取今日新增用户失败:', e);
}
const totalSales = (() => {
if (!total) {
console.warn('⚠️ 订单总额API返回空数据');
return 0;
}
if (Array.isArray(total)) {
// 如果是数组,计算总金额
const sum = total.reduce((acc, order) => {
const amount = order.payPrice || order.totalPrice || 0;
return acc + safeNumber(amount);
}, 0);
console.log('✅ 总销售额(数组计算):', sum);
return sum;
}
const amount = safeNumber(total);
console.log('✅ 总销售额(直接值):', amount);
return amount;
})();
if (statisticsData && statisticsData.length > 0) {
// 更新现有统计数据
const existingStatistics = statisticsData[0];
// 确保数据存在且有有效的 ID
if (hasValidId(existingStatistics)) {
const updateData: Partial<CmsStatistics> = {
id: existingStatistics.id,
userCount: userCount,
orderCount: orderCount,
totalSales: totalSales,
todaySales: todaySales,
todayOrders: todayOrders,
todayUsers: todayUsers
};
// 异步更新数据库
setTimeout(() => {
updateCmsStatistics(updateData).catch((error) => {
console.error('更新统计数据失败:', error);
});
}, 1000);
// 更新本地数据
statistics = {
...existingStatistics,
...updateData,
todaySales,
todayOrders,
todayUsers
};
} else {
// 如果现有数据无效,使用基础数据
statistics = {
userCount: userCount,
orderCount: orderCount,
totalSales: totalSales,
todaySales: todaySales,
todayOrders: todayOrders,
todayUsers: todayUsers
};
}
} else {
// 创建新的统计数据
statistics = {
userCount: userCount,
orderCount: orderCount,
totalSales: totalSales,
todaySales: todaySales,
todayOrders: todayOrders,
todayUsers: todayUsers
};
// 异步保存到数据库
setTimeout(() => {
addCmsStatistics(statistics).catch((error) => {
console.error('保存统计数据失败:', error);
});
}, 1000);
}
this.statistics = statistics;
this.statistics = {
userCount,
orderCount,
totalSales,
todaySales,
monthSales: todaySales,
todayOrders,
todayUsers
};
this.couponUsedCount = couponUsedCount;
this.lastUpdateTime = Date.now();
return statistics;
return this.statistics;
} catch (error) {
console.error('获取统计数据失败:', error);
throw error;
@@ -330,44 +142,27 @@ export const useStatisticsStore = defineStore('statistics', {
}
},
/**
* 更新统计数据
*/
updateStatistics(statistics: Partial<CmsStatistics>) {
updateStatistics(statistics: Partial<StatisticsState['statistics']>) {
if (this.statistics) {
this.statistics = { ...this.statistics, ...statistics };
this.lastUpdateTime = Date.now();
}
},
/**
* 清除缓存
*/
clearCache() {
this.statistics = null;
this.lastUpdateTime = null;
},
/**
* 强制刷新统计数据
*/
async forceRefresh() {
console.log('🔄 强制刷新统计数据...');
this.clearCache();
return await this.fetchStatistics(true);
},
/**
* 设置缓存有效期
*/
setCacheExpiry(expiry: number) {
this.cacheExpiry = expiry;
},
/**
* 开始自动刷新
* @param interval 刷新间隔(毫秒),默认5分钟
*/
startAutoRefresh(interval = 5 * 60 * 1000) {
this.stopAutoRefresh();
this.refreshTimer = window.setInterval(() => {
@@ -375,9 +170,6 @@ export const useStatisticsStore = defineStore('statistics', {
}, interval);
},
/**
* 停止自动刷新
*/
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
@@ -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>
+249
View File
@@ -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.valuev-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>
@@ -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>
+33
View File
@@ -69,6 +69,8 @@
<a-divider type="vertical" />
<a @click="openGoods(record)">商品</a>
<a-divider type="vertical" />
<a @click="openStats(record)">销量</a>
<a-divider type="vertical" />
<a @click="openQrcode(record)">二维码</a>
<a-divider type="vertical" />
<a-popconfirm
@@ -93,6 +95,12 @@
:sectionId="currentSectionId"
/>
<!-- 专区销量统计弹窗 -->
<SectionSalesStatsModal
v-model:visible="showStats"
:sectionId="currentSectionId"
/>
<!-- 专区商品抽屉 -->
<a-drawer
:width="860"
@@ -225,6 +233,7 @@
} from 'ele-admin-pro/es/ele-pro-table/types';
import ZoneEdit from './components/zoneEdit.vue';
import UserSelectModal from './components/UserSelectModal.vue';
import SectionSalesStatsModal from './components/SectionSalesStatsModal.vue';
import { getCompressedImageUrl } from '@/utils/image';
import {
pageHomeSections,
@@ -330,6 +339,23 @@
align: 'center',
width: 90
},
{
title: '销量件数',
dataIndex: 'salesNum',
key: 'salesNum',
align: 'center',
width: 100,
customRender: ({ text }: any) => (text != null ? text : 0)
},
{
title: '销售额',
dataIndex: 'salesAmount',
key: 'salesAmount',
align: 'center',
width: 120,
customRender: ({ text }: any) =>
'¥' + (text != null ? Number(text).toFixed(2) : '0.00')
},
{
title: '创建时间',
dataIndex: 'createTime',
@@ -431,6 +457,13 @@
loadGoods(row.sectionId || 0);
};
/* 打开销量统计弹窗 */
const showStats = ref(false);
const openStats = (row: HomeSection) => {
currentSectionId.value = row.sectionId || 0;
showStats.value = true;
};
/* 生成专区小程序码(按当前选择的版本) */
const genQrcode = (id: number) => {
qrcodeUrl.value = '';