数据分析ui补充完善,接入数据库
This commit is contained in:
@@ -109,7 +109,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import supa, { 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'
|
||||
@@ -176,9 +176,160 @@ export default {
|
||||
|
||||
methods: {
|
||||
async loadCouponData() {
|
||||
// TODO: 实现优惠券数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
try {
|
||||
// 1) 计算时间范围
|
||||
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()
|
||||
const endIso = now.toISOString()
|
||||
|
||||
// 2) 确保 Supabase 会话就绪
|
||||
await ensureSupabaseReady()
|
||||
|
||||
// 3) 优先调用后端 RPC(推荐在 Supabase 中创建对应函数)
|
||||
// - rpc_coupon_effectiveness_overview 概览 KPI
|
||||
// - rpc_coupon_type_stats 按券类型统计
|
||||
// - rpc_coupon_channel_stats 发放渠道统计
|
||||
// - rpc_coupon_trend_daily 每日发放/使用趋势
|
||||
// - rpc_coupon_conversion_effect GMV/订单转化效果
|
||||
|
||||
let overviewRow: UTSJSONObject | null = null
|
||||
let typeList: Array<UTSJSONObject> = []
|
||||
let channelList: Array<UTSJSONObject> = []
|
||||
let trendList: Array<UTSJSONObject> = []
|
||||
let conversionList: Array<UTSJSONObject> = []
|
||||
|
||||
// 3.1 概览 KPI
|
||||
const overviewRes = await supa.rpc('rpc_coupon_effectiveness_overview', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
|
||||
if (overviewRes.status === 404) {
|
||||
// RPC 未创建:保留默认 0 值,由后续 SQL 实现补上真实逻辑
|
||||
console.warn('rpc_coupon_effectiveness_overview not found, using default zeros')
|
||||
} else if (overviewRes.error != null) {
|
||||
throw overviewRes.error
|
||||
} else {
|
||||
const anyData = overviewRes.data as any
|
||||
if (Array.isArray(anyData) && anyData.length > 0) {
|
||||
overviewRow = anyData[0] as UTSJSONObject
|
||||
}
|
||||
}
|
||||
|
||||
// 3.2 券类型统计
|
||||
const typeRes = await supa.rpc('rpc_coupon_type_stats', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
if (typeRes.status === 404) {
|
||||
console.warn('rpc_coupon_type_stats not found, type analysis will be empty')
|
||||
} else if (typeRes.error != null) {
|
||||
throw typeRes.error
|
||||
} else {
|
||||
const typeAny = typeRes.data as any
|
||||
typeList = Array.isArray(typeAny) ? typeAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 3.3 渠道统计
|
||||
const channelRes = await supa.rpc('rpc_coupon_channel_stats', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
if (channelRes.status === 404) {
|
||||
console.warn('rpc_coupon_channel_stats not found, channel analysis will be empty')
|
||||
} else if (channelRes.error != null) {
|
||||
throw channelRes.error
|
||||
} else {
|
||||
const chAny = channelRes.data as any
|
||||
channelList = Array.isArray(chAny) ? chAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 3.4 使用趋势
|
||||
const trendRes = await supa.rpc('rpc_coupon_trend_daily', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
if (trendRes.status === 404) {
|
||||
console.warn('rpc_coupon_trend_daily not found, trend analysis will be empty')
|
||||
} else if (trendRes.error != null) {
|
||||
throw trendRes.error
|
||||
} else {
|
||||
const trAny = trendRes.data as any
|
||||
trendList = Array.isArray(trAny) ? trAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 3.5 转化效果(GMV/订单)
|
||||
const convRes = await supa.rpc('rpc_coupon_conversion_effect', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
if (convRes.status === 404) {
|
||||
console.warn('rpc_coupon_conversion_effect not found, conversion chart will be empty')
|
||||
} else if (convRes.error != null) {
|
||||
throw convRes.error
|
||||
} else {
|
||||
const cvAny = convRes.data as any
|
||||
conversionList = Array.isArray(cvAny) ? cvAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 4) 计算 KPI 概览
|
||||
let totalIssued = 0
|
||||
let totalUsed = 0
|
||||
let gmvIncrease = 0.0
|
||||
let issuedGrowth = 0.0
|
||||
let usageRate = 0.0
|
||||
let gmvGrowth = 0.0
|
||||
let roi = 0.0
|
||||
|
||||
if (overviewRow != null) {
|
||||
totalIssued = overviewRow.getNumber('total_issued') ?? 0
|
||||
totalUsed = overviewRow.getNumber('total_used') ?? 0
|
||||
gmvIncrease = overviewRow.getNumber('gmv_increase') ?? 0
|
||||
issuedGrowth = overviewRow.getNumber('issued_growth') ?? 0
|
||||
usageRate = overviewRow.getNumber('usage_rate') ?? 0
|
||||
gmvGrowth = overviewRow.getNumber('gmv_growth') ?? 0
|
||||
roi = overviewRow.getNumber('roi') ?? 0
|
||||
} else {
|
||||
// 概览 RPC 不存在时,使用类型统计简单近似(只保证页面可用)
|
||||
for (let i = 0; i < typeList.length; i++) {
|
||||
const r = typeList[i]
|
||||
totalIssued += r.getNumber('total_issued') ?? 0
|
||||
totalUsed += r.getNumber('total_used') ?? 0
|
||||
}
|
||||
if (totalIssued > 0) {
|
||||
usageRate = (totalUsed / totalIssued) * 100
|
||||
}
|
||||
}
|
||||
|
||||
this.couponData = {
|
||||
total_issued: totalIssued,
|
||||
issued_growth: issuedGrowth,
|
||||
total_used: totalUsed,
|
||||
usage_rate: usageRate,
|
||||
gmv_increase: gmvIncrease,
|
||||
gmv_growth: gmvGrowth,
|
||||
roi: roi
|
||||
} as CouponData
|
||||
|
||||
// 将原始行数据挂到实例上,方便绘制图表
|
||||
;(this as any)._typeRows = typeList
|
||||
;(this as any)._channelRows = channelList
|
||||
;(this as any)._trendRows = trendList
|
||||
;(this as any)._conversionRows = conversionList
|
||||
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
} catch (e) {
|
||||
console.error('loadCouponData failed:', e)
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
uni.showToast({ title: '优惠券分析数据加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
@@ -224,11 +375,176 @@ export default {
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.typeChartOption = {}
|
||||
this.channelChartOption = {}
|
||||
this.trendChartOption = {}
|
||||
this.conversionChartOption = {}
|
||||
const typeAny = (this as any)._typeRows as any
|
||||
const channelAny = (this as any)._channelRows as any
|
||||
const trendAny = (this as any)._trendRows as any
|
||||
const convAny = (this as any)._conversionRows as any
|
||||
|
||||
const typeRows = Array.isArray(typeAny) ? typeAny as Array<UTSJSONObject> : []
|
||||
const channelRows = Array.isArray(channelAny) ? channelAny as Array<UTSJSONObject> : []
|
||||
const trendRows = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
|
||||
const convRows = Array.isArray(convAny) ? convAny as Array<UTSJSONObject> : []
|
||||
|
||||
// 1) 券类型分析:柱状图(发放/使用/使用率)
|
||||
const typeNames: string[] = []
|
||||
const typeIssued: number[] = []
|
||||
const typeUsed: number[] = []
|
||||
const typeUsageRate: number[] = []
|
||||
|
||||
for (let i = 0; i < typeRows.length; i++) {
|
||||
const r = typeRows[i]
|
||||
const t = r.getNumber('coupon_type') ?? 0
|
||||
// 映射 coupon_type 枚举到中文名称(1..8)
|
||||
let label = '未知'
|
||||
if (t === 1) label = '满减券'
|
||||
else if (t === 2) label = '折扣券'
|
||||
else if (t === 3) label = '免运费券'
|
||||
else if (t === 4) label = '新人券'
|
||||
else if (t === 5) label = '会员券'
|
||||
else if (t === 6) label = '品类券'
|
||||
else if (t === 7) label = '商家券'
|
||||
else if (t === 8) label = '限时券'
|
||||
typeNames.push(label)
|
||||
typeIssued.push(r.getNumber('total_issued') ?? 0)
|
||||
typeUsed.push(r.getNumber('total_used') ?? 0)
|
||||
typeUsageRate.push(r.getNumber('usage_rate') ?? 0)
|
||||
}
|
||||
|
||||
this.typeChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['发放数量', '使用数量', '使用率'],
|
||||
top: 'bottom'
|
||||
},
|
||||
// 增加 top 间距,避免左侧“数量”与上方说明文字发生遮挡
|
||||
grid: { left: 40, right: 40, top: 40, bottom: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: typeNames,
|
||||
axisLabel: { interval: 0, rotate: 20 }
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '数量' },
|
||||
{ type: 'value', name: '使用率', min: 0, max: 100, position: 'right' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '发放数量',
|
||||
type: 'bar',
|
||||
data: typeIssued,
|
||||
barMaxWidth: 22,
|
||||
itemStyle: { color: '#3b82f6' }
|
||||
},
|
||||
{
|
||||
name: '使用数量',
|
||||
type: 'bar',
|
||||
data: typeUsed,
|
||||
barMaxWidth: 22,
|
||||
itemStyle: { color: '#22c55e' }
|
||||
},
|
||||
{
|
||||
name: '使用率',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 2, color: '#111827' },
|
||||
itemStyle: { color: '#111827' },
|
||||
z: 5,
|
||||
data: typeUsageRate
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 2) 发放渠道效果:条形图
|
||||
const channelNames: string[] = []
|
||||
const channelIssued: number[] = []
|
||||
const channelUsed: number[] = []
|
||||
|
||||
for (let i = 0; i < channelRows.length; i++) {
|
||||
const r = channelRows[i]
|
||||
const ch = r.getString('channel') ?? ''
|
||||
let chLabel = ch
|
||||
if (ch === 'manual') chLabel = '主动领取'
|
||||
else if (ch === 'auto') chLabel = '自动发放'
|
||||
else if (ch === 'campaign') chLabel = '活动赠送'
|
||||
else if (ch === 'invite') chLabel = '邀请奖励'
|
||||
else if (ch === 'cs') chLabel = '客服赠送'
|
||||
else if (ch === 'points') chLabel = '积分兑换'
|
||||
else if (ch.trim() === '') chLabel = '未知'
|
||||
channelNames.push(chLabel)
|
||||
channelIssued.push(r.getNumber('total_issued') ?? 0)
|
||||
channelUsed.push(r.getNumber('total_used') ?? 0)
|
||||
}
|
||||
|
||||
this.channelChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['发放数量', '使用数量'], top: 'bottom' },
|
||||
grid: { left: 80, right: 30, top: 20, bottom: 60 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: channelNames },
|
||||
series: [
|
||||
{ name: '发放数量', type: 'bar', data: channelIssued },
|
||||
{ name: '使用数量', type: 'bar', data: channelUsed }
|
||||
]
|
||||
}
|
||||
|
||||
// 3) 使用趋势:发放 vs 使用
|
||||
const trendDays: string[] = []
|
||||
const trendIssued: number[] = []
|
||||
const trendUsed: number[] = []
|
||||
|
||||
for (let i = 0; i < trendRows.length; i++) {
|
||||
const r = trendRows[i]
|
||||
const day = r.getString('day') ?? ''
|
||||
trendDays.push(day.length >= 10 ? day.substring(5, 10) : day)
|
||||
trendIssued.push(r.getNumber('issued') ?? 0)
|
||||
trendUsed.push(r.getNumber('used') ?? 0)
|
||||
}
|
||||
|
||||
this.trendChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['发放数量', '使用数量'], top: 'bottom' },
|
||||
// 增加顶部间距,避免“数量”与上方说明文字遮挡
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 60 },
|
||||
xAxis: { type: 'category', data: trendDays },
|
||||
yAxis: { type: 'value', name: '数量' },
|
||||
series: [
|
||||
{ name: '发放数量', type: 'bar', data: trendIssued },
|
||||
{ name: '使用数量', type: 'line', smooth: true, data: trendUsed }
|
||||
]
|
||||
}
|
||||
|
||||
// 4) 转化效果:对比有券/无券 GMV & 订单数
|
||||
const convNames: string[] = []
|
||||
const convWith: number[] = []
|
||||
const convWithout: number[] = []
|
||||
|
||||
for (let i = 0; i < convRows.length; i++) {
|
||||
const r = convRows[i]
|
||||
const metric = r.getString('metric') ?? ''
|
||||
let metricLabel = metric
|
||||
if (metric === 'GMV') metricLabel = 'GMV(成交额)'
|
||||
else if (metric === 'orders') metricLabel = '订单数'
|
||||
else if (metric === 'avg_order_amount') metricLabel = '客单价'
|
||||
else if (metric.trim() === '') metricLabel = '未知'
|
||||
convNames.push(metricLabel)
|
||||
convWith.push(r.getNumber('with_coupon') ?? 0)
|
||||
convWithout.push(r.getNumber('without_coupon') ?? 0)
|
||||
}
|
||||
|
||||
this.conversionChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['使用优惠券', '未使用优惠券'], top: 'bottom' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 60 },
|
||||
xAxis: { type: 'category', data: convNames },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '使用优惠券', type: 'bar', data: convWith },
|
||||
{ name: '未使用优惠券', type: 'bar', data: convWithout }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
toggleMoreMenu() {
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import supa, { 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'
|
||||
@@ -160,9 +160,106 @@ export default {
|
||||
|
||||
methods: {
|
||||
async loadDetailData() {
|
||||
// TODO: 实现详细数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
try {
|
||||
const now = new Date()
|
||||
const end = new Date(now.getTime())
|
||||
const start = new Date(now.getTime())
|
||||
|
||||
if (this.timeRangeText === '最近7天') {
|
||||
start.setDate(start.getDate() - 7)
|
||||
} else if (this.timeRangeText === '最近30天') {
|
||||
start.setDate(start.getDate() - 30)
|
||||
} else if (this.timeRangeText === '最近90天') {
|
||||
start.setDate(start.getDate() - 90)
|
||||
} else {
|
||||
// 自定义:暂时按最近30天处理
|
||||
start.setDate(start.getDate() - 30)
|
||||
}
|
||||
|
||||
const startIso = start.toISOString()
|
||||
const endIso = end.toISOString()
|
||||
|
||||
await ensureSupabaseReady()
|
||||
|
||||
// 当前周期明细:复用 rpc_analytics_market_trend_daily(按天 GMV / 订单 / 用户)
|
||||
let currentRows: Array<UTSJSONObject> = []
|
||||
let compareRows: Array<UTSJSONObject> = []
|
||||
|
||||
const curRes = await supa.rpc('rpc_analytics_market_trend_daily', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
if (curRes.status === 404) {
|
||||
console.warn('rpc_analytics_market_trend_daily not found, data-detail will be empty')
|
||||
} else if (curRes.error != null) {
|
||||
console.error('rpc_analytics_market_trend_daily error:', curRes.error)
|
||||
} else {
|
||||
const anyData = curRes.data as any
|
||||
currentRows = Array.isArray(anyData) ? anyData as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 对比周期:与当前周期长度相同的上一段时间
|
||||
const spanMs = end.getTime() - start.getTime()
|
||||
const prevEnd = new Date(start.getTime())
|
||||
const prevStart = new Date(start.getTime() - spanMs)
|
||||
const prevStartIso = prevStart.toISOString()
|
||||
const prevEndIso = prevEnd.toISOString()
|
||||
|
||||
const prevRes = await supa.rpc('rpc_analytics_market_trend_daily', {
|
||||
p_start: prevStartIso,
|
||||
p_end: prevEndIso
|
||||
})
|
||||
if (prevRes.status === 404) {
|
||||
console.warn('rpc_analytics_market_trend_daily not found for compare period')
|
||||
} else if (prevRes.error != null) {
|
||||
console.error('rpc_analytics_market_trend_daily (compare) error:', prevRes.error)
|
||||
} else {
|
||||
const anyPrev = prevRes.data as any
|
||||
compareRows = Array.isArray(anyPrev) ? anyPrev as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 映射到表格数据
|
||||
const table: Array<any> = []
|
||||
for (let i = 0; i < currentRows.length; i++) {
|
||||
const r = currentRows[i]
|
||||
const dayStr = r.getString('day') ?? ''
|
||||
table.push({
|
||||
id: dayStr + '_' + i.toString(),
|
||||
date: dayStr,
|
||||
gmv: r.getNumber('gmv') ?? 0,
|
||||
orders: r.getNumber('orders') ?? 0,
|
||||
users: r.getNumber('users') ?? 0
|
||||
} as any)
|
||||
}
|
||||
this.tableData = table
|
||||
|
||||
// 简单生成钻取卡片:总 GMV / 总订单 / 总用户
|
||||
let totalGmv = 0
|
||||
let totalOrders = 0
|
||||
let totalUsers = 0
|
||||
for (let i = 0; i < table.length; i++) {
|
||||
const row = table[i]
|
||||
totalGmv += row.gmv as number
|
||||
totalOrders += row.orders as number
|
||||
totalUsers += row.users as number
|
||||
}
|
||||
this.drillDownItems = [
|
||||
{ id: 'gmv_total', label: '当前周期 GMV 总计', value: this.formatCellValue(totalGmv, 'money'), type: 'gmv' } as DrillDownItem,
|
||||
{ id: 'orders_total', label: '当前周期订单总数', value: this.formatCellValue(totalOrders, 'number'), type: 'orders' } as DrillDownItem,
|
||||
{ id: 'users_total', label: '当前周期下单用户数', value: this.formatCellValue(totalUsers, 'number'), type: 'users' } as DrillDownItem
|
||||
]
|
||||
|
||||
;(this as any)._currentRows = currentRows
|
||||
;(this as any)._compareRows = compareRows
|
||||
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
} catch (e) {
|
||||
console.error('loadDetailData failed:', e)
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
uni.showToast({ title: '详细数据加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
selectTimeRange() {
|
||||
@@ -201,7 +298,16 @@ export default {
|
||||
this.sortKey = key
|
||||
this.sortOrder = 'asc'
|
||||
}
|
||||
// TODO: 实现排序逻辑
|
||||
const data = this.tableData.slice()
|
||||
data.sort((a: any, b: any) => {
|
||||
const va = a[key]
|
||||
const vb = b[key]
|
||||
const na = typeof va === 'number' ? va : Number(va)
|
||||
const nb = typeof vb === 'number' ? vb : Number(vb)
|
||||
if (this.sortOrder === 'asc') return na - nb
|
||||
return nb - na
|
||||
})
|
||||
this.tableData = data
|
||||
},
|
||||
|
||||
formatCellValue(value: any, type: string): string {
|
||||
@@ -245,8 +351,58 @@ export default {
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.compareChartOption = {}
|
||||
const curAny = (this as any)._currentRows as any
|
||||
const prevAny = (this as any)._compareRows as any
|
||||
const curRows = Array.isArray(curAny) ? curAny as Array<UTSJSONObject> : []
|
||||
const prevRows = Array.isArray(prevAny) ? prevAny as Array<UTSJSONObject> : []
|
||||
|
||||
if (!this.compareMode || curRows.length === 0) {
|
||||
this.compareChartOption = {}
|
||||
return
|
||||
}
|
||||
|
||||
const curDays: string[] = []
|
||||
const curGmv: number[] = []
|
||||
const prevGmv: number[] = []
|
||||
|
||||
for (let i = 0; i < curRows.length; i++) {
|
||||
const r = curRows[i]
|
||||
const dayStr = r.getString('day') ?? ''
|
||||
curDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
|
||||
curGmv.push(r.getNumber('gmv') ?? 0)
|
||||
}
|
||||
|
||||
// 对比周期的数据按顺序对齐(长度可能不同,超出部分用 0 填充)
|
||||
for (let i = 0; i < curRows.length; i++) {
|
||||
const rPrev = i < prevRows.length ? prevRows[i] : null
|
||||
if (rPrev == null) {
|
||||
prevGmv.push(0)
|
||||
} else {
|
||||
prevGmv.push(rPrev.getNumber('gmv') ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
this.compareChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['当前周期 GMV', '对比周期 GMV'], top: 'bottom' },
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 60 },
|
||||
xAxis: { type: 'category', data: curDays },
|
||||
yAxis: { type: 'value', name: 'GMV' },
|
||||
series: [
|
||||
{
|
||||
name: '当前周期 GMV',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: curGmv
|
||||
},
|
||||
{
|
||||
name: '对比周期 GMV',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: prevGmv
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
handleMenu() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import supa, { 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'
|
||||
@@ -148,9 +148,108 @@ export default {
|
||||
|
||||
methods: {
|
||||
async loadMarketData() {
|
||||
// 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()
|
||||
const endIso = now.toISOString()
|
||||
|
||||
await ensureSupabaseReady()
|
||||
|
||||
// 1) 市场整体趋势(按天 GMV / 订单 / 用户)
|
||||
let trendRows: Array<UTSJSONObject> = []
|
||||
let categoryRows: Array<UTSJSONObject> = []
|
||||
let seasonalRows: Array<UTSJSONObject> = []
|
||||
let priceRows: Array<UTSJSONObject> = []
|
||||
let competitionRows: Array<UTSJSONObject> = []
|
||||
|
||||
const marketRes = await supa.rpc('rpc_analytics_market_trend_daily', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
if (marketRes.status === 404) {
|
||||
console.warn('rpc_analytics_market_trend_daily not found, market trend will be empty')
|
||||
} else if (marketRes.error != null) {
|
||||
console.error('rpc_analytics_market_trend_daily error:', marketRes.error)
|
||||
} else {
|
||||
const anyData = marketRes.data as any
|
||||
trendRows = Array.isArray(anyData) ? anyData as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 2) 行业对比(按分类 GMV)
|
||||
const catRes = await supa.rpc('rpc_analytics_category_sales', {
|
||||
p_start_date: startIso.substring(0, 10),
|
||||
p_end_date: endIso.substring(0, 10)
|
||||
})
|
||||
if (catRes.status === 404) {
|
||||
console.warn('rpc_analytics_category_sales not found, industry comparison will be empty')
|
||||
} else if (catRes.error != null) {
|
||||
console.error('rpc_analytics_category_sales error:', catRes.error)
|
||||
} else {
|
||||
const cAny = catRes.data as any
|
||||
categoryRows = Array.isArray(cAny) ? cAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 3) 季节性趋势(按月 GMV)
|
||||
const seaRes = await supa.rpc('rpc_analytics_seasonal_trend', {
|
||||
p_start_date: startIso.substring(0, 10),
|
||||
p_end_date: endIso.substring(0, 10)
|
||||
})
|
||||
if (seaRes.status === 404) {
|
||||
console.warn('rpc_analytics_seasonal_trend not found, seasonal trend will be empty')
|
||||
} else if (seaRes.error != null) {
|
||||
console.error('rpc_analytics_seasonal_trend error:', seaRes.error)
|
||||
} else {
|
||||
const sAny = seaRes.data as any
|
||||
seasonalRows = Array.isArray(sAny) ? sAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 4) 价格趋势(按天平均单价)
|
||||
const priceRes = await supa.rpc('rpc_analytics_price_trend', {
|
||||
p_start: startIso,
|
||||
p_end: endIso
|
||||
})
|
||||
if (priceRes.status === 404) {
|
||||
console.warn('rpc_analytics_price_trend not found, price trend will be empty')
|
||||
} else if (priceRes.error != null) {
|
||||
console.error('rpc_analytics_price_trend error:', priceRes.error)
|
||||
} else {
|
||||
const pAny = priceRes.data as any
|
||||
priceRows = Array.isArray(pAny) ? pAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
// 5) 竞争分析(商家 GMV 榜单)
|
||||
const compRes = await supa.rpc('rpc_analytics_competition_share', {
|
||||
p_start_date: startIso.substring(0, 10),
|
||||
p_end_date: endIso.substring(0, 10)
|
||||
})
|
||||
if (compRes.status === 404) {
|
||||
console.warn('rpc_analytics_competition_share not found, competition analysis will be empty')
|
||||
} else if (compRes.error != null) {
|
||||
console.error('rpc_analytics_competition_share error:', compRes.error)
|
||||
} else {
|
||||
const cpAny = compRes.data as any
|
||||
competitionRows = Array.isArray(cpAny) ? cpAny as Array<UTSJSONObject> : []
|
||||
}
|
||||
|
||||
;(this as any)._marketTrendRows = trendRows
|
||||
;(this as any)._industryRows = categoryRows
|
||||
;(this as any)._seasonalRows = seasonalRows
|
||||
;(this as any)._priceRows = priceRows
|
||||
;(this as any)._competitionRows = competitionRows
|
||||
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
} catch (e) {
|
||||
console.error('loadMarketData failed:', e)
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
uni.showToast({ title: '市场趋势数据加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
@@ -178,12 +277,162 @@ export default {
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.marketTrendOption = {}
|
||||
this.industryCompareOption = {}
|
||||
this.seasonalTrendOption = {}
|
||||
this.priceTrendOption = {}
|
||||
this.competitionOption = {}
|
||||
const trendAny = (this as any)._marketTrendRows as any
|
||||
const industryAny = (this as any)._industryRows as any
|
||||
const seasonalAny = (this as any)._seasonalRows as any
|
||||
const priceAny = (this as any)._priceRows as any
|
||||
const compAny = (this as any)._competitionRows as any
|
||||
|
||||
const trendRows = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
|
||||
const industryRows = Array.isArray(industryAny) ? industryAny as Array<UTSJSONObject> : []
|
||||
const seasonalRows = Array.isArray(seasonalAny) ? seasonalAny as Array<UTSJSONObject> : []
|
||||
const priceRows = Array.isArray(priceAny) ? priceAny as Array<UTSJSONObject> : []
|
||||
const compRows = Array.isArray(compAny) ? compAny as Array<UTSJSONObject> : []
|
||||
|
||||
// 1) 市场整体趋势:GMV / 订单数 / 用户数
|
||||
const mtDays: string[] = []
|
||||
const mtGmv: number[] = []
|
||||
const mtOrders: number[] = []
|
||||
const mtUsers: number[] = []
|
||||
|
||||
for (let i = 0; i < trendRows.length; i++) {
|
||||
const r = trendRows[i]
|
||||
const dayStr = r.getString('day') ?? ''
|
||||
mtDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
|
||||
mtGmv.push(r.getNumber('gmv') ?? 0)
|
||||
mtOrders.push(r.getNumber('orders') ?? 0)
|
||||
mtUsers.push(r.getNumber('users') ?? 0)
|
||||
}
|
||||
|
||||
this.marketTrendOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['GMV', '订单数', '用户数'],
|
||||
top: 'bottom'
|
||||
},
|
||||
grid: { left: 50, right: 60, top: 40, bottom: 60 },
|
||||
xAxis: { type: 'category', data: mtDays },
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'GMV', splitLine: { lineStyle: { color: '#e5e7eb' } } },
|
||||
{ type: 'value', name: '数量', position: 'right', splitLine: { show: false } }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'GMV',
|
||||
type: 'bar',
|
||||
data: mtGmv,
|
||||
barMaxWidth: 26,
|
||||
itemStyle: { color: '#3b82f6' }
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
data: mtOrders
|
||||
},
|
||||
{
|
||||
name: '用户数',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
data: mtUsers
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 2) 行业对比:分类 GMV
|
||||
const catNames: string[] = []
|
||||
const catSales: number[] = []
|
||||
for (let i = 0; i < industryRows.length; i++) {
|
||||
const r = industryRows[i]
|
||||
catNames.push(r.getString('category_name') ?? '未分类')
|
||||
catSales.push(r.getNumber('total_sales') ?? 0)
|
||||
}
|
||||
this.industryCompareOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 80, right: 20, top: 30, bottom: 60 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: catNames },
|
||||
series: [
|
||||
{
|
||||
name: 'GMV',
|
||||
type: 'bar',
|
||||
data: catSales
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 3) 季节性趋势:按月 GMV
|
||||
const seaMonths: string[] = []
|
||||
const seaGmv: number[] = []
|
||||
for (let i = 0; i < seasonalRows.length; i++) {
|
||||
const r = seasonalRows[i]
|
||||
seaMonths.push(r.getString('month') ?? '')
|
||||
seaGmv.push(r.getNumber('total_gmv') ?? 0)
|
||||
}
|
||||
this.seasonalTrendOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 60 },
|
||||
xAxis: { type: 'category', data: seaMonths },
|
||||
yAxis: { type: 'value', name: 'GMV' },
|
||||
series: [
|
||||
{
|
||||
name: 'GMV',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: seaGmv
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 4) 价格趋势:按天平均价格
|
||||
const priceDays: string[] = []
|
||||
const avgPrices: number[] = []
|
||||
for (let i = 0; i < priceRows.length; i++) {
|
||||
const r = priceRows[i]
|
||||
const d = r.getString('day') ?? ''
|
||||
priceDays.push(d.length >= 10 ? d.substring(5, 10) : d)
|
||||
avgPrices.push(r.getNumber('avg_price') ?? 0)
|
||||
}
|
||||
this.priceTrendOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 60 },
|
||||
xAxis: { type: 'category', data: priceDays },
|
||||
yAxis: { type: 'value', name: '平均价格' },
|
||||
series: [
|
||||
{
|
||||
name: '平均价格',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: avgPrices
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 5) 竞争分析:商家 GMV 对比
|
||||
const merchantNames: string[] = []
|
||||
const merchantGmv: number[] = []
|
||||
for (let i = 0; i < compRows.length; i++) {
|
||||
const r = compRows[i]
|
||||
merchantNames.push(r.getString('merchant_name') ?? '未知商家')
|
||||
merchantGmv.push(r.getNumber('gmv') ?? 0)
|
||||
}
|
||||
this.competitionOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { top: 'bottom' },
|
||||
series: [
|
||||
{
|
||||
name: '商家GMV',
|
||||
type: 'pie',
|
||||
radius: ['35%', '65%'],
|
||||
center: ['50%', '50%'],
|
||||
data: merchantNames.map((n, idx) => {
|
||||
return { name: n, value: merchantGmv[idx] }
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
handleMenu() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
✅ 明白了!你不需要完整代码,而是希望我**用简洁的 Markdown 文档形式,解释这个 `delivery` 文件夹下的项目结构和每个页面的功能定位**。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { IS_TEST_MODE } from '@/ak/config.uts'
|
||||
|
||||
export default {
|
||||
onLoad() {
|
||||
@@ -37,37 +38,23 @@
|
||||
methods: {
|
||||
checkAndRedirect() {
|
||||
console.log('boot: start redirect check')
|
||||
try {
|
||||
const sessionInfo = supa.getSession();
|
||||
if (sessionInfo != null && sessionInfo.user != null) {
|
||||
// 已登录 -> 直接进入消费者端首页
|
||||
console.log('boot: found session, go consumer/index')
|
||||
uni.reLaunch({
|
||||
url: '/pages/mall/consumer/index',
|
||||
success: () => {
|
||||
console.log('boot: redirect to consumer/index success')
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('boot: redirect to consumer/index failed', err)
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('boot: error checking session', e);
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
// 测试阶段:不做强制重定向,保留你手动输入的 URL / 目标页面
|
||||
return
|
||||
}
|
||||
|
||||
console.log('boot: no session, go login')
|
||||
// 未登录 -> 登录页
|
||||
uni.reLaunch({
|
||||
url: '/pages/user/login',
|
||||
success: () => {
|
||||
console.log('boot: redirect to login success')
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('boot: redirect to login failed', err)
|
||||
try {
|
||||
const sessionInfo = supa.getSession()
|
||||
if (sessionInfo != null && sessionInfo.user != null) {
|
||||
uni.reLaunch({ url: '/pages/mall/consumer/index' })
|
||||
return
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('boot: error checking session', e)
|
||||
}
|
||||
|
||||
uni.reLaunch({ url: '/pages/user/login' })
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { IS_TEST_MODE } from '@/ak/config.uts'
|
||||
import { getCurrentUser, logout } from '@/utils/store.uts'
|
||||
|
||||
const cssVars = {
|
||||
@@ -171,6 +172,7 @@ const codeCountdown = ref<number>(0)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (IS_TEST_MODE) return
|
||||
const sessionInfo = supa.getSession()
|
||||
if (sessionInfo != null && sessionInfo.user != null) {
|
||||
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
||||
@@ -298,9 +300,11 @@ const handleLogin = async () => {
|
||||
}
|
||||
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
||||
}, 500)
|
||||
if (!IS_TEST_MODE) {
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/mall/consumer/index' })
|
||||
}, 500)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('登录错误:', err)
|
||||
let msg = '登录失败,请重试'
|
||||
|
||||
Reference in New Issue
Block a user