Files
medical-mall/pages/mall/analytics/delivery-analysis.uvue
2026-01-30 16:11:23 +08:00

849 lines
20 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="page" @click="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'配送效率分析'"
:lastUpdateTime="lastUpdateTime"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshData"
@search="handleSearch"
@notification="handleNotification"
@fullscreen="handleFullscreen"
@mobile="handleMobile"
@dropdown="handleDropdown"
@settings="handleSettings"
/>
<view class="page-layout">
<!-- 侧边栏菜单组件 -->
<AnalyticsSidebarMenu
:visible="showSidebarMenu"
:currentPath="currentPath"
@visible-change="handleSidebarUpdate"
/>
<!-- 主内容区域 -->
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
</view>
<!-- KPI 指标卡片 -->
<view class="kpi-grid">
<view class="kpi-card">
<text class="kpi-label">配送时效</text>
<text class="kpi-value">{{ deliveryData.avg_delivery_time }}分钟</text>
<text class="kpi-meta">较上期:{{ formatPct(deliveryData.time_growth) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">配送费用</text>
<text class="kpi-value">¥{{ formatMoney(deliveryData.total_fee) }}</text>
<text class="kpi-meta">平均:¥{{ formatMoney(deliveryData.avg_fee) }}</text>
</view>
<view class="kpi-card">
<text class="kpi-label">配送员效率</text>
<text class="kpi-value">{{ formatInt(deliveryData.avg_orders_per_driver) }}</text>
<text class="kpi-meta">单/人/天</text>
</view>
<view class="kpi-card">
<text class="kpi-label">客户满意度</text>
<text class="kpi-value">{{ formatScore(deliveryData.satisfaction_rate) }}分</text>
<text class="kpi-meta">较上期:{{ formatPct(deliveryData.satisfaction_growth) }}</text>
</view>
</view>
<!-- 配送时效 & 满意度(合并图表) -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">配送时效 & 满意度</text>
<text class="card-desc">{{ selectedPeriodText }} · 平均配送时间 / 满意度趋势</text>
</view>
<EChartsView class="chart-box" :option="timeChartOption" />
</view>
<!-- 配送费用分析 -->
<view class="card">
<view class="card-head">
<text class="card-title">配送费用分析</text>
<text class="card-desc">费用分布情况</text>
</view>
<EChartsView class="chart-box" :option="feeChartOption" />
</view>
<!-- 配送员效率排行 -->
<view class="card">
<view class="card-head">
<text class="card-title">配送员效率排行 TOP 10</text>
<text class="card-desc">按订单数排序</text>
</view>
<view class="rank-scroll" @mouseenter="onRankHover(true)" @mouseleave="onRankHover(false)">
<scroll-view class="rank-scroll-inner" :scroll-y="true" :show-scrollbar="true">
<view class="rank-list">
<view v-for="d in topDrivers" :key="d.id" class="rank-item">
<text class="rank-no">{{ d.rank }}</text>
<text class="rank-name">{{ d.name }}</text>
<view class="rank-right">
<text class="rank-val">{{ d.orders }} 单</text>
<text class="chip" :class="d.rating >= 4.5 ? 'pos' : 'neg'">
⭐{{ d.rating }}
</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchDeliveryAnalysis } from '@/services/analytics/deliveryAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type DeliveryData = {
avg_delivery_time: number
time_growth: number
total_fee: number
avg_fee: number
avg_orders_per_driver: number
satisfaction_rate: number
satisfaction_growth: number
}
type DriverRank = { id: string; rank: number; name: string; orders: number; rating: number }
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/delivery-analysis',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
],
deliveryData: {
avg_delivery_time: 0,
time_growth: 0,
total_fee: 0,
avg_fee: 0,
avg_orders_per_driver: 0,
satisfaction_rate: 0,
satisfaction_growth: 0
} as DeliveryData,
topDrivers: [] as Array<DriverRank>,
timeChartOption: {} as any,
feeChartOption: {} as any,
isRankHover: false
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
onLoad() {
this.updateTime()
this.loadDeliveryData()
},
methods: {
async loadDeliveryData() {
try {
const data = await fetchDeliveryAnalysis(this.selectedPeriod)
const trendList = data.trendList
const topList = data.topList
// 3) 转成页面内部 trendRows 格式
const trendRows: Array<UTSJSONObject> = []
let totalFee = 0
let totalOrders = 0
for (let i = 0; i < trendList.length; i++) {
const r = trendList[i]
const dayStr = r.getString('day') ?? ''
const orders = r.getNumber('completed_orders') ?? 0
const avgMin = r.getNumber('avg_delivery_minutes') ?? 0
const avgFee = r.getNumber('avg_fee') ?? 0
const tFee = r.getNumber('total_fee') ?? 0
totalOrders += orders
totalFee += tFee
const obj = new UTSJSONObject()
obj.set('day', dayStr)
obj.set('avg_delivery_time', avgMin)
obj.set('avg_fee', avgFee)
// 满意度趋势:目前来源为配送员表 rating_avg后续如有配送评价表可替换
obj.set('satisfaction_rate', 0)
trendRows.push(obj)
}
// 4) 满意度:用 TOP10 里的 rating_avg 做平均(简单可用;也可以后续改为全量司机或配送评价表)
let satisSum = 0
let satisCnt = 0
for (let i = 0; i < topList.length; i++) {
const r = topList[i]
const rating = r.getNumber('rating_avg')
if (rating != null) {
satisSum += rating
satisCnt += 1
}
}
const satisAvg = satisCnt > 0 ? (satisSum / satisCnt) : 0
for (let i = 0; i < trendRows.length; i++) {
trendRows[i].set('satisfaction_rate', satisAvg)
}
// 5) KPI最后一天 vs 前一天环比
const last = trendRows.length > 0 ? trendRows[trendRows.length - 1] : null
const prev = trendRows.length > 1 ? trendRows[trendRows.length - 2] : null
const lastAvgTime = last != null ? (last.getNumber('avg_delivery_time') ?? 0) : 0
const prevAvgTime = prev != null ? (prev.getNumber('avg_delivery_time') ?? 0) : 0
const timeGrowth = prevAvgTime > 0 ? ((lastAvgTime - prevAvgTime) / prevAvgTime) * 100 : 0
const lastSatis = last != null ? (last.getNumber('satisfaction_rate') ?? 0) : 0
const prevSatis = prev != null ? (prev.getNumber('satisfaction_rate') ?? 0) : 0
const satisGrowth = prevSatis > 0 ? ((lastSatis - prevSatis) / prevSatis) * 100 : 0
// 配送员效率:单/人/天(按 TOP10 近似人数 + 趋势天数)
const dayCount = Math.max(1, trendRows.length)
const driverCount = Math.max(1, topList.length)
const avgOrdersPerDriverPerDay = (totalOrders / driverCount) / dayCount
this.deliveryData = {
avg_delivery_time: Math.round(lastAvgTime),
time_growth: timeGrowth,
total_fee: totalFee,
avg_fee: totalOrders > 0 ? (totalFee / totalOrders) : 0,
avg_orders_per_driver: avgOrdersPerDriverPerDay,
satisfaction_rate: lastSatis,
satisfaction_growth: satisGrowth
} as DeliveryData
;(this as any)._trendRows = trendRows
// 6) TOP10 映射
const list: DriverRank[] = []
for (let i = 0; i < topList.length; i++) {
const r = topList[i]
list.push({
id: r.getString('driver_id') ?? String(i),
rank: i + 1,
name: r.getString('driver_name') ?? '未知',
orders: r.getNumber('orders') ?? 0,
rating: r.getNumber('rating_avg') ?? 0
} as DriverRank)
}
this.topDrivers = list
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadDeliveryData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '配送分析数据加载失败' }), icon: 'none' })
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadDeliveryData()
},
refreshData() {
this.loadDeliveryData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(2)
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
formatScore(n: number): string {
const v = isFinite(n) ? n : 0
return v.toFixed(1)
},
buildChartOptions() {
const rowsAny = (this as any)._trendRows as any
const rows = Array.isArray(rowsAny) ? rowsAny as Array<UTSJSONObject> : []
const xAxis: string[] = []
const timeSeries: number[] = []
const feeSeries: number[] = []
const satisSeries: number[] = []
for (let i = 0; i < rows.length; i++) {
const r = rows[i]
const day = r.getString('day') ?? ''
xAxis.push(day.length >= 10 ? day.substring(5, 10) : day)
timeSeries.push(r.getNumber('avg_delivery_time') ?? 0)
feeSeries.push(r.getNumber('avg_fee') ?? 0)
satisSeries.push(r.getNumber('satisfaction_rate') ?? 0)
}
this.timeChartOption = {
tooltip: { trigger: 'axis' },
legend: {
data: ['平均配送时间(分钟)', '满意度(评分)'],
top: 'bottom',
itemGap: 30,
itemWidth: 16,
itemHeight: 16
},
grid: { left: 40, right: 50, top: 30, bottom: 60 },
xAxis: {
type: 'category',
data: xAxis,
axisTick: { alignWithLabel: true }
},
yAxis: [
{
type: 'value',
name: '配送时间',
min: 0,
splitLine: { lineStyle: { color: '#e5e7eb' } }
},
{
type: 'value',
name: '满意度',
min: 0,
max: 5,
position: 'right',
splitLine: { show: false }
}
],
series: [
{
name: '平均配送时间(分钟)',
type: 'line',
smooth: true,
symbolSize: 6,
data: timeSeries,
yAxisIndex: 0
},
{
name: '满意度(评分)',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: satisSeries,
yAxisIndex: 1
}
]
}
this.feeChartOption = {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: xAxis },
yAxis: { type: 'value' },
series: [
{
name: '平均配送费(元)',
type: 'bar',
data: feeSeries
}
]
}
this.satisfactionChartOption = {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: xAxis },
yAxis: { type: 'value', min: 0, max: 5 },
series: [
{
name: '满意度(评分)',
type: 'line',
smooth: true,
data: satisSeries
}
]
}
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
}
}
}
</script>
<style>
.page {
min-height: 100vh;
background: #f6f7fb;
}
/* 页面布局:宽屏时侧边栏+内容,窄屏时全屏内容 */
.page-layout {
display: flex;
flex-direction: row !important;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 64px; /* 为固定顶部导航栏留出空间 */
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 顶部栏 */
.topbar {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.topbar-left {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.menu-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.menu-icon .icon {
font-size: 18px;
color: #111;
line-height: 1;
}
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
white-space: nowrap;
}
.icon-btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.icon-btn-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.icon-btn-icon .icon {
font-size: 16px;
line-height: 1;
}
.more-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.more-btn.active {
background: #e5e7eb;
}
.more-btn .icon {
font-size: 18px;
line-height: 1;
color: #111;
}
/* 时间维度 tabs */
.tabs {
margin-top: 12px;
display: flex;
flex-direction: row !important;
gap: 8px;
padding: 8px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
overflow-x: auto;
flex-wrap: wrap;
justify-content: center;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #111;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.tab.active {
background: #111;
color: #fff;
}
/* KPI 网格 */
.kpi-grid {
margin-top: 12px;
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
}
.kpi-card {
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
padding: 14px;
box-sizing: border-box;
flex: 1 1 calc(50% - 6px);
min-width: 260px;
}
.kpi-label {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.kpi-value {
margin-top: 8px;
font-size: 22px;
font-weight: 800;
color: #111;
}
.kpi-meta {
margin-top: 8px;
font-size: 12px;
color: rgba(0,0,0,0.55);
}
/* 卡片 */
.card {
margin-top: 12px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
padding: 14px;
box-sizing: border-box;
}
.card-full {
width: 100%;
}
.card-head {
display: flex;
flex-direction: row !important;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.card-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.chart-box {
width: 100%;
height: 360px;
}
/* 排行滚动容器:固定高度(约 5 条) */
.rank-scroll {
/* 5条左右的可视高度5*(10px上下padding + 28px内容 + 10px gap) 约 300 */
height: 320px;
border-radius: 12px;
}
.rank-scroll-inner {
height: 100%;
}
/* H5默认隐藏滚动条悬停时显示 */
.rank-scroll-inner::-webkit-scrollbar {
width: 0;
height: 0;
}
.rank-scroll:hover .rank-scroll-inner::-webkit-scrollbar {
width: 8px;
}
.rank-scroll:hover .rank-scroll-inner::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.25);
border-radius: 999px;
}
.rank-scroll:hover .rank-scroll-inner::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.06);
}
/* 排行列表 */
.rank-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-item {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.rank-item:last-child {
border-bottom: none;
}
.rank-no {
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(0,0,0,0.06);
text-align: center;
line-height: 28px;
font-size: 12px;
flex: 0 0 auto;
}
.rank-name {
flex: 1;
font-size: 13px;
color: #111;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.rank-right {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.chip {
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
}
.chip.pos {
background: rgba(34,197,94,0.12);
color: #16a34a;
}
.chip.neg {
background: rgba(239,68,68,0.12);
color: #dc2626;
}
/* 响应式 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
}
@media screen and (max-width: 960px) {
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
</style>