Files
medical-mall/pages/mall/admin/order/order-statistics/index.uvue
2026-03-20 15:24:59 +08:00

591 lines
15 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>
<view class="admin-page">
<view class="admin-sections">
<!-- 时间选择卡片 -->
<view class="admin-card filter-section">
<view class="filter-item">
<text class="filter-label">时间选择:</text>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
</view>
</view>
<!-- 数据汇总卡片 (响应式 kpi-grid 4列) -->
<view class="kpi-grid">
<!-- 订单量 -->
<view class="admin-card stat-card">
<view class="icon-wrap blue-bg">
<text class="icon-char">≡</text>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.order_count ?? 0 }}</text>
<text class="stat-desc">订单量</text>
</view>
</view>
<!-- 订单销售额 -->
<view class="admin-card stat-card">
<view class="icon-wrap orange-bg">
<text class="icon-char">¥</text>
</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="admin-card stat-card">
<view class="icon-wrap green-bg">
<text class="icon-char">↩</text>
</view>
<view class="stat-info">
<text class="stat-value">{{ orderStats?.refund_count ?? 0 }}</text>
<text class="stat-desc">退款订单数</text>
</view>
</view>
<!-- 退款金额 -->
<view class="admin-card stat-card">
<view class="icon-wrap pink-bg">
<text class="icon-char">↺</text>
</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="admin-card chart-card">
<view class="chart-card-header">
<text class="card-title">营业趋势</text>
<text class="download-icon-text">↓</text>
</view>
<EChartsView :option="trendOption" class="trend-chart" />
</view>
<!-- 底部双图表区域 -->
<view class="bottom-charts-grid">
<!-- 订单来源分析 -->
<view class="admin-card 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="admin-card 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>
</view>
</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 AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchOrderStats, fetchOrderTrend, fetchOrderSourceStats, fetchOrderTypeStats } from '@/services/orderService.uts'
import AkReq from '@/uni_modules/ak-req/ak-req.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 startDate = ref<string>('')
const endDate = ref<string>('')
const orderTypeData = ref([
{ name: '普通订单', amount: '0.00', rate: '0.00' }
])
onMounted(() => {
if (AkReq.getToken() == null || AkReq.getToken() === '') {
return
}
// 默认最近 30 天
const end = new Date()
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
startDate.value = start.toISOString().substring(0, 10)
endDate.value = end.toISOString().substring(0, 10)
loadAllData()
})
function onApplyRange(payload: any) {
startDate.value = payload?.start ?? ''
endDate.value = payload?.end ?? ''
loadAllData()
}
function onClearRange() {
startDate.value = ''
endDate.value = ''
loadAllData()
}
async function loadAllData() {
const st = startDate.value ? (startDate.value + ' 00:00:00') : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const et = endDate.value ? (endDate.value + ' 23:59:59') : new Date().toISOString()
// 各图表独立 try-catch单个接口失败不影响其他图表展示
// 1. 加载汇总数据
try {
orderStats.value = await fetchOrderStats(st, et)
} catch (e) {
console.error('[order-stats] 汇总数据加载失败', e)
}
// 2. 加载趋势数据
try {
const trendData = await fetchOrderTrend(st, et)
initTrendChart(trendData)
} catch (e) {
console.error('[order-stats] 趋势图加载失败', e)
}
// 3. 加载来源数据
try {
const sourceData = await fetchOrderSourceStats(st, et)
initSourceChart(sourceData)
} catch (e) {
console.error('[order-stats] 来源图加载失败', e)
}
// 4. 加载订单类型数据rpc_admin_order_type_stats 目前返回 400单独隔离
try {
const typeData = await fetchOrderTypeStats(st, et)
orderTypeData.value = typeData
} catch (e) {
console.error('[order-stats] 订单类型数据加载失败', e)
}
}
/**
* 转换 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">
/* ===== 筛选区 ===== */
.filter-section {
/* admin-card 提供 background / padding / border-radius */
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.filter-label {
font-size: 14px;
color: #333;
white-space: nowrap;
}
/* ===== KPI 统计卡片 ===== */
/* .kpi-grid 由 admin-responsive.css 全局提供响应式 grid>=1200px 4列 / >=768px 2列 / <768px 1列 */
.stat-card {
/* admin-card 提供背景/内边距,这里只定义内部 flex 布局 */
display: flex;
flex-direction: row;
align-items: center;
min-width: 0;
}
.icon-wrap {
width: 54px;
height: 54px;
border-radius: 27px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
flex-shrink: 0;
}
.blue-bg { background-color: #1890ff; }
.orange-bg { background-color: #fa8c16; }
.green-bg { background-color: #52c41a; }
.pink-bg { background-color: #eb2f96; }
.stat-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-weight: 500;
color: #1a1a1a;
line-height: 1.2;
}
.stat-desc {
font-size: 13px;
color: #8c8c8c;
margin-top: 4px;
}
/* 自定义图标 */
.icon-char {
font-size: 20px;
color: #fff;
font-weight: bold;
line-height: 1;
}
/* ===== 趋势图表 ===== */
.chart-card {
/* admin-card 提供背景/内边距 */
}
.chart-card-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.download-icon-text {
font-size: 18px;
color: #bfbfbf;
cursor: pointer;
padding: 4px 6px;
}
.trend-chart {
width: 100%;
height: 400px;
}
/* ===== 底部双图表 ===== */
.bottom-charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--admin-section-gap, 20px);
}
@media (max-width: 900px) {
.bottom-charts-grid {
grid-template-columns: 1fr;
}
}
.bottom-chart-card {
/* admin-card 提供背景/内边距 */
min-width: 0;
}
.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;
cursor: pointer;
}
.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;
}
.table-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child { border-bottom: none; }
}
.td-text {
font-size: 14px;
color: #262626;
}
/* 列宽 */
.col-id { width: 60px; text-align: center; flex-shrink: 0; }
.col-name { flex: 1; text-align: left; padding-left: 24px; min-width: 0; }
.col-money { width: 120px; text-align: left; flex-shrink: 0; }
.col-rate { width: 200px; text-align: left; flex-shrink: 0; }
.rate-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.progress-wrap {
width: 100px;
height: 6px;
background-color: #f5f5f5;
border-radius: 3px;
margin-right: 10px;
overflow: hidden;
flex-shrink: 0;
}
.progress-bar {
height: 100%;
border-radius: 3px;
}
.rate-val {
font-size: 13px;
color: #595959;
min-width: 44px;
text-align: right;
}
</style>
<style scoped lang="scss">
@import '@/uni.scss';
.page { }
.header {
padding: var(--admin-card-padding);
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>