1683 lines
41 KiB
Plaintext
1683 lines
41 KiB
Plaintext
<template>
|
||
<view class="page" @click.self="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 && !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; /* 建议用 px,H5 更稳 */
|
||
}
|
||
|
||
.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>
|