614 lines
14 KiB
Plaintext
614 lines
14 KiB
Plaintext
<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>
|
||
|