2026-06-30 15:32:17 +08:00

415 lines
13 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>
<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="wrappedOrderPageGet" :columns="columns" :pagination="true"
:tool-button="true">
<template #tools>
<el-button v-auth="'order:export'" type="primary" :loading="exportLoading" @click="handleExport">
<template #icon>
<el-icon><Download /></el-icon>
</template>
导出
</el-button>
</template>
<template #index="scope">
{{ scope.$index + 1 }}
</template>
<template #paymentMethod="scope">
{{ scope.row.paymentMethod || '-' }}
</template>
<template #orderType="scope">
<el-tag :type="scope.row.orderType == '收入'? 'success' : 'error'">
{{ (scope.row.orderType == '收入' ? '收款' : scope.row.orderType) || '-' }}
</el-tag>
</template>
<template #registrationMode="scope">
{{ scope.row.registrationMode || '-' }}
</template>
<template #orderStatus="scope">
{{ scope.row.orderStatusText || '-' }}
</template>
<template #paymentProofUrl="scope">
<PreviewImage v-if="scope.row.paymentProofUrl" :src="scope.row.paymentProofUrl" width="40px"
height="40px" fit="cover" radius="4px" :show-zoom-icon="true" />
<span v-else>-</span>
</template>
<template #originalAmount="scope">
{{ formatAmount(scope.row.originalAmount) }}
</template>
<template #discountAmount="scope">
{{ formatAmount(scope.row.discountAmount) }}
</template>
<template #refundAmount="scope">
{{ formatAmount(scope.row.refundAmount) }}
</template>
<template #actualAmount="scope">
{{ formatActualAmount(scope.row) }}
</template>
<template #action="scope">
<el-button size="small" text type="primary" @click="onOpenMenu(scope.row.id)">
查看详情
</el-button>
</template>
</ProTable>
<!-- 弹窗 -->
<el-dialog title="订单详情" v-model="isShowDialog" width="1100px">
<el-skeleton :rows="6" animated v-if="detailLoading" />
<el-empty v-else-if="!detailTableData.length" description="暂无订单明细数据" />
<el-table v-else :data="detailTableData" border>
<el-table-column prop="projectName" label="项目" align="center" />
<el-table-column prop="groupName" label="组别" align="center" />
<el-table-column prop="participantNames" label="参赛队员" align="center" show-overflow-tooltip />
<el-table-column v-if="shouldShowFee" prop="fee" label="费用(元)" align="center" width="120">
<template #default="scope">
{{ formatAmount(scope.row.fee) }}
</template>
</el-table-column>
</el-table>
<template #footer>
<span class="dialog-footer">
<el-button @click="isShowDialog = false">取消</el-button>
<el-button type="primary" @click="isShowDialog = false">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { Download } from '@element-plus/icons-vue';
import ProTable from '/@/components/ProTable/index.vue';
import PreviewImage from '/@/components/PreviewImage/index.vue';
import { orderPageGet, orderDetailGet, orderExportGet, orderStatsGet } from '/@/api/match/order';
const tableRef = ref();
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 (searchParams) => {
try {
statsLoading.value = true;
const params = searchParams || tableRef.value?.searchParam || {};
const { succeeded, data } = await orderStatsGet(params);
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;
}
};
// 包装 orderPageGet,在数据加载完成后调用 fetchStats
const wrappedOrderPageGet = async (params) => {
const result = await orderPageGet(params);
// 等待结果返回后调用 fetchStats
fetchStats(params);
return result;
};
const formData = ref({
orderNo: '',
eventId: 0,
eventName: '',
registrationMode: 0,
teamName: '',
contactName: '',
contactPhone: '',
totalAmount: 0,
discountAmount: 0,
payableAmount: 0,
status: 0,
createdTime: '',
paidTime: '',
items: [],
persons: []
})
const columns = ref([
{ prop: 'orderNo', label: '订单编号', align: 'center', search: { el: 'input', }, fixed: 'left', width: 150 },
{ width: 180, prop: 'unitName', label: '单位名称', align: 'center', },
{ 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: 'relatedOrderNo', label: '关联订单' },
{ 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: 'registrationMode', label: '报名方式', search: { el: 'select', }, enum: [{ label: '全部', value: 0 }, { label: '个人', value: 1 }, { label: '团队', value: 2 },], },
{ width: 120, prop: 'contactName', label: '报名用户' },
{ width: 120, prop: 'contactPhone', label: '报名账号', search: { el: 'input' } },
{ width: 100, prop: 'originalAmount', label: '原金额' },
{ width: 120, prop: 'actualAmount', label: '实付金额' },
{ width: 100, prop: 'discountAmount', label: '优惠金额' },
{ 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: 160, prop: 'createdTime', label: '生成时间', search: { el: 'date-picker', props: { type: 'daterange' } } },
{ prop: 'action', label: '操作', align: 'center', fixed: 'right', width: 120, },
]);
const isShowDialog = ref(false);
const detailLoading = ref(false);
const formatAmount = (value) => {
if (value === null || value === undefined || value === '') return '-';
const num = Number(value);
if (isNaN(num) || num === 0) return '-';
return num.toFixed(2);
};
const formatActualAmount = (row) => {
if (row.isRefundOrder) {
if (row.relatedOrderActualAmount !== null && row.relatedOrderActualAmount !== undefined) {
const num = Number(row.relatedOrderActualAmount);
const amountStr = isNaN(num) || num === 0 ? '-' : num.toFixed(2);
return amountStr;
}
return '-';
} else {
return formatAmount(row.actualAmount);
}
};
// ChargeMode 枚举值:0=免费,1=定价(赛事),2=单价(赛事),3=单价(项目)
// 只有单价(项目)时才显示费用列
const shouldShowFee = computed(() => {
return formData.value?.chargeMode === 3;
});
const detailTableData = computed(() => {
const items = formData.value?.items ?? [];
const persons = formData.value?.persons ?? [];
return items.map((item) => {
let personNamesByItem = persons
.filter((p) => String(p?.registrationItemId) === String(item?.id))
.map((p) => p?.name)
.filter(Boolean);
if (personNamesByItem.length === 0) {
personNamesByItem = persons
.filter((p) => String(p?.projectId) === String(item?.projectId))
.map((p) => p?.name)
.filter(Boolean);
}
if (personNamesByItem.length === 0) {
personNamesByItem = [...new Set(
persons
.filter((p) => p?.groupName === item?.groupName)
.map((p) => p?.name)
.filter(Boolean)
)];
}
const participantNames = personNamesByItem.length > 0
? personNamesByItem.join('、')
: '-';
const fee = item?.fee ?? item?.subtotal ?? ((Number(item?.unitPrice) || 0) * (Number(item?.personCount) || 0));
return {
projectName: item?.projectName ?? '-',
groupName: item?.groupName ?? item?.projectTypeName ?? '-',
participantNames,
fee,
};
});
});
const onOpenMenu = async (id) => {
if (!id) return;
isShowDialog.value = true;
detailLoading.value = true;
const { succeeded, data } = await orderDetailGet({ id });
if (!succeeded) {
ElMessage.error('订单详情加载失败');
formData.value.items = [];
formData.value.persons = [];
detailLoading.value = false;
return;
}
formData.value = {
...formData.value,
...(data ?? {}),
items: data?.items ?? [],
persons: data?.persons ?? [],
};
detailLoading.value = false;
isShowDialog.value = true;
};
const handleExport = async () => {
try {
exportLoading.value = true;
const searchParams = tableRef.value?.searchParam || {};
const response = await orderExportGet(searchParams);
const blob = new Blob([response], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `订单列表_${new Date().getTime()}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('导出成功');
} catch (error) {
console.error('导出失败:', error);
ElMessage.error('导出失败,请稍后重试');
} finally {
exportLoading.value = false;
}
};
</script>
<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 {
margin-top: 80px;
}
/* 响应式设计 - 移动端垂直排列 */
@media (max-width: 768px) {
.stats-card {
padding: 16px;
}
.stats-row {
flex-direction: column;
gap: 24px;
}
.stat-divider {
width: 60%;
height: 1px;
}
.stat-value {
font-size: 24px;
}
}
</style>