Files
medical-mall/pages/mall/admin/order/order-statistics/index.uvue

614 lines
14 KiB
Plaintext
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>
<AdminLayout :currentPage="currentPage">
<view class="order-statistic-page">
<!-- 时间选择卡片 -->
<view class="filter-card">
<view class="filter-item">
<text class="filter-label">时间选择:</text>
<view class="date-picker-mock">
<image class="calendar-icon" src="/static/icons/calendar.png" mode="aspectFit" />
<text class="date-range">2026/01/04 - 2026/02/02</text>
</view>
</view>
</view>
<!-- 数据汇总卡片 -->
<view class="stat-cards-row">
<!-- 订单量 -->
<view class="stat-card">
<view class="icon-wrap blue-bg">
<view class="custom-icon icon-order"></view>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.order_count ?? 0 }}</text>
<text class="stat-desc">订单量</text>
</view>
</view>
<!-- 订单销售额 -->
<view class="stat-card">
<view class="icon-wrap orange-bg">
<view class="custom-icon icon-money"></view>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.total_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">订单销售额</text>
</view>
</view>
<!-- 退款订单数 -->
<view class="stat-card">
<view class="icon-wrap green-bg">
<view class="custom-icon icon-refund"></view>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.refund_count ?? 0 }}</text>
<text class="stat-desc">退款订单数</text>
</view>
</view>
<!-- 退款金额 -->
<view class="stat-card last-card">
<view class="icon-wrap pink-bg">
<view class="custom-icon icon-refund-money"></view>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.refund_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">退款金额</text>
</view>
</view>
</view>
<!-- 营业趋势图表 -->
<view class="chart-card">
<view class="card-header">
<text class="card-title">营业趋势</text>
</view>
<view class="chart-container">
<EChartsView :option="trendOption" class="trend-chart" />
</view>
</view>
<!-- 底部双图表区域 -->
<view class="bottom-charts-row">
<!-- 订单来源分析 -->
<view class="bottom-chart-card">
<view class="card-header-row">
<text class="card-title">订单来源分析</text>
<view class="style-toggle">
<text class="toggle-text">切换样式</text>
</view>
</view>
<view class="pie-chart-container">
<EChartsView :option="sourceOption" class="source-chart" />
</view>
</view>
<!-- 订单类型分析 -->
<view class="bottom-chart-card">
<view class="card-header-row">
<text class="card-title">订单类型分析</text>
<view class="style-toggle">
<text class="toggle-text">切换样式</text>
</view>
</view>
<view class="type-table-container">
<view class="table-header">
<text class="th-text col-id">序号</text>
<text class="th-text col-name">来源</text>
<text class="th-text col-money">金额</text>
<text class="th-text col-rate">占比率</text>
</view>
<view class="table-body">
<view v-for="(item, index) in orderTypeData" :key="index" class="table-row">
<text class="td-text col-id">{{ index + 1 }}</text>
<text class="td-text col-name">{{ item.name }}</text>
<text class="td-text col-money">{{ item.amount }}</text>
<view class="col-rate rate-box">
<view class="progress-wrap">
<view class="progress-bar" :style="{ width: item.rate + '%', backgroundColor: '#1890ff' }"></view>
</view>
<text class="rate-val">{{ item.rate }}%</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchOrderStats, fetchOrderTrend, fetchOrderSourceStats } from '@/services/orderService.uts'
const currentPage = ref<string>('order_statistic')
const title = ref<string>('订单统计')
const trendOption = ref<any>({})
const sourceOption = ref<any>({})
const orderStats = ref<any>(null)
const orderTypeData = ref([
{ name: '普通订单', amount: '0.00', rate: '0.00' }
])
onMounted(() => {
loadAllData()
})
async function loadAllData() {
const endTime = new Date().toISOString()
const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
try {
// 1. 加载汇总数据
orderStats.value = await fetchOrderStats(startTime, endTime)
// 2. 加载趋势数据
const trendData = await fetchOrderTrend(startTime, endTime)
initTrendChart(trendData)
// 3. 加载来源数据
const sourceData = await fetchOrderSourceStats(startTime, endTime)
initSourceChart(sourceData)
} catch (e) {
uni.showToast({ title: '加载统计数据失败', icon: 'none' })
}
}
/**
* 转换 UTS 对象为纯 JS 对象,确保 ECharts 能正确解析
*/
function toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item: any) : any => toPlainObject(item))
}
const plain: any = {}
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object') {
plain[key] = toPlainObject(value)
} else {
plain[key] = value
}
}
return plain
}
function initSourceChart(data: any[]) {
const chartData = data.map(item => ({
name: item.source === 'unknown' ? '全渠道' : item.source,
value: item.order_count
}))
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
right: '10%',
top: 'center',
itemWidth: 10,
itemHeight: 10,
icon: 'circle',
textStyle: { color: '#8c8c8c' }
},
color: ['#1890ff', '#52c41a', '#597ef7', '#ffc53d', '#ff7875'],
series: [
{
name: '订单来源',
type: 'pie',
radius: ['45%', '70%'],
center: ['40%', '50%'],
avoidLabelOverlap: false,
label: {
show: true,
position: 'outside',
formatter: '{b}'
},
labelLine: {
show: true
},
data: chartData
}
]
}
sourceOption.value = toPlainObject(option)
}
function initTrendChart(data: any[]) {
const dates = data.map(item => item.date_group.substring(5))
const orderAmounts = data.map(item => item.total_amount)
const orderCounts = data.map(item => item.order_count)
const refundAmounts = data.map(item => item.refund_amount)
const refundCounts = data.map(item => item.refund_count)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: { color: '#ccc', width: 1 }
}
},
legend: {
data: ['订单金额', '订单量', '退款金额', '退款订单量'],
top: 10,
right: 'center',
icon: 'circle',
textStyle: { color: '#8c8c8c' }
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '60px',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', rotate: 45 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { type: 'dashed', color: '#f0f0f0' } },
axisLabel: { color: '#8c8c8c' }
},
series: [
{
name: '订单金额',
type: 'line',
smooth: false,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#5b8ff9' },
lineStyle: { width: 2 },
data: orderAmounts
},
{
name: '订单量',
type: 'line',
itemStyle: { color: '#5ad8a6' },
data: orderCounts
},
{
name: '退款金额',
type: 'line',
itemStyle: { color: '#ff9d4d' },
data: refundAmounts
},
{
name: '退款订单量',
type: 'line',
itemStyle: { color: '#9270ca' },
data: refundCounts
}
]
}
trendOption.value = toPlainObject(option)
}
</script>
<style scoped lang="scss">
.order-statistic-page {
padding: 16px;
background-color: #f0f2f5;
min-height: 100%;
}
.filter-card {
background-color: #fff;
padding: 24px;
border-radius: 4px;
margin-bottom: 20px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
}
.filter-label {
font-size: 14px;
color: #333;
margin-right: 12px;
}
.date-picker-mock {
display: flex;
flex-direction: row;
align-items: center;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 4px 12px;
width: 240px;
}
.calendar-icon {
width: 16px;
height: 16px;
margin-right: 8px;
opacity: 0.45;
}
.date-range {
font-size: 14px;
color: #595959;
}
.stat-cards-row {
display: flex;
flex-direction: row;
gap: 16px;
margin-bottom: 20px;
}
.chart-card {
background-color: #fff;
border-radius: 4px;
padding: 24px;
}
.card-header {
margin-bottom: 20px;
}
.card-title {
font-size: 16px;
font-weight: bold;
color: #1a1a1a;
}
.chart-container {
width: 100%;
height: 400px;
}
.trend-chart {
width: 100%;
height: 100%;
}
.bottom-charts-row {
display: flex;
flex-direction: row;
gap: 16px;
margin-top: 20px;
}
.bottom-chart-card {
flex: 1;
background-color: #fff;
border-radius: 4px;
padding: 24px;
}
.card-header-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.style-toggle {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 2px 8px;
}
.toggle-text {
font-size: 12px;
color: #595959;
}
.pie-chart-container {
width: 100%;
height: 320px;
}
.source-chart {
width: 100%;
height: 100%;
}
.type-table-container {
width: 100%;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #e6f7ff;
padding: 12px 0;
border-radius: 4px 4px 0 0;
}
.th-text {
font-size: 14px;
color: #595959;
}
/* 统一列宽与对齐方式 */
.col-id { width: 80px; text-align: center; }
.col-name { flex: 1; text-align: left; padding-left: 40px; }
.col-money { width: 180px; text-align: left; }
.col-rate { width: 240px; text-align: left; }
.table-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.td-text {
font-size: 14px;
color: #262626;
}
.rate-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start; /* 改为左对齐,与表头对齐样式一致 */
}
.progress-wrap {
width: 120px;
height: 6px;
background-color: #f5f5f5;
border-radius: 3px;
margin-right: 12px;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 3px;
}
.rate-val {
font-size: 13px;
color: #595959;
width: 50px;
text-align: right;
}
.stat-card {
flex: 1;
background-color: #fff;
border-radius: 4px;
padding: 24px;
display: flex;
flex-direction: row;
align-items: center;
}
.icon-wrap {
width: 54px;
height: 54px;
border-radius: 27px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
}
/* 颜色背景 - 1:1 匹配截图 */
.blue-bg {
background-color: #e6f7ff;
border: 2px solid #bae7ff;
}
.orange-bg {
background-color: #fff7e6;
border: 2px solid #ffe58f;
}
.green-bg {
background-color: #f6ffed;
border: 2px solid #b7eb8f;
}
.pink-bg {
background-color: #fff0f6;
border: 2px solid #ffadd2;
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 28px;
font-weight: 500;
color: #1a1a1a;
line-height: 1.2;
}
.stat-desc {
font-size: 13px;
color: #8c8c8c;
margin-top: 4px;
}
/* 自定义图标 1:1 形状模拟 - 内部使用伪元素或形状模拟截图形状 */
.custom-icon {
width: 24px;
height: 24px;
position: relative;
}
.icon-order {
background-color: #1890ff;
border-radius: 4px;
&::before {
content: '';
position: absolute;
top: 6px; left: 4px; right: 4px; height: 2px;
background: #fff;
}
}
.icon-money {
background-color: #faad14;
border-radius: 50%;
&::after {
content: '¥';
color: #fff;
font-size: 12px;
font-weight: bold;
display: flex; justify-content: center; align-items: center; height: 100%;
}
}
.icon-refund {
background-color: #52c41a;
border-radius: 4px;
&::before {
content: '↺';
color: #fff;
font-size: 16px;
display: flex; justify-content: center; align-items: center; height: 100%;
}
}
.icon-refund-money {
background-color: #eb2f96;
border-radius: 50%;
&::after {
content: '';
color: #fff;
font-size: 18px;
display: flex; justify-content: center; align-items: center; height: 100%;
}
}
</style>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>