数据分析ui补充完善,接入数据库

This commit is contained in:
comlibmb
2026-01-29 17:30:39 +08:00
parent 3e89513e8b
commit b53d2376ff
13 changed files with 1161 additions and 889 deletions

View File

@@ -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;