Files
medical-mall/pages/mall/analytics/index.uvue
2026-02-06 12:06:33 +08:00

1683 lines
43 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">鏍稿績瓒嬪娍锛圙MV / 璁㈠崟鏁帮級</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>
<!-- 鍙充晶锛氬晢瀹舵帓琛孴OP -->
<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>
<!-- 蹇€熷伐鍏峰崱鐗囧尯锛?涓伐鍏峰叆鍙o級 -->
<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">鍙戞斁銆佷娇鐢ㄣ€丷OI</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>
/* 椤甸潰锛氱櫧搴?+ 瀹藉睆灞呬腑 + 鑷€傚簲 */
/* 璇存槑锛歶ni-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 removed */ 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锛孒5 鏇寸ǔ */
}
.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;
}
/* 瀹藉睆锛欿PI 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>