chore: sync local changes before merging huangzhenbao-admin

This commit is contained in:
comlibmb
2026-02-03 08:56:35 +08:00
325 changed files with 55791 additions and 13448 deletions

View 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>

View File

@@ -129,7 +129,7 @@ export default {
let result = params[0].name + '<br/>'
for (let i = 0; i < params.length; i++) {
const p = params[i]
if (p.seriesName === 'GMV') {
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/>`
@@ -142,11 +142,11 @@ export default {
},
legend: {
top: 8,
left: 8,
left: 'center',
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 12 },
data: ['GMV', '订单数'],
data: ['订单金额', '订单数'],
bottom: 'auto'
},
xAxis: {
@@ -156,15 +156,14 @@ export default {
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.12)' } },
axisLabel: {
color: 'rgba(0,0,0,0.55)',
rotate: x.length > 12 ? 45 : 0,
// 数据量大时不要强制全部展示,否则会全部重叠
interval: x.length > 60 ? 'auto' : 0
rotate: x.length > 20 ? 45 : 0,
interval: x.length > 15 ? 1 : 0
}
},
yAxis: [
{
type: 'value',
name: 'GMV',
name: '金额',
position: 'left',
axisLine: { show: false },
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } },
@@ -172,7 +171,7 @@ export default {
color: 'rgba(0,0,0,0.55)',
formatter: (value: number) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万'
return (value / 10000).toFixed(0) + '万'
}
return String(Math.round(value))
}
@@ -180,7 +179,7 @@ export default {
},
{
type: 'value',
name: '订单数',
name: '数',
position: 'right',
alignTicks: true,
axisLine: { show: false },
@@ -193,14 +192,14 @@ export default {
],
series: [
{
name: 'GMV',
name: '订单金额',
type: 'bar',
yAxisIndex: 0,
data: bar,
barMaxWidth: 14,
barCategoryGap: '35%',
itemStyle: {
borderRadius: [6, 6, 0, 0],
borderRadius: [2, 2, 0, 0],
color: '#3b82f6'
}
},
@@ -209,7 +208,7 @@ export default {
type: 'line',
yAxisIndex: 1,
data: line,
smooth: true,
smooth: false,
symbol: 'circle',
symbolSize: 6,
lineStyle: {

View 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>

View 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>

View 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>

View 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>