Files
medical-mall/pages/mall/analytics/index.uvue
2026-01-26 21:34:17 +08:00

1733 lines
45 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" @click="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'数据分析中心'"
:lastUpdateTime="lastUpdateTime"
:sidebarVisible="showSidebarMenu"
@menu-click="handleMenu"
@refresh="refreshAll"
@search="handleSearch"
@notification="handleNotification"
@fullscreen="handleFullscreen"
@mobile="handleMobile"
@dropdown="handleDropdown"
@settings="handleSettings"
/>
<view class="page-layout">
<!-- 侧边栏菜单组件 -->
<AnalyticsSidebarMenu
:visible="showSidebarMenu"
:currentPath="currentPath"
@visible-change="handleSidebarUpdate"
/>
<!-- 主内容区域 -->
<view class="main-content">
<view class="container">
<!-- KPI宽屏 4列窄屏 2列增强版渐变背景 + sparkline -->
<view class="kpi-grid">
<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>
<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>
<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>
<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>
<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">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
</view>
<!-- 核心趋势:占满横向(柱+折 组合图) -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">核心趋势GMV / 订单数)</text>
<text class="card-desc">{{ selectedPeriodText }} · 柱GMV · 线:订单数</text>
</view>
<AnalyticsComboChart
:xLabels="trend.x"
:gmv="trend.gmv"
:orders="trend.orders"
:height="320"
/>
</view>
<!-- 用户结构:横向占满 -->
<view class="card fullwide">
<view class="card-head">
<text class="card-title">用户结构(环形图)</text>
<text class="card-desc">未消费 / 首购 / 复购 / 回流</text>
</view>
<EChartsView class="chart-box" :option="userSegmentOption" />
</view>
<!-- 洞察区:宽屏左右分栏,窄屏自动上下 -->
<view class="insights-row">
<!-- 左侧:大图表 -->
<view class="insights-left card">
<view class="card-head">
<text class="card-title">流量来源(条形)</text>
<text class="card-desc">占比%</text>
</view>
<EChartsView class="chart-box" :option="trafficBarOption" />
</view>
<!-- 右侧:两个小卡片纵向堆叠 -->
<view class="insights-right">
<view class="card">
<view class="card-head">
<text class="card-title">热销商品 TOP</text>
<text class="card-desc">按销量</text>
</view>
<view class="rank-list">
<view v-for="p in topProducts" :key="p.id" class="rank-item">
<text class="rank-no">{{ p.rank }}</text>
<text class="rank-name">{{ p.name }}</text>
<text class="rank-val">{{ p.sales }} 件</text>
</view>
</view>
</view>
<view class="card">
<view class="card-head">
<text class="card-title">商家排行 TOP</text>
<text class="card-desc">按 GMV</text>
</view>
<view class="rank-list">
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
<text class="rank-no">{{ m.rank }}</text>
<text class="rank-name">{{ m.name }}</text>
<view class="rank-right">
<text class="rank-val">¥{{ formatMoney(m.sales) }}</text>
<text class="chip" :class="m.growth >= 0 ? 'pos' : 'neg'">
{{ m.growth >= 0 ? '+' : '' }}{{ m.growth }}%
</text>
</view>
</view>
</view>
</view>
</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>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
type SegmentItem = { name: string; value: number }
type TrafficItem = { name: string; value: number }
type TopProductItem = { id: string; rank: number; name: string; sales: number }
type TopMerchantItem = { id: string; rank: number; name: string; sales: number; growth: number }
export default {
components: {
AnalyticsComboChart,
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
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天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
],
realTime: {
gmv: 0,
gmv_growth: 0,
orders: 0,
order_growth: 0,
online_users: 0,
conversion_rate: 0,
conversion_growth: 0
},
trend: {
x: [] as Array<string>,
gmv: [] as Array<number>,
orders: [] as Array<number>
} as TrendData,
userSegments: [] as Array<SegmentItem>,
trafficSources: [] as Array<TrafficItem>,
topProducts: [] as Array<TopProductItem>,
topMerchants: [] as Array<TopMerchantItem>,
// 图表 options
trafficBarOption: {} as any,
userSegmentOption: {} as any,
kpiSparklineOptions: {
gmv: {} as any,
orders: {} as any,
users: {} as any,
conversion: {} as any
}
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
watch: {
trafficSources(newVal, oldVal) {
this.buildChartOptions()
},
userSegments(newVal, oldVal) {
this.buildChartOptions()
}
},
onLoad() {
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()
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() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
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 loadTrend() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
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)
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 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)
// 即使失败也设置空数据,避免图表报错
this.trend = { x: [], gmv: [], orders: [] }
}
},
// 实时指标:核心是"强制数值化 + 兜底",避免对象直接渲染
async loadRealTime() {
try {
const now = new Date()
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const todayISO = today0.toISOString()
const ySame = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const y0 = new Date(ySame.getFullYear(), ySame.getMonth(), ySame.getDate())
const p = new UTSJSONObject()
p.set('p_start', todayISO)
p.set('p_end', now.toISOString())
p.set('p_compare_start', y0.toISOString())
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)
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 || 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('❌ loadRealTime failed', e)
}
},
async loadTopProducts() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 5)
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_top_products', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const list: Array<TopProductItem> = []
for (let i = 0; i < rows.length; i++) {
list.push({
id: `${rows[i].id}`,
rank: i + 1,
name: `${rows[i].name}`,
sales: Number(rows[i].sales) || 0
})
}
this.topProducts = list
} catch (e) {
console.error('loadTopProducts failed', e)
}
},
async loadTopMerchants() {
try {
const { startDate, endDate } = this.calcDateRange()
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 5)
const res: any = await supa.rpc('rpc_analytics_top_merchants', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const list: Array<TopMerchantItem> = []
for (let i = 0; i < rows.length; i++) {
list.push({
id: `${rows[i].id}`,
rank: i + 1,
name: `${rows[i].name}`,
sales: Number(rows[i].sales) || 0,
growth: Number(rows[i].growth) || 0
})
}
this.topMerchants = list
} catch (e) {
console.error('loadTopMerchants failed', e)
}
},
async loadUserSegments() {
try {
const { startDate, endDate } = this.calcDateRange()
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)
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++) {
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 })
}
console.log('👥 loadUserSegments: 最终数据', list)
// 即使为空也更新,确保图表能正确显示空状态
this.userSegments = list
} catch (e) {
console.error('❌ loadUserSegments failed', e)
this.userSegments = []
}
},
async loadTrafficSources() {
try {
const { startDate, endDate } = this.calcDateRange()
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)
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++) {
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 })
}
console.log('🌐 loadTrafficSources: 最终数据', list)
// 即使为空也更新
this.trafficSources = list
} catch (e) {
console.error('❌ loadTrafficSources failed', e)
this.trafficSources = []
}
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
handleSearch() {
uni.showToast({ title: '搜索功能', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知中心', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏模式', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端预览', icon: 'none' })
},
handleDropdown() {
uni.showActionSheet({
itemList: ['crmeb demo', '切换项目', '项目设置'],
success: () => {}
})
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleMoreAction(action: string) {
this.showMoreMenu = false
switch (action) {
case 'refresh':
this.refreshAll()
break
case 'search':
this.handleSearch()
break
case 'notification':
this.handleNotification()
break
case 'fullscreen':
this.handleFullscreen()
break
case 'mobile':
this.handleMobile()
break
case 'settings':
this.handleSettings()
break
}
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
// 构建图表 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' },
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: (() => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})()
}))
const colors = ['#FF6B6B', '#4ECDC4', '#A8E6CF', '#FFD93D', '#95A5A6']
this.userSegmentOption = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
left: 0,
bottom: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 12 }
},
color: colors,
series: [
{
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: true, formatter: '{b}\n{d}%' },
labelLine: { length: 10, length2: 10 },
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' })
}
}
}
</script>
<style>
/* 页面:白底 + 宽屏居中 + 自适应 */
/* 说明uni-app 的 rpx 会随屏宽缩放,宽屏 H5 建议用 max-width 控制内容宽度。 */
.page {
min-height: 100vh;
background: #f6f7fb;
}
/* 页面布局:宽屏时侧边栏+内容,窄屏时全屏内容 */
.page-layout {
display: flex;
flex-direction: row !important;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 64px; /* 为固定顶部导航栏留出空间 */
}
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 16px 16px 28px;
box-sizing: border-box;
flex: 1;
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
/* 顶部 */
/* ✅ 强制:顶部必须横排(避免被全局 view:flex-direction:column 影响) */
.topbar {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.topbar-left {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.menu-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.menu-icon .icon {
font-size: 18px;
color: #111;
line-height: 1;
}
/* 左侧标题组仍然是纵向 */
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0; /* 允许内部 text 做省略 */
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ✅ 右侧按钮永不换成竖列(必要时只裁切,不换行) */
.topbar-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
white-space: nowrap;
}
.icon-btn {
padding: 8px 12px;
border-radius: 10px;
background: #f3f4f6;
color: #111;
font-size: 13px;
}
.icon-btn.primary {
background: #111;
color: #fff;
}
/* 图标按钮样式 */
.icon-btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.icon-btn-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.icon-btn-icon .icon {
font-size: 16px;
line-height: 1;
}
/* 通知图标带红点 */
.icon-btn-icon.notification .badge {
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
border: 1px solid #fff;
}
/* 下拉菜单 */
.dropdown {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 4px;
padding: 6px 10px;
border-radius: 8px;
background: #f3f4f6;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
/* 更多按钮(默认隐藏,窄屏时显示) */
.more-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.more-btn.active {
background: #e5e7eb;
}
.more-btn .icon {
font-size: 18px;
line-height: 1;
color: #111;
}
/* 更多菜单下拉 */
.more-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: #fff;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 140px;
z-index: 1000;
overflow: hidden;
display: flex;
flex-direction: column !important;
}
.more-menu-item {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.2s;
}
.more-menu-item:active {
background: #f3f4f6;
}
.more-menu-item .icon {
font-size: 16px;
line-height: 1;
}
.more-menu-item .text {
font-size: 13px;
color: #111;
}
.dropdown:active {
background: #e5e7eb;
}
.dropdown-text {
font-size: 13px;
color: #111;
}
.dropdown-arrow {
font-size: 10px;
color: #666;
line-height: 1;
}
/* KPI默认 2列宽屏 4列 */
/* ✅ 核心修复:用 flex + calc(50%) 替代 width避免 rpx + CSS var 失效 */
.kpi-grid {
margin-top: 12px;
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
}
.kpi-card {
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
padding: 18px;
box-sizing: border-box;
flex: 1 1 calc(50% - 6px);
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: 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 {
font-size: 28px;
font-weight: 800;
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 {
font-size: 12px;
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 横排 */
.tabs {
margin-top: 12px;
display: flex;
flex-direction: row !important;
gap: 8px;
padding: 8px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
overflow-x: auto;
flex-wrap: wrap;
justify-content: center;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #111;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.tab.active {
background: #111;
color: #fff;
}
/* 卡片 */
.card {
margin-top: 12px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
padding: 14px;
box-sizing: border-box;
}
.card-full {
width: 100%;
}
.fullwide {
margin-top: 12px;
}
.card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.card-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
/* 图表必须给高度H5 否则可能 0 高) */
.chart-box {
width: 100%;
height: 360px; /* 建议用 pxH5 更稳 */
}
.fullwide .chart-box {
height: 420px; /* 大图更高 */
}
/* 关键:左右分栏(宽屏) */
/* ✅ 修复:确保 flex 布局在 H5 正常工作 */
.insights-row {
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
/* 左边更宽,右边更窄 */
.insights-left {
flex: 1 1 calc(66.666% - 8px);
min-width: 360px; /* 窄屏自动掉到一列 */
}
.insights-right {
flex: 1 1 calc(33.333% - 8px);
min-width: 360px; /* 窄屏自动掉到一列 */
display: flex;
flex-direction: column;
gap: 12px;
}
.insights-right .card {
margin-top: 0;
}
/* 列表样式 */
.rank-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-item {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.rank-item:last-child {
border-bottom: none;
}
.rank-no {
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(0,0,0,0.06);
text-align: center;
line-height: 28px;
font-size: 12px;
flex: 0 0 auto;
}
.rank-name {
flex: 1;
font-size: 13px;
color: #111;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.rank-right {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.chip {
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
}
.chip.pos {
background: rgba(34,197,94,0.12);
color: #16a34a;
}
.chip.neg {
background: rgba(239,68,68,0.12);
color: #dc2626;
}
/* 宽屏KPI 4列 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
/* 宽屏时显示所有按钮,隐藏"更多"按钮 */
.topbar-right .btn-hidden {
display: flex !important;
}
.more-btn {
display: none !important;
}
/* 宽屏时工具卡片 3列 */
.tools-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* 自适应:窄屏自动变一列(断点用 px */
@media screen and (max-width: 960px) {
.insights-row {
flex-direction: column;
}
.insights-left,
.insights-right {
flex: 1 1 100%;
min-width: 100%;
}
.chart-box {
height: 320px;
}
.fullwide .chart-box {
height: 360px;
}
/* 顶部栏按钮在小屏幕上:隐藏部分按钮,显示"更多"按钮 */
.topbar-right {
gap: 6px;
}
/* 隐藏标记为 btn-hidden 的按钮 */
.topbar-right .btn-hidden {
display: none !important;
}
/* 显示"更多"按钮 */
.more-btn {
display: flex !important;
}
/* 标题在窄屏时允许省略号 */
.title,
.subtitle {
max-width: 200px;
}
.icon-btn-icon {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.icon-btn-icon .icon {
font-size: 14px;
}
.dropdown {
padding: 4px 8px;
flex-shrink: 0;
}
.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>