Files
medical-mall/pages/mall/analytics/index.uvue
2026-01-30 16:11:23 +08:00

1725 lines
46 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>
<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>
<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>
<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>
<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>
<view v-if="loading || !trend.x || trend.x.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else style="width: 100%; height: 320px; position: relative; overflow: hidden; min-height: 320px;">
<AnalyticsComboChart
:xLabels="trend.x"
:gmv="trend.gmv"
:orders="trend.orders"
:height="320"
:key="'trend-' + trend.x.length + '-' + trend.gmv.length"
/>
</view>
</view>
<!-- 用户结构和流量来源:横排显示 -->
<view class="charts-row">
<!-- 左侧:用户结构 -->
<view class="charts-left card">
<view class="card-head">
<text class="card-title">用户结构(环形图)</text>
<text class="card-desc">未消费 / 首购 / 复购 / 回流</text>
</view>
<view v-if="loading || !userSegmentOption || !userSegmentOption.series || (userSegmentOption.series && userSegmentOption.series.length === 0)" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else class="chart-box">
<EChartsView
:option="userSegmentOption"
:key="'user-segment-' + userSegments.length + '-' + (userSegmentOption?.series?.[0]?.data?.length || 0)"
/>
</view>
</view>
<!-- 右侧:流量来源 -->
<view class="charts-right card">
<view class="card-head">
<text class="card-title">流量来源(条形)</text>
<text class="card-desc">占比%</text>
</view>
<view v-if="loading || !trafficBarOption || !trafficBarOption.series || trafficBarOption.series.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else class="chart-box">
<EChartsView
:option="trafficBarOption"
:key="'traffic-' + trafficSources.length + '-' + Date.now()"
/>
</view>
</view>
</view>
<!-- 两个TOP排行横排显示 -->
<view class="tops-row">
<!-- 左侧热销商品TOP -->
<view class="tops-left card">
<view class="card-head">
<text class="card-title">热销商品 TOP</text>
<text class="card-desc">按销量</text>
</view>
<view class="rank-scroll-container">
<view class="rank-scroll-wrapper" :class="{ 'has-scroll': topProducts.length >= 6 }">
<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 v-if="topProducts.length >= 6" v-for="p in topProducts" :key="'copy-' + 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>
</view>
<!-- 右侧商家排行TOP -->
<view class="tops-right card">
<view class="card-head">
<text class="card-title">商家排行 TOP</text>
<text class="card-desc">按 GMV</text>
</view>
<view class="rank-scroll-container">
<view class="rank-scroll-wrapper" :class="{ 'has-scroll': topMerchants.length >= 6 }">
<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 v-if="topMerchants.length >= 6" v-for="m in topMerchants" :key="'copy-' + 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>
</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 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'
import { fetchDashboardRealtime, fetchDashboardTrend, fetchDashboardUserSegments, fetchDashboardTrafficSources, fetchDashboardTopProducts, fetchDashboardTopMerchants } from '@/services/analytics/dashboardService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
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
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
watch: {
trafficSources: {
handler(newVal, oldVal) {
console.log('👀 watch trafficSources 触发', newVal)
if (newVal && newVal.length > 0) {
this.buildChartOptions()
}
},
deep: true,
immediate: false
},
userSegments: {
handler(newVal, oldVal) {
console.log('👀 watch userSegments 触发', newVal)
if (newVal && newVal.length > 0) {
this.buildChartOptions()
}
},
deep: true,
immediate: false
}
},
onLoad() {
this.initDashboard()
},
onUnload() {
this.showMoreMenu = false
this.stopAutoRefresh()
},
onShow() {
// 页面显示时恢复自动刷新
if (this.autoRefreshEnabled) {
this.startAutoRefresh()
}
},
onHide() {
// 页面隐藏时暂停自动刷新
this.stopAutoRefresh()
},
methods: {
async initDashboard() {
this.loading = true
try {
await this.refreshAll()
// refreshAll 内部已经构建了图表,这里不需要再次构建
if (this.autoRefreshEnabled) {
this.startAutoRefresh()
}
} finally {
this.loading = false
}
},
async refreshAll() {
this.updateTime()
try {
await Promise.all([
this.loadRealTime(),
this.loadTrend(),
this.loadUserSegments(),
this.loadTrafficSources(),
this.loadTopProducts(),
this.loadTopMerchants()
])
this.updateTime()
console.log('✅ refreshAll: 所有数据加载完成,开始构建图表')
// 使用 nextTick 或 requestAnimationFrame 确保数据更新后再构建图表(比 setTimeout 更快)
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => {
this.buildChartOptions()
console.log('✅ refreshAll: 图表配置构建完成')
resolve(null)
})
} else {
setTimeout(() => {
this.buildChartOptions()
console.log('✅ refreshAll: 图表配置构建完成')
resolve(null)
}, 50) // 减少延迟时间
}
})
} catch (e) {
console.error('❌ refreshAll failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), 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' })
}
},
async selectPeriod(p: string) {
this.selectedPeriod = p
this.loading = true
try {
// 并行加载所有数据
await Promise.all([
this.loadTrend(),
this.loadUserSegments(),
this.loadTrafficSources(),
this.loadTopProducts(),
this.loadTopMerchants()
])
// 数据加载完成后,使用 nextTick 构建图表配置(比 setTimeout 更快)
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => {
this.buildChartOptions()
console.log('✅ selectPeriod: 图表配置构建完成')
resolve(null)
})
} else {
setTimeout(() => {
this.buildChartOptions()
console.log('✅ selectPeriod: 图表配置构建完成')
resolve(null)
}, 50) // 减少延迟时间
}
})
} catch (e) {
console.error('❌ selectPeriod failed', e)
} finally {
this.loading = false
}
},
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 {
this.trend = await fetchDashboardTrend(this.selectedPeriod)
} catch (e) {
console.error('❌ loadTrend failed', e)
this.trend = { x: [], gmv: [], orders: [] }
}
},
// 实时指标:核心是"强制数值化 + 兜底",避免对象直接渲染
async loadRealTime() {
try {
this.realTime = await fetchDashboardRealtime()
} catch (e) {
console.error('❌ loadRealTime failed', e)
}
},
async loadTopProducts() {
try {
const list = await fetchDashboardTopProducts(this.selectedPeriod, 50)
// 如果数据少于6条添加假数据以达到滚动效果
if (list.length < 6) {
const fakeProducts = [
{ id: 'fake-1', rank: list.length + 1, name: '示例商品A', sales: Math.floor(Math.random() * 100) + 50 },
{ id: 'fake-2', rank: list.length + 2, name: '示例商品B', sales: Math.floor(Math.random() * 100) + 30 },
{ id: 'fake-3', rank: list.length + 3, name: '示例商品C', sales: Math.floor(Math.random() * 100) + 20 },
{ id: 'fake-4', rank: list.length + 4, name: '示例商品D', sales: Math.floor(Math.random() * 100) + 10 },
{ id: 'fake-5', rank: list.length + 5, name: '示例商品E', sales: Math.floor(Math.random() * 100) + 5 }
]
const needCount = 6 - list.length
for (let i = 0; i < needCount; i++) {
list.push(fakeProducts[i % fakeProducts.length])
}
}
this.topProducts = list
} catch (e) {
console.error('❌ loadTopProducts failed', e)
const fakeProducts = [
{ id: 'fake-1', rank: 1, name: '示例商品A', sales: 88 },
{ id: 'fake-2', rank: 2, name: '示例商品B', sales: 76 },
{ id: 'fake-3', rank: 3, name: '示例商品C', sales: 65 },
{ id: 'fake-4', rank: 4, name: '示例商品D', sales: 54 },
{ id: 'fake-5', rank: 5, name: '示例商品E', sales: 43 },
{ id: 'fake-6', rank: 6, name: '示例商品F', sales: 32 }
]
this.topProducts = fakeProducts
}
},
async loadTopMerchants() {
try {
const list = await fetchDashboardTopMerchants(this.selectedPeriod, 50)
// 如果数据少于6条添加假数据以达到滚动效果
if (list.length < 6) {
const fakeMerchants = [
{ id: 'fake-1', rank: list.length + 1, name: '示例商家A', sales: Math.floor(Math.random() * 10000) + 5000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-2', rank: list.length + 2, name: '示例商家B', sales: Math.floor(Math.random() * 8000) + 3000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-3', rank: list.length + 3, name: '示例商家C', sales: Math.floor(Math.random() * 6000) + 2000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-4', rank: list.length + 4, name: '示例商家D', sales: Math.floor(Math.random() * 5000) + 1000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-5', rank: list.length + 5, name: '示例商家E', sales: Math.floor(Math.random() * 4000) + 500, growth: Math.floor(Math.random() * 20) - 10 }
]
const needCount = 6 - list.length
for (let i = 0; i < needCount; i++) {
list.push(fakeMerchants[i % fakeMerchants.length])
}
}
this.topMerchants = list
} catch (e) {
console.error('❌ loadTopMerchants failed', e)
const fakeMerchants = [
{ id: 'fake-1', rank: 1, name: '示例商家A', sales: 8888, growth: 12.5 },
{ id: 'fake-2', rank: 2, name: '示例商家B', sales: 7654, growth: 8.3 },
{ id: 'fake-3', rank: 3, name: '示例商家C', sales: 6543, growth: -2.1 },
{ id: 'fake-4', rank: 4, name: '示例商家D', sales: 5432, growth: 5.7 },
{ id: 'fake-5', rank: 5, name: '示例商家E', sales: 4321, growth: -1.2 },
{ id: 'fake-6', rank: 6, name: '示例商家F', sales: 3210, growth: 3.4 }
]
this.topMerchants = fakeMerchants
}
},
async loadUserSegments() {
try {
this.userSegments = await fetchDashboardUserSegments(this.selectedPeriod)
} catch (e) {
console.error('❌ loadUserSegments failed', e)
this.userSegments = []
}
},
async loadTrafficSources() {
try {
this.trafficSources = await fetchDashboardTrafficSources(this.selectedPeriod)
} 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)}%`
},
// 工具函数:将 UTS 对象转换为纯 JavaScript 对象(用于 ECharts
// 优化:避免深拷贝,只处理必要的属性
toPlainObject(obj: any): any {
if (obj == null) return null
// 基本类型直接返回
if (typeof obj !== 'object') return obj
// 数组处理
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
// 对象处理:只处理一层,避免深拷贝
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
// 跳过函数和 UTS 内部属性
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
// 对于嵌套对象,只在必要时递归(避免过度深拷贝)
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
// 检查是否是简单的数据对象(只有基本类型属性)
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
},
// 构建图表 options
buildChartOptions() {
console.log('📊 buildChartOptions: 开始构建图表配置')
console.log('📊 buildChartOptions: trafficSources', this.trafficSources, '数量:', this.trafficSources.length)
console.log('📊 buildChartOptions: userSegments', this.userSegments, '数量:', this.userSegments.length)
// 确保数据已加载
if (!this.trafficSources || !this.userSegments) {
console.warn('⚠️ buildChartOptions: 数据未准备好,跳过构建')
return
}
// 流量来源条形图(增强:渐变 + 动画)
const trafficX = this.trafficSources.map((it) => String(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)
console.log('📊 buildChartOptions: 流量来源数据', { trafficX, trafficY, total, count: trafficX.length })
// 如果数据为空,使用占位数据
if (trafficX.length === 0 || total === 0) {
console.warn('⚠️ buildChartOptions: 流量来源数据为空,使用占位数据')
this.trafficBarOption = this.toPlainObject({
grid: { left: 80, right: 24, top: 18, bottom: 18 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'category', data: ['暂无数据'], axisTick: { show: false } },
series: [{ type: 'bar', data: [0], barWidth: 14 }]
})
} else {
// 创建新对象确保响应式更新(使用纯 JS 对象,避免 UTS Proxy 问题)
const newTrafficOption = {
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'
}]
}
// 转换为纯 JS 对象并赋值(确保 ECharts 能正确接收)
this.trafficBarOption = this.toPlainObject(newTrafficOption)
}
console.log('📊 buildChartOptions: trafficBarOption 构建完成', this.trafficBarOption)
console.log('📊 buildChartOptions: trafficBarOption series 数量', this.trafficBarOption.series?.length)
console.log('📊 buildChartOptions: trafficBarOption yAxis data', this.trafficBarOption.yAxis?.data)
// 用户结构环形图(增强:颜色 + 动画)
const segmentData = this.userSegments.map((it) => ({
name: String(it.name),
value: (() => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})()
}))
console.log('📊 buildChartOptions: 用户结构数据', segmentData, '数量:', segmentData.length)
// 如果数据为空,使用占位数据
if (segmentData.length === 0) {
console.warn('⚠️ buildChartOptions: 用户结构数据为空,使用占位数据')
this.userSegmentOption = this.toPlainObject({
tooltip: { trigger: 'item' },
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
color: ['#95A5A6'],
series: [{
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '45%'],
data: [{ name: '暂无数据', value: 1 }],
label: { show: true, formatter: '{b}\n{d}%' }
}]
})
} else {
const colors = ['#FF6B6B', '#4ECDC4', '#A8E6CF', '#FFD93D', '#95A5A6']
// 创建新对象确保响应式更新(使用纯 JS 对象)
const newUserSegmentOption = {
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
}
]
}
// 转换为纯 JS 对象并赋值(确保 ECharts 能正确接收)
this.userSegmentOption = this.toPlainObject(newUserSegmentOption)
}
console.log('📊 buildChartOptions: userSegmentOption 构建完成', this.userSegmentOption)
console.log('📊 buildChartOptions: userSegmentOption series 数量', this.userSegmentOption.series?.length)
console.log('📊 buildChartOptions: userSegmentOption data 数量', this.userSegmentOption.series?.[0]?.data?.length)
console.log('📊 buildChartOptions: 图表配置构建完成')
},
// 快速工具跳转
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;
position: relative;
width: 100%;
}
.kpi-label {
font-size: 13px;
color: rgba(255,255,255,0.9);
font-weight: 500;
}
.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; /* 大图更高 */
}
/* 图表加载状态 */
.chart-loading {
width: 100%;
height: 360px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
.fullwide .chart-loading {
height: 420px;
}
/* 用户结构和流量来源:横排显示 */
.charts-row {
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
.charts-left,
.charts-right {
flex: 1 1 calc(50% - 6px);
min-width: 360px;
}
/* 两个TOP排行横排显示 */
.tops-row {
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
.tops-left,
.tops-right {
flex: 1 1 calc(50% - 6px);
min-width: 360px;
}
/* 滚动容器 */
.rank-scroll-container {
width: 100%;
height: 300px;
overflow: hidden;
position: relative;
}
.rank-scroll-wrapper {
width: 100%;
}
.rank-scroll-wrapper.has-scroll {
animation: scrollRank 15s linear infinite;
}
/* 列表样式 */
.rank-list {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
/* 滚动动画当数据超过5条时自动滚动 */
@keyframes scrollRank {
0% {
transform: translateY(0);
}
100% {
/* 滚动到第一份数据的末尾,实现无缝循环 */
/* 每条 rank-item 高度约 50px包括 padding 和 gap滚动一半高度 */
transform: translateY(calc(-50%));
}
}
.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) {
.charts-row,
.tops-row {
flex-direction: column;
}
.charts-left,
.charts-right,
.tops-left,
.tops-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>