Files
medical-mall/pages/mall/analytics/index.uvue

1683 lines
41 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">
<!-- 固定顶部导航栏 -->
<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 && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
自定义
</view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<!-- 核心趋势:占满横向(柱+折 组合图) -->
<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 setup lang="uts">
import { computed, reactive, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.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'
import type { TrendData, SegmentItem, TrafficItem, TopProductItem, TopMerchantItem } from '@/types/analytics/dashboard.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/index')
const loading = ref(true)
const autoRefreshEnabled = ref(true)
const autoRefreshInterval = ref(60000)
const autoRefreshTimer = ref<any>(null)
const timePeriods = ref<Array<{ value: string; label: string }>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
const realTime = reactive({
gmv: 0,
gmv_growth: 0,
orders: 0,
order_growth: 0,
online_users: 0,
conversion_rate: 0,
conversion_growth: 0
})
const trend = reactive<TrendData>({ x: [], gmv: [], orders: [] })
const userSegments = reactive<Array<SegmentItem>>([])
const trafficSources = reactive<Array<TrafficItem>>([])
const topProducts = reactive<Array<TopProductItem>>([])
const topMerchants = reactive<Array<TopMerchantItem>>([])
const trafficBarOption = ref<any>({})
const userSegmentOption = ref<any>({})
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function startAutoRefresh() {
stopAutoRefresh()
autoRefreshTimer.value = setInterval(() => {
refreshAll()
}, autoRefreshInterval.value)
}
function stopAutoRefresh() {
if (autoRefreshTimer.value != null) {
clearInterval(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
}
async function loadTrend() {
try {
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const data = await fetchDashboardTrend(selectedPeriod.value, range)
trend.x = data.x
trend.gmv = data.gmv
trend.orders = data.orders
} catch (e) {
console.error('❌ loadTrend failed', e)
trend.x = []
trend.gmv = []
trend.orders = []
}
}
async function loadRealTime() {
try {
const data = await fetchDashboardRealtime()
realTime.gmv = data.gmv
realTime.gmv_growth = data.gmv_growth
realTime.orders = data.orders
realTime.order_growth = data.order_growth
realTime.online_users = data.online_users
realTime.conversion_rate = data.conversion_rate
realTime.conversion_growth = data.conversion_growth
} catch (e) {
console.error('❌ loadRealTime failed', e)
}
}
async function loadTopProducts() {
try {
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const list = await fetchDashboardTopProducts(selectedPeriod.value, 50, range)
topProducts.splice(0, topProducts.length, ...list)
} catch (e) {
console.error('❌ loadTopProducts failed', e)
topProducts.splice(0, topProducts.length)
}
}
async function loadTopMerchants() {
try {
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const list = await fetchDashboardTopMerchants(selectedPeriod.value, 50, range)
topMerchants.splice(0, topMerchants.length, ...list)
} catch (e) {
console.error('❌ loadTopMerchants failed', e)
topMerchants.splice(0, topMerchants.length)
}
}
async function loadUserSegments() {
try {
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const list = await fetchDashboardUserSegments(selectedPeriod.value, range)
userSegments.splice(0, userSegments.length, ...list)
} catch (e) {
console.error('❌ loadUserSegments failed', e)
userSegments.splice(0, userSegments.length)
}
}
async function loadTrafficSources() {
try {
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const list = await fetchDashboardTrafficSources(selectedPeriod.value, range)
trafficSources.splice(0, trafficSources.length, ...list)
} catch (e) {
console.error('❌ loadTrafficSources failed', e)
trafficSources.splice(0, trafficSources.length)
}
}
function toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
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 } : toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
}
function buildChartOptions() {
console.log('📊 buildChartOptions: 开始构建图表配置')
console.log('📊 buildChartOptions: trafficSources', trafficSources, '数量:', trafficSources.length)
console.log('📊 buildChartOptions: userSegments', userSegments, '数量:', userSegments.length)
if (!trafficSources || !userSegments) {
console.warn('⚠️ buildChartOptions: 数据未准备好,跳过构建')
return
}
const trafficX = trafficSources.map((it) => String(it.name))
const trafficY = 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: 流量来源数据为空,使用占位数据')
trafficBarOption.value = 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 {
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'
}
]
}
trafficBarOption.value = toPlainObject(newTrafficOption)
}
console.log('📊 buildChartOptions: trafficBarOption 构建完成', trafficBarOption.value)
const segmentData = 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: 用户结构数据为空,使用占位数据')
userSegmentOption.value = 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']
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
}
]
}
userSegmentOption.value = toPlainObject(newUserSegmentOption)
}
console.log('📊 buildChartOptions: userSegmentOption 构建完成', userSegmentOption.value)
console.log('📊 buildChartOptions: 图表配置构建完成')
}
watch(
trafficSources,
(newVal) => {
console.log('👀 watch trafficSources 触发', newVal)
if (newVal && newVal.length > 0) {
buildChartOptions()
}
},
{ deep: true, immediate: false }
)
watch(
userSegments,
(newVal) => {
console.log('👀 watch userSegments 触发', newVal)
if (newVal && newVal.length > 0) {
buildChartOptions()
}
},
{ deep: true, immediate: false }
)
async function refreshAll() {
updateTime()
try {
await Promise.all([
loadRealTime(),
loadTrend(),
loadUserSegments(),
loadTrafficSources(),
loadTopProducts(),
loadTopMerchants()
])
updateTime()
console.log('✅ refreshAll: 所有数据加载完成,开始构建图表')
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => {
buildChartOptions()
console.log('✅ refreshAll: 图表配置构建完成')
resolve(null)
})
} else {
setTimeout(() => {
buildChartOptions()
console.log('✅ refreshAll: 图表配置构建完成')
resolve(null)
}, 50)
}
})
} catch (e) {
console.error('❌ refreshAll failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), icon: 'none' })
}
}
async function initDashboard() {
loading.value = true
try {
await refreshAll()
if (autoRefreshEnabled.value) {
startAutoRefresh()
}
} finally {
loading.value = false
}
}
async function selectPeriod(p: string) {
selectedPeriod.value = p
// 切换到快捷时间段时,退出自定义范围
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loading.value = true
try {
await Promise.all([loadTrend(), loadUserSegments(), loadTrafficSources(), loadTopProducts(), loadTopMerchants()])
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => {
buildChartOptions()
console.log('✅ selectPeriod: 图表配置构建完成')
resolve(null)
})
} else {
setTimeout(() => {
buildChartOptions()
console.log('✅ selectPeriod: 图表配置构建完成')
resolve(null)
}, 50)
}
})
} catch (e) {
console.error('❌ selectPeriod failed', e)
} finally {
loading.value = false
}
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索功能', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知中心', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏模式', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端预览', icon: 'none' })
}
function handleDropdown() {
uni.showActionSheet({
itemList: ['crmeb demo', '切换项目', '项目设置'],
success: () => {}
})
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
refreshAll()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
refreshAll()
}
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
function formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
}
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
function goToSalesReport() {
uni.navigateTo({ url: '/pages/mall/analytics/sales-report' })
}
function goToUserAnalysis() {
uni.navigateTo({ url: '/pages/mall/analytics/user-analysis' })
}
function goToProductInsights() {
uni.navigateTo({ url: '/pages/mall/analytics/product-insights' })
}
function goToMarketTrends() {
uni.navigateTo({ url: '/pages/mall/analytics/market-trends' })
}
function goToCouponAnalysis() {
uni.navigateTo({ url: '/pages/mall/analytics/coupon-analysis' })
}
function goToCustomReport() {
uni.navigateTo({ url: '/pages/mall/analytics/custom-report' })
}
function handleMoreAction(action: string) {
showMoreMenu.value = false
switch (action) {
case 'refresh':
refreshAll()
break
case 'search':
handleSearch()
break
case 'notification':
handleNotification()
break
case 'fullscreen':
handleFullscreen()
break
case 'mobile':
handleMobile()
break
case 'settings':
handleSettings()
break
}
}
function toggleAutoRefresh() {
autoRefreshEnabled.value = !autoRefreshEnabled.value
if (autoRefreshEnabled.value) {
startAutoRefresh()
uni.showToast({ title: '已开启自动刷新', icon: 'success' })
} else {
stopAutoRefresh()
uni.showToast({ title: '已关闭自动刷新', icon: 'none' })
}
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
onLoad(() => {
if (!ensureAnalyticsLogin({ toastTitle: '请先登录后查看数据分析' })) return
initDashboard()
})
onUnload(() => {
showMoreMenu.value = false
stopAutoRefresh()
})
onShow(() => {
if (autoRefreshEnabled.value) {
startAutoRefresh()
}
})
onHide(() => {
stopAutoRefresh()
})
</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>