接入数据库
This commit is contained in:
@@ -26,29 +26,71 @@
|
||||
<!-- 主内容区域 -->
|
||||
<view class="main-content">
|
||||
<view class="container">
|
||||
<!-- KPI:宽屏 4列,窄屏 2列 -->
|
||||
<!-- KPI:宽屏 4列,窄屏 2列(增强版:渐变背景 + sparkline) -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">实时 GMV</text>
|
||||
<view class="kpi-card kpi-card-gmv" @click="goToSalesReport">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">实时 GMV</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.gmv" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">¥{{ formatMoney(realTime.gmv) }}</text>
|
||||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.gmv_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">实时订单</text>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">较昨日同刻</text>
|
||||
<text class="kpi-chip" :class="realTime.gmv_growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatPct(realTime.gmv_growth) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-card kpi-card-orders" @click="goToSalesReport">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">实时订单</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.orders" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">{{ formatInt(realTime.orders) }}</text>
|
||||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.order_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">在线用户</text>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">较昨日同刻</text>
|
||||
<text class="kpi-chip" :class="realTime.order_growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatPct(realTime.order_growth) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-card kpi-card-users" @click="goToUserAnalysis">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">在线用户</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.users" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">{{ formatInt(realTime.online_users) }}</text>
|
||||
<text class="kpi-meta">最近 5 分钟</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">转化率</text>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">最近 5 分钟</text>
|
||||
<text class="kpi-chip neutral">实时</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-card kpi-card-conversion" @click="goToSalesReport">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">转化率</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.conversion" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">{{ formatPct(realTime.conversion_rate) }}</text>
|
||||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.conversion_growth) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">较昨日同刻</text>
|
||||
<text class="kpi-chip" :class="realTime.conversion_growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatPct(realTime.conversion_growth) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间维度:横排 -->
|
||||
<view class="tabs">
|
||||
@@ -135,6 +177,46 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快速工具卡片区(6个工具入口) -->
|
||||
<view class="tools-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">快速分析工具</text>
|
||||
<text class="section-desc">点击进入详细分析</text>
|
||||
</view>
|
||||
<view class="tools-grid">
|
||||
<view class="tool-card" @click="goToSalesReport">
|
||||
<view class="tool-icon sales">📊</view>
|
||||
<text class="tool-title">销售报表</text>
|
||||
<text class="tool-desc">GMV、订单、转化率</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToUserAnalysis">
|
||||
<view class="tool-icon users">👥</view>
|
||||
<text class="tool-title">用户分析</text>
|
||||
<text class="tool-desc">增长、活跃、留存</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToProductInsights">
|
||||
<view class="tool-icon products">📦</view>
|
||||
<text class="tool-title">商品洞察</text>
|
||||
<text class="tool-desc">销量、库存、价格</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToMarketTrends">
|
||||
<view class="tool-icon market">📈</view>
|
||||
<text class="tool-title">市场趋势</text>
|
||||
<text class="tool-desc">整体趋势、行业对比</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToCouponAnalysis">
|
||||
<view class="tool-icon coupon">🎫</view>
|
||||
<text class="tool-title">优惠券分析</text>
|
||||
<text class="tool-desc">发放、使用、ROI</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToCustomReport">
|
||||
<view class="tool-icon custom">⚙️</view>
|
||||
<text class="tool-title">自定义报表</text>
|
||||
<text class="tool-desc">创建专属报表</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
@@ -170,6 +252,10 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/index',
|
||||
loading: true,
|
||||
autoRefreshEnabled: true,
|
||||
autoRefreshInterval: 60000, // 60秒自动刷新
|
||||
autoRefreshTimer: null as any,
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
@@ -193,35 +279,23 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
orders: [] as Array<number>
|
||||
} as TrendData,
|
||||
|
||||
userSegments: [
|
||||
{ name: '未消费用户', value: 72 },
|
||||
{ name: '消费一次用户', value: 14 },
|
||||
{ name: '留存客户', value: 9 },
|
||||
{ name: '回流客户', value: 5 }
|
||||
] as Array<SegmentItem>,
|
||||
userSegments: [] as Array<SegmentItem>,
|
||||
|
||||
trafficSources: [
|
||||
{ name: '直接访问', value: 45 },
|
||||
{ name: '搜索引擎', value: 28 },
|
||||
{ name: '社交媒体', value: 18 },
|
||||
{ name: '广告推广', value: 9 }
|
||||
] as Array<TrafficItem>,
|
||||
trafficSources: [] as Array<TrafficItem>,
|
||||
|
||||
topProducts: [
|
||||
{ id: '1', rank: 1, name: '苹果 iPhone 15', sales: 580 },
|
||||
{ id: '2', rank: 2, name: '华为 Mate 60', sales: 456 },
|
||||
{ id: '3', rank: 3, name: '小米 14 Pro', sales: 389 }
|
||||
],
|
||||
topProducts: [] as Array<TopProductItem>,
|
||||
|
||||
topMerchants: [
|
||||
{ id: '1', rank: 1, name: '华强北电子城', sales: 580000, growth: 15.6 },
|
||||
{ id: '2', rank: 2, name: '时尚服装馆', sales: 456000, growth: 12.3 },
|
||||
{ id: '3', rank: 3, name: '美食天地', sales: 389000, growth: -2.1 }
|
||||
],
|
||||
topMerchants: [] as Array<TopMerchantItem>,
|
||||
|
||||
// 图表 options
|
||||
trafficBarOption: {} as any,
|
||||
userSegmentOption: {} as any
|
||||
userSegmentOption: {} as any,
|
||||
kpiSparklineOptions: {
|
||||
gmv: {} as any,
|
||||
orders: {} as any,
|
||||
users: {} as any,
|
||||
conversion: {} as any
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -242,30 +316,92 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.refreshAll()
|
||||
this.buildChartOptions()
|
||||
this.initDashboard()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this.showMoreMenu = false
|
||||
this.stopAutoRefresh()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 页面显示时恢复自动刷新
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh()
|
||||
}
|
||||
},
|
||||
|
||||
onHide() {
|
||||
// 页面隐藏时暂停自动刷新
|
||||
this.stopAutoRefresh()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initDashboard() {
|
||||
this.loading = true
|
||||
await this.refreshAll()
|
||||
this.loading = false
|
||||
this.buildChartOptions()
|
||||
this.buildSparklineOptions()
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh()
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAll() {
|
||||
this.updateTime()
|
||||
await this.loadRealTime()
|
||||
await this.loadTrend()
|
||||
await this.loadUserSegments()
|
||||
await this.loadTrafficSources()
|
||||
await this.loadTopProducts()
|
||||
await this.loadTopMerchants()
|
||||
this.updateTime()
|
||||
uni.showToast({ title: '已刷新', icon: 'success' })
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadRealTime(),
|
||||
this.loadTrend(),
|
||||
this.loadUserSegments(),
|
||||
this.loadTrafficSources(),
|
||||
this.loadTopProducts(),
|
||||
this.loadTopMerchants()
|
||||
])
|
||||
this.updateTime()
|
||||
// 更新 sparkline
|
||||
this.buildSparklineOptions()
|
||||
// 更新图表
|
||||
this.buildChartOptions()
|
||||
} catch (e) {
|
||||
console.error('refreshAll failed', e)
|
||||
uni.showToast({ title: '数据加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
startAutoRefresh() {
|
||||
this.stopAutoRefresh()
|
||||
this.autoRefreshTimer = setInterval(() => {
|
||||
this.refreshAll()
|
||||
}, this.autoRefreshInterval)
|
||||
},
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.autoRefreshTimer != null) {
|
||||
clearInterval(this.autoRefreshTimer)
|
||||
this.autoRefreshTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
toggleAutoRefresh() {
|
||||
this.autoRefreshEnabled = !this.autoRefreshEnabled
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh()
|
||||
uni.showToast({ title: '已开启自动刷新', icon: 'success' })
|
||||
} else {
|
||||
this.stopAutoRefresh()
|
||||
uni.showToast({ title: '已关闭自动刷新', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.loadTrend()
|
||||
this.loadUserSegments()
|
||||
this.loadTrafficSources()
|
||||
this.loadTopProducts()
|
||||
this.loadTopMerchants()
|
||||
},
|
||||
|
||||
updateTime() {
|
||||
@@ -291,21 +427,49 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
p.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
p.set('p_merchant_id', null)
|
||||
|
||||
console.log('📊 loadTrend: 请求参数', {
|
||||
start_date: startDate.toISOString().slice(0, 10),
|
||||
end_date: endDate.toISOString().slice(0, 10)
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
console.log('📊 loadTrend: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构:可能是 res.data 或 res 本身
|
||||
let rows: Array<any> = []
|
||||
if (Array.isArray(res.data)) {
|
||||
rows = res.data as Array<any>
|
||||
} else if (Array.isArray(res)) {
|
||||
rows = res as Array<any>
|
||||
} else if (res && typeof res === 'object') {
|
||||
// 可能是 { data: [...] } 或其他结构
|
||||
const data = res.data || res.rows || res.result || []
|
||||
rows = Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
console.log('📊 loadTrend: 解析后的 rows', rows, '数量:', rows.length)
|
||||
|
||||
const x: Array<string> = []
|
||||
const gmv: Array<number> = []
|
||||
const orders: Array<number> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = `${rows[i].date}` // yyyy-mm-dd
|
||||
x.push(d.slice(5))
|
||||
gmv.push(Number(rows[i].gmv) || 0)
|
||||
orders.push(Number(rows[i].orders) || 0)
|
||||
const row = rows[i]
|
||||
const d = `${row.date || row.day || row.date_key}` // 兼容不同字段名
|
||||
if (d && d.length >= 10) {
|
||||
x.push(d.slice(5)) // MM-DD
|
||||
} else {
|
||||
x.push(`${i + 1}`)
|
||||
}
|
||||
gmv.push(Number(row.gmv || row.total_amount || 0) || 0)
|
||||
orders.push(Number(row.orders || row.order_count || 0) || 0)
|
||||
}
|
||||
|
||||
console.log('📊 loadTrend: 最终数据', { x: x.length, gmv: gmv.length, orders: orders.length })
|
||||
this.trend = { x, gmv, orders }
|
||||
} catch (e) {
|
||||
console.error('loadTrend failed', e)
|
||||
console.error('❌ loadTrend failed', e)
|
||||
// 即使失败也设置空数据,避免图表报错
|
||||
this.trend = { x: [], gmv: [], orders: [] }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -326,24 +490,46 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
p.set('p_compare_end', ySame.toISOString())
|
||||
p.set('p_merchant_id', null)
|
||||
|
||||
console.log('⚡ loadRealTime: 请求参数', {
|
||||
p_start: todayISO,
|
||||
p_end: now.toISOString(),
|
||||
p_compare_start: y0.toISOString(),
|
||||
p_compare_end: ySame.toISOString()
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_realtime_kpis', p)
|
||||
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
|
||||
console.log('⚡ loadRealTime: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构
|
||||
let row: any = {}
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
row = res.data[0]
|
||||
} else if (Array.isArray(res) && res.length > 0) {
|
||||
row = res[0]
|
||||
} else if (res && typeof res === 'object' && !Array.isArray(res)) {
|
||||
// 可能是直接返回对象,或者 { data: {...} }
|
||||
row = res.data || res.result || res
|
||||
}
|
||||
|
||||
console.log('⚡ loadRealTime: 解析后的 row', row)
|
||||
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
this.realTime = {
|
||||
gmv: Math.round(safe(row.gmv)),
|
||||
gmv_growth: safe(row.gmv_growth),
|
||||
orders: Math.round(safe(row.orders)),
|
||||
order_growth: safe(row.order_growth),
|
||||
online_users: Math.round(safe(row.online_users)),
|
||||
conversion_rate: safe(row.conversion_rate),
|
||||
conversion_growth: safe(row.conversion_growth)
|
||||
gmv: Math.round(safe(row.gmv || row.total_gmv || row.revenue)),
|
||||
gmv_growth: safe(row.gmv_growth || row.gmv_growth_rate || row.revenue_growth),
|
||||
orders: Math.round(safe(row.orders || row.order_count || row.total_orders)),
|
||||
order_growth: safe(row.order_growth || row.order_growth_rate),
|
||||
online_users: Math.round(safe(row.online_users || row.active_users || row.current_users)),
|
||||
conversion_rate: safe(row.conversion_rate || row.conversion),
|
||||
conversion_growth: safe(row.conversion_growth || row.conversion_growth_rate)
|
||||
}
|
||||
|
||||
console.log('⚡ loadRealTime: 最终数据', this.realTime)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error('❌ loadRealTime failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -403,15 +589,42 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
p.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
|
||||
console.log('👥 loadUserSegments: 请求参数', {
|
||||
start_date: startDate.toISOString().slice(0, 10),
|
||||
end_date: endDate.toISOString().slice(0, 10)
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_user_segments', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
console.log('👥 loadUserSegments: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构
|
||||
let rows: Array<any> = []
|
||||
if (Array.isArray(res.data)) {
|
||||
rows = res.data as Array<any>
|
||||
} else if (Array.isArray(res)) {
|
||||
rows = res as Array<any>
|
||||
} else if (res && typeof res === 'object') {
|
||||
const data = res.data || res.rows || res.result || []
|
||||
rows = Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
console.log('👥 loadUserSegments: 解析后的 rows', rows, '数量:', rows.length)
|
||||
|
||||
const list: Array<SegmentItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
|
||||
const row = rows[i]
|
||||
const name = `${row.name || row.segment_name || row.label || '未知'}`
|
||||
const value = Number(row.value || row.count || row.amount || 0) || 0
|
||||
list.push({ name, value })
|
||||
}
|
||||
if (list.length > 0) this.userSegments = list
|
||||
|
||||
console.log('👥 loadUserSegments: 最终数据', list)
|
||||
// 即使为空也更新,确保图表能正确显示空状态
|
||||
this.userSegments = list
|
||||
} catch (e) {
|
||||
console.error('loadUserSegments failed', e)
|
||||
console.error('❌ loadUserSegments failed', e)
|
||||
this.userSegments = []
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,15 +634,42 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
p.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
|
||||
console.log('🌐 loadTrafficSources: 请求参数', {
|
||||
start_date: startDate.toISOString().slice(0, 10),
|
||||
end_date: endDate.toISOString().slice(0, 10)
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
console.log('🌐 loadTrafficSources: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构
|
||||
let rows: Array<any> = []
|
||||
if (Array.isArray(res.data)) {
|
||||
rows = res.data as Array<any>
|
||||
} else if (Array.isArray(res)) {
|
||||
rows = res as Array<any>
|
||||
} else if (res && typeof res === 'object') {
|
||||
const data = res.data || res.rows || res.result || []
|
||||
rows = Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
console.log('🌐 loadTrafficSources: 解析后的 rows', rows, '数量:', rows.length)
|
||||
|
||||
const list: Array<TrafficItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
|
||||
const row = rows[i]
|
||||
const name = `${row.name || row.source_name || row.label || '未知'}`
|
||||
const value = Number(row.value || row.count || row.amount || 0) || 0
|
||||
list.push({ name, value })
|
||||
}
|
||||
if (list.length > 0) this.trafficSources = list
|
||||
|
||||
console.log('🌐 loadTrafficSources: 最终数据', list)
|
||||
// 即使为空也更新
|
||||
this.trafficSources = list
|
||||
} catch (e) {
|
||||
console.error('loadTrafficSources failed', e)
|
||||
console.error('❌ loadTrafficSources failed', e)
|
||||
this.trafficSources = []
|
||||
}
|
||||
},
|
||||
|
||||
@@ -526,21 +766,59 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
|
||||
// 构建图表 options
|
||||
buildChartOptions() {
|
||||
// 流量来源条形图
|
||||
// 流量来源条形图(增强:渐变 + 动画)
|
||||
const trafficX = this.trafficSources.map((it) => it.name)
|
||||
const trafficY = this.trafficSources.map((it) => {
|
||||
const n = Number(it.value)
|
||||
return isFinite(n) ? n : 0
|
||||
})
|
||||
const total = trafficY.reduce((sum, v) => sum + v, 0)
|
||||
this.trafficBarOption = {
|
||||
grid: { left: 80, right: 24, top: 18, bottom: 18 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
yAxis: { type: 'category', data: trafficX, axisTick: { show: false }, axisLabel: { color: 'rgba(0,0,0,0.65)' } },
|
||||
series: [{ type: 'bar', data: trafficY, barWidth: 14, itemStyle: { borderRadius: 6 } }]
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const p = params[0]
|
||||
const percent = total > 0 ? ((p.value / total) * 100).toFixed(1) : '0'
|
||||
return `${p.name}<br/>${p.marker} ${p.value} 次 (${percent}%)`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: 'rgba(0,0,0,0.55)' },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: trafficX,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.65)' }
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: trafficY,
|
||||
barWidth: 14,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#FF4D4F' },
|
||||
{ offset: 1, color: '#FF7A45' }
|
||||
]
|
||||
}
|
||||
},
|
||||
animationDuration: 800,
|
||||
animationEasing: 'cubicOut'
|
||||
}]
|
||||
}
|
||||
|
||||
// 用户结构环形图
|
||||
// 用户结构环形图(增强:颜色 + 动画)
|
||||
const segmentData = this.userSegments.map((it) => ({
|
||||
name: it.name,
|
||||
value: (() => {
|
||||
@@ -548,9 +826,20 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
return isFinite(n) ? n : 0
|
||||
})()
|
||||
}))
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#A8E6CF', '#FFD93D', '#95A5A6']
|
||||
this.userSegmentOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 12 }
|
||||
},
|
||||
color: colors,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
@@ -559,10 +848,112 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
labelLine: { length: 10, length2: 10 },
|
||||
data: segmentData
|
||||
data: segmentData,
|
||||
animationType: 'scale',
|
||||
animationEasing: 'elasticOut',
|
||||
animationDelay: (idx: number) => idx * 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// 构建 KPI sparkline(mini 趋势图)
|
||||
buildSparklineOptions() {
|
||||
// 从 trend 数据提取最近 7 天的数据用于 sparkline
|
||||
const recentDays = Math.min(7, this.trend.gmv.length)
|
||||
const sparkX = this.trend.x.slice(-recentDays)
|
||||
const sparkGmv = this.trend.gmv.slice(-recentDays)
|
||||
const sparkOrders = this.trend.orders.slice(-recentDays)
|
||||
|
||||
// GMV sparkline
|
||||
this.kpiSparklineOptions.gmv = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkGmv,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
|
||||
// Orders sparkline
|
||||
this.kpiSparklineOptions.orders = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkOrders,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
|
||||
// Users sparkline(模拟数据,实际应该从 user_sessions 获取)
|
||||
const sparkUsers = sparkOrders.map((v) => Math.round(v * 0.8))
|
||||
this.kpiSparklineOptions.users = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkUsers,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
|
||||
// Conversion sparkline(基于 GMV/Orders 计算)
|
||||
const sparkConversion = sparkGmv.map((gmv, i) => {
|
||||
const orders = sparkOrders[i] || 1
|
||||
return (gmv / orders / 100).toFixed(2)
|
||||
})
|
||||
this.kpiSparklineOptions.conversion = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkConversion,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
// 快速工具跳转
|
||||
goToSalesReport() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/sales-report' })
|
||||
},
|
||||
|
||||
goToUserAnalysis() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/user-analysis' })
|
||||
},
|
||||
|
||||
goToProductInsights() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/product-insights' })
|
||||
},
|
||||
|
||||
goToMarketTrends() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/market-trends' })
|
||||
},
|
||||
|
||||
goToCouponAnalysis() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/coupon-analysis' })
|
||||
},
|
||||
|
||||
goToCustomReport() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/custom-report' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,30 +1248,125 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
|
||||
.kpi-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 14px;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
min-width: 260px; /* 窄屏自动掉到一列 */
|
||||
min-width: 260px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.kpi-card:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* KPI 卡片渐变背景 */
|
||||
.kpi-card-gmv {
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FF4D4F 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-card-orders {
|
||||
background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-card-users {
|
||||
background: linear-gradient(135deg, #A8E6CF 0%, #7FCDBB 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-card-conversion {
|
||||
background: linear-gradient(135deg, #FFD93D 0%, #FFA07A 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kpi-sparkline {
|
||||
width: 80px;
|
||||
height: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sparkline-skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.kpi-footer {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kpi-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.kpi-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.kpi-chip.pos {
|
||||
background: rgba(34,197,94,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-chip.neg {
|
||||
background: rgba(239,68,68,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-chip.neutral {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 时间维度 tabs 横排 */
|
||||
@@ -1069,6 +1555,11 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
.more-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 宽屏时工具卡片 3列 */
|
||||
.tools-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 自适应:窄屏自动变一列(断点用 px) */
|
||||
@@ -1127,6 +1618,115 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
.dropdown-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 窄屏时 KPI 卡片单列 */
|
||||
.kpi-card {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
/* 窄屏时工具卡片 2列 */
|
||||
.tools-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 快速工具卡片区 */
|
||||
.tools-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 20px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.tool-card:active {
|
||||
transform: scale(0.96);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.tool-icon.sales {
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FF4D4F 100%);
|
||||
}
|
||||
|
||||
.tool-icon.users {
|
||||
background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);
|
||||
}
|
||||
|
||||
.tool-icon.products {
|
||||
background: linear-gradient(135deg, #A8E6CF 0%, #7FCDBB 100%);
|
||||
}
|
||||
|
||||
.tool-icon.market {
|
||||
background: linear-gradient(135deg, #FFD93D 0%, #FFA07A 100%);
|
||||
}
|
||||
|
||||
.tool-icon.coupon {
|
||||
background: linear-gradient(135deg, #FF9A9E 0%, #FECFEF 100%);
|
||||
}
|
||||
|
||||
.tool-icon.custom {
|
||||
background: linear-gradient(135deg, #A8C0FF 0%, #3B82F6 100%);
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user