订单统计

This commit is contained in:
马梓彭 2026-05-15 14:01:11 +08:00
parent 4615ee99f3
commit 1afa287f5d
2 changed files with 286 additions and 153 deletions

View File

@ -175,4 +175,13 @@ export const orderExportGet = (params) => {
*/ */
export const orderSetstatusPatch = (data) => { export const orderSetstatusPatch = (data) => {
return http.patch('/order/setstatus', data); return http.patch('/order/setstatus', data);
};
/**
* 获取订单统计数据
* @param {Object} params - 查询参数
* @returns {Promise<{totalOriginalAmount: number, totalDiscountAmount: number, totalActualAmount: number, totalRefundAmount: number}>}
*/
export const orderStatsGet = (params) => {
return http.get('/order/stats', { params });
}; };

View File

@ -1,7 +1,33 @@
<template> <template>
<div class="system-menu-container layout-padding"> <div class="system-menu-container layout-padding">
<!-- 统计区块 -->
<div class="registration-stats-container">
<div class="stats-card" v-loading="statsLoading">
<div class="stats-row">
<div class="stat-item">
<div class="stat-label">原金额</div>
<div class="stat-value">{{ animatedCounts.totalOriginalAmount }}<span class="stat-unit">元</span></div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-label">优惠金额</div>
<div class="stat-value">{{ animatedCounts.totalDiscountAmount }}<span class="stat-unit">元</span></div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-label">实付金额</div>
<div class="stat-value">{{ animatedCounts.totalActualAmount }}<span class="stat-unit">元</span></div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-label">退款金额</div>
<div class="stat-value" style="color: #e6a23c;">{{ animatedCounts.totalRefundAmount }}<span class="stat-unit">元</span></div>
</div>
</div>
</div>
</div>
<ProTable ref="tableRef" :request-api="orderPageGet" :columns="columns" :pagination="true" <ProTable ref="tableRef" :request-api="orderPageGet" :columns="columns" :pagination="true"
:tool-button="true"> :tool-button="true" @search="fetchStats" @reset="fetchStats">
<template #tools> <template #tools>
<el-button v-auth="'order:export'" type="primary" :loading="exportLoading" @click="handleExport"> <el-button v-auth="'order:export'" type="primary" :loading="exportLoading" @click="handleExport">
<template #icon> <template #icon>
@ -49,7 +75,8 @@
</template> </template>
<template #action="scope"> <template #action="scope">
<el-button size="small" text type="primary" @click="onOpenMenu(scope.row.id)"> <el-button size="small" text type="primary" @click="onOpenMenu(scope.row.id)">
查看详情 </el-button> 查看详情
</el-button>
</template> </template>
</ProTable> </ProTable>
@ -77,18 +104,76 @@
</div> </div>
</template> </template>
<script setup > <script setup>
import { ref, computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { Download } from '@element-plus/icons-vue'; import { Download } from '@element-plus/icons-vue';
import ProTable from '/@/components/ProTable/index.vue'; import ProTable from '/@/components/ProTable/index.vue';
import { orderPageGet , orderDetailGet, orderExportGet } from '/@/api/match/order'; import { orderPageGet, orderDetailGet, orderExportGet, orderStatsGet } from '/@/api/match/order';
const tableRef = ref(); const tableRef = ref();
const exportLoading = ref(false); const exportLoading = ref(false);
const statsLoading = ref(false);
// 统计数据
const statsData = ref({
totalOriginalAmount: 0,
totalDiscountAmount: 0,
totalActualAmount: 0,
totalRefundAmount: 0
});
// 动画计数
const animatedCounts = ref({
totalOriginalAmount: '0.00',
totalDiscountAmount: '0.00',
totalActualAmount: '0.00',
totalRefundAmount: '0.00'
});
// 数字动画效果
const animateNumber = (key, targetValue) => {
const duration = 1000;
const startTime = Date.now();
const startValue = parseFloat(animatedCounts.value[key]) || 0;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const currentValue = startValue + (targetValue - startValue) * easeProgress;
animatedCounts.value[key] = currentValue.toFixed(2);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
};
// 获取统计数据
const fetchStats = async () => {
try {
statsLoading.value = true;
await new Promise(resolve => setTimeout(resolve, 200));
const searchParams = tableRef.value?.searchParam || {};
const { succeeded, data } = await orderStatsGet(searchParams);
if (succeeded && data) {
statsData.value = data;
animateNumber('totalOriginalAmount', data.totalOriginalAmount);
animateNumber('totalDiscountAmount', data.totalDiscountAmount);
animateNumber('totalActualAmount', data.totalActualAmount);
animateNumber('totalRefundAmount', data.totalRefundAmount);
}
} catch (error) {
console.error('获取统计数据失败:', error);
} finally {
statsLoading.value = false;
}
};
const formData = ref({ const formData = ref({
orderNo: '', orderNo: '',
eventId: 0, eventId: 0,
eventName: '', eventName: '',
registrationMode: 0, registrationMode: 0,
@ -101,187 +186,226 @@ const formData = ref({
status: 0, status: 0,
createdTime: '', createdTime: '',
paidTime: '', paidTime: '',
items:[], items: [],
persons:[] persons: []
}) })
const columns = ref([ const columns = ref([
{ prop: 'orderNo', label: '订单编号', align: 'center', search: { el: 'input', }, fixed: 'left', width: 150 }, { prop: 'orderNo', label: '订单编号', align: 'center', search: { el: 'input', }, fixed: 'left', width: 150 },
{ width: 180, prop: 'unitName', label: '单位名称', align: 'center', }, { width: 180, prop: 'unitName', label: '单位名称', align: 'center', },
{ width: 120, prop: 'eventName', label: '所属赛事', search: { el: 'input', }, }, { width: 120, prop: 'eventName', label: '所属赛事', search: { el: 'input', }, },
{ width: 120, prop: 'orderType', label: '订单类型', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '收款', value: 1 }, { label: '退款', value: 2 },], }, { width: 120, prop: 'orderType', label: '订单类型', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '收款', value: 1 }, { label: '退款', value: 2 },], },
{ width: 120, prop: 'relatedOrderNo', label: '关联订单' }, { width: 120, prop: 'relatedOrderNo', label: '关联订单' },
{ width: 100, prop: 'paymentMethod', label: '支付方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '线上', value: 2 }, { label: '线下', value: 1 },], }, { width: 100, prop: 'paymentMethod', label: '支付方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '线上', value: 2 }, { label: '线下', value: 1 },], },
{ width: 100, prop: 'paymentProofUrl', label: '付款凭证' }, { width: 100, prop: 'paymentProofUrl', label: '付款凭证' },
{ width: 100, prop: 'registrationMode', label: '报名方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '个人', value: 1 }, { label: '团队', value: 2 },], }, { width: 100, prop: 'registrationMode', label: '报名方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '个人', value: 1 }, { label: '团队', value: 2 },], },
{ width: 120, prop: 'contactName', label: '报名用户' }, { width: 120, prop: 'contactName', label: '报名用户' },
{ width: 120, prop: 'contactPhone', label: '报名账号', search: { el: 'input' } }, { width: 120, prop: 'contactPhone', label: '报名账号', search: { el: 'input' } },
{ width: 100, prop: 'originalAmount', label: '原金额' }, { width: 100, prop: 'originalAmount', label: '原金额' },
{ width: 120, prop: 'actualAmount', label: '实付金额' }, { width: 120, prop: 'actualAmount', label: '实付金额' },
{ width: 100, prop: 'discountAmount', label: '优惠金额' }, { width: 100, prop: 'discountAmount', label: '优惠金额' },
{ width: 100, prop: 'refundAmount', label: '退款金额', align: 'center' }, { width: 100, prop: 'refundAmount', label: '退款金额', align: 'center' },
{ width: 90, prop: 'orderStatus', label: '订单状态', search: { el: 'select', }, enum: [{ label: '全部', value: '' }, { label: '待支付', value: 0 }, { label: '已支付', value: 1 }, { label: '已退款', value: 3 }, { label: '报名失败', value: 6 },], }, { width: 90, prop: 'orderStatus', label: '订单状态', search: { el: 'select', }, enum: [{ label: '全部', value: '' }, { label: '待支付', value: 0 }, { label: '已支付', value: 1 }, { label: '已退款', value: 3 }, { label: '报名失败', value: 6 },], },
{ width: 160, prop: 'createdTime', label: '生成时间', search: { el: 'date-picker', props: { type: 'daterange' } } }, { width: 160, prop: 'createdTime', label: '生成时间', search: { el: 'date-picker', props: { type: 'daterange' } } },
{ prop: 'action', label: '操作', align: 'center', fixed: 'right', width: 120, }, { prop: 'action', label: '操作', align: 'center', fixed: 'right', width: 120, },
]); ]);
const isShowDialog = ref(false); const isShowDialog = ref(false);
const detailLoading = ref(false); const detailLoading = ref(false);
const formatAmount = (value) => { const formatAmount = (value) => {
if (value === null || value === undefined || value === '') return '-'; if (value === null || value === undefined || value === '') return '-';
const num = Number(value); const num = Number(value);
if (isNaN(num) || num === 0) return '-'; if (isNaN(num) || num === 0) return '-';
return num.toFixed(2); return num.toFixed(2);
}; };
const formatActualAmount = (row) => { const formatActualAmount = (row) => {
if (row.isRefundOrder) { if (row.isRefundOrder) {
if (row.relatedOrderActualAmount !== null && row.relatedOrderActualAmount !== undefined) { if (row.relatedOrderActualAmount !== null && row.relatedOrderActualAmount !== undefined) {
const num = Number(row.relatedOrderActualAmount); const num = Number(row.relatedOrderActualAmount);
const amountStr = isNaN(num) || num === 0 ? '-' : num.toFixed(2); const amountStr = isNaN(num) || num === 0 ? '-' : num.toFixed(2);
return amountStr; return amountStr;
} }
return '-'; return '-';
} else { } else {
return formatAmount(row.actualAmount); return formatAmount(row.actualAmount);
} }
};
const normalizeNames = (value) => {
if (Array.isArray(value)) return value.filter(Boolean).join('、');
if (typeof value === 'string') return value;
return '';
}; };
// ChargeMode 枚举值:0=免费,1=定价(赛事),2=单价(赛事),3=单价(项目) // ChargeMode 枚举值:0=免费,1=定价(赛事),2=单价(赛事),3=单价(项目)
// 只有单价(项目)时才显示费用列 // 只有单价(项目)时才显示费用列
const shouldShowFee = computed(() => { const shouldShowFee = computed(() => {
return formData.value?.chargeMode === 3; return formData.value?.chargeMode === 3;
}); });
const detailTableData = computed(() => { const detailTableData = computed(() => {
const items = formData.value?.items ?? []; const items = formData.value?.items ?? [];
const persons = formData.value?.persons ?? []; const persons = formData.value?.persons ?? [];
return items.map((item) => { return items.map((item) => {
// 方案1: 使用 registrationItemId 进行精确匹配(强一致) let personNamesByItem = persons
let personNamesByItem = persons .filter((p) => String(p?.registrationItemId) === String(item?.id))
.filter((p) => String(p?.registrationItemId) === String(item?.id)) .map((p) => p?.name)
.map((p) => p?.name) .filter(Boolean);
.filter(Boolean);
if (personNamesByItem.length === 0) {
// 方案2: 使用项目ID匹配 personNamesByItem = persons
if (personNamesByItem.length === 0) { .filter((p) => String(p?.projectId) === String(item?.projectId))
personNamesByItem = persons .map((p) => p?.name)
.filter((p) => String(p?.projectId) === String(item?.projectId)) .filter(Boolean);
.map((p) => p?.name) }
.filter(Boolean);
} if (personNamesByItem.length === 0) {
personNamesByItem = [...new Set(
// 方案3: 最后保底,用项目名和组名去重匹配 persons
if (personNamesByItem.length === 0) { .filter((p) => p?.groupName === item?.groupName)
personNamesByItem = [...new Set( .map((p) => p?.name)
persons .filter(Boolean)
.filter((p) => p?.groupName === item?.groupName) )];
.map((p) => p?.name) }
.filter(Boolean)
)]; const participantNames = personNamesByItem.length > 0
} ? personNamesByItem.join('、')
: '-';
const participantNames = personNamesByItem.length > 0 const fee = item?.fee ?? item?.subtotal ?? ((Number(item?.unitPrice) || 0) * (Number(item?.personCount) || 0));
? personNamesByItem.join('、') return {
: '-'; projectName: item?.projectName ?? '-',
const fee = item?.fee ?? item?.subtotal ?? ((Number(item?.unitPrice) || 0) * (Number(item?.personCount) || 0)); groupName: item?.groupName ?? item?.projectTypeName ?? '-',
return { participantNames,
projectName: item?.projectName ?? '-', fee,
groupName: item?.groupName ?? item?.projectTypeName ?? '-', };
participantNames, });
fee,
};
});
}); });
const onOpenMenu = async(id) => { const onOpenMenu = async (id) => {
if (!id) return; if (!id) return;
isShowDialog.value = true; isShowDialog.value = true;
detailLoading.value = true; detailLoading.value = true;
const { succeeded, data } = await orderDetailGet({ id }); const { succeeded, data } = await orderDetailGet({ id });
if (!succeeded) { if (!succeeded) {
ElMessage.error('订单详情加载失败'); ElMessage.error('订单详情加载失败');
formData.value.items = []; formData.value.items = [];
formData.value.persons = []; formData.value.persons = [];
detailLoading.value = false; detailLoading.value = false;
return; return;
} }
formData.value = { formData.value = {
...formData.value, ...formData.value,
...(data ?? {}), ...(data ?? {}),
items: data?.items ?? [], items: data?.items ?? [],
persons: data?.persons ?? [], persons: data?.persons ?? [],
}; };
detailLoading.value = false; detailLoading.value = false;
isShowDialog.value = true; isShowDialog.value = true;
}; };
const handleExport = async () => { const handleExport = async () => {
try { try {
exportLoading.value = true; exportLoading.value = true;
const searchParams = tableRef.value?.searchParam || {}; const searchParams = tableRef.value?.searchParam || {};
const response = await orderExportGet(searchParams); const response = await orderExportGet(searchParams);
const blob = new Blob([response], { const blob = new Blob([response], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}); });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = `订单列表_${new Date().getTime()}.xlsx`; link.download = `订单列表_${new Date().getTime()}.xlsx`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
ElMessage.success('导出成功'); ElMessage.success('导出成功');
} catch (error) { } catch (error) {
console.error('导出失败:', error); console.error('导出失败:', error);
ElMessage.error('导出失败,请稍后重试'); ElMessage.error('导出失败,请稍后重试');
} finally { } finally {
exportLoading.value = false; exportLoading.value = false;
} }
}; };
onMounted(() => {
fetchStats();
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.registration-stats-container {
margin-bottom: 16px;
}
.stats-card {
background-color: #fff;
border-radius: 8px;
padding: 24px 32px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.stats-row {
display: flex;
align-items: center;
justify-content: space-around;
gap: 16px;
}
.stat-item {
flex: 1;
text-align: center;
min-width: 0;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #409eff;
}
.stat-unit {
font-size: 16px;
margin-left: 4px;
}
.stat-divider {
width: 1px;
height: 60px;
background-color: #ebeef5;
}
.dialog-footer { .dialog-footer {
margin-top: 80px; margin-top: 80px;
} }
/* 时长组合框样式 */ /* 响应式设计 - 移动端垂直排列 */
.duration-row { @media (max-width: 768px) {
display: flex; .stats-card {
align-items: center; padding: 16px;
gap: 8px; }
}
.duration-row .el-input { .stats-row {
width: 120px; flex-direction: column;
} gap: 24px;
}
.duration-separator { .stat-divider {
color: #606266; width: 60%;
font-size: 14px; height: 1px;
} }
.duration-label { .stat-value {
color: #909399; font-size: 24px;
font-size: 14px; }
} }
</style> </style>