界面设计
This commit is contained in:
@@ -26,9 +26,33 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
xLabels: { handler() { this.updateOption() }, deep: true },
|
xLabels: {
|
||||||
gmv: { handler() { this.updateOption() }, deep: true },
|
handler() {
|
||||||
orders: { handler() { this.updateOption() }, deep: true },
|
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: {
|
height: {
|
||||||
handler() {
|
handler() {
|
||||||
this.heightPx = `${this.height}px`
|
this.heightPx = `${this.height}px`
|
||||||
@@ -38,11 +62,53 @@ export default {
|
|||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.heightPx = `${this.height}px`
|
this.heightPx = `${this.height}px`
|
||||||
this.updateOption()
|
// 延迟初始化,确保 props 已传递
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.xLabels && this.xLabels.length > 0 && this.gmv && this.gmv.length > 0) {
|
||||||
|
this.updateOption()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
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() {
|
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 x = (this.xLabels as Array<any>).map((s) => String(s))
|
||||||
const bar = (this.gmv as Array<any>).map((v) => {
|
const bar = (this.gmv as Array<any>).map((v) => {
|
||||||
const n = Number(v)
|
const n = Number(v)
|
||||||
@@ -53,7 +119,8 @@ export default {
|
|||||||
return isFinite(n) ? n : 0
|
return isFinite(n) ? n : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
this.chartOption = {
|
// 构建图表配置并转换为纯 JS 对象
|
||||||
|
const option = {
|
||||||
grid: { left: 60, right: 60, top: 70, bottom: 40 },
|
grid: { left: 60, right: 60, top: 70, bottom: 40 },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
@@ -154,6 +221,9 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转换为纯 JS 对象确保 ECharts 能正确接收
|
||||||
|
this.chartOption = this.toPlainObject(option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,9 +232,15 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.chart-wrap {
|
.chart-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
379
components/analytics/AnalyticsRegionMap.uvue
Normal file
379
components/analytics/AnalyticsRegionMap.uvue
Normal 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>
|
||||||
@@ -769,7 +769,7 @@ async select(table : string, filter ?: string | null, options ?: AkSupaSelectOpt
|
|||||||
//console.log(url)
|
//console.log(url)
|
||||||
|
|
||||||
// 确定HTTP方法:如果是head模式,使用HEAD方法
|
// 确定HTTP方法:如果是head模式,使用HEAD方法
|
||||||
let httpMethod = 'GET';
|
let httpMethod: 'GET' | 'HEAD' = 'GET';
|
||||||
if (options != null && options.head == true) {
|
if (options != null && options.head == true) {
|
||||||
httpMethod = 'HEAD';
|
httpMethod = 'HEAD';
|
||||||
//console.log('使用 HEAD 方法进行 count 查询');
|
//console.log('使用 HEAD 方法进行 count 查询');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,11 @@
|
|||||||
<text class="card-title">销售趋势分析</text>
|
<text class="card-title">销售趋势分析</text>
|
||||||
<text class="card-desc">{{ selectedPeriodText }} · 柱:GMV(元) · 线:订单数</text>
|
<text class="card-desc">{{ selectedPeriodText }} · 柱:GMV(元) · 线:订单数</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="loading || !trend.x || trend.x.length === 0" class="chart-loading">
|
||||||
|
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||||
|
</view>
|
||||||
<AnalyticsComboChart
|
<AnalyticsComboChart
|
||||||
|
v-else
|
||||||
:xLabels="trend.x"
|
:xLabels="trend.x"
|
||||||
:gmv="trend.gmv"
|
:gmv="trend.gmv"
|
||||||
:orders="trend.orders"
|
:orders="trend.orders"
|
||||||
@@ -78,50 +82,64 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 商品销售排行 -->
|
<!-- 销售地域分布(左地图 + 右双列表,同一块) -->
|
||||||
<view class="card">
|
<view class="card card-full sales-overview-card">
|
||||||
<view class="card-head">
|
<view class="sales-split">
|
||||||
<text class="card-title">商品销售排行 TOP 10</text>
|
<view class="sales-split-left">
|
||||||
<text class="card-desc">按销量排序</text>
|
<AnalyticsRegionMap
|
||||||
</view>
|
:startDate="calcDateRange().startDate"
|
||||||
<view class="rank-list">
|
:endDate="calcDateRange().endDate"
|
||||||
<view v-for="p in topProducts" :key="p.id" class="rank-item">
|
:topMerchants="topMerchants"
|
||||||
<text class="rank-no">{{ p.rank }}</text>
|
:loading="loading"
|
||||||
<text class="rank-name">{{ p.name }}</text>
|
/>
|
||||||
<text class="rank-val">{{ p.sales }} 件</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<view class="sales-split-right">
|
||||||
</view>
|
<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>
|
||||||
|
<text class="rank-name">{{ p.name }}</text>
|
||||||
|
<text class="rank-val">{{ p.sales }} 件</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 商家销售排行 -->
|
<view class="sales-split-list">
|
||||||
<view class="card">
|
<view class="list-head">
|
||||||
<view class="card-head">
|
<text class="list-title">商家销售排行 TOP 10</text>
|
||||||
<text class="card-title">商家销售排行 TOP 10</text>
|
<text class="list-desc">按 GMV 排序</text>
|
||||||
<text class="card-desc">按 GMV 排序</text>
|
</view>
|
||||||
</view>
|
<view v-if="loading || topMerchants.length === 0" class="chart-loading chart-loading-compact">
|
||||||
<view class="rank-list">
|
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
|
||||||
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
|
</view>
|
||||||
<text class="rank-no">{{ m.rank }}</text>
|
<view v-else class="rank-scroll">
|
||||||
<text class="rank-name">{{ m.name }}</text>
|
<view class="rank-list">
|
||||||
<view class="rank-right">
|
<view v-for="m in topMerchants" :key="m.id" class="rank-item">
|
||||||
<text class="rank-val">¥{{ formatMoney(m.sales) }}</text>
|
<text class="rank-no">{{ m.rank }}</text>
|
||||||
<text class="chip" :class="m.growth >= 0 ? 'pos' : 'neg'">
|
<text class="rank-name">{{ m.name }}</text>
|
||||||
{{ m.growth >= 0 ? '+' : '' }}{{ m.growth }}%
|
<view class="rank-right">
|
||||||
</text>
|
<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>
|
</view>
|
||||||
</view>
|
</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 style="height: 24px;"></view>
|
<view style="height: 24px;"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -135,7 +153,7 @@ import supa from '@/components/supadb/aksupainstance.uts'
|
|||||||
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
|
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
|
||||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.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 TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
|
||||||
type SalesData = {
|
type SalesData = {
|
||||||
@@ -156,7 +174,7 @@ export default {
|
|||||||
AnalyticsComboChart,
|
AnalyticsComboChart,
|
||||||
AnalyticsSidebarMenu,
|
AnalyticsSidebarMenu,
|
||||||
AnalyticsTopBar,
|
AnalyticsTopBar,
|
||||||
EChartsView
|
AnalyticsRegionMap
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -165,6 +183,7 @@ export default {
|
|||||||
showMoreMenu: false,
|
showMoreMenu: false,
|
||||||
showSidebarMenu: false,
|
showSidebarMenu: false,
|
||||||
currentPath: '/pages/mall/analytics/sales-report',
|
currentPath: '/pages/mall/analytics/sales-report',
|
||||||
|
loading: false,
|
||||||
timePeriods: [
|
timePeriods: [
|
||||||
{ value: '7d', label: '7天' },
|
{ value: '7d', label: '7天' },
|
||||||
{ value: '30d', label: '30天' },
|
{ value: '30d', label: '30天' },
|
||||||
@@ -190,9 +209,7 @@ export default {
|
|||||||
} as TrendData,
|
} as TrendData,
|
||||||
|
|
||||||
topProducts: [] as Array<ProductRank>,
|
topProducts: [] as Array<ProductRank>,
|
||||||
topMerchants: [] as Array<MerchantRank>,
|
topMerchants: [] as Array<MerchantRank>
|
||||||
|
|
||||||
regionChartOption: {} as any
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -218,6 +235,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadSalesData() {
|
async loadSalesData() {
|
||||||
|
this.loading = true
|
||||||
try {
|
try {
|
||||||
this.updateTime()
|
this.updateTime()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -278,20 +296,43 @@ export default {
|
|||||||
const pTopP = new UTSJSONObject()
|
const pTopP = new UTSJSONObject()
|
||||||
pTopP.set('p_start_date', startDate.toISOString().slice(0, 10))
|
pTopP.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||||
pTopP.set('p_end_date', endDate.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)
|
pTopP.set('p_merchant_id', null)
|
||||||
const topPRes: any = await supa.rpc('rpc_analytics_top_products', pTopP)
|
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 pRows: Array<any> = Array.isArray(topPRes.data) ? (topPRes.data as Array<any>) : []
|
||||||
const pList: Array<ProductRank> = []
|
const pList: Array<ProductRank> = []
|
||||||
for (let i = 0; i < pRows.length; i++) {
|
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 })
|
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
|
this.topProducts = pList
|
||||||
|
|
||||||
const pTopM = new UTSJSONObject()
|
const pTopM = new UTSJSONObject()
|
||||||
pTopM.set('p_start_date', startDate.toISOString().slice(0, 10))
|
pTopM.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||||
pTopM.set('p_end_date', endDate.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 topMRes: any = await supa.rpc('rpc_analytics_top_merchants', pTopM)
|
||||||
const mRows: Array<any> = Array.isArray(topMRes.data) ? (topMRes.data as Array<any>) : []
|
const mRows: Array<any> = Array.isArray(topMRes.data) ? (topMRes.data as Array<any>) : []
|
||||||
const mList: Array<MerchantRank> = []
|
const mList: Array<MerchantRank> = []
|
||||||
@@ -304,19 +345,36 @@ export default {
|
|||||||
growth: Number(mRows[i].growth) || 0
|
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
|
this.topMerchants = mList
|
||||||
|
|
||||||
// 4) 地域分布:当前基础表无“省份/城市”字段,这里用“商家 GMV 分布”做动态替代
|
// 4) 地域分布:由 AnalyticsRegionMap 组件自动处理
|
||||||
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 } }]
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('loadSalesData failed', e)
|
console.error('❌ loadSalesData failed', e)
|
||||||
|
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
|
||||||
} finally {
|
} finally {
|
||||||
|
this.loading = false
|
||||||
this.updateTime()
|
this.updateTime()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -663,12 +721,141 @@ export default {
|
|||||||
color: rgba(0,0,0,0.55);
|
color: rgba(0,0,0,0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.chart-box {
|
.chart-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 360px;
|
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 {
|
.rank-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -29,22 +29,46 @@
|
|||||||
export default {
|
export default {
|
||||||
onLoad() {
|
onLoad() {
|
||||||
// 启动页:根据登录态重定向
|
// 启动页:根据登录态重定向
|
||||||
setTimeout(() => {
|
this.checkAndRedirect();
|
||||||
console.log('boot onLoad: start redirect check')
|
},
|
||||||
|
onShow() {
|
||||||
|
// 启动页仅在首次进入时做一次跳转,避免影响 H5 手动输入 URL
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
checkAndRedirect() {
|
||||||
|
console.log('boot: start redirect check')
|
||||||
try {
|
try {
|
||||||
const sessionInfo = supa.getSession();
|
const sessionInfo = supa.getSession();
|
||||||
if (sessionInfo != null && sessionInfo.user != null) {
|
if (sessionInfo != null && sessionInfo.user != null) {
|
||||||
// 已登录 -> 直接进入消费者端首页
|
// 已登录 -> 直接进入消费者端首页
|
||||||
console.log('boot onLoad: found session, go consumer/index')
|
console.log('boot: found session, go consumer/index')
|
||||||
uni.reLaunch({ url: '/pages/mall/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;
|
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' });
|
uni.reLaunch({
|
||||||
}, 0);
|
url: '/pages/user/login',
|
||||||
|
success: () => {
|
||||||
|
console.log('boot: redirect to login success')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('boot: redirect to login failed', err)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,38 +23,391 @@ export default {
|
|||||||
<script module="ec" lang="renderjs">
|
<script module="ec" lang="renderjs">
|
||||||
import * as echarts from "echarts";
|
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;
|
let chinaMapLoaded = false;
|
||||||
chart = echarts.init(el, null, { renderer: "canvas" });
|
let chinaMapLoading = false;
|
||||||
|
|
||||||
// 自适应:监听容器尺寸变化
|
// 加载并注册中国地图
|
||||||
if (typeof ResizeObserver !== "undefined") {
|
async function loadChinaMap() {
|
||||||
const ro = new ResizeObserver(() => {
|
if (chinaMapLoaded) {
|
||||||
chart && chart.resize();
|
return Promise.resolve();
|
||||||
});
|
}
|
||||||
ro.observe(el);
|
|
||||||
} else {
|
if (chinaMapLoading) {
|
||||||
// 兜底
|
// 如果正在加载,等待加载完成
|
||||||
window.addEventListener("resize", () => chart && chart.resize());
|
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((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 {
|
||||||
|
// 兜底
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
return chart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mounted() {
|
mounted() {
|
||||||
const el = this.$el.querySelector(".ec-canvas") || this.$el;
|
const el = this.$el.querySelector(".ec-canvas") || this.$el;
|
||||||
ensureChart(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: {
|
methods: {
|
||||||
setOption(option) {
|
async setOption(option, oldOption) {
|
||||||
const el = this.$el.querySelector(".ec-canvas") || this.$el;
|
const el = this.$el.querySelector(".ec-canvas") || this.$el;
|
||||||
const c = ensureChart(el);
|
if (!el) {
|
||||||
if (!option) return;
|
console.error('[EChartsView] setOption: 找不到容器元素');
|
||||||
c.setOption(option, true);
|
return;
|
||||||
// 首次渲染后再 resize 一次,避免 H5 初始宽高为 0
|
}
|
||||||
setTimeout(() => c.resize(), 16);
|
|
||||||
|
// 检查 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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user