界面设计

This commit is contained in:
comlibmb
2026-01-27 11:20:10 +08:00
parent 3fbd9a2b3d
commit a052ac1d7b
7 changed files with 1719 additions and 404 deletions

View File

@@ -26,9 +26,33 @@ export default {
},
watch: {
xLabels: { handler() { this.updateOption() }, deep: true },
gmv: { handler() { this.updateOption() }, deep: true },
orders: { handler() { this.updateOption() }, deep: true },
xLabels: {
handler() {
if (this.xLabels && this.xLabels.length > 0) {
this.updateOption()
}
},
deep: true,
immediate: false
},
gmv: {
handler() {
if (this.gmv && this.gmv.length > 0) {
this.updateOption()
}
},
deep: true,
immediate: false
},
orders: {
handler() {
if (this.orders && this.orders.length > 0) {
this.updateOption()
}
},
deep: true,
immediate: false
},
height: {
handler() {
this.heightPx = `${this.height}px`
@@ -38,11 +62,53 @@ export default {
mounted() {
this.heightPx = `${this.height}px`
// 延迟初始化,确保 props 已传递
setTimeout(() => {
if (this.xLabels && this.xLabels.length > 0 && this.gmv && this.gmv.length > 0) {
this.updateOption()
}
}, 100)
},
methods: {
// 工具函数:将 UTS 对象转换为纯 JavaScript 对象
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
},
updateOption() {
// 检查数据是否有效
if (!this.xLabels || !this.gmv || !this.orders ||
this.xLabels.length === 0 || this.gmv.length === 0 || this.orders.length === 0) {
return
}
const x = (this.xLabels as Array<any>).map((s) => String(s))
const bar = (this.gmv as Array<any>).map((v) => {
const n = Number(v)
@@ -53,7 +119,8 @@ export default {
return isFinite(n) ? n : 0
})
this.chartOption = {
// 构建图表配置并转换为纯 JS 对象
const option = {
grid: { left: 60, right: 60, top: 70, bottom: 40 },
tooltip: {
trigger: 'axis',
@@ -154,6 +221,9 @@ export default {
}
]
}
// 转换为纯 JS 对象确保 ECharts 能正确接收
this.chartOption = this.toPlainObject(option)
}
}
}
@@ -162,9 +232,15 @@ export default {
<style>
.chart-wrap {
width: 100%;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.chart {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@@ -0,0 +1,379 @@
<template>
<view class="region-map">
<view class="map-head">
<view class="map-head-left">
<text class="map-title">销售地域分布</text>
<view class="map-switch">
<view
class="map-switch-btn"
:class="{ active: mapType === 'china' }"
@click="switchMapType('china')"
>
中国地图
</view>
<view
class="map-switch-btn"
:class="{ active: mapType === 'world' }"
@click="switchMapType('world')"
>
全国地图
</view>
</view>
</view>
</view>
<view v-if="loading || !chartOption || !chartOption.series || chartOption.series.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box" :option="chartOption" :key="'map-' + mapType" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type RegionDataItem = { name: string; value: number }
export default {
components: {
EChartsView
},
props: {
startDate: { type: Date, required: true },
endDate: { type: Date, required: true },
topMerchants: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
},
data() {
return {
mapType: 'china', // 'china' | 'world'
regionData: [] as Array<RegionDataItem>,
chartOption: {} as any
}
},
watch: {
startDate: { handler() { this.loadData() }, deep: true },
endDate: { handler() { this.loadData() }, deep: true },
topMerchants: { handler() { this.loadData() }, deep: true }
},
mounted() {
this.loadData()
},
methods: {
switchMapType(type: string) {
this.mapType = type
this.buildChartOption()
},
async loadData() {
try {
// 暂时使用模拟数据,后续可以创建 RPC 函数获取真实省份数据
// 基于商家数据生成省份分布(模拟)
const mockProvinces: Array<RegionDataItem> = [
{ name: '广东', value: 0 },
{ name: '北京', value: 0 },
{ name: '上海', value: 0 },
{ name: '浙江', value: 0 },
{ name: '江苏', value: 0 },
{ name: '山东', value: 0 },
{ name: '河南', value: 0 },
{ name: '四川', value: 0 },
{ name: '湖北', value: 0 },
{ name: '湖南', value: 0 },
{ name: '福建', value: 0 },
{ name: '安徽', value: 0 },
{ name: '河北', value: 0 },
{ name: '陕西', value: 0 },
{ name: '江西', value: 0 },
{ name: '重庆', value: 0 },
{ name: '辽宁', value: 0 },
{ name: '云南', value: 0 },
{ name: '广西', value: 0 },
{ name: '山西', value: 0 },
{ name: '内蒙古', value: 0 },
{ name: '贵州', value: 0 },
{ name: '新疆', value: 0 },
{ name: '天津', value: 0 },
{ name: '吉林', value: 0 },
{ name: '黑龙江', value: 0 },
{ name: '海南', value: 0 },
{ name: '甘肃', value: 0 },
{ name: '宁夏', value: 0 },
{ name: '青海', value: 0 },
{ name: '西藏', value: 0 }
]
// 如果有商家数据,可以基于商家数量或 GMV 分配省份
const merchants = this.topMerchants as Array<any>
const totalSales = merchants.reduce((sum: number, m: any) => {
return sum + (Number(m.sales) || 0)
}, 0)
if (totalSales > 0 && merchants.length > 0) {
// 基于商家数量分配省份
const merchantCount = merchants.length
for (let i = 0; i < Math.min(merchantCount, mockProvinces.length); i++) {
const sales = Number(merchants[i].sales) || 0
mockProvinces[i].value = Math.round(sales * (Math.random() * 0.5 + 0.5))
}
} else {
// 使用随机数据
for (let i = 0; i < mockProvinces.length; i++) {
mockProvinces[i].value = Math.round(Math.random() * 100000)
}
}
this.regionData = mockProvinces
this.buildChartOption()
} catch (e) {
console.error('❌ AnalyticsRegionMap loadData failed', e)
this.regionData = []
this.buildChartOption()
}
},
buildChartOption() {
if (!this.regionData || this.regionData.length === 0) {
this.chartOption = {}
return
}
const maxValue = Math.max(...this.regionData.map((d) => d.value), 1)
if (this.mapType === 'china') {
// 中国地图配置(使用 geo 组件,兼容性更好)
this.chartOption = {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const name = params.name || '未知'
const value = params.value || 0
return `${name}<br/>销售额: ¥${this.formatMoney(value)}`
}
},
visualMap: {
min: 0,
max: maxValue,
left: 'left',
top: 'bottom',
text: ['高', '低'],
inRange: {
color: ['#e0f2fe', '#0ea5e9', '#0284c7', '#0369a1']
},
calculable: true,
textStyle: {
color: 'rgba(0,0,0,0.65)',
fontSize: 12
}
},
geo: {
map: 'china',
roam: false,
zoom: 1.5,
top: '40%',
itemStyle: {
areaColor: '#f0f0f0',
borderColor: '#fff',
borderWidth: 1
},
emphasis: {
itemStyle: {
areaColor: '#0ea5e9'
},
label: {
show: true,
color: '#111'
}
}
},
series: [
{
name: '销售额',
type: 'map',
map: 'china',
geoIndex: 0,
data: this.regionData,
label: {
show: true,
fontSize: 11,
color: 'rgba(0,0,0,0.75)'
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1
},
emphasis: {
itemStyle: {
areaColor: '#0ea5e9',
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
color: '#fff',
fontSize: 12,
fontWeight: 'bold'
}
}
}
]
}
} else {
// 全国地图配置(简化版,使用柱状图展示 TOP 省份)
this.chartOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: { left: 60, right: 20, top: 36, bottom: 60 },
xAxis: {
type: 'category',
data: this.regionData
.sort((a, b) => b.value - a.value)
.slice(0, 15)
.map((d) => d.name),
axisLabel: {
rotate: 45,
color: 'rgba(0,0,0,0.55)',
fontSize: 12
}
},
yAxis: {
type: 'value',
axisLabel: {
color: 'rgba(0,0,0,0.55)',
formatter: (value: number) => {
if (value >= 10000) return (value / 10000).toFixed(1) + '万'
return String(Math.round(value))
}
},
splitLine: {
lineStyle: { color: 'rgba(0,0,0,0.06)' }
}
},
series: [
{
name: '销售额',
type: 'bar',
data: this.regionData
.sort((a, b) => b.value - a.value)
.slice(0, 15)
.map((d) => d.value),
barWidth: 18,
itemStyle: {
borderRadius: [6, 6, 0, 0],
color: '#3b82f6'
}
}
]
}
}
// 转换为纯 JS 对象
this.chartOption = this.toPlainObject(this.chartOption)
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
// 工具函数:将 UTS 对象转换为纯 JavaScript 对象
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
}
}
}
</script>
<style>
.region-map {
width: 100%;
}
.map-head {
margin-bottom: 8px;
}
.map-head-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
flex: 1;
}
.map-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.map-switch {
display: flex;
flex-direction: row;
gap: 4px;
background: #f3f4f6;
border-radius: 8px;
padding: 2px;
}
.map-switch-btn {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
color: rgba(0,0,0,0.65);
background: transparent;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.map-switch-btn.active {
background: #fff;
color: #111;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.chart-box {
width: 100%;
height: 360px;
}
.chart-loading {
width: 100%;
height: 360px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
</style>

View File

@@ -769,7 +769,7 @@ async select(table : string, filter ?: string | null, options ?: AkSupaSelectOpt
//console.log(url)
// 确定HTTP方法如果是head模式使用HEAD方法
let httpMethod = 'GET';
let httpMethod: 'GET' | 'HEAD' = 'GET';
if (options != null && options.head == true) {
httpMethod = 'HEAD';
//console.log('使用 HEAD 方法进行 count 查询');

View File

@@ -31,10 +31,6 @@
<view class="kpi-card kpi-card-gmv" @click="goToSalesReport">
<view class="kpi-header">
<text class="kpi-label">实时 GMV</text>
<view class="kpi-sparkline">
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.gmv" />
<view v-else class="sparkline-skeleton"></view>
</view>
</view>
<text class="kpi-value">¥{{ formatMoney(realTime.gmv) }}</text>
<view class="kpi-footer">
@@ -47,10 +43,6 @@
<view class="kpi-card kpi-card-orders" @click="goToSalesReport">
<view class="kpi-header">
<text class="kpi-label">实时订单</text>
<view class="kpi-sparkline">
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.orders" />
<view v-else class="sparkline-skeleton"></view>
</view>
</view>
<text class="kpi-value">{{ formatInt(realTime.orders) }}</text>
<view class="kpi-footer">
@@ -63,10 +55,6 @@
<view class="kpi-card kpi-card-users" @click="goToUserAnalysis">
<view class="kpi-header">
<text class="kpi-label">在线用户</text>
<view class="kpi-sparkline">
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.users" />
<view v-else class="sparkline-skeleton"></view>
</view>
</view>
<text class="kpi-value">{{ formatInt(realTime.online_users) }}</text>
<view class="kpi-footer">
@@ -77,10 +65,6 @@
<view class="kpi-card kpi-card-conversion" @click="goToSalesReport">
<view class="kpi-header">
<text class="kpi-label">转化率</text>
<view class="kpi-sparkline">
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.conversion" />
<view v-else class="sparkline-skeleton"></view>
</view>
</view>
<text class="kpi-value">{{ formatPct(realTime.conversion_rate) }}</text>
<view class="kpi-footer">
@@ -112,55 +96,92 @@
<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="card fullwide">
<!-- 用户结构和流量来源:横排显示 -->
<view class="charts-row">
<!-- 左侧:用户结构 -->
<view class="charts-left card">
<view class="card-head">
<text class="card-title">用户结构(环形图)</text>
<text class="card-desc">未消费 / 首购 / 复购 / 回流</text>
</view>
<EChartsView class="chart-box" :option="userSegmentOption" />
<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="insights-row">
<!-- 左侧:大图表 -->
<view class="insights-left card">
<!-- 右侧:流量来源 -->
<view class="charts-right card">
<view class="card-head">
<text class="card-title">流量来源(条形)</text>
<text class="card-desc">占比%</text>
</view>
<EChartsView class="chart-box" :option="trafficBarOption" />
<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>
<!-- 右侧:两个小卡片纵向堆叠 -->
<view class="insights-right">
<view class="card">
<!-- 两个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>
<view class="card">
<!-- 右侧商家排行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>
@@ -172,6 +193,18 @@
</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>
@@ -226,7 +259,7 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
@@ -289,13 +322,7 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
// 图表 options
trafficBarOption: {} as any,
userSegmentOption: {} as any,
kpiSparklineOptions: {
gmv: {} as any,
orders: {} as any,
users: {} as any,
conversion: {} as any
}
userSegmentOption: {} as any
}
},
@@ -307,13 +334,27 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
},
watch: {
trafficSources(newVal, oldVal) {
trafficSources: {
handler(newVal, oldVal) {
console.log('👀 watch trafficSources 触发', newVal)
if (newVal && newVal.length > 0) {
this.buildChartOptions()
}
},
userSegments(newVal, oldVal) {
deep: true,
immediate: false
},
userSegments: {
handler(newVal, oldVal) {
console.log('👀 watch userSegments 触发', newVal)
if (newVal && newVal.length > 0) {
this.buildChartOptions()
}
},
deep: true,
immediate: false
}
},
onLoad() {
this.initDashboard()
@@ -339,13 +380,15 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
methods: {
async initDashboard() {
this.loading = true
try {
await this.refreshAll()
this.loading = false
this.buildChartOptions()
this.buildSparklineOptions()
// refreshAll 内部已经构建了图表,这里不需要再次构建
if (this.autoRefreshEnabled) {
this.startAutoRefresh()
}
} finally {
this.loading = false
}
},
async refreshAll() {
@@ -360,12 +403,27 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
this.loadTopMerchants()
])
this.updateTime()
// 更新 sparkline
this.buildSparklineOptions()
// 更新图表
console.log('✅ refreshAll: 所有数据加载完成,开始构建图表')
// 使用 nextTick 或 requestAnimationFrame 确保数据更新后再构建图表(比 setTimeout 更快)
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => {
this.buildChartOptions()
console.log('✅ refreshAll: 图表配置构建完成')
resolve(null)
})
} else {
setTimeout(() => {
this.buildChartOptions()
console.log('✅ refreshAll: 图表配置构建完成')
resolve(null)
}, 50) // 减少延迟时间
}
})
} catch (e) {
console.error('refreshAll failed', e)
console.error('refreshAll failed', e)
uni.showToast({ title: '数据加载失败', icon: 'none' })
}
},
@@ -395,13 +453,40 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
}
},
selectPeriod(p: string) {
async selectPeriod(p: string) {
this.selectedPeriod = p
this.loadTrend()
this.loadUserSegments()
this.loadTrafficSources()
this.loadTopProducts()
this.loading = true
try {
// 并行加载所有数据
await Promise.all([
this.loadTrend(),
this.loadUserSegments(),
this.loadTrafficSources(),
this.loadTopProducts(),
this.loadTopMerchants()
])
// 数据加载完成后,使用 nextTick 构建图表配置(比 setTimeout 更快)
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => {
this.buildChartOptions()
console.log('✅ selectPeriod: 图表配置构建完成')
resolve(null)
})
} else {
setTimeout(() => {
this.buildChartOptions()
console.log('✅ selectPeriod: 图表配置构建完成')
resolve(null)
}, 50) // 减少延迟时间
}
})
} catch (e) {
console.error('❌ selectPeriod failed', e)
} finally {
this.loading = false
}
},
updateTime() {
@@ -539,22 +624,78 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 5)
p.set('p_limit', 50)
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_top_products', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
console.log('📦 loadTopProducts: RPC 返回结果', res)
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
if (res.data) {
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
const dataObj = res.data as any
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
}
console.log('📦 loadTopProducts: 解析后的 rows', rows, '数量:', rows.length)
const list: Array<TopProductItem> = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
list.push({
id: `${rows[i].id}`,
id: `${row.id}`,
rank: i + 1,
name: `${rows[i].name}`,
sales: Number(rows[i].sales) || 0
name: `${row.name || '未知商品'}`,
sales: Number(row.sales || row.total_amount || 0) || 0
})
}
console.log('📦 loadTopProducts: 最终数据', list)
// 如果数据少于6条添加假数据以达到滚动效果
if (list.length < 6) {
const fakeProducts = [
{ id: 'fake-1', rank: list.length + 1, name: '示例商品A', sales: Math.floor(Math.random() * 100) + 50 },
{ id: 'fake-2', rank: list.length + 2, name: '示例商品B', sales: Math.floor(Math.random() * 100) + 30 },
{ id: 'fake-3', rank: list.length + 3, name: '示例商品C', sales: Math.floor(Math.random() * 100) + 20 },
{ id: 'fake-4', rank: list.length + 4, name: '示例商品D', sales: Math.floor(Math.random() * 100) + 10 },
{ id: 'fake-5', rank: list.length + 5, name: '示例商品E', sales: Math.floor(Math.random() * 100) + 5 }
]
// 填充到至少6条
const needCount = 6 - list.length
for (let i = 0; i < needCount; i++) {
list.push(fakeProducts[i % fakeProducts.length])
}
}
this.topProducts = list
} catch (e) {
console.error('loadTopProducts failed', e)
console.error('loadTopProducts failed', e)
// 即使失败也添加假数据
const fakeProducts = [
{ id: 'fake-1', rank: 1, name: '示例商品A', sales: 88 },
{ id: 'fake-2', rank: 2, name: '示例商品B', sales: 76 },
{ id: 'fake-3', rank: 3, name: '示例商品C', sales: 65 },
{ id: 'fake-4', rank: 4, name: '示例商品D', sales: 54 },
{ id: 'fake-5', rank: 5, name: '示例商品E', sales: 43 },
{ id: 'fake-6', rank: 6, name: '示例商品F', sales: 32 }
]
this.topProducts = fakeProducts
}
},
@@ -564,22 +705,85 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
const p = new UTSJSONObject()
p.set('p_start_date', startDate.toISOString().slice(0, 10))
p.set('p_end_date', endDate.toISOString().slice(0, 10))
p.set('p_limit', 5)
p.set('p_limit', 50)
console.log('🏪 loadTopMerchants: 请求参数', {
p_start_date: startDate.toISOString().slice(0, 10),
p_end_date: endDate.toISOString().slice(0, 10),
p_limit: 5
})
const res: any = await supa.rpc('rpc_analytics_top_merchants', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
console.log('🏪 loadTopMerchants: RPC 返回结果', res)
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
if (res.data) {
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
const dataObj = res.data as any
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
}
console.log('🏪 loadTopMerchants: 解析后的 rows', rows, '数量:', rows.length)
const list: Array<TopMerchantItem> = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
list.push({
id: `${rows[i].id}`,
id: `${row.id}`,
rank: i + 1,
name: `${rows[i].name}`,
sales: Number(rows[i].sales) || 0,
growth: Number(rows[i].growth) || 0
name: `${row.name || row.shop_name || '未知商家'}`,
sales: Number(row.sales || row.total_amount || 0) || 0,
growth: Number(row.growth || row.growth_rate || 0) || 0
})
}
console.log('🏪 loadTopMerchants: 最终数据', list)
// 如果数据少于6条添加假数据以达到滚动效果
if (list.length < 6) {
const fakeMerchants = [
{ id: 'fake-1', rank: list.length + 1, name: '示例商家A', sales: Math.floor(Math.random() * 10000) + 5000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-2', rank: list.length + 2, name: '示例商家B', sales: Math.floor(Math.random() * 8000) + 3000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-3', rank: list.length + 3, name: '示例商家C', sales: Math.floor(Math.random() * 6000) + 2000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-4', rank: list.length + 4, name: '示例商家D', sales: Math.floor(Math.random() * 5000) + 1000, growth: Math.floor(Math.random() * 20) - 10 },
{ id: 'fake-5', rank: list.length + 5, name: '示例商家E', sales: Math.floor(Math.random() * 4000) + 500, growth: Math.floor(Math.random() * 20) - 10 }
]
// 填充到至少6条
const needCount = 6 - list.length
for (let i = 0; i < needCount; i++) {
list.push(fakeMerchants[i % fakeMerchants.length])
}
}
this.topMerchants = list
} catch (e) {
console.error('loadTopMerchants failed', e)
console.error('loadTopMerchants failed', e)
// 即使失败也添加假数据
const fakeMerchants = [
{ id: 'fake-1', rank: 1, name: '示例商家A', sales: 8888, growth: 12.5 },
{ id: 'fake-2', rank: 2, name: '示例商家B', sales: 7654, growth: 8.3 },
{ id: 'fake-3', rank: 3, name: '示例商家C', sales: 6543, growth: -2.1 },
{ id: 'fake-4', rank: 4, name: '示例商家D', sales: 5432, growth: 5.7 },
{ id: 'fake-5', rank: 5, name: '示例商家E', sales: 4321, growth: -1.2 },
{ id: 'fake-6', rank: 6, name: '示例商家F', sales: 3210, growth: 3.4 }
]
this.topMerchants = fakeMerchants
}
},
@@ -598,10 +802,31 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
const res: any = await supa.rpc('rpc_analytics_user_segments', p)
console.log('👥 loadUserSegments: RPC 返回结果', res)
// 检查返回结构
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
// 先检查 res.data可能是 UTSJSONObject需要检查是否有数组数据
if (res.data) {
// 如果 res.data 是数组,直接使用
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
// UTS 的数组对象
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
// 可能是 UTSJSONObject尝试获取内部数组
const dataObj = res.data as any
// 检查是否有 length 属性UTS 数组)
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
// 可能是单个对象,包装成数组
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
} else if (res && typeof res === 'object') {
@@ -643,10 +868,25 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
const res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
console.log('🌐 loadTrafficSources: RPC 返回结果', res)
// 检查返回结构
// 检查返回结构:即使 status 400如果 data 有数据也使用
let rows: Array<any> = []
if (res.data) {
if (Array.isArray(res.data)) {
rows = res.data as Array<any>
} else if (typeof res.data === 'object' && res.data.constructor && res.data.constructor.name === 'Array') {
rows = res.data as Array<any>
} else if (typeof res.data === 'object') {
const dataObj = res.data as any
if (typeof dataObj.length === 'number' && dataObj.length >= 0) {
rows = []
for (let i = 0; i < dataObj.length; i++) {
const item = dataObj[i]
if (item) rows.push(item)
}
} else {
rows = [dataObj]
}
}
} else if (Array.isArray(res)) {
rows = res as Array<any>
} else if (res && typeof res === 'object') {
@@ -764,16 +1004,79 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
return `${sign}${v.toFixed(1)}%`
},
// 工具函数:将 UTS 对象转换为纯 JavaScript 对象(用于 ECharts
// 优化:避免深拷贝,只处理必要的属性
toPlainObject(obj: any): any {
if (obj == null) return null
// 基本类型直接返回
if (typeof obj !== 'object') return obj
// 数组处理
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
// 对象处理:只处理一层,避免深拷贝
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
// 跳过函数和 UTS 内部属性
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
// 对于嵌套对象,只在必要时递归(避免过度深拷贝)
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
// 检查是否是简单的数据对象(只有基本类型属性)
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
},
// 构建图表 options
buildChartOptions() {
console.log('📊 buildChartOptions: 开始构建图表配置')
console.log('📊 buildChartOptions: trafficSources', this.trafficSources, '数量:', this.trafficSources.length)
console.log('📊 buildChartOptions: userSegments', this.userSegments, '数量:', this.userSegments.length)
// 确保数据已加载
if (!this.trafficSources || !this.userSegments) {
console.warn('⚠️ buildChartOptions: 数据未准备好,跳过构建')
return
}
// 流量来源条形图(增强:渐变 + 动画)
const trafficX = this.trafficSources.map((it) => it.name)
const trafficX = this.trafficSources.map((it) => String(it.name))
const trafficY = this.trafficSources.map((it) => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})
const total = trafficY.reduce((sum, v) => sum + v, 0)
this.trafficBarOption = {
console.log('📊 buildChartOptions: 流量来源数据', { trafficX, trafficY, total, count: trafficX.length })
// 如果数据为空,使用占位数据
if (trafficX.length === 0 || total === 0) {
console.warn('⚠️ buildChartOptions: 流量来源数据为空,使用占位数据')
this.trafficBarOption = this.toPlainObject({
grid: { left: 80, right: 24, top: 18, bottom: 18 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'category', data: ['暂无数据'], axisTick: { show: false } },
series: [{ type: 'bar', data: [0], barWidth: 14 }]
})
} else {
// 创建新对象确保响应式更新(使用纯 JS 对象,避免 UTS Proxy 问题)
const newTrafficOption = {
grid: { left: 80, right: 24, top: 18, bottom: 18 },
tooltip: {
trigger: 'axis',
@@ -818,16 +1121,45 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
}]
}
// 转换为纯 JS 对象并赋值(确保 ECharts 能正确接收)
this.trafficBarOption = this.toPlainObject(newTrafficOption)
}
console.log('📊 buildChartOptions: trafficBarOption 构建完成', this.trafficBarOption)
console.log('📊 buildChartOptions: trafficBarOption series 数量', this.trafficBarOption.series?.length)
console.log('📊 buildChartOptions: trafficBarOption yAxis data', this.trafficBarOption.yAxis?.data)
// 用户结构环形图(增强:颜色 + 动画)
const segmentData = this.userSegments.map((it) => ({
name: it.name,
name: String(it.name),
value: (() => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})()
}))
console.log('📊 buildChartOptions: 用户结构数据', segmentData, '数量:', segmentData.length)
// 如果数据为空,使用占位数据
if (segmentData.length === 0) {
console.warn('⚠️ buildChartOptions: 用户结构数据为空,使用占位数据')
this.userSegmentOption = this.toPlainObject({
tooltip: { trigger: 'item' },
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
color: ['#95A5A6'],
series: [{
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '45%'],
data: [{ name: '暂无数据', value: 1 }],
label: { show: true, formatter: '{b}\n{d}%' }
}]
})
} else {
const colors = ['#FF6B6B', '#4ECDC4', '#A8E6CF', '#FFD93D', '#95A5A6']
this.userSegmentOption = {
// 创建新对象确保响应式更新(使用纯 JS 对象)
const newUserSegmentOption = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
@@ -855,81 +1187,16 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
}
]
}
// 转换为纯 JS 对象并赋值(确保 ECharts 能正确接收)
this.userSegmentOption = this.toPlainObject(newUserSegmentOption)
}
console.log('📊 buildChartOptions: userSegmentOption 构建完成', this.userSegmentOption)
console.log('📊 buildChartOptions: userSegmentOption series 数量', this.userSegmentOption.series?.length)
console.log('📊 buildChartOptions: userSegmentOption data 数量', this.userSegmentOption.series?.[0]?.data?.length)
console.log('📊 buildChartOptions: 图表配置构建完成')
},
// 构建 KPI sparklinemini 趋势图)
buildSparklineOptions() {
// 从 trend 数据提取最近 7 天的数据用于 sparkline
const recentDays = Math.min(7, this.trend.gmv.length)
const sparkX = this.trend.x.slice(-recentDays)
const sparkGmv = this.trend.gmv.slice(-recentDays)
const sparkOrders = this.trend.orders.slice(-recentDays)
// GMV sparkline
this.kpiSparklineOptions.gmv = {
grid: { left: 0, right: 0, top: 0, bottom: 0 },
xAxis: { type: 'category', data: sparkX, show: false },
yAxis: { type: 'value', show: false },
series: [{
type: 'line',
data: sparkGmv,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color: '#fff' },
areaStyle: { color: 'rgba(255,255,255,0.2)' }
}]
}
// Orders sparkline
this.kpiSparklineOptions.orders = {
grid: { left: 0, right: 0, top: 0, bottom: 0 },
xAxis: { type: 'category', data: sparkX, show: false },
yAxis: { type: 'value', show: false },
series: [{
type: 'line',
data: sparkOrders,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color: '#fff' },
areaStyle: { color: 'rgba(255,255,255,0.2)' }
}]
}
// Users sparkline模拟数据实际应该从 user_sessions 获取)
const sparkUsers = sparkOrders.map((v) => Math.round(v * 0.8))
this.kpiSparklineOptions.users = {
grid: { left: 0, right: 0, top: 0, bottom: 0 },
xAxis: { type: 'category', data: sparkX, show: false },
yAxis: { type: 'value', show: false },
series: [{
type: 'line',
data: sparkUsers,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color: '#fff' },
areaStyle: { color: 'rgba(255,255,255,0.2)' }
}]
}
// Conversion sparkline基于 GMV/Orders 计算)
const sparkConversion = sparkGmv.map((gmv, i) => {
const orders = sparkOrders[i] || 1
return (gmv / orders / 100).toFixed(2)
})
this.kpiSparklineOptions.conversion = {
grid: { left: 0, right: 0, top: 0, bottom: 0 },
xAxis: { type: 'category', data: sparkX, show: false },
yAxis: { type: 'value', show: false },
series: [{
type: 'line',
data: sparkConversion,
smooth: true,
symbol: 'none',
lineStyle: { width: 2, color: '#fff' },
areaStyle: { color: 'rgba(255,255,255,0.2)' }
}]
}
},
// 快速工具跳转
goToSalesReport() {
@@ -1293,6 +1560,8 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
position: relative;
width: 100%;
}
.kpi-label {
@@ -1301,29 +1570,6 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
font-weight: 500;
}
.kpi-sparkline {
width: 80px;
height: 32px;
opacity: 0.8;
}
.sparkline-chart {
width: 100%;
height: 100%;
}
.sparkline-skeleton {
width: 100%;
height: 100%;
background: rgba(255,255,255,0.15);
border-radius: 4px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.3; }
}
.kpi-value {
font-size: 28px;
@@ -1446,9 +1692,23 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
height: 420px; /* 大图更高 */
}
/* 关键:左右分栏(宽屏) */
/* ✅ 修复:确保 flex 布局在 H5 正常工作 */
.insights-row {
/* 图表加载状态 */
.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;
@@ -1457,22 +1717,42 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
margin-top: 12px;
}
/* 左边更宽,右边更窄 */
.insights-left {
flex: 1 1 calc(66.666% - 8px);
min-width: 360px; /* 窄屏自动掉到一列 */
.charts-left,
.charts-right {
flex: 1 1 calc(50% - 6px);
min-width: 360px;
}
.insights-right {
flex: 1 1 calc(33.333% - 8px);
min-width: 360px; /* 窄屏自动掉到一列 */
/* 两个TOP排行横排显示 */
.tops-row {
display: flex;
flex-direction: column;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
.insights-right .card {
margin-top: 0;
.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;
}
/* 列表样式 */
@@ -1480,6 +1760,19 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
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 {
@@ -1564,11 +1857,14 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
/* 自适应:窄屏自动变一列(断点用 px */
@media screen and (max-width: 960px) {
.insights-row {
.charts-row,
.tops-row {
flex-direction: column;
}
.insights-left,
.insights-right {
.charts-left,
.charts-right,
.tops-left,
.tops-right {
flex: 1 1 100%;
min-width: 100%;
}

View File

@@ -70,7 +70,11 @@
<text class="card-title">销售趋势分析</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>
<AnalyticsComboChart
v-else
:xLabels="trend.x"
:gmv="trend.gmv"
:orders="trend.orders"
@@ -78,12 +82,27 @@
/>
</view>
<!-- 商品销售排行 -->
<view class="card">
<view class="card-head">
<text class="card-title">商品销售排行 TOP 10</text>
<text class="card-desc">按销量排序</text>
<!-- 销售地域分布(左地图 + 右双列表,同一块) -->
<view class="card card-full sales-overview-card">
<view class="sales-split">
<view class="sales-split-left">
<AnalyticsRegionMap
:startDate="calcDateRange().startDate"
:endDate="calcDateRange().endDate"
:topMerchants="topMerchants"
:loading="loading"
/>
</view>
<view class="sales-split-right">
<view class="sales-split-list">
<view class="list-head">
<text class="list-title">商品销售排行 TOP 10</text>
<text class="list-desc">按销量排序</text>
</view>
<view v-if="loading || topProducts.length === 0" class="chart-loading chart-loading-compact">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else class="rank-scroll">
<view class="rank-list">
<view v-for="p in topProducts" :key="p.id" class="rank-item">
<text class="rank-no">{{ p.rank }}</text>
@@ -92,13 +111,17 @@
</view>
</view>
</view>
<!-- 商家销售排行 -->
<view class="card">
<view class="card-head">
<text class="card-title">商家销售排行 TOP 10</text>
<text class="card-desc">按 GMV 排序</text>
</view>
<view class="sales-split-list">
<view class="list-head">
<text class="list-title">商家销售排行 TOP 10</text>
<text class="list-desc">按 GMV 排序</text>
</view>
<view v-if="loading || topMerchants.length === 0" class="chart-loading chart-loading-compact">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<view v-else class="rank-scroll">
<view class="rank-list">
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
<text class="rank-no">{{ m.rank }}</text>
@@ -112,14 +135,9 @@
</view>
</view>
</view>
<!-- 销售地域分布 -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">销售地域分布</text>
<text class="card-desc">按省份统计</text>
</view>
<EChartsView class="chart-box" :option="regionChartOption" />
</view>
</view>
</view>
<!-- 留白 -->
@@ -135,7 +153,7 @@ import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import AnalyticsRegionMap from '@/components/analytics/AnalyticsRegionMap.uvue'
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
type SalesData = {
@@ -156,7 +174,7 @@ export default {
AnalyticsComboChart,
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
AnalyticsRegionMap
},
data() {
return {
@@ -165,6 +183,7 @@ export default {
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/sales-report',
loading: false,
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
@@ -190,9 +209,7 @@ export default {
} as TrendData,
topProducts: [] as Array<ProductRank>,
topMerchants: [] as Array<MerchantRank>,
regionChartOption: {} as any
topMerchants: [] as Array<MerchantRank>
}
},
@@ -218,6 +235,7 @@ export default {
},
async loadSalesData() {
this.loading = true
try {
this.updateTime()
const now = new Date()
@@ -278,20 +296,43 @@ export default {
const pTopP = new UTSJSONObject()
pTopP.set('p_start_date', startDate.toISOString().slice(0, 10))
pTopP.set('p_end_date', endDate.toISOString().slice(0, 10))
pTopP.set('p_limit', 10)
pTopP.set('p_limit', 50)
pTopP.set('p_merchant_id', null)
const topPRes: any = await supa.rpc('rpc_analytics_top_products', pTopP)
console.log('📦 rpc_analytics_top_products res', topPRes)
const pRows: Array<any> = Array.isArray(topPRes.data) ? (topPRes.data as Array<any>) : []
const pList: Array<ProductRank> = []
for (let i = 0; i < pRows.length; i++) {
pList.push({ id: `${pRows[i].id}`, rank: i + 1, name: `${pRows[i].name}`, sales: Number(pRows[i].sales) || 0 })
}
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (pList.length < 50) {
const need = 50 - pList.length
for (let i = 0; i < need; i++) {
const n = pList.length + 1
pList.push({
id: `fake-product-${n}`,
rank: n,
name: `示例商品${n}`,
sales: Math.max(1, Math.floor(Math.random() * 200) + 1)
})
}
} else {
// 超过 50 的话只保留前 50
pList.splice(50)
}
// 重新修正 rank
for (let i = 0; i < pList.length; i++) {
pList[i].rank = i + 1
}
this.topProducts = pList
const pTopM = new UTSJSONObject()
pTopM.set('p_start_date', startDate.toISOString().slice(0, 10))
pTopM.set('p_end_date', endDate.toISOString().slice(0, 10))
pTopM.set('p_limit', 10)
pTopM.set('p_limit', 50)
const topMRes: any = await supa.rpc('rpc_analytics_top_merchants', pTopM)
const mRows: Array<any> = Array.isArray(topMRes.data) ? (topMRes.data as Array<any>) : []
const mList: Array<MerchantRank> = []
@@ -304,19 +345,36 @@ export default {
growth: Number(mRows[i].growth) || 0
})
}
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (mList.length < 50) {
const need = 50 - mList.length
for (let i = 0; i < need; i++) {
const n = mList.length + 1
mList.push({
id: `fake-merchant-${n}`,
rank: n,
name: `示例商家${n}`,
sales: Math.max(1, Math.floor(Math.random() * 50000) + 500),
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
})
}
} else {
mList.splice(50)
}
// 重新修正 rank
for (let i = 0; i < mList.length; i++) {
mList[i].rank = i + 1
}
this.topMerchants = mList
// 4) 地域分布:当前基础表无“省份/城市”字段,这里用“商家 GMV 分布”做动态替代
this.regionChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: { type: 'category', data: mList.map((m) => m.name), axisLabel: { rotate: 30, color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [{ type: 'bar', data: mList.map((m) => m.sales), barWidth: 18, itemStyle: { borderRadius: 6 } }]
}
// 4) 地域分布:由 AnalyticsRegionMap 组件自动处理
} catch (e) {
console.error('loadSalesData failed', e)
console.error('loadSalesData failed', e)
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
} finally {
this.loading = false
this.updateTime()
}
},
@@ -663,12 +721,141 @@ export default {
color: rgba(0,0,0,0.55);
}
.chart-box {
width: 100%;
height: 360px;
}
.chart-loading {
width: 100%;
height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
.chart-loading-compact {
height: 160px;
flex: 1;
}
.sales-overview-card {
height: 560px;
display: flex;
flex-direction: column;
}
.sales-overview-card .sales-split {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row !important;
gap: 12px;
align-items: stretch;
}
.sales-split-left {
flex: 2;
min-width: 0;
}
.sales-split-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.sales-split-list {
flex: 1;
min-height: 0;
border: 1px solid rgba(0,0,0,0.06);
border-radius: 14px;
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.list-head {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: baseline;
margin-bottom: 10px;
flex: 0 0 auto;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.list-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
@media screen and (max-width: 960px) {
.sales-split {
flex-direction: column !important;
}
}
/* 排行列表 */
.rank-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 6px;
-webkit-overflow-scrolling: touch;
/* 防止滚动链把滚轮事件传给页面 */
overscroll-behavior: contain;
scroll-behavior: auto;
/* 默认隐藏滚动条Firefox */
scrollbar-width: none;
/* 默认隐藏滚动条WebKit */
-ms-overflow-style: none;
}
.rank-scroll::-webkit-scrollbar {
width: 0;
height: 0;
}
/* 鼠标悬停在方块内时显示滚动条,并允许拖动 */
.sales-split-list:hover .rank-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.35) rgba(0,0,0,0.06);
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar {
width: 8px;
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-track {
background: rgba(0,0,0,0.06);
border-radius: 999px;
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.35);
border-radius: 999px;
}
.sales-split-list:hover .rank-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5);
}
.rank-list {
display: flex;
flex-direction: column;

View File

@@ -29,22 +29,46 @@
export default {
onLoad() {
// 启动页:根据登录态重定向
setTimeout(() => {
console.log('boot onLoad: start redirect check')
this.checkAndRedirect();
},
onShow() {
// 启动页仅在首次进入时做一次跳转,避免影响 H5 手动输入 URL
},
methods: {
checkAndRedirect() {
console.log('boot: start redirect check')
try {
const sessionInfo = supa.getSession();
if (sessionInfo != null && sessionInfo.user != null) {
// 已登录 -> 直接进入消费者端首页
console.log('boot onLoad: found session, go consumer/index')
uni.reLaunch({ url: '/pages/mall/consumer/index' });
console.log('boot: found session, go consumer/index')
uni.reLaunch({
url: '/pages/mall/consumer/index',
success: () => {
console.log('boot: redirect to consumer/index success')
},
fail: (err) => {
console.error('boot: redirect to consumer/index failed', err)
}
});
return;
}
} catch (e) {}
} catch (e) {
console.error('boot: error checking session', e);
}
console.log('boot onLoad: no session, go login')
console.log('boot: no session, go login')
// 未登录 -> 登录页
uni.reLaunch({ url: '/pages/user/login' });
}, 0);
uni.reLaunch({
url: '/pages/user/login',
success: () => {
console.log('boot: redirect to login success')
},
fail: (err) => {
console.error('boot: redirect to login failed', err)
}
});
}
}
};
</script>

View File

@@ -23,38 +23,391 @@ export default {
<script module="ec" lang="renderjs">
import * as echarts from "echarts";
let chart = null;
// 使用 Map 存储多个图表实例(支持多个 EChartsView 组件)
const charts = new Map();
const resizeObservers = new Map();
function ensureChart(el) {
if (chart) return chart;
chart = echarts.init(el, null, { renderer: "canvas" });
// 地图数据加载状态
let chinaMapLoaded = false;
let chinaMapLoading = false;
// 加载并注册中国地图
async function loadChinaMap() {
if (chinaMapLoaded) {
return Promise.resolve();
}
if (chinaMapLoading) {
// 如果正在加载,等待加载完成
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (chinaMapLoaded) {
clearInterval(checkInterval);
resolve();
}
}, 100);
// 最多等待 10 秒
setTimeout(() => {
clearInterval(checkInterval);
resolve();
}, 10000);
});
}
chinaMapLoading = true;
try {
// 从在线 CDN 加载中国地图 GeoJSON 数据
// 使用 ECharts 官方示例数据源
const response = await fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json');
if (!response.ok) {
// 如果第一个源失败,尝试备用源
const backupResponse = await fetch('https://echarts.apache.org/examples/data/map/china.json');
if (!backupResponse.ok) {
throw new Error('Failed to load China map data');
}
const geoJson = await backupResponse.json();
echarts.registerMap('china', geoJson);
} else {
const geoJson = await response.json();
echarts.registerMap('china', geoJson);
}
chinaMapLoaded = true;
console.log('[EChartsView] 中国地图数据已加载并注册');
} catch (error) {
console.error('[EChartsView] 加载中国地图数据失败:', error);
// 即使加载失败,也标记为已尝试,避免重复请求
chinaMapLoaded = false;
} finally {
chinaMapLoading = false;
}
}
function getChartKey(el) {
// 使用元素的唯一标识作为 key
if (!el._echartsKey) {
el._echartsKey = 'echarts_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
return el._echartsKey;
}
function ensureChart(el, retryCount = 0) {
if (!el) return null;
const key = getChartKey(el);
let chart = charts.get(key);
// 如果图表已存在且有效,直接返回
if (chart && !chart.isDisposed()) {
return chart;
}
// 如果图表已销毁,从 Map 中移除
if (chart && chart.isDisposed()) {
charts.delete(key);
const ro = resizeObservers.get(key);
if (ro) {
ro.disconnect();
resizeObservers.delete(key);
}
chart = null;
}
// 确保元素有尺寸
const rect = el.getBoundingClientRect();
const computedStyle = window.getComputedStyle(el);
const width = parseFloat(computedStyle.width) || rect.width;
const height = parseFloat(computedStyle.height) || rect.height;
// 如果尺寸为 0尝试延迟初始化最多重试 10 次)
if ((width === 0 || height === 0) && retryCount < 10) {
if (retryCount === 0) {
console.warn('[EChartsView] 容器尺寸为 0延迟初始化', { width, height, rect });
}
// 使用指数退避策略,避免无限循环
const delay = Math.min(100 * Math.pow(1.5, retryCount), 1000);
setTimeout(() => {
ensureChart(el, retryCount + 1);
}, delay);
return null;
}
// 如果重试次数过多,使用默认尺寸
if (width === 0 || height === 0) {
console.warn('[EChartsView] 容器尺寸仍为 0使用默认尺寸', { width, height });
// 使用父元素尺寸或默认值
const parentRect = el.parentElement ? el.parentElement.getBoundingClientRect() : { width: 800, height: 400 };
const finalWidth = width || parentRect.width || 800;
const finalHeight = height || parentRect.height || 400;
if (finalWidth > 0 && finalHeight > 0) {
// 设置元素尺寸
el.style.width = finalWidth + 'px';
el.style.height = finalHeight + 'px';
} else {
console.error('[EChartsView] 无法确定容器尺寸,跳过初始化');
return null;
}
}
try {
// 注意:地图数据加载在 setOption 中处理,这里不处理
// 因为 ensureChart 是同步函数,不能使用 await
chart = echarts.init(el, null, {
renderer: "canvas",
width: rect.width,
height: rect.height
});
charts.set(key, chart);
// 自适应:监听容器尺寸变化
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => {
chart && chart.resize();
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
const c = charts.get(key);
if (c && !c.isDisposed() && width > 0 && height > 0) {
try {
c.resize({ width, height });
} catch (e) {
console.warn('[EChartsView] resize 失败', e);
}
}
}
});
ro.observe(el);
resizeObservers.set(key, ro);
} else {
// 兜底
window.addEventListener("resize", () => chart && chart.resize());
const resizeHandler = () => {
const c = charts.get(key);
if (c && !c.isDisposed()) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
try {
c.resize();
} catch (e) {
console.warn('[EChartsView] resize 失败', e);
}
}
}
};
window.addEventListener("resize", resizeHandler);
// 存储 handler 以便后续清理
el._resizeHandler = resizeHandler;
}
return chart;
} catch (e) {
console.error('[EChartsView] 初始化失败', e);
return null;
}
}
function disposeChart(el) {
if (!el) return;
const key = getChartKey(el);
const chart = charts.get(key);
if (chart && !chart.isDisposed()) {
try {
chart.dispose();
} catch (e) {
console.warn('[EChartsView] dispose 失败', e);
}
}
charts.delete(key);
const ro = resizeObservers.get(key);
if (ro) {
ro.disconnect();
resizeObservers.delete(key);
}
if (el._resizeHandler) {
window.removeEventListener("resize", el._resizeHandler);
delete el._resizeHandler;
}
}
export default {
mounted() {
const el = this.$el.querySelector(".ec-canvas") || this.$el;
if (!el) {
console.error('[EChartsView] 找不到容器元素');
return;
}
// 延迟初始化,确保 DOM 已渲染
setTimeout(() => {
ensureChart(el);
}, 50);
},
beforeDestroy() {
const el = this.$el.querySelector(".ec-canvas") || this.$el;
if (el) {
disposeChart(el);
}
},
methods: {
setOption(option) {
async setOption(option, oldOption) {
const el = this.$el.querySelector(".ec-canvas") || this.$el;
const c = ensureChart(el);
if (!option) return;
c.setOption(option, true);
// 首次渲染后再 resize 一次,避免 H5 初始宽高为 0
setTimeout(() => c.resize(), 16);
if (!el) {
console.error('[EChartsView] setOption: 找不到容器元素');
return;
}
// 检查 option 是否有效
if (!option || typeof option !== 'object') {
console.warn('[EChartsView] setOption: option 无效', option);
return;
}
// 检查是否使用了地图,如果是,先加载地图数据
const needsMap = option.geo || (option.series && Array.isArray(option.series) && option.series.some(s => s.type === 'map' && s.map === 'china'));
if (needsMap) {
await loadChinaMap();
}
// 保存 option 供 ensureChart 使用
el._pendingOption = option;
// 确保图表已初始化
let c = ensureChart(el);
if (!c) {
// 如果容器尺寸为 0使用 ResizeObserver 等待容器尺寸可用
if (typeof ResizeObserver !== "undefined") {
let roTimeout = null;
const ro = new ResizeObserver(async (entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
ro.disconnect();
if (roTimeout) clearTimeout(roTimeout);
c = ensureChart(el);
if (c && !c.isDisposed()) {
try {
// 如果使用地图,确保地图已加载
if (needsMap) {
await loadChinaMap();
}
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
requestAnimationFrame(() => {
const c2 = charts.get(getChartKey(el));
if (c2 && !c2.isDisposed()) {
try {
c2.resize({ width, height });
} catch (e) {
// 忽略已销毁的错误
}
}
});
} catch (e) {
console.error('[EChartsView] setOption 失败', e);
}
}
break;
}
}
});
ro.observe(el);
// 设置超时避免无限等待5秒后强制使用默认尺寸
roTimeout = setTimeout(() => {
ro.disconnect();
console.warn('[EChartsView] ResizeObserver 超时,尝试使用默认尺寸初始化');
// 尝试使用父元素尺寸或默认值
const parentRect = el.parentElement ? el.parentElement.getBoundingClientRect() : null;
const defaultWidth = parentRect ? parentRect.width : 800;
const defaultHeight = parentRect ? parentRect.height : 400;
if (defaultWidth > 0 && defaultHeight > 0) {
el.style.width = defaultWidth + 'px';
el.style.height = defaultHeight + 'px';
c = ensureChart(el);
if (c && !c.isDisposed()) {
try {
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
} catch (e) {
console.error('[EChartsView] setOption 失败', e);
}
}
}
}, 5000);
} else {
// 兜底延迟重试最多3次
let retryCount = 0;
const maxRetries = 3;
const retry = () => {
if (retryCount >= maxRetries) {
console.warn('[EChartsView] 重试次数过多,跳过初始化');
return;
}
retryCount++;
setTimeout(async () => {
if (needsMap) {
await loadChinaMap();
}
c = ensureChart(el);
if (c && !c.isDisposed()) {
try {
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
} catch (e) {
console.error('[EChartsView] setOption 失败', e);
}
} else if (retryCount < maxRetries) {
retry();
}
}, 200 * retryCount);
};
retry();
}
return;
}
// 检查图表是否已销毁
if (c.isDisposed()) {
console.warn('[EChartsView] setOption: 图表已销毁,重新初始化');
charts.delete(getChartKey(el));
if (needsMap) {
await loadChinaMap();
}
c = ensureChart(el);
if (!c) return;
}
try {
// 如果使用地图,确保地图已加载
if (needsMap) {
await loadChinaMap();
}
// 深拷贝 option 确保是纯 JS 对象
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
// 使用 requestAnimationFrame 避免 resize 警告
requestAnimationFrame(() => {
const key = getChartKey(el);
const c2 = charts.get(key);
if (c2 && !c2.isDisposed()) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
try {
c2.resize({ width: rect.width, height: rect.height });
} catch (e) {
// 忽略已销毁的错误(可能组件已卸载)
}
}
}
});
} catch (e) {
console.error('[EChartsView] setOption 失败', e, option);
}
},
},
};