数据分析ui补充完善,接入数据库
This commit is contained in:
@@ -59,16 +59,16 @@
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">客户满意度</text>
|
||||
<text class="kpi-value">{{ deliveryData.satisfaction_rate }}%</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>
|
||||
<text class="card-title">配送时效 & 满意度</text>
|
||||
<text class="card-desc">{{ selectedPeriodText }} · 平均配送时间 / 满意度趋势</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="timeChartOption" />
|
||||
</view>
|
||||
@@ -88,28 +88,24 @@
|
||||
<text class="card-title">配送员效率排行 TOP 10</text>
|
||||
<text class="card-desc">按订单数排序</text>
|
||||
</view>
|
||||
<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 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>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 客户满意度分析 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">客户满意度分析</text>
|
||||
<text class="card-desc">评分分布</text>
|
||||
</view>
|
||||
<EChartsView class="chart-box" :option="satisfactionChartOption" />
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
@@ -121,6 +117,7 @@
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
@@ -170,7 +167,7 @@ export default {
|
||||
|
||||
timeChartOption: {} as any,
|
||||
feeChartOption: {} as any,
|
||||
satisfactionChartOption: {} as any
|
||||
isRankHover: false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -188,9 +185,220 @@ export default {
|
||||
|
||||
methods: {
|
||||
async loadDeliveryData() {
|
||||
// TODO: 实现配送数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
try {
|
||||
// 根据时间维度计算起始时间
|
||||
const now = new Date()
|
||||
const start = new Date(now.getTime())
|
||||
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
|
||||
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
|
||||
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
|
||||
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
|
||||
const startIso = start.toISOString()
|
||||
|
||||
await ensureSupabaseReady()
|
||||
|
||||
// 1) 查询周期内已送达任务(按 assigned_at 统计周期,口径:接单 assigned_at -> 送达 delivered_at)
|
||||
// 表结构来源:doc_mall/database/complete_mall_database.sql
|
||||
// 1) 趋势:RPC(数据库侧聚合)
|
||||
const endIso = now.toISOString()
|
||||
|
||||
// 优先走 RPC(需要在 Supabase 执行 DELIVERY_ANALYSIS_RPCS.sql 创建函数)
|
||||
let trendList: Array<UTSJSONObject> = []
|
||||
let topList: Array<UTSJSONObject> = []
|
||||
|
||||
const trendRes = await supa.rpc('rpc_delivery_efficiency_daily', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
|
||||
if (trendRes.status === 404) {
|
||||
// RPC 不存在:降级到直查表聚合(测试阶段兜底)
|
||||
const taskRes = await supa
|
||||
.from('ml_delivery_tasks')
|
||||
.select('id,driver_id,assigned_at,delivered_at,delivery_fee', {})
|
||||
.eq('status', 5)
|
||||
.gte('assigned_at', startIso)
|
||||
.order('assigned_at', { ascending: true })
|
||||
.execute()
|
||||
|
||||
if (taskRes.error != null) throw taskRes.error
|
||||
|
||||
const rowsAny = (taskRes.data != null ? taskRes.data : []) as any
|
||||
const tasks = Array.isArray(rowsAny) ? rowsAny as Array<UTSJSONObject> : []
|
||||
|
||||
const dayAgg = new Map<string, UTSJSONObject>()
|
||||
const driverAgg = new Map<string, number>()
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const t = tasks[i]
|
||||
const assignedAt = t.getString('assigned_at') ?? ''
|
||||
const deliveredAt = t.getString('delivered_at') ?? ''
|
||||
const driverId = t.getString('driver_id') ?? ''
|
||||
if (assignedAt.trim() === '' || deliveredAt.trim() === '') continue
|
||||
|
||||
const day = assignedAt.length >= 10 ? assignedAt.substring(0, 10) : assignedAt
|
||||
const a = new Date(assignedAt)
|
||||
const d = new Date(deliveredAt)
|
||||
const diffMin = Math.max(0, (d.getTime() - a.getTime()) / 60000)
|
||||
const fee = t.getNumber('delivery_fee') ?? 0
|
||||
|
||||
const old = dayAgg.get(day)
|
||||
if (old == null) {
|
||||
const obj = new UTSJSONObject()
|
||||
obj.set('day', day)
|
||||
obj.set('completed_orders', 1)
|
||||
obj.set('sum_minutes', diffMin)
|
||||
obj.set('total_fee', fee)
|
||||
dayAgg.set(day, obj)
|
||||
} else {
|
||||
old.set('completed_orders', (old.getNumber('completed_orders') ?? 0) + 1)
|
||||
old.set('sum_minutes', (old.getNumber('sum_minutes') ?? 0) + diffMin)
|
||||
old.set('total_fee', (old.getNumber('total_fee') ?? 0) + fee)
|
||||
}
|
||||
|
||||
if (driverId.trim() !== '') {
|
||||
const c = driverAgg.get(driverId) ?? 0
|
||||
driverAgg.set(driverId, c + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Array.from(dayAgg.keys()).sort()
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i]
|
||||
const agg = dayAgg.get(k) as UTSJSONObject
|
||||
const cnt = agg.getNumber('completed_orders') ?? 0
|
||||
const sumMin = agg.getNumber('sum_minutes') ?? 0
|
||||
const tFee = agg.getNumber('total_fee') ?? 0
|
||||
|
||||
const row = new UTSJSONObject()
|
||||
row.set('day', k)
|
||||
row.set('completed_orders', cnt)
|
||||
row.set('avg_delivery_minutes', cnt > 0 ? (sumMin / cnt) : 0)
|
||||
row.set('total_fee', tFee)
|
||||
row.set('avg_fee', cnt > 0 ? (tFee / cnt) : 0)
|
||||
trendList.push(row)
|
||||
}
|
||||
|
||||
// TOP:仅用 driverAgg 计算(姓名/评分未知,先兜底)
|
||||
const ids = Array.from(driverAgg.keys()).sort((a, b) => (driverAgg.get(b) ?? 0) - (driverAgg.get(a) ?? 0))
|
||||
const topN = Math.min(10, ids.length)
|
||||
for (let i = 0; i < topN; i++) {
|
||||
const id = ids[i]
|
||||
const row = new UTSJSONObject()
|
||||
row.set('driver_id', id)
|
||||
row.set('driver_name', '未知')
|
||||
row.set('orders', driverAgg.get(id) ?? 0)
|
||||
row.set('rating_avg', 0)
|
||||
topList.push(row)
|
||||
}
|
||||
} else {
|
||||
if (trendRes.error != null) throw trendRes.error
|
||||
const trendAny = (trendRes.data != null ? trendRes.data : []) as any
|
||||
trendList = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
|
||||
|
||||
const topRes = await supa.rpc('rpc_delivery_efficiency_top_drivers', {
|
||||
p_start: startIso,
|
||||
p_end: endIso,
|
||||
p_limit: 10
|
||||
})
|
||||
|
||||
if (topRes.error != null) throw topRes.error
|
||||
const topAny = (topRes.data != null ? topRes.data : []) as any
|
||||
topList = Array.isArray(topAny) ? topAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 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: '配送分析数据加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
@@ -235,11 +443,109 @@ export default {
|
||||
return `${sign}${v.toFixed(1)}%`
|
||||
},
|
||||
|
||||
formatScore(n: number): string {
|
||||
const v = isFinite(n) ? n : 0
|
||||
return v.toFixed(1)
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.timeChartOption = {}
|
||||
this.feeChartOption = {}
|
||||
this.satisfactionChartOption = {}
|
||||
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() {
|
||||
@@ -528,6 +834,36 @@ export default {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user