接入数据库

This commit is contained in:
comlibmb
2026-01-26 21:34:17 +08:00
parent c14f67cfc8
commit 3fbd9a2b3d
26 changed files with 3559 additions and 427 deletions

View File

@@ -107,6 +107,7 @@
<script lang="uts">
import supa 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'
type TableColumn = { key: string; label: string; type: string; sortable: boolean }

View File

@@ -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 sparklinemini 趋势图)
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>

View File

@@ -25,232 +25,232 @@
<!-- 主内容区域 -->
<view class="main-content">
<view class="analytics-profile">
<!-- 分析师信息头部 -->
<view class="profile-header">
<image :src="analystInfo.avatar_url || '/static/default-avatar.png'" class="analyst-avatar" @click="editProfile" />
<view class="analyst-info">
<text class="analyst-name">{{ analystInfo.nickname || analystInfo.phone }}</text>
<text class="analyst-role">{{ getAnalystRole() }}</text>
<view class="analyst-stats">
<text class="stat-item">工作经验: {{ workExperience }}年</text>
<text class="stat-item">专业领域: {{ expertise }}</text>
</view>
</view>
<view class="settings-icon" @click="goToSettings">⚙️</view>
</view>
<view class="analytics-profile">
<!-- 分析师信息头部 -->
<view class="profile-header">
<image :src="analystInfo.avatar_url || '/static/default-avatar.png'" class="analyst-avatar" @click="editProfile" />
<view class="analyst-info">
<text class="analyst-name">{{ analystInfo.nickname || analystInfo.phone }}</text>
<text class="analyst-role">{{ getAnalystRole() }}</text>
<view class="analyst-stats">
<text class="stat-item">工作经验: {{ workExperience }}年</text>
<text class="stat-item">专业领域: {{ expertise }}</text>
</view>
</view>
<view class="settings-icon" @click="goToSettings">⚙️</view>
</view>
<!-- 数据概览 -->
<view class="data-overview">
<view class="section-title">数据概览</view>
<view class="overview-cards">
<view class="overview-card" @click="goToReports('sales')">
<text class="card-icon">💰</text>
<text class="card-title">销售数据</text>
<text class="card-value">¥{{ overviewData.totalSales }}</text>
<text class="card-change positive">+{{ overviewData.salesGrowth }}%</text>
</view>
<view class="overview-card" @click="goToReports('users')">
<text class="card-icon">👥</text>
<text class="card-title">用户增长</text>
<text class="card-value">{{ overviewData.totalUsers }}</text>
<text class="card-change positive">+{{ overviewData.userGrowth }}%</text>
</view>
<view class="overview-card" @click="goToReports('orders')">
<text class="card-icon">📋</text>
<text class="card-title">订单量</text>
<text class="card-value">{{ overviewData.totalOrders }}</text>
<text class="card-change" :class="{ positive: overviewData.orderGrowth > 0 }">
{{ overviewData.orderGrowth > 0 ? '+' : '' }}{{ overviewData.orderGrowth }}%
</text>
</view>
<view class="overview-card" @click="goToReports('conversion')">
<text class="card-icon">📈</text>
<text class="card-title">转化率</text>
<text class="card-value">{{ overviewData.conversionRate }}%</text>
<text class="card-change positive">+{{ overviewData.conversionGrowth }}%</text>
</view>
<!-- 数据概览 -->
<view class="data-overview">
<view class="section-title">数据概览</view>
<view class="overview-cards">
<view class="overview-card" @click="goToReports('sales')">
<text class="card-icon">💰</text>
<text class="card-title">销售数据</text>
<text class="card-value">¥{{ overviewData.totalSales }}</text>
<text class="card-change positive">+{{ overviewData.salesGrowth }}%</text>
</view>
<view class="overview-card" @click="goToReports('users')">
<text class="card-icon">👥</text>
<text class="card-title">用户增长</text>
<text class="card-value">{{ overviewData.totalUsers }}</text>
<text class="card-change positive">+{{ overviewData.userGrowth }}%</text>
</view>
<view class="overview-card" @click="goToReports('orders')">
<text class="card-icon">📋</text>
<text class="card-title">订单量</text>
<text class="card-value">{{ overviewData.totalOrders }}</text>
<text class="card-change" :class="{ positive: overviewData.orderGrowth > 0 }">
{{ overviewData.orderGrowth > 0 ? '+' : '' }}{{ overviewData.orderGrowth }}%
</text>
</view>
<view class="overview-card" @click="goToReports('conversion')">
<text class="card-icon">📈</text>
<text class="card-title">转化率</text>
<text class="card-value">{{ overviewData.conversionRate }}%</text>
<text class="card-change positive">+{{ overviewData.conversionGrowth }}%</text>
</view>
</view>
</view>
<!-- 报表管理 -->
<view class="report-management">
<view class="section-title">报表管理</view>
<view class="report-tabs">
<view class="report-tab" @click="goToReports('all')">
<text class="tab-icon">📊</text>
<text class="tab-text">全部报表</text>
<text v-if="reportCounts.total > 0" class="tab-badge">{{ reportCounts.total }}</text>
</view>
<view class="report-tab" @click="goToReports('pending')">
<text class="tab-icon">⏳</text>
<text class="tab-text">待生成</text>
<text v-if="reportCounts.pending > 0" class="tab-badge alert">{{ reportCounts.pending }}</text>
</view>
<view class="report-tab" @click="goToReports('scheduled')">
<text class="tab-icon">📅</text>
<text class="tab-text">定时报表</text>
<text v-if="reportCounts.scheduled > 0" class="tab-badge">{{ reportCounts.scheduled }}</text>
</view>
<view class="report-tab" @click="goToReports('shared')">
<text class="tab-icon">🔗</text>
<text class="tab-text">共享报表</text>
<text v-if="reportCounts.shared > 0" class="tab-badge">{{ reportCounts.shared }}</text>
</view>
</view>
</view>
<!-- 今日数据洞察 -->
<view class="today-insights">
<view class="section-title">今日洞察</view>
<view class="insight-grid">
<view class="insight-card">
<text class="insight-icon">🔥</text>
<text class="insight-title">热销商品</text>
<text class="insight-value">{{ todayInsights.hotProduct }}</text>
<text class="insight-desc">销量同比增长156%</text>
</view>
<view class="insight-card">
<text class="insight-icon">⚡</text>
<text class="insight-title">流量峰值</text>
<text class="insight-value">{{ todayInsights.peakTraffic }}</text>
<text class="insight-desc">14:30达到峰值</text>
</view>
<view class="insight-card">
<text class="insight-icon">🎯</text>
<text class="insight-title">转化异常</text>
<text class="insight-value">{{ todayInsights.conversionAnomaly }}</text>
<text class="insight-desc">需要关注</text>
</view>
<view class="insight-card">
<text class="insight-icon">📱</text>
<text class="insight-title">移动端占比</text>
<text class="insight-value">{{ todayInsights.mobileRatio }}%</text>
<text class="insight-desc">持续增长</text>
</view>
</view>
</view>
<!-- 最近生成的报表 -->
<view class="recent-reports">
<view class="section-header">
<text class="section-title">最近报表</text>
<text class="view-all" @click="goToReports('all')">查看全部 ></text>
</view>
<view v-if="recentReports.length > 0" class="report-list">
<view v-for="report in recentReports" :key="report.id" class="report-item" @click="viewReportDetail(report.id)">
<view class="report-icon">
<text class="icon-text">📊</text>
</view>
<view class="report-info">
<text class="report-title">{{ report.title }}</text>
<text class="report-desc">{{ report.description }}</text>
<text class="report-time">{{ formatTime(report.created_at) }}</text>
</view>
<view class="report-status">
<text class="status-text" :class="'status-' + report.status">{{ getReportStatusText(report.status) }}</text>
</view>
</view>
</view>
<view v-else class="no-data">
<text class="no-data-text">暂无最近报表</text>
</view>
</view>
<!-- 数据趋势图表 -->
<view class="trend-chart">
<view class="section-header">
<text class="section-title">数据趋势</text>
<view class="chart-controls">
<text class="control-btn" :class="{ active: trendPeriod === 'week' }" @click="changeTrendPeriod('week')">周</text>
<text class="control-btn" :class="{ active: trendPeriod === 'month' }" @click="changeTrendPeriod('month')">月</text>
<text class="control-btn" :class="{ active: trendPeriod === 'quarter' }" @click="changeTrendPeriod('quarter')">季</text>
</view>
</view>
<view class="chart-container">
<view class="chart-legend">
<view class="legend-item">
<view class="legend-color sales"></view>
<text class="legend-text">销售额</text>
</view>
<view class="legend-item">
<view class="legend-color orders"></view>
<text class="legend-text">订单量</text>
</view>
</view>
<view class="chart-area">
<view class="chart-bars">
<view v-for="(data, index) in trendData" :key="index" class="bar-group">
<view class="bar sales" :style="{ height: (data.sales / maxSales * 100) + '%' }"></view>
<view class="bar orders" :style="{ height: (data.orders / maxOrders * 100) + '%' }"></view>
<text class="bar-label">{{ data.label }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 报表管理 -->
<view class="report-management">
<view class="section-title">报表管理</view>
<view class="report-tabs">
<view class="report-tab" @click="goToReports('all')">
<text class="tab-icon">📊</text>
<text class="tab-text">全部报表</text>
<text v-if="reportCounts.total > 0" class="tab-badge">{{ reportCounts.total }}</text>
</view>
<view class="report-tab" @click="goToReports('pending')">
<text class="tab-icon">⏳</text>
<text class="tab-text">待生成</text>
<text v-if="reportCounts.pending > 0" class="tab-badge alert">{{ reportCounts.pending }}</text>
</view>
<view class="report-tab" @click="goToReports('scheduled')">
<text class="tab-icon">📅</text>
<text class="tab-text">定时报表</text>
<text v-if="reportCounts.scheduled > 0" class="tab-badge">{{ reportCounts.scheduled }}</text>
</view>
<view class="report-tab" @click="goToReports('shared')">
<text class="tab-icon">🔗</text>
<text class="tab-text">共享报表</text>
<text v-if="reportCounts.shared > 0" class="tab-badge">{{ reportCounts.shared }}</text>
</view>
</view>
</view>
<!-- 分析工具 -->
<view class="analysis-tools">
<view class="section-title">分析工具</view>
<view class="tool-grid">
<view class="tool-item" @click="goToTool('dashboard')">
<text class="tool-icon">📊</text>
<text class="tool-label">数据看板</text>
</view>
<view class="tool-item" @click="goToTool('funnel')">
<text class="tool-icon">🔽</text>
<text class="tool-label">转化漏斗</text>
</view>
<view class="tool-item" @click="goToTool('cohort')">
<text class="tool-icon">👥</text>
<text class="tool-label">用户留存</text>
</view>
<view class="tool-item" @click="goToTool('attribution')">
<text class="tool-icon">🎯</text>
<text class="tool-label">归因分析</text>
</view>
<view class="tool-item" @click="goToTool('segmentation')">
<text class="tool-icon">🔍</text>
<text class="tool-label">用户分群</text>
</view>
<view class="tool-item" @click="goToTool('prediction')">
<text class="tool-icon">🔮</text>
<text class="tool-label">预测分析</text>
</view>
</view>
</view>
<!-- 今日数据洞察 -->
<view class="today-insights">
<view class="section-title">今日洞察</view>
<view class="insight-grid">
<view class="insight-card">
<text class="insight-icon">🔥</text>
<text class="insight-title">热销商品</text>
<text class="insight-value">{{ todayInsights.hotProduct }}</text>
<text class="insight-desc">销量同比增长156%</text>
</view>
<view class="insight-card">
<text class="insight-icon"></text>
<text class="insight-title">流量峰值</text>
<text class="insight-value">{{ todayInsights.peakTraffic }}</text>
<text class="insight-desc">14:30达到峰值</text>
</view>
<view class="insight-card">
<text class="insight-icon">🎯</text>
<text class="insight-title">转化异常</text>
<text class="insight-value">{{ todayInsights.conversionAnomaly }}</text>
<text class="insight-desc">需要关注</text>
</view>
<view class="insight-card">
<text class="insight-icon">📱</text>
<text class="insight-title">移动端占比</text>
<text class="insight-value">{{ todayInsights.mobileRatio }}%</text>
<text class="insight-desc">持续增长</text>
</view>
</view>
</view>
<!-- 最近生成的报表 -->
<view class="recent-reports">
<view class="section-header">
<text class="section-title">最近报表</text>
<text class="view-all" @click="goToReports('all')">查看全部 ></text>
</view>
<view v-if="recentReports.length > 0" class="report-list">
<view v-for="report in recentReports" :key="report.id" class="report-item" @click="viewReportDetail(report.id)">
<view class="report-icon">
<text class="icon-text">📊</text>
</view>
<view class="report-info">
<text class="report-title">{{ report.title }}</text>
<text class="report-desc">{{ report.description }}</text>
<text class="report-time">{{ formatTime(report.created_at) }}</text>
</view>
<view class="report-status">
<text class="status-text" :class="'status-' + report.status">{{ getReportStatusText(report.status) }}</text>
</view>
</view>
</view>
<view v-else class="no-data">
<text class="no-data-text">暂无最近报表</text>
</view>
</view>
<!-- 数据趋势图表 -->
<view class="trend-chart">
<view class="section-header">
<text class="section-title">数据趋势</text>
<view class="chart-controls">
<text class="control-btn" :class="{ active: trendPeriod === 'week' }" @click="changeTrendPeriod('week')">周</text>
<text class="control-btn" :class="{ active: trendPeriod === 'month' }" @click="changeTrendPeriod('month')">月</text>
<text class="control-btn" :class="{ active: trendPeriod === 'quarter' }" @click="changeTrendPeriod('quarter')">季</text>
</view>
</view>
<view class="chart-container">
<view class="chart-legend">
<view class="legend-item">
<view class="legend-color sales"></view>
<text class="legend-text">销售额</text>
</view>
<view class="legend-item">
<view class="legend-color orders"></view>
<text class="legend-text">订单量</text>
</view>
</view>
<view class="chart-area">
<view class="chart-bars">
<view v-for="(data, index) in trendData" :key="index" class="bar-group">
<view class="bar sales" :style="{ height: (data.sales / maxSales * 100) + '%' }"></view>
<view class="bar orders" :style="{ height: (data.orders / maxOrders * 100) + '%' }"></view>
<text class="bar-label">{{ data.label }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 分析工具 -->
<view class="analysis-tools">
<view class="section-title">分析工具</view>
<view class="tool-grid">
<view class="tool-item" @click="goToTool('dashboard')">
<text class="tool-icon">📊</text>
<text class="tool-label">数据看板</text>
</view>
<view class="tool-item" @click="goToTool('funnel')">
<text class="tool-icon">🔽</text>
<text class="tool-label">转化漏斗</text>
</view>
<view class="tool-item" @click="goToTool('cohort')">
<text class="tool-icon">👥</text>
<text class="tool-label">用户留存</text>
</view>
<view class="tool-item" @click="goToTool('attribution')">
<text class="tool-icon">🎯</text>
<text class="tool-label">归因分析</text>
</view>
<view class="tool-item" @click="goToTool('segmentation')">
<text class="tool-icon">🔍</text>
<text class="tool-label">用户分群</text>
</view>
<view class="tool-item" @click="goToTool('prediction')">
<text class="tool-icon">🔮</text>
<text class="tool-label">预测分析</text>
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="function-menu">
<view class="menu-group">
<view class="menu-item" @click="goToDataSource">
<text class="menu-icon">🗄️</text>
<text class="menu-label">数据源管理</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToAlerts">
<text class="menu-icon">🚨</text>
<text class="menu-label">数据预警</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToExport">
<text class="menu-icon">📤</text>
<text class="menu-label">数据导出</text>
<text class="menu-arrow">></text>
</view>
</view>
<view class="menu-group">
<view class="menu-item" @click="goToHelp">
<text class="menu-icon">❓</text>
<text class="menu-label">帮助中心</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToFeedback">
<text class="menu-icon">💬</text>
<text class="menu-label">意见反馈</text>
<text class="menu-arrow">></text>
<!-- 功能菜单 -->
<view class="function-menu">
<view class="menu-group">
<view class="menu-item" @click="goToDataSource">
<text class="menu-icon">🗄️</text>
<text class="menu-label">数据源管理</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToAlerts">
<text class="menu-icon">🚨</text>
<text class="menu-label">数据预警</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToExport">
<text class="menu-icon">📤</text>
<text class="menu-label">数据导出</text>
<text class="menu-arrow">></text>
</view>
</view>
<view class="menu-group">
<view class="menu-item" @click="goToHelp">
<text class="menu-icon">❓</text>
<text class="menu-label">帮助中心</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToFeedback">
<text class="menu-icon">💬</text>
<text class="menu-label">意见反馈</text>
<text class="menu-arrow">></text>
</view>
</view>
</view>
@@ -396,7 +396,7 @@ async function loadAnalystInfo() {
.eq('id', currentUserId.value)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
if (rows.length > 0) {
analystInfo.value = {
analystInfo.value = {
...(analystInfo.value as any),
id: `${rows[0].id}`,
phone: `${rows[0].phone || ''}`,

View File

@@ -209,9 +209,116 @@ export default {
},
methods: {
calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate, days }
},
async loadSalesData() {
// TODO: 实现销售数据加载
this.updateTime()
try {
this.updateTime()
const now = new Date()
const { startDate, endDate, days } = this.calcDateRange()
// 1) KPI复用 realtime_kpis 的口径GMV/订单/转化率),把窗口替换成“周期范围 vs 上一周期”
const periodStart = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const periodEnd = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate() + 1) // 包含 endDate 当天
const prevStart = new Date(periodStart.getTime() - days * 24 * 60 * 60 * 1000)
const prevEnd = new Date(periodStart.getTime())
const pKpi = new UTSJSONObject()
pKpi.set('p_start', periodStart.toISOString())
pKpi.set('p_end', periodEnd.toISOString())
pKpi.set('p_compare_start', prevStart.toISOString())
pKpi.set('p_compare_end', prevEnd.toISOString())
pKpi.set('p_merchant_id', null)
const kpiRes: any = await supa.rpc('rpc_analytics_realtime_kpis', pKpi)
const row = Array.isArray(kpiRes.data) && kpiRes.data.length > 0 ? kpiRes.data[0] : (kpiRes.data || {})
const safe = (v: any): number => {
const n = Number(v)
return isFinite(n) ? n : 0
}
const gmv = safe(row.gmv)
const orders = safe(row.orders)
const avgOrder = orders > 0 ? gmv / orders : 0
this.salesData = {
gmv: Math.round(gmv),
gmv_growth: safe(row.gmv_growth),
orders: Math.round(orders),
order_growth: safe(row.order_growth),
conversion_rate: safe(row.conversion_rate),
conversion_growth: safe(row.conversion_growth),
avg_order_amount: avgOrder,
avg_order_growth: safe(row.gmv_growth) // 兜底:暂无独立口径,先跟随 GMV 增长
}
// 2) 趋势(复用 trend_data
const pTrend = new UTSJSONObject()
pTrend.set('p_start_date', startDate.toISOString().slice(0, 10))
pTrend.set('p_end_date', endDate.toISOString().slice(0, 10))
pTrend.set('p_merchant_id', null)
const trendRes: any = await supa.rpc('rpc_analytics_trend_data', pTrend)
const tRows: Array<any> = Array.isArray(trendRes.data) ? (trendRes.data as Array<any>) : []
const x: Array<string> = []
const gmvArr: Array<number> = []
const orderArr: Array<number> = []
for (let i = 0; i < tRows.length; i++) {
const d = `${tRows[i].date}`
x.push(d.slice(5))
gmvArr.push(Number(tRows[i].gmv) || 0)
orderArr.push(Number(tRows[i].orders) || 0)
}
this.trend = { x, gmv: gmvArr, orders: orderArr }
// 3) TOP 商品/商家
const pTopP = new UTSJSONObject()
pTopP.set('p_start_date', startDate.toISOString().slice(0, 10))
pTopP.set('p_end_date', endDate.toISOString().slice(0, 10))
pTopP.set('p_limit', 10)
pTopP.set('p_merchant_id', null)
const topPRes: any = await supa.rpc('rpc_analytics_top_products', pTopP)
const pRows: Array<any> = Array.isArray(topPRes.data) ? (topPRes.data as Array<any>) : []
const pList: Array<ProductRank> = []
for (let i = 0; i < pRows.length; i++) {
pList.push({ id: `${pRows[i].id}`, rank: i + 1, name: `${pRows[i].name}`, sales: Number(pRows[i].sales) || 0 })
}
this.topProducts = pList
const pTopM = new UTSJSONObject()
pTopM.set('p_start_date', startDate.toISOString().slice(0, 10))
pTopM.set('p_end_date', endDate.toISOString().slice(0, 10))
pTopM.set('p_limit', 10)
const topMRes: any = await supa.rpc('rpc_analytics_top_merchants', pTopM)
const mRows: Array<any> = Array.isArray(topMRes.data) ? (topMRes.data as Array<any>) : []
const mList: Array<MerchantRank> = []
for (let i = 0; i < mRows.length; i++) {
mList.push({
id: `${mRows[i].id}`,
rank: i + 1,
name: `${mRows[i].name}`,
sales: Number(mRows[i].sales) || 0,
growth: Number(mRows[i].growth) || 0
})
}
this.topMerchants = mList
// 4) 地域分布:当前基础表无“省份/城市”字段,这里用“商家 GMV 分布”做动态替代
this.regionChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: { type: 'category', data: mList.map((m) => m.name), axisLabel: { rotate: 30, color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [{ type: 'bar', data: mList.map((m) => m.sales), barWidth: 18, itemStyle: { borderRadius: 6 } }]
}
} catch (e) {
console.error('loadSalesData failed', e)
} finally {
this.updateTime()
}
},
selectPeriod(p: string) {

View File

@@ -0,0 +1,66 @@
# Analytics 测试数据快速开始(更新版)
> 本文档基于 **2026-01 修订后的数据库脚本**(含 RLS 安全、中文注释、幂等执行)
>
> 请务必按下述 **执行顺序** 依次运行 SQL否则会出现外键或 RLS 限制导致的插入失败。
---
## 🗂️ SQL 执行顺序(只创建,不删除)
| 步骤 | 作用 | 文件 | 需要权限 |
| ---- | ----------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------- |
| 1 | 创建基础业务表orders/users/user_sessions/products/merchants/page_views 等) | `01_create_tables.sql` | 任意(不清空数据,可重复执行) |
| 2 | 创建用户资料表ak_users+ RLS + 用户资料函数 | `../../user/test/USER_AUTH_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
| 3 | 创建 auth.users → ak_users 触发器 | `../../user/test/USER_AUTH_TRIGGER.sql` | **需要访问 auth schema建议 Dashboard SQL Editor** |
| 4 | 创建 analytics_* 表 + RLS + RPC | `ANALYTICS_DB_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
| 5 | 插入业务侧测试数据 | `02_insert_test_data.sql` | **service_role**¹ |
| 6 | 插入 analytics_* 测试数据 | `ANALYTICS_TEST_SEED.sql` | **service_role**¹ |
| 7 | (可选) 查询验证 | `03_test_queries.sql` | 登录用户 |
¹ *原因:两份 seed 脚本要写入带 RLS 的表,直接用 anon / authenticated 会被策略拦截。Dashboard SQL Editor 默认具备等价于 postgres/service_role 的权限可直接执行CLI 请使用 `psql … -U postgres`(或你的 DB 管理员账号)执行。*
---
## 🚀 执行步骤(以 Supabase Dashboard 为例)
1. 打开 **SQL Editor** → 依次新建 Query 运行 *步骤14*
2. 登出 / 使用普通账号登录,再运行 *步骤5* 查询验证。
---
## ⚠️ 常见问题
1. **RLS 阻挡插入**
请确认 seed 在 Dashboard 执行,或先 `SET ROLE service_role;`
不建议在 seed 中禁用 RLS。
2. **重复执行报错**
脚本为“只创建,不删除”模式:表/索引使用 `IF NOT EXISTS`,触发器/策略使用系统表判断后再创建,可重复执行。若仍报错,请先 `ROLLBACK;` 再重试。
3. **前端查不到 seed 数据**
登陆用户的 `auth.uid()`必须与 seed 中 `orders.user_id` 等字段匹配;否则受 RLS 影响会看不到。测试时可在 seed 中把某条 `user_id` 改成你自己的 UID。
---
## 🔐 权限矩阵(简版)
| 表 / 功能 | anon | authenticated | service_role |
| -------------------------------------- | ----------------- | ------------------- | ------------ |
| `orders / order_items / user_sessions` | Insert❌ / Select❌ | ✅(仅本人) | ✅(全部) |
| `products / merchants` | Select✅ | CRUD⚠ (受策略) | ✅ |
| `page_views` | Insert✅ / Select❌ | Select✅(本人) | ✅ |
| `analytics_*` 表 | ❌ | ✅ (按 owner/shared) | ✅ |
| RPC (analytics) | ❌ | ✅ | ✅ |
> 详细策略请见各 SQL 文件内注释。
---
## 🧹 清理
执行 `04_cleanup.sql` 可按时间 / 用户删除测试数据,脚本已更新为幂等。
---
最后更新2026-01-26

View File

@@ -39,22 +39,14 @@
### 方式 1: 通过 Supabase Dashboard推荐
1. **访问 Dashboard**
```
http://192.168.1.63:8000
http://192.168.1.63:3000 (Studio 默认端口)
```
1. **打开 Supabase Studio / Dashboard**
- 请使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
2. **登录**
- 用户名:`supabase`
- 密码:`D4ce5p8YBpfYzEoDGZ_7MzehZcWrdCNyDEj_VSUBmOw`
3. **打开 SQL Editor**
2. **打开 SQL Editor**
- 在左侧菜单找到 "SQL Editor"
- 点击 "New Query"
4. **执行脚本**
3. **执行脚本**
- 复制 `01_create_tables.sql` 的内容,粘贴并执行
- 复制 `02_insert_test_data.sql` 的内容,粘贴并执行
- (可选)复制 `03_test_queries.sql` 的内容,验证数据
@@ -62,10 +54,8 @@
### 方式 2: 使用命令行PostgreSQL
```bash
# 连接到内网 Supabase 数据库
psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
# 输入密码yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc
# 连接到 Supabase Postgres参数请按你的环境填写
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
# 执行 SQL 文件(需要完整路径)
\i D:/datas/hfkj/mall/pages/mall/analytics/test/01_create_tables.sql
@@ -76,17 +66,20 @@ psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
### 方式 3: 使用图形工具DBeaver / pgAdmin
1. **创建连接**
- 主机:`192.168.1.63`
- 端口:`5432`
- 主机:`<DB_HOST>`
- 端口:`<DB_PORT>`
- 数据库:`postgres`
- 用户名:`postgres`
- 密码:`yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc`
- 用户名:`postgres`(或你的管理员账号)
- 密码:`<DB_PASSWORD>`
2. **执行 SQL**
- 打开 SQL 编辑器
- 复制 SQL 文件内容并执行
**详细说明请查看:`SQL_USAGE_GUIDE.md`**
**详细说明请查看:**
- **`ANALYTICS_DATA_QUICK_START.md`** - ⭐ **SQL 文件执行顺序指南(必读!)**
- `SQL_USAGE_GUIDE.md` - SQL 脚本执行详细指南
- `TEST_DATA_INSERT_GUIDE.md` - 测试数据插入指南(包含 RLS 处理说明)
## 测试数据说明
@@ -111,6 +104,20 @@ psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
- **今日下单用户数:** 约 8个从 orders 表去重统计)
- **预期转化率:** 约 53-80%(根据实际数据计算)
## ⚠️ 重要RLS行级安全策略说明
**所有表已启用 RLS**,插入测试数据时需要注意:
1. **推荐方式**:使用 Supabase Dashboard 的 SQL Editor 执行脚本
- Dashboard 默认使用 `service_role` 权限,可以绕过 RLS
- 无需额外配置,直接执行即可
2. **命令行方式**:如果使用命令行或脚本执行
- 需要临时禁用 RLS`02_insert_test_data.sql` 中的注释说明)
- 或使用 `SECURITY DEFINER` 函数(见 `TEST_DATA_INSERT_GUIDE.md`
3. **详细说明**:请查看 `TEST_DATA_INSERT_GUIDE.md` 获取完整的插入指南
## 注意事项
1. **时间依赖**
@@ -131,24 +138,14 @@ psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
- 所有 ID 使用 UUID 格式
- 测试数据使用了固定的 UUID 便于识别
5. **RLS 权限**
- 插入数据后,前端查询需要用户已登录
- 测试数据的 `user_id` 需要与登录用户的 `auth.uid()` 匹配才能查询到
- 或者使用公开数据(如 `products``merchants` 表)
## 清理测试数据
如果需要清理测试数据,可以执行:
```sql
-- 谨慎操作:清空测试数据
TRUNCATE TABLE orders, user_sessions, users, order_items, page_views CASCADE;
```
或者删除特定时间范围的数据:
```sql
-- 删除今日的测试订单
DELETE FROM orders WHERE created_at >= DATE_TRUNC('day', NOW());
-- 删除测试用户会话
DELETE FROM user_sessions WHERE created_at >= DATE_TRUNC('day', NOW());
```
如需清理测试数据,请使用独立的清理脚本(例如 `04_cleanup.sql`)。
## 验证实时大屏功能

View File

@@ -0,0 +1,15 @@
# SQL 文件执行顺序指南(已弃用)
> 本文件已停止维护,避免与新脚本冲突。
>
> ✅ **请以 `ANALYTICS_DATA_QUICK_START.md` 为唯一权威执行顺序与权限说明文档。**
## 当前推荐执行顺序(摘要)
1. `01_create_tables.sql`(基础业务表 + RLS + 中文注释Drop-first
2. `../../user/test/USER_AUTH_SCHEMA.sql``ak_users` + RLS + 资料函数Drop-first
3. `../../user/test/USER_AUTH_TRIGGER.sql`auth.users → ak_users 触发器)
4. `ANALYTICS_DB_SCHEMA.sql`analytics_* 表 + RLS + RPCDrop-first
5. `02_insert_test_data.sql`(基础表测试数据,需 service_role/postgres
6. `ANALYTICS_TEST_SEED.sql`analytics_* 测试数据,需 service_role/postgres
7. `03_test_queries.sql`(可选:验证查询)

View File

@@ -19,18 +19,10 @@ pages/mall/analytics/test/
如果您的内网 Supabase 有 Dashboard 界面:
1. **访问 Dashboard**
```
http://192.168.1.63:8000
http://192.168.1.63:3000 (Studio 默认端口)
```
1. **打开 Supabase Studio / Dashboard**
- 使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
2. **登录**
- 用户名:`supabase`(根据您的配置)
- 密码:`D4ce5p8YBpfYzEoDGZ_7MzehZcWrdCNyDEj_VSUBmOw`
3. **打开 SQL Editor**
2. **打开 SQL Editor**
- 在左侧菜单找到 "SQL Editor" 或 "SQL"
- 点击 "New Query"
@@ -51,10 +43,9 @@ pages/mall/analytics/test/
1. **连接数据库**
```bash
# 使用 psql 连接
psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
# 输入密码(根据您的配置
# POSTGRES_PASSWORD=yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc
# 密码请按你的环境输入/从安全渠道获取(不要写进仓库
```
2. **执行 SQL 文件**
@@ -70,41 +61,26 @@ pages/mall/analytics/test/
### 方式 3: 通过 DBeaver / pgAdmin 等图形工具
1. **创建新连接**
- 主机:`192.168.1.63`
- 端口:`5432`
- 主机:`<DB_HOST>`
- 端口:`<DB_PORT>`
- 数据库:`postgres`
- 用户名:`postgres`
- 密码:`yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc`
- 密码:`<DB_PASSWORD>`
2. **执行 SQL**
- 打开 SQL 编辑器
- 复制 SQL 文件内容
- 执行脚本
### 方式 4: 通过 HTTP API(程序化执行)
使用 Supabase REST API 执行 SQL需要 service_role key
```javascript
// 注意:这种方式需要 Supabase 的 SQL 执行功能
// 通常不推荐,因为安全风险较高
const response = await fetch('http://192.168.1.63:8000/rest/v1/rpc/exec_sql', {
method: 'POST',
headers: {
'apikey': 'YOUR_SERVICE_ROLE_KEY',
'Authorization': 'Bearer YOUR_SERVICE_ROLE_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
sql: 'SELECT * FROM users LIMIT 1;'
})
})
```
> 不建议通过 HTTP API “执行任意 SQL”高风险
> 如需服务端能力,请用 Supabase Edge Functions + 限定输入输出的 RPC。
## 📝 执行顺序
**重要:必须按顺序执行!**
> ✅ 以 `ANALYTICS_DATA_QUICK_START.md` 为权威执行顺序与权限说明(本文件只做执行方式补充)。
1. ✅ **第一步:创建表结构**
```sql
-- 执行 01_create_tables.sql
@@ -190,17 +166,11 @@ GRANT ALL PRIVILEGES ON SCHEMA public TO postgres;
### 2. 表已存在
如果表已存在,脚本会使用 `CREATE TABLE IF NOT EXISTS`,不会报错。
但如果需要重新创建:
```sql
-- 先删除表(谨慎操作)
DROP TABLE IF EXISTS order_items CASCADE;
DROP TABLE IF EXISTS page_views CASCADE;
DROP TABLE IF EXISTS user_sessions CASCADE;
DROP TABLE IF EXISTS orders CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS products CASCADE;
```
如果表已存在
- `01_create_tables.sql` / `ANALYTICS_DB_SCHEMA.sql` 现为 **只创建Create-only** 脚本,不包含 `DROP/DELETE/TRUNCATE`,可重复执行且不会清空数据。
- 如需结构变更请用迁移脚本ALTER TABLE
> 如确实要“清理后重建”,请另外单独维护清理脚本(避免把破坏性操作放进默认文档/默认流程)。
### 3. 时间依赖
@@ -238,8 +208,8 @@ Error: password authentication failed
Error: relation "orders" already exists
```
**解决:**
- 脚本已使用 `IF NOT EXISTS`,通常不会报错
- 如需重新创建,先删除表
- 说明你执行的脚本版本与当前仓库不一致,或只拷贝了部分 SQL
- 请按 `ANALYTICS_DATA_QUICK_START.md` 的顺序完整执行最新脚本Drop-first不应出现该错误
### Q4: 权限不足
```

View File

@@ -0,0 +1,209 @@
# 测试数据插入指南
> 本文档说明如何在启用 RLS行级安全策略的情况下插入测试数据。
## 📋 前置条件
1. **已执行表结构创建脚本**
- `01_create_tables.sql` - 创建表结构和 RLS 策略
- `ANALYTICS_DB_SCHEMA.sql` - 创建 analytics_* 表(可选)
2. **确认 Supabase 连接**
- 已配置 Supabase 项目
- 可以访问 Supabase Dashboard 的 SQL Editor
## 🚀 插入测试数据的三种方式
### 方式一:使用 Supabase Dashboard推荐
**优点**:最简单,无需处理 RLS 权限问题
**适用场景**:开发测试、快速验证
**步骤**
1. 打开 Supabase Dashboard
2. 进入 **SQL Editor**
3. 复制 `02_insert_test_data.sql` 的全部内容
4. 粘贴到 SQL Editor 中
5. 点击 **Run** 执行
**说明**Supabase Dashboard 的 SQL Editor 默认使用 `service_role` 权限,可以绕过 RLS 策略,直接插入数据。
---
### 方式二:临时禁用 RLS适用于命令行
**优点**:可以在命令行或脚本中执行
**适用场景**自动化脚本、CI/CD
**步骤**(不推荐,除非你明确理解风险):
1. 编辑 `02_insert_test_data.sql`
2. 取消文件开头关于禁用 RLS 的注释(第 12-19 行)
3. 取消文件末尾关于重新启用 RLS 的注释(第 137-144 行)
4. 执行脚本
**示例**
```sql
-- 在脚本开头添加
BEGIN;
ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions DISABLE ROW LEVEL SECURITY;
-- ... 其他表
-- 插入数据...
-- 在脚本末尾添加
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
-- ... 其他表
COMMIT;
```
**⚠️ 注意**:执行完成后务必重新启用 RLS否则数据将不受保护
---
### 方式三:使用 SECURITY DEFINER 函数(高级)
**优点**:更安全,不需要禁用 RLS
**适用场景**:生产环境、需要定期插入测试数据
**步骤**
1. 创建一个 SECURITY DEFINER 函数来插入测试数据
2. 调用该函数执行插入
**示例函数**
```sql
CREATE OR REPLACE FUNCTION insert_test_data()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- 插入测试用户
INSERT INTO users (id, phone, email, nickname, last_login_at) VALUES
('11111111-1111-1111-1111-111111111111', '13800000001', 'user1@test.com', '测试用户1', NOW() - INTERVAL '2 minutes')
ON CONFLICT (id) DO NOTHING;
-- 插入其他测试数据...
END;
$$;
-- 执行函数
SELECT insert_test_data();
```
---
## ✅ 验证数据插入
执行以下查询验证数据是否插入成功:
```sql
-- 检查用户数量
SELECT COUNT(*) FROM users;
-- 预期8
-- 检查订单数量
SELECT COUNT(*) FROM orders;
-- 预期2515个今日订单 + 10个昨日订单
-- 检查用户会话数量
SELECT COUNT(*) FROM user_sessions;
-- 预期10
-- 检查访问日志数量
SELECT COUNT(*) FROM page_views;
-- 预期15
-- 检查商家数量
SELECT COUNT(*) FROM merchants;
-- 预期2
-- 检查商品数量
SELECT COUNT(*) FROM products;
-- 预期3
```
---
## 🔍 常见问题
### Q1: 执行 INSERT 时提示 "new row violates row-level security policy"
**原因**RLS 策略阻止了插入操作。
**解决方案**
- 使用方式一Supabase Dashboard
- 或使用方式二(临时禁用 RLS
- 或使用方式三SECURITY DEFINER 函数)
### Q2: 插入数据后,前端查询不到数据
**原因**RLS 策略限制了查询权限。
**解决方案**
1. 确认前端已正确登录(`auth.uid()` 不为 NULL
2. 检查 RLS 策略是否正确配置
3. 确认测试数据的 `user_id` 与登录用户的 `auth.uid()` 匹配
### Q3: 如何清空测试数据重新插入?
为避免在默认文档里包含破坏性 SQL本项目将“清理/删除”动作放在独立清理脚本中(如 `04_cleanup.sql`)。
如你需要重新生成测试数据:
- 先执行清理脚本
- 再重新执行 seed 脚本
---
## 📝 测试数据说明
### 用户数据
- **数量**8 个测试用户
- **UUID 范围**`11111111-...``88888888-...`
- **用途**:用于订单、会话、访问日志等关联数据
### 订单数据
- **今日订单**15 笔status = 2已支付
- **昨日订单**10 笔(用于增长率对比)
- **总 GMV**:约 3,500 元(今日)
### 在线用户
- **最近 5 分钟活跃**5 个用户
- **用于**:实时大屏的"在线用户"统计
### 访问日志
- **数量**15 条
- **来源分布**direct/search/social/ad
- **用于**:转化率计算、流量来源分析
---
## 🔗 相关文件
- `01_create_tables.sql` - 表结构创建脚本
- `02_insert_test_data.sql` - 测试数据插入脚本
- `03_test_queries.sql` - 数据验证查询脚本
- `ANALYTICS_DB_SCHEMA.sql` - Analytics 表结构(可选)
---
## 📚 下一步
插入测试数据后,可以:
1. **验证前端页面**
- 访问 `/pages/mall/analytics/index` 查看实时大屏
- 检查 KPI 数据是否正确显示
2. **执行验证查询**
- 运行 `03_test_queries.sql` 验证数据计算逻辑
3. **测试 RPC 函数**
- 调用 `rpc_analytics_realtime_kpis` 验证实时 KPI 计算

View File

@@ -186,10 +186,79 @@ export default {
},
methods: {
calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate }
},
async loadUserData() {
// TODO: 实现用户数据加载
this.updateTime()
this.buildChartOptions()
try {
this.updateTime()
const { startDate, endDate } = this.calcDateRange()
const startStr = startDate.toISOString().slice(0, 10)
const endStr = endDate.toISOString().slice(0, 10)
const p = new UTSJSONObject()
p.set('p_start_date', startStr)
p.set('p_end_date', endStr)
// KPI新 RPC
const res: any = await supa.rpc('rpc_analytics_user_kpis', p)
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
const safe = (v: any): number => {
const n = Number(v)
return isFinite(n) ? n : 0
}
this.userData = {
total_users: Math.round(safe(row.total_users)),
user_growth: safe(row.user_growth),
new_users: Math.round(safe(row.new_users)),
new_user_growth: safe(row.new_user_growth),
active_rate: safe(row.active_rate),
active_growth: safe(row.active_growth),
repurchase_rate: safe(row.repurchase_rate),
repurchase_growth: safe(row.repurchase_growth)
}
// 增长趋势(新 RPC
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
const rows: Array<any> = Array.isArray(tRes.data) ? (tRes.data as Array<any>) : []
const x: Array<string> = []
const newArr: Array<number> = []
const totalArr: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
newArr.push(Number(rows[i].new_users) || 0)
totalArr.push(Number(rows[i].total_users) || 0)
}
// 构建图表(先把“用户增长趋势”做成真实动态图)
this.growthChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
legend: { data: ['新用户', '总用户'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [
{ name: '新用户', type: 'bar', data: newArr, barWidth: 14, itemStyle: { borderRadius: 6 } },
{ name: '总用户', type: 'line', data: totalArr, smooth: true, symbolSize: 6 }
]
}
// 其余图表:先用“有文案的占位”避免空白(后续可按业务字段继续增强)
this.retentionChartOption = { title: { text: '留存率(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.activityChartOption = { title: { text: '活跃度(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.comparisonChartOption = { title: { text: '新老用户对比(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
this.profileChartOption = { title: { text: '用户画像(待接入:需要性别/年龄/地域字段)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
} catch (e) {
console.error('loadUserData failed', e)
} finally {
this.updateTime()
}
},
selectPeriod(p: string) {
@@ -228,14 +297,7 @@ export default {
return `${sign}${v.toFixed(1)}%`
},
buildChartOptions() {
// TODO: 构建图表配置
this.growthChartOption = {}
this.retentionChartOption = {}
this.activityChartOption = {}
this.comparisonChartOption = {}
this.profileChartOption = {}
},
buildChartOptions() {},
handleMenu() {
this.showSidebarMenu = true