consumer模块完成90%,前端完成supabase对接
This commit is contained in:
125
mall/components/analytics/AnalyticsAreaChart.uvue
Normal file
125
mall/components/analytics/AnalyticsAreaChart.uvue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<view class="chart-wrap" :style="{ height: heightPx }">
|
||||
<EChartsView :option="chartOption" class="chart" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
props: {
|
||||
xLabels: { type: Array, default: () => [] },
|
||||
data: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 300 }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
heightPx: '300px',
|
||||
chartOption: {} as any
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
xLabels: { handler() { this.updateOption() }, deep: true },
|
||||
data: { handler() { this.updateOption() }, deep: true },
|
||||
height: {
|
||||
handler() {
|
||||
this.heightPx = `${this.height}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.heightPx = `${this.height}px`
|
||||
setTimeout(() => {
|
||||
this.updateOption()
|
||||
}, 150)
|
||||
},
|
||||
methods: {
|
||||
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.data || this.xLabels.length === 0 || this.data.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const x = (this.xLabels as Array<any>).map((s) => String(s))
|
||||
const d = (this.data as Array<any>).map((v) => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
})
|
||||
|
||||
const option = {
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 40 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: x,
|
||||
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.1)' } },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.45)', fontSize: 10, interval: 'auto' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.05)' } },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.45)', fontSize: 10 }
|
||||
},
|
||||
series: [{
|
||||
data: d,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbolSize: 0,
|
||||
lineStyle: { width: 2, color: '#1890ff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(24, 144, 255, 0.2)' },
|
||||
{ offset: 1, color: 'rgba(24, 144, 255, 0.01)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
this.chartOption = this.toPlainObject(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-wrap { width: 100%; position: relative; overflow: hidden; }
|
||||
.chart { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
||||
</style>
|
||||
|
||||
60
mall/components/analytics/AnalyticsBarMini.uvue
Normal file
60
mall/components/analytics/AnalyticsBarMini.uvue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<view class="chart-wrap" :style="{ height: heightPx }">
|
||||
<EChartsView :option="chartOption" class="chart" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
type Item = { name: string; value: number }
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
props: {
|
||||
items: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 300 }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
heightPx: '300px',
|
||||
chartOption: {} as any
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
items: { handler() { this.updateOption() }, deep: true },
|
||||
height: {
|
||||
handler() {
|
||||
this.heightPx = `${this.height}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.heightPx = `${this.height}px`
|
||||
this.updateOption()
|
||||
},
|
||||
methods: {
|
||||
updateOption() {
|
||||
const x = (this.items as Array<Item>).map((it) => it.name)
|
||||
const y = (this.items as Array<Item>).map((it) => {
|
||||
const n = Number(it.value)
|
||||
return isFinite(n) ? n : 0
|
||||
})
|
||||
this.chartOption = {
|
||||
grid: { left: 80, right: 24, top: 18, bottom: 18 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
yAxis: { type: 'category', data: x, axisTick: { show: false }, axisLabel: { color: 'rgba(0,0,0,0.65)' } },
|
||||
series: [{ type: 'bar', data: y, barWidth: 14, itemStyle: { borderRadius: 6 } }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.chart-wrap { width: 100%; }
|
||||
.chart { width: 100%; height: 100%; }
|
||||
</style>
|
||||
246
mall/components/analytics/AnalyticsComboChart.uvue
Normal file
246
mall/components/analytics/AnalyticsComboChart.uvue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<view class="chart-wrap" :style="{ height: heightPx }">
|
||||
<EChartsView :option="chartOption" class="chart" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
props: {
|
||||
xLabels: { type: Array, default: () => [] },
|
||||
gmv: { type: Array, default: () => [] },
|
||||
orders: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 320 }
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
heightPx: '320px',
|
||||
chartOption: {} as any
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
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`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
return isFinite(n) ? n : 0
|
||||
})
|
||||
const line = (this.orders as Array<any>).map((v) => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
})
|
||||
|
||||
// 构建图表配置并转换为纯 JS 对象
|
||||
const option = {
|
||||
grid: { left: 60, right: 60, top: 70, bottom: 40 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
let result = params[0].name + '<br/>'
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const p = params[i]
|
||||
if (p.seriesName === '订单金额' || p.seriesName === 'GMV') {
|
||||
const val = Number(p.value)
|
||||
const formatted = val >= 10000 ? (val / 10000).toFixed(1) + '万' : val.toFixed(0)
|
||||
result += `${p.marker} ${p.seriesName}: ¥${formatted}<br/>`
|
||||
} else {
|
||||
result += `${p.marker} ${p.seriesName}: ${p.value}<br/>`
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
top: 8,
|
||||
left: 'center',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 12 },
|
||||
data: ['订单金额', '订单数'],
|
||||
bottom: 'auto'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: x,
|
||||
axisTick: { alignWithLabel: true },
|
||||
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.12)' } },
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.55)',
|
||||
rotate: x.length > 20 ? 45 : 0,
|
||||
interval: x.length > 15 ? 1 : 0
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额',
|
||||
position: 'left',
|
||||
axisLine: { show: false },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } },
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.55)',
|
||||
formatter: (value: number) => {
|
||||
if (value >= 10000) {
|
||||
return (value / 10000).toFixed(0) + '万'
|
||||
}
|
||||
return String(Math.round(value))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '数量',
|
||||
position: 'right',
|
||||
alignTicks: true,
|
||||
axisLine: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.55)',
|
||||
formatter: (value: number) => String(Math.round(value))
|
||||
}
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '订单金额',
|
||||
type: 'bar',
|
||||
yAxisIndex: 0,
|
||||
data: bar,
|
||||
barMaxWidth: 14,
|
||||
barCategoryGap: '35%',
|
||||
itemStyle: {
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
color: '#3b82f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: line,
|
||||
smooth: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#10b981'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#10b981'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 转换为纯 JS 对象确保 ECharts 能正确接收
|
||||
this.chartOption = this.toPlainObject(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
129
mall/components/analytics/AnalyticsDateRangePicker.uvue
Normal file
129
mall/components/analytics/AnalyticsDateRangePicker.uvue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<view class="date-range-picker">
|
||||
<view class="picker-item">
|
||||
<text class="label">开始日期</text>
|
||||
<picker mode="date" :value="startDate" @change="onStartDateChange">
|
||||
<view class="picker-value">{{ startDate || '请选择' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="picker-item">
|
||||
<text class="label">结束日期</text>
|
||||
<picker mode="date" :value="endDate" @change="onEndDateChange">
|
||||
<view class="picker-value">{{ endDate || '请选择' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="actions">
|
||||
<button class="btn apply" @click="applyRange">应用</button>
|
||||
<button class="btn clear" @click="clearRange">清空</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialStartDate: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
initialEndDate: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['apply', 'clear'])
|
||||
|
||||
const startDate = ref(props.initialStartDate)
|
||||
const endDate = ref(props.initialEndDate)
|
||||
|
||||
watch(() => props.initialStartDate, (val) => {
|
||||
startDate.value = val
|
||||
})
|
||||
|
||||
watch(() => props.initialEndDate, (val) => {
|
||||
endDate.value = val
|
||||
})
|
||||
|
||||
function onStartDateChange(e : any) {
|
||||
startDate.value = e.detail.value
|
||||
}
|
||||
|
||||
function onEndDateChange(e : any) {
|
||||
endDate.value = e.detail.value
|
||||
}
|
||||
|
||||
function applyRange() {
|
||||
if (startDate.value && endDate.value) {
|
||||
emit('apply', { start: startDate.value, end: endDate.value })
|
||||
} else {
|
||||
uni.showToast({ title: '请选择完整的日期范围', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function clearRange() {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-range-picker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
padding: 8px 12px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.apply {
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.clear {
|
||||
background-color: #6c757d;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
70
mall/components/analytics/AnalyticsDonutChart.uvue
Normal file
70
mall/components/analytics/AnalyticsDonutChart.uvue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<view class="chart-wrap" :style="{ height: heightPx }">
|
||||
<EChartsView :option="chartOption" class="chart" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
type Item = { name: string; value: number }
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
props: {
|
||||
items: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 300 }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
heightPx: '300px',
|
||||
chartOption: {} as any
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
items: { handler() { this.updateOption() }, deep: true },
|
||||
height: {
|
||||
handler() {
|
||||
this.heightPx = `${this.height}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.heightPx = `${this.height}px`
|
||||
this.updateOption()
|
||||
},
|
||||
methods: {
|
||||
updateOption() {
|
||||
const data = (this.items as Array<Item>).map((it) => ({
|
||||
name: it.name,
|
||||
value: (() => {
|
||||
const n = Number(it.value)
|
||||
return isFinite(n) ? n : 0
|
||||
})()
|
||||
}))
|
||||
this.chartOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['55%', '75%'],
|
||||
center: ['50%', '45%'],
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
labelLine: { length: 10, length2: 10 },
|
||||
data
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.chart-wrap { width: 100%; }
|
||||
.chart { width: 100%; height: 100%; }
|
||||
</style>
|
||||
123
mall/components/analytics/AnalyticsMultiLineChart.uvue
Normal file
123
mall/components/analytics/AnalyticsMultiLineChart.uvue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<view class="chart-wrap" :style="{ height: heightPx }">
|
||||
<EChartsView :option="chartOption" class="chart" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
props: {
|
||||
xLabels: { type: Array, default: () => [] },
|
||||
series: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 400 }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
heightPx: '400px',
|
||||
chartOption: {} as any
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
xLabels: { handler() { this.updateOption() }, deep: true },
|
||||
series: { handler() { this.updateOption() }, deep: true },
|
||||
height: {
|
||||
handler() {
|
||||
this.heightPx = `${this.height}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.heightPx = `${this.height}px`
|
||||
setTimeout(() => {
|
||||
this.updateOption()
|
||||
}, 200)
|
||||
},
|
||||
methods: {
|
||||
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.xLabels.length === 0) return
|
||||
|
||||
const seriesData = (this.series as Array<any>).map(s => {
|
||||
return {
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbolSize: 6,
|
||||
itemStyle: { color: s.color },
|
||||
lineStyle: { width: 2 },
|
||||
data: s.data
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
grid: { left: 50, right: 30, top: 20, bottom: 40 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 'center',
|
||||
itemWidth: 12,
|
||||
itemHeight: 2,
|
||||
textStyle: { fontSize: 12, color: '#333' }
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: this.xLabels,
|
||||
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.1)' } },
|
||||
axisLabel: {
|
||||
color: 'rgba(0,0,0,0.45)',
|
||||
fontSize: 10,
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.05)' } },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.45)', fontSize: 10 }
|
||||
},
|
||||
series: seriesData
|
||||
}
|
||||
this.chartOption = this.toPlainObject(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-wrap { width: 100%; position: relative; overflow: hidden; }
|
||||
.chart { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
||||
</style>
|
||||
115
mall/components/analytics/AnalyticsPieChart.uvue
Normal file
115
mall/components/analytics/AnalyticsPieChart.uvue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<view class="chart-wrap" :style="{ height: heightPx }">
|
||||
<EChartsView :option="chartOption" class="chart" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
props: {
|
||||
items: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 300 }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
heightPx: '300px',
|
||||
chartOption: {} as any
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
items: { handler() { this.updateOption() }, deep: true },
|
||||
height: {
|
||||
handler() {
|
||||
this.heightPx = `${this.height}px`
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.heightPx = `${this.height}px`
|
||||
setTimeout(() => {
|
||||
this.updateOption()
|
||||
}, 200)
|
||||
},
|
||||
methods: {
|
||||
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.items || this.items.length === 0) return
|
||||
|
||||
const data = (this.items as Array<any>).map((it) => {
|
||||
return {
|
||||
name: String(it.name),
|
||||
value: Number(it.value),
|
||||
itemStyle: this.toPlainObject(it.itemStyle)
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 11, color: '#666' }
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['35%', '50%'],
|
||||
data: data,
|
||||
avoidLabelOverlap: true,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
labelLine: { show: false },
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
}
|
||||
}]
|
||||
}
|
||||
this.chartOption = this.toPlainObject(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-wrap { width: 100%; position: relative; overflow: hidden; }
|
||||
.chart { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
||||
</style>
|
||||
|
||||
379
mall/components/analytics/AnalyticsRegionMap.uvue
Normal file
379
mall/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>
|
||||
435
mall/components/analytics/AnalyticsSidebarMenu.uvue
Normal file
435
mall/components/analytics/AnalyticsSidebarMenu.uvue
Normal file
@@ -0,0 +1,435 @@
|
||||
<!-- 数据分析侧边栏菜单组件 -->
|
||||
<template>
|
||||
<view>
|
||||
<!-- 侧边栏菜单 -->
|
||||
<view class="sidebar-menu" :class="{ active: showMenu, 'always-visible': isWideScreen }" @click.stop>
|
||||
<view class="sidebar-content">
|
||||
<view
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
>
|
||||
<view
|
||||
class="menu-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="onParentClick(item)"
|
||||
>
|
||||
<text class="menu-icon">{{ item.icon }}</text>
|
||||
<text class="menu-text">{{ item.title }}</text>
|
||||
<text v-if="item.children && item.children.length > 0" class="menu-caret">{{ isExpanded(item.path) ? '▾' : '▸' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="item.children && item.children.length > 0 && isExpanded(item.path)" class="submenu">
|
||||
<view
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
class="menu-item menu-item-child"
|
||||
:class="{ active: isActive(child.path), disabled: isCustomReportChildDisabled(item, child) }"
|
||||
@click="onChildClick(item, child)"
|
||||
>
|
||||
<text class="menu-icon">{{ child.icon }}</text>
|
||||
<text class="menu-text">{{ child.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 遮罩层(仅窄屏时显示) -->
|
||||
<view class="sidebar-overlay" v-if="showMenu && !isWideScreen" @click="closeMenu"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
|
||||
import { listCustomReports } from '@/services/analytics/customReportService.uts'
|
||||
|
||||
// 菜单项类型
|
||||
type MenuItem = {
|
||||
path: string
|
||||
title: string
|
||||
icon: string
|
||||
children?: Array<MenuItem>
|
||||
}
|
||||
|
||||
// 菜单配置
|
||||
const MENU_ITEMS: Array<MenuItem> = [
|
||||
{ path: '/pages/mall/analytics/index', title: '数据分析中心', icon: '📊' },
|
||||
{ path: '/pages/mall/analytics/profile', title: '个人中心', icon: '👤' },
|
||||
{ path: '/pages/mall/analytics/sales-report', title: '销售报表', icon: '💰' },
|
||||
{ path: '/pages/mall/analytics/user-analysis', title: '用户分析', icon: '👥' },
|
||||
{ path: '/pages/mall/analytics/product-insights', title: '商品洞察', icon: '📦' },
|
||||
{ path: '/pages/mall/analytics/delivery-analysis', title: '配送效率分析', icon: '🚚' },
|
||||
{ path: '/pages/mall/analytics/coupon-analysis', title: '优惠券效果分析', icon: '🎫' },
|
||||
{ path: '/pages/mall/analytics/market-trends', title: '市场趋势', icon: '📈' },
|
||||
{
|
||||
path: '/pages/mall/analytics/custom-report',
|
||||
title: '自定义报表',
|
||||
icon: '📋',
|
||||
children: [
|
||||
{ path: '/pages/mall/analytics/report-detail', title: '报表详情', icon: '📄' },
|
||||
{ path: '/pages/mall/analytics/data-detail', title: '数据分析详情', icon: '🔍' },
|
||||
{ path: '/pages/mall/analytics/insight-detail', title: '数据洞察详情', icon: '💡' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default {
|
||||
props: {
|
||||
// 是否显示菜单
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 当前页面路径
|
||||
currentPath: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['visible-change'],
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
menuItems: MENU_ITEMS,
|
||||
isWideScreen: false,
|
||||
screenWidth: 0,
|
||||
expanded: {} as any,
|
||||
hasAnyCustomReport: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(newVal: boolean) {
|
||||
// 宽屏时自动显示,窄屏时根据 visible 控制
|
||||
if (this.isWideScreen) {
|
||||
this.showMenu = true
|
||||
} else {
|
||||
this.showMenu = newVal
|
||||
}
|
||||
},
|
||||
showMenu(newVal: boolean) {
|
||||
// 同步到父组件(仅窄屏时)
|
||||
if (!this.isWideScreen) {
|
||||
this.$emit('visible-change', newVal)
|
||||
}
|
||||
},
|
||||
currentPath: {
|
||||
handler() {
|
||||
this.syncExpandedWithRoute()
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.checkScreenSize()
|
||||
this.checkCustomReports()
|
||||
},
|
||||
onShow() {
|
||||
// 每次显示时检查屏幕尺寸
|
||||
this.checkScreenSize()
|
||||
this.checkCustomReports()
|
||||
},
|
||||
methods: {
|
||||
isActive(path: string): boolean {
|
||||
const cur = this.currentPath || ''
|
||||
if (cur === path) return true
|
||||
if (cur.startsWith(path + '?')) return true
|
||||
if (cur.startsWith(path + '/')) return true
|
||||
return false
|
||||
},
|
||||
|
||||
async checkCustomReports() {
|
||||
try {
|
||||
const uid = getUserIdOrNull()
|
||||
if (uid == null || uid.length === 0) {
|
||||
this.hasAnyCustomReport = false
|
||||
return
|
||||
}
|
||||
const list = await listCustomReports(uid)
|
||||
this.hasAnyCustomReport = Array.isArray(list) && list.length > 0
|
||||
} catch (e) {
|
||||
this.hasAnyCustomReport = false
|
||||
}
|
||||
},
|
||||
|
||||
checkScreenSize() {
|
||||
// 获取屏幕宽度
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
this.screenWidth = systemInfo.windowWidth || systemInfo.screenWidth
|
||||
// 宽屏阈值:960px(与页面响应式断点一致)
|
||||
this.isWideScreen = this.screenWidth >= 960
|
||||
|
||||
// 宽屏时自动显示菜单
|
||||
if (this.isWideScreen) {
|
||||
this.showMenu = true
|
||||
}
|
||||
},
|
||||
|
||||
isExpanded(path: string): boolean {
|
||||
const v: any = (this as any).expanded
|
||||
return v != null && v[path] === true
|
||||
},
|
||||
|
||||
toggleExpanded(path: string) {
|
||||
const v: any = (this as any).expanded
|
||||
if (v == null) return
|
||||
v[path] = !(v[path] === true)
|
||||
;(this as any).expanded = { ...v }
|
||||
},
|
||||
|
||||
syncExpandedWithRoute() {
|
||||
for (let i = 0; i < this.menuItems.length; i++) {
|
||||
const item: any = this.menuItems[i]
|
||||
if (item.children && item.children.length > 0) {
|
||||
let shouldExpand = false
|
||||
for (let j = 0; j < item.children.length; j++) {
|
||||
const child = item.children[j]
|
||||
if (this.isActive(child.path)) {
|
||||
shouldExpand = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.isActive(item.path)) {
|
||||
shouldExpand = true
|
||||
}
|
||||
const v: any = (this as any).expanded
|
||||
v[item.path] = shouldExpand
|
||||
}
|
||||
}
|
||||
|
||||
const v: any = (this as any).expanded
|
||||
;(this as any).expanded = { ...v }
|
||||
},
|
||||
|
||||
isCustomReportChildDisabled(parent: any, child: any): boolean {
|
||||
if (parent == null || child == null) return false
|
||||
if (parent.path !== '/pages/mall/analytics/custom-report') return false
|
||||
return this.hasAnyCustomReport !== true
|
||||
},
|
||||
|
||||
onChildClick(parent: any, child: any) {
|
||||
if (this.isCustomReportChildDisabled(parent, child)) {
|
||||
uni.showToast({ title: '请先创建自定义报表', icon: 'none', duration: 2000 })
|
||||
// 引导去自定义报表页
|
||||
this.navigateToPage('/pages/mall/analytics/custom-report')
|
||||
return
|
||||
}
|
||||
this.navigateToPage(child.path)
|
||||
},
|
||||
|
||||
onParentClick(item: any) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 有子菜单:
|
||||
// - 如果当前就在该父级页面:切换展开/收起
|
||||
// - 否则:先跳转到父级页面(自定义报表列表),并确保展开
|
||||
if (this.isActive(item.path)) {
|
||||
this.toggleExpanded(item.path)
|
||||
} else {
|
||||
const v: any = (this as any).expanded
|
||||
if (v != null) {
|
||||
v[item.path] = true
|
||||
;(this as any).expanded = { ...v }
|
||||
}
|
||||
this.navigateToPage(item.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.navigateToPage(item.path)
|
||||
},
|
||||
|
||||
closeMenu() {
|
||||
// 宽屏时不允许关闭
|
||||
if (this.isWideScreen) {
|
||||
return
|
||||
}
|
||||
this.showMenu = false
|
||||
this.$emit('visible-change', false)
|
||||
},
|
||||
|
||||
navigateToPage(path: string) {
|
||||
if (this.currentPath === path) {
|
||||
// 窄屏时关闭菜单
|
||||
if (!this.isWideScreen) {
|
||||
this.closeMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
uni.redirectTo({
|
||||
url: path,
|
||||
fail: () => {
|
||||
// navigateTo 失败时通常是页面栈满(最多10层),这里降级为 redirectTo
|
||||
uni.navigateTo({
|
||||
url: path,
|
||||
fail: () => {
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
// 窄屏时关闭菜单
|
||||
if (!this.isWideScreen) {
|
||||
this.closeMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 侧边栏菜单 */
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 998;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 999;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 窄屏:抽屉效果 */
|
||||
.sidebar-menu.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 宽屏:固定显示在左侧 */
|
||||
.sidebar-menu.always-visible {
|
||||
position: relative;
|
||||
transform: translateX(0);
|
||||
box-shadow: none;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-close .icon {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.submenu {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.menu-item-child {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.menu-caret {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.menu-item.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-item.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.menu-item.active .menu-text {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:宽屏时固定显示 */
|
||||
@media screen and (min-width: 960px) {
|
||||
.sidebar-menu {
|
||||
position: relative;
|
||||
transform: translateX(0);
|
||||
box-shadow: none;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
332
mall/components/analytics/AnalyticsTopBar.uvue
Normal file
332
mall/components/analytics/AnalyticsTopBar.uvue
Normal file
@@ -0,0 +1,332 @@
|
||||
<!-- 数据分析顶部导航栏组件 -->
|
||||
<template>
|
||||
<view class="analytics-topbar">
|
||||
<view class="topbar-left">
|
||||
<!-- 仅窄屏且侧边栏未打开时显示菜单按钮 -->
|
||||
<view class="menu-icon" v-if="showMenuIcon" @click="handleMenu">
|
||||
<text class="icon">☰</text>
|
||||
</view>
|
||||
<view class="title-group">
|
||||
<text class="title">{{ title }}</text>
|
||||
<text class="subtitle">最后更新:{{ lastUpdateTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="topbar-right">
|
||||
<!-- 宽屏时显示的按钮 -->
|
||||
<view class="icon-btn-icon btn-visible" @click="handleRefresh">
|
||||
<text class="icon">🔄</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-visible" @click="handleSearch">
|
||||
<text class="icon">🔍</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon notification btn-hidden" @click="handleNotification">
|
||||
<text class="icon">🔔</text>
|
||||
<view class="badge"></view>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-hidden" @click="handleFullscreen">
|
||||
<text class="icon">⛶</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-hidden" @click="handleMobile">
|
||||
<text class="icon">📱</text>
|
||||
</view>
|
||||
<view class="dropdown btn-visible" @click="handleDropdown">
|
||||
<text class="dropdown-text">crmeb demo</text>
|
||||
<text class="dropdown-arrow">▼</text>
|
||||
</view>
|
||||
<view class="icon-btn-icon btn-hidden" @click="handleSettings">
|
||||
<text class="icon">⚙️</text>
|
||||
</view>
|
||||
|
||||
<!-- 更多按钮(窄屏时显示) -->
|
||||
<view class="more-btn" :class="{ active: showMoreMenu }" @click.stop="toggleMoreMenu">
|
||||
<text class="icon">⋯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 更多菜单下拉 -->
|
||||
<view class="more-menu" v-if="showMoreMenu" @click.stop>
|
||||
<view class="more-menu-item" @click="handleNotification">
|
||||
<text class="icon">🔔</text>
|
||||
<text>通知</text>
|
||||
</view>
|
||||
<view class="more-menu-item" @click="handleFullscreen">
|
||||
<text class="icon">⛶</text>
|
||||
<text>全屏</text>
|
||||
</view>
|
||||
<view class="more-menu-item" @click="handleMobile">
|
||||
<text class="icon">📱</text>
|
||||
<text>移动端</text>
|
||||
</view>
|
||||
<view class="more-menu-item" @click="handleSettings">
|
||||
<text class="icon">⚙️</text>
|
||||
<text>设置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '数据分析中心'
|
||||
},
|
||||
lastUpdateTime: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 由页面传入:当前侧边栏是否处于“打开/显示”状态(窄屏下用于隐藏菜单按钮)
|
||||
sidebarVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMoreMenu: false,
|
||||
isWideScreen: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showMenuIcon(): boolean {
|
||||
// 宽屏不显示;窄屏仅在侧边栏未打开时显示
|
||||
return !this.isWideScreen && !this.sidebarVisible
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.checkScreenSize()
|
||||
},
|
||||
onShow() {
|
||||
this.checkScreenSize()
|
||||
},
|
||||
methods: {
|
||||
checkScreenSize() {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const w = systemInfo.windowWidth || systemInfo.screenWidth
|
||||
// 与侧边栏一致:960px 以上视为宽屏
|
||||
this.isWideScreen = w >= 960
|
||||
},
|
||||
handleMenu() {
|
||||
this.$emit('menu-click')
|
||||
},
|
||||
handleRefresh() {
|
||||
this.$emit('refresh')
|
||||
},
|
||||
handleSearch() {
|
||||
this.$emit('search')
|
||||
},
|
||||
handleNotification() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('notification')
|
||||
},
|
||||
handleFullscreen() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('fullscreen')
|
||||
},
|
||||
handleMobile() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('mobile')
|
||||
},
|
||||
handleDropdown() {
|
||||
this.$emit('dropdown')
|
||||
},
|
||||
handleSettings() {
|
||||
this.showMoreMenu = false
|
||||
this.$emit('settings')
|
||||
},
|
||||
toggleMoreMenu() {
|
||||
this.showMoreMenu = !this.showMoreMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.analytics-topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 16px;
|
||||
z-index: 1000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-icon .icon {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.icon-btn-icon .icon {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.icon-btn-icon.notification .badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dropdown-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.more-btn .icon {
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 16px;
|
||||
margin-top: 8px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px 0;
|
||||
min-width: 160px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.more-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.more-menu-item .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.more-menu-item text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media screen and (max-width: 960px) {
|
||||
.btn-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.title,
|
||||
.subtitle {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
176
mall/components/analytics/AnalyticsUserGenderSection.uvue
Normal file
176
mall/components/analytics/AnalyticsUserGenderSection.uvue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<view class="gender-card">
|
||||
<view class="card-header">
|
||||
<text class="title">用户性别比例</text>
|
||||
</view>
|
||||
|
||||
<view class="card-content">
|
||||
<view class="chart-container">
|
||||
<EChartsView :option="chartOption" class="donut-chart" />
|
||||
<view class="center-text">
|
||||
<text class="total-label">总用户数</text>
|
||||
<text class="total-value">{{ totalUsers }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
totalUsers: 525,
|
||||
chartOption: {} as any
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
this.initChart()
|
||||
}, 200)
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
top: '0%',
|
||||
left: 'center',
|
||||
icon: 'rect',
|
||||
itemWidth: 15,
|
||||
itemHeight: 15,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '性别比例',
|
||||
type: 'pie',
|
||||
radius: ['50%', '75%'],
|
||||
center: ['50%', '60%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{ value: 450, name: '未知', itemStyle: { color: '#999999' } },
|
||||
{ value: 50, name: '男', itemStyle: { color: '#3b82f6' } },
|
||||
{ value: 25, name: '女', itemStyle: { color: '#f97316' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
this.chartOption = this.toPlainObject(option)
|
||||
},
|
||||
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 scoped lang="scss">
|
||||
.gender-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
height: 521px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.center-text {
|
||||
position: absolute;
|
||||
top: 60%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
242
mall/components/analytics/AnalyticsUserMapTable.uvue
Normal file
242
mall/components/analytics/AnalyticsUserMapTable.uvue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<view class="user-map-card">
|
||||
<view class="card-header">
|
||||
<text class="title">用户地域分布</text>
|
||||
</view>
|
||||
|
||||
<view class="card-content">
|
||||
<!-- 左侧地图 -->
|
||||
<view class="map-section">
|
||||
<EChartsView :option="mapOption" class="map-chart" />
|
||||
</view>
|
||||
|
||||
<!-- 右侧表格 -->
|
||||
<view class="table-section">
|
||||
<view class="table-header">
|
||||
<text class="th province">TOP省份</text>
|
||||
<text class="th count">累积用户数</text>
|
||||
<text class="th count">新增用户数</text>
|
||||
<text class="th count">访客数</text>
|
||||
<text class="th amount">支付金额</text>
|
||||
</view>
|
||||
<scroll-view class="table-body" scroll-y="true">
|
||||
<view v-for="(item, index) in tableData" :key="index" class="table-row">
|
||||
<text class="td province">{{ item.name }}</text>
|
||||
<text class="td count">{{ item.total }}</text>
|
||||
<text class="td count">{{ item.newUsers }}</text>
|
||||
<text class="td count">{{ item.visitors }}</text>
|
||||
<text class="td amount">{{ item.amount }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
type RegionData = {
|
||||
name: string
|
||||
total: number
|
||||
newUsers: number
|
||||
visitors: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EChartsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mapOption: {} as any,
|
||||
tableData: [
|
||||
{ name: '未知', total: 55398, newUsers: 463, visitors: 1112, amount: 287460.5 },
|
||||
{ name: '广东', total: 2697, newUsers: 0, visitors: 9, amount: 0 },
|
||||
{ name: '北京', total: 1058, newUsers: 0, visitors: 4, amount: 0 },
|
||||
{ name: '河南', total: 823, newUsers: 0, visitors: 3, amount: 0 },
|
||||
{ name: '福建', total: 799, newUsers: 0, visitors: 4, amount: 0 },
|
||||
{ name: '山东', total: 792, newUsers: 0, visitors: 0, amount: 0 },
|
||||
{ name: '浙江', total: 780, newUsers: 0, visitors: 1, amount: 0 },
|
||||
{ name: '江苏', total: 779, newUsers: 0, visitors: 4, amount: 0 },
|
||||
{ name: '四川', total: 768, newUsers: 0, visitors: 3, amount: 0 },
|
||||
{ name: '湖南', total: 520, newUsers: 0, visitors: 2, amount: 0 },
|
||||
{ name: '湖北', total: 480, newUsers: 0, visitors: 1, amount: 0 }
|
||||
] as Array<RegionData>
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
this.initMap()
|
||||
}, 200)
|
||||
},
|
||||
methods: {
|
||||
initMap() {
|
||||
// 提取地图数据
|
||||
const data = this.tableData.filter(it => it.name !== '未知').map(it => ({
|
||||
name: it.name,
|
||||
value: it.total
|
||||
}))
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}<br/>累积用户: {c}'
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: 3000,
|
||||
left: 'left',
|
||||
top: 'bottom',
|
||||
text: ['高', '低'],
|
||||
inRange: {
|
||||
color: ['#fff7ed', '#fbbf24', '#f59e0b', '#d97706']
|
||||
},
|
||||
calculable: true
|
||||
},
|
||||
geo: {
|
||||
map: 'china',
|
||||
roam: false,
|
||||
zoom: 1.2,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: '#f97316'
|
||||
},
|
||||
label: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#f8fafc',
|
||||
borderColor: '#e2e8f0'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '用户数',
|
||||
type: 'map',
|
||||
geoIndex: 0,
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}
|
||||
this.mapOption = this.toPlainObject(option)
|
||||
},
|
||||
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 scoped lang="scss">
|
||||
.user-map-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
.map-section {
|
||||
flex: 4;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
flex: 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #f8faff;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
|
||||
&:hover {
|
||||
background: #f9fbff;
|
||||
}
|
||||
}
|
||||
|
||||
.th, .td {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.province { flex: 1.5; text-align: left; padding-left: 15px; }
|
||||
.count { flex: 1.5; }
|
||||
.amount { flex: 2; }
|
||||
|
||||
.th {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.td {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
57
mall/components/analytics/ChartCard.uvue
Normal file
57
mall/components/analytics/ChartCard.uvue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<view class="card">
|
||||
<view class="hd">
|
||||
<view class="left">
|
||||
<text class="t">{{ title }}</text>
|
||||
<text class="d" v-if="desc">{{ desc }}</text>
|
||||
</view>
|
||||
<slot name="extra"></slot>
|
||||
</view>
|
||||
<view class="bd">
|
||||
<slot></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border: 1rpx solid rgba(17, 17, 17, 0.08);
|
||||
border-radius: 16rpx;
|
||||
padding: 14rpx;
|
||||
background: #fff;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hd {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.t {
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.d {
|
||||
font-size: 20rpx;
|
||||
color: rgba(17, 17, 17, 0.55);
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bd {
|
||||
padding-top: 4rpx;
|
||||
}
|
||||
</style>
|
||||
98
mall/components/analytics/KpiCard.uvue
Normal file
98
mall/components/analytics/KpiCard.uvue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<view class="kpi" :class="tone">
|
||||
<text class="t">{{ title }}</text>
|
||||
<text class="v">{{ value }}</text>
|
||||
|
||||
<view class="row" v-if="!deltaHidden">
|
||||
<text class="delta" :class="delta >= 0 ? 'pos' : 'neg'">
|
||||
{{ delta >= 0 ? '+' : '' }}{{ delta.toFixed(1) }}%
|
||||
</text>
|
||||
<text class="s">{{ subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<text class="s" v-else>{{ subtitle }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
value: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
delta: { type: Number, default: 0 },
|
||||
tone: { type: String, default: 'danger' },
|
||||
deltaHidden: { type: Boolean, default: false }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.kpi {
|
||||
width: calc(50% - 7rpx);
|
||||
padding: 16rpx;
|
||||
border-radius: 16rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.t {
|
||||
font-size: 22rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
margin-top: 10rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.s {
|
||||
font-size: 20rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.delta {
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.delta.pos {
|
||||
}
|
||||
|
||||
.delta.neg {
|
||||
}
|
||||
|
||||
/* tones */
|
||||
.danger {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
|
||||
}
|
||||
|
||||
.teal {
|
||||
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
||||
}
|
||||
|
||||
.green {
|
||||
background: linear-gradient(135deg, #a8e6cf 0%, #7fcdbb 100%);
|
||||
}
|
||||
|
||||
.amber {
|
||||
background: linear-gradient(135deg, #ffd93d 0%, #ffa07a 100%);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.kpi {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
mall/components/analytics/PeriodTabs.uvue
Normal file
49
mall/components/analytics/PeriodTabs.uvue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="it in items"
|
||||
:key="it.value"
|
||||
class="tab"
|
||||
:class="value === it.value ? 'on' : ''"
|
||||
@click="pick(it.value)"
|
||||
>
|
||||
<text>{{ it.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
props: {
|
||||
value: { type: String, default: '30d' },
|
||||
items: { type: Array, default: () => [] }
|
||||
},
|
||||
methods: {
|
||||
pick(v: string) {
|
||||
this.$emit('change', v)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: rgba(17, 17, 17, 0.04);
|
||||
border-radius: 999rpx;
|
||||
padding: 6rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
color: rgba(17, 17, 17, 0.65);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ff4d4f 0%, #ff7a45 100%);
|
||||
}
|
||||
</style>
|
||||
23
mall/components/analytics/charts/AreaLine.uvue
Normal file
23
mall/components/analytics/charts/AreaLine.uvue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<view class="wrap" :style="{ height: height + 'px' }">
|
||||
<EChartsView :option="option" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: { EChartsView },
|
||||
props: {
|
||||
option: { type: Object, default: () => ({}) },
|
||||
height: { type: Number, default: 280 }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
23
mall/components/analytics/charts/ComboBarLine.uvue
Normal file
23
mall/components/analytics/charts/ComboBarLine.uvue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<view class="wrap" :style="{ height: height + 'px' }">
|
||||
<EChartsView :option="option" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: { EChartsView },
|
||||
props: {
|
||||
option: { type: Object, default: () => ({}) },
|
||||
height: { type: Number, default: 320 }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
23
mall/components/analytics/charts/DonutPie.uvue
Normal file
23
mall/components/analytics/charts/DonutPie.uvue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<view class="wrap" :style="{ height: height + 'px' }">
|
||||
<EChartsView :option="option" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
export default {
|
||||
components: { EChartsView },
|
||||
props: {
|
||||
option: { type: Object, default: () => ({}) },
|
||||
height: { type: Number, default: 280 }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user