Initial commit
This commit is contained in:
@@ -41,6 +41,11 @@
|
||||
<span>{{ row.age }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="城市" width="100px" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span>{{ row.city || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已报项目" align="center" min-width="200">
|
||||
<template slot-scope="{row}">
|
||||
<div v-if="row.enrolled_projects && row.enrolled_projects.length > 0" style="text-align: left;">
|
||||
@@ -115,6 +120,9 @@
|
||||
<el-form-item label="年龄" prop="age">
|
||||
<el-input v-model.number="temp.age" />
|
||||
</el-form-item>
|
||||
<el-form-item label="城市" prop="city">
|
||||
<el-input v-model="temp.city" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址" prop="address">
|
||||
<el-input v-model="temp.address" />
|
||||
</el-form-item>
|
||||
@@ -219,6 +227,7 @@ export default {
|
||||
name: '',
|
||||
phone: '',
|
||||
age: undefined,
|
||||
city: '',
|
||||
address: '',
|
||||
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
||||
openid: '',
|
||||
@@ -276,6 +285,7 @@ export default {
|
||||
name: '',
|
||||
phone: '',
|
||||
age: undefined,
|
||||
city: '',
|
||||
address: '',
|
||||
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
|
||||
openid: '',
|
||||
|
||||
@@ -20,11 +20,11 @@ export default {
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
default: '350px'
|
||||
},
|
||||
chartData: {
|
||||
type: Object,
|
||||
default: () => ({ names: [], counts: [], title: '热门机构' })
|
||||
default: () => ({ names: [], counts: [], title: '机构学员' })
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -50,9 +50,16 @@ export default {
|
||||
|
||||
const names = this.chartData.names || []
|
||||
const counts = this.chartData.counts || []
|
||||
const title = this.chartData.title || '热门机构'
|
||||
const title = this.chartData.title || '机构学员'
|
||||
|
||||
this.chart.setOption({
|
||||
title: {
|
||||
text: title,
|
||||
textStyle: {
|
||||
color: '#fff'
|
||||
},
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { // 坐标轴指示器,坐标轴触发有效
|
||||
@@ -60,7 +67,7 @@ export default {
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
top: 40,
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '3%',
|
||||
@@ -71,12 +78,34 @@ export default {
|
||||
data: names,
|
||||
axisTick: {
|
||||
alignWithLabel: true
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#fff'
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
}
|
||||
}],
|
||||
yAxis: [{
|
||||
type: 'value',
|
||||
minInterval: 1, // Ensure integer ticks
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#fff'
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
}
|
||||
}],
|
||||
series: [{
|
||||
@@ -85,7 +114,34 @@ export default {
|
||||
stack: 'vistors',
|
||||
barWidth: '60%',
|
||||
data: counts,
|
||||
animationDuration: 6000
|
||||
animationDuration: 6000,
|
||||
itemStyle: {
|
||||
color: function(params) {
|
||||
const colorList = [
|
||||
['#4facfe', '#00f2fe'],
|
||||
['#43e97b', '#38f9d7'],
|
||||
['#fa709a', '#fee140'],
|
||||
['#a18cd1', '#fbc2eb'],
|
||||
['#ff9a9e', '#fecfef'],
|
||||
['#667eea', '#764ba2'],
|
||||
['#f093fb', '#f5576c'],
|
||||
['#8ec5fc', '#e0c3fc']
|
||||
];
|
||||
const index = params.dataIndex % colorList.length;
|
||||
const color = colorList[index];
|
||||
return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: color[0] },
|
||||
{ offset: 1, color: color[1] }
|
||||
]);
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0] // Rounded top
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0,0,0,0.3)'
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,6 +68,14 @@ export default {
|
||||
boundaryGap: false,
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#fff'
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
@@ -87,10 +95,26 @@ export default {
|
||||
yAxis: {
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#fff'
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['上周', '本周']
|
||||
data: ['上周', '本周'],
|
||||
textStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '上周', itemStyle: {
|
||||
@@ -98,7 +122,9 @@ export default {
|
||||
color: '#FF005A',
|
||||
lineStyle: {
|
||||
color: '#FF005A',
|
||||
width: 2
|
||||
width: 3,
|
||||
shadowColor: 'rgba(255, 0, 90, 0.3)',
|
||||
shadowBlur: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,13 +140,27 @@ export default {
|
||||
type: 'line',
|
||||
itemStyle: {
|
||||
normal: {
|
||||
color: '#3888fa',
|
||||
color: '#00f2fe',
|
||||
lineStyle: {
|
||||
color: '#3888fa',
|
||||
width: 2
|
||||
color: '#00f2fe',
|
||||
width: 3,
|
||||
shadowColor: 'rgba(0, 242, 254, 0.3)',
|
||||
shadowBlur: 10
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f3f8ff'
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0, color: 'rgba(0, 242, 254, 0.3)' // 0% 处的颜色
|
||||
}, {
|
||||
offset: 1, color: 'rgba(0, 242, 254, 0)' // 100% 处的颜色
|
||||
}],
|
||||
global: false // 缺省为 false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
155
admin/client/src/views/dashboard/components/MapChart.vue
Normal file
155
admin/client/src/views/dashboard/components/MapChart.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div :class="className" :style="{height:height,width:width}" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
import resize from '../mixins/resize'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
mixins: [resize],
|
||||
props: {
|
||||
className: {
|
||||
type: String,
|
||||
default: 'chart'
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '700px'
|
||||
},
|
||||
chartData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chart: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
chartData: {
|
||||
deep: true,
|
||||
handler(val) {
|
||||
this.setOptions(val)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initChart()
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (!this.chart) {
|
||||
return
|
||||
}
|
||||
this.chart.dispose()
|
||||
this.chart = null
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
this.chart = echarts.init(this.$el, 'macarons')
|
||||
|
||||
// Check if map is already registered
|
||||
if (echarts.getMap('china')) {
|
||||
this.setOptions(this.chartData)
|
||||
} else {
|
||||
// Fetch China Map GeoJSON
|
||||
// Using a reliable public source
|
||||
axios.get('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json')
|
||||
.then(response => {
|
||||
echarts.registerMap('china', response.data)
|
||||
this.setOptions(this.chartData)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load China map data:', error)
|
||||
// Fallback or show error? For now just log.
|
||||
})
|
||||
}
|
||||
},
|
||||
setOptions(data) {
|
||||
if (!this.chart) return
|
||||
|
||||
this.chart.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '学员城市分布',
|
||||
left: 'center',
|
||||
top: 20,
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function(params) {
|
||||
if (params.value) {
|
||||
return params.name + ': ' + params.value + '人';
|
||||
}
|
||||
return params.name;
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: data.length > 0 ? Math.max(...data.map(d => d.value)) : 100,
|
||||
left: '50',
|
||||
bottom: '50',
|
||||
text: ['高', '低'],
|
||||
textStyle: {
|
||||
color: '#fff'
|
||||
},
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: ['#e0ffff', '#006edd']
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '学员分布',
|
||||
type: 'map',
|
||||
mapType: 'china',
|
||||
roam: true, // Allow zooming and panning
|
||||
zoom: 1.2,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
formatter: function(params) {
|
||||
if (params.value > 0) {
|
||||
return params.name + '\n' + params.value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff'
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#fbc2eb',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: 'rgba(20, 41, 87, 0.6)',
|
||||
borderColor: '#4facfe',
|
||||
borderWidth: 1
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,8 +2,8 @@
|
||||
<div>
|
||||
<el-row :gutter="40" class="panel-group">
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('students')">
|
||||
<div class="card-panel-icon-wrapper icon-people">
|
||||
<div class="card-panel card-panel-blue" @click="handleSetLineChartData('students')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="peoples" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -17,8 +17,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('organizations')">
|
||||
<div class="card-panel-icon-wrapper icon-message">
|
||||
<div class="card-panel card-panel-purple" @click="handleSetLineChartData('organizations')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="education" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -30,8 +30,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper icon-money">
|
||||
<div class="card-panel card-panel-orange" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="component" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -43,8 +43,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper icon-shopping">
|
||||
<div class="card-panel card-panel-green" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="money" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -60,8 +60,8 @@
|
||||
</el-row>
|
||||
<el-row :gutter="40" class="panel-group">
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper icon-money">
|
||||
<div class="card-panel card-panel-red" @click="handleSetLineChartData('projects')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="list" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -73,8 +73,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper icon-banner">
|
||||
<div class="card-panel card-panel-yellow" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="tab" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -86,8 +86,8 @@
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
|
||||
<div class="card-panel" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper icon-video">
|
||||
<div class="card-panel card-panel-pink" @click="handleSetLineChartData('coupons')">
|
||||
<div class="card-panel-icon-wrapper">
|
||||
<svg-icon icon-class="star" class-name="card-panel-icon" />
|
||||
</div>
|
||||
<div class="card-panel-description">
|
||||
@@ -148,96 +148,46 @@ export default {
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #666;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(0, 0, 0, .05);
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.card-panel-icon-wrapper {
|
||||
color: #fff;
|
||||
transform: scale(1.1);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.icon-people {
|
||||
background: #40c9c6;
|
||||
box-shadow: 0 4px 12px rgba(64, 201, 198, 0.4);
|
||||
}
|
||||
|
||||
.icon-message {
|
||||
background: #36a3f7;
|
||||
box-shadow: 0 4px 12px rgba(54, 163, 247, 0.4);
|
||||
}
|
||||
|
||||
.icon-money {
|
||||
background: #f4516c;
|
||||
box-shadow: 0 4px 12px rgba(244, 81, 108, 0.4);
|
||||
}
|
||||
|
||||
.icon-shopping {
|
||||
background: #34bfa3;
|
||||
box-shadow: 0 4px 12px rgba(52, 191, 163, 0.4);
|
||||
}
|
||||
|
||||
.icon-banner {
|
||||
background: #ff9800;
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.icon-video {
|
||||
background: #9c27b0;
|
||||
box-shadow: 0 4px 12px rgba(156, 39, 176, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-people {
|
||||
color: #40c9c6;
|
||||
background: rgba(64, 201, 198, 0.1);
|
||||
}
|
||||
|
||||
.icon-message {
|
||||
color: #36a3f7;
|
||||
background: rgba(54, 163, 247, 0.1);
|
||||
}
|
||||
|
||||
.icon-money {
|
||||
color: #f4516c;
|
||||
background: rgba(244, 81, 108, 0.1);
|
||||
}
|
||||
|
||||
.icon-shopping {
|
||||
color: #34bfa3;
|
||||
background: rgba(52, 191, 163, 0.1);
|
||||
}
|
||||
|
||||
.icon-banner {
|
||||
color: #ff9800;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.icon-video {
|
||||
color: #9c27b0;
|
||||
background: rgba(156, 39, 176, 0.1);
|
||||
}
|
||||
|
||||
.card-panel-icon-wrapper {
|
||||
float: none;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
transition: all 0.38s ease-out;
|
||||
border-radius: 16px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.card-panel-icon {
|
||||
float: left;
|
||||
font-size: 48px;
|
||||
font-size: 32px;
|
||||
fill: #fff !important; /* Force white icon */
|
||||
color: #fff; /* Some svgs use color */
|
||||
}
|
||||
|
||||
.card-panel-description {
|
||||
@@ -250,16 +200,44 @@ export default {
|
||||
|
||||
.card-panel-text {
|
||||
line-height: 18px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-panel-num {
|
||||
font-size: 20px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Specific Gradient Backgrounds */
|
||||
.card-panel-blue {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
.card-panel-purple {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.card-panel-orange {
|
||||
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%); /* Lighter peach */
|
||||
/* Let's try stronger orange */
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
.card-panel-green {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
.card-panel-red {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
}
|
||||
.card-panel-yellow {
|
||||
background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%);
|
||||
}
|
||||
.card-panel-pink {
|
||||
background: linear-gradient(135deg, #ff0844 0%, #ffb199 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:550px) {
|
||||
@@ -272,6 +250,8 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
|
||||
.svg-icon {
|
||||
display: block;
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
<template>
|
||||
<div :class="className" :style="{width:width}">
|
||||
<div ref="chart" :style="{height:height,width:'100%'}" />
|
||||
<div v-if="legendData && legendData.length > 0" class="chart-legend">
|
||||
<div v-for="(item, index) in legendData" :key="index" class="legend-item">
|
||||
<span class="legend-icon" :style="{background: item.color}"></span>
|
||||
<span class="legend-text">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="className" :style="{height:height,width:width}" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -28,7 +20,7 @@ export default {
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
default: '350px'
|
||||
},
|
||||
chartData: {
|
||||
type: Array,
|
||||
@@ -58,15 +50,27 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
this.chart = echarts.init(this.$refs.chart, 'macarons')
|
||||
this.chart = echarts.init(this.$el, 'macarons')
|
||||
|
||||
this.chart.setOption({
|
||||
title: {
|
||||
text: '项目人数',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b} : {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
left: 'center',
|
||||
bottom: '10',
|
||||
data: this.legendData && this.legendData.map(item => item.name),
|
||||
textStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -74,16 +78,27 @@ export default {
|
||||
type: 'pie',
|
||||
roseType: 'radius',
|
||||
radius: [15, 95],
|
||||
center: ['50%', '38%'],
|
||||
center: ['50%', '45%'],
|
||||
data: this.chartData || [],
|
||||
animationEasing: 'cubicInOut',
|
||||
animationDuration: 2600,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}'
|
||||
formatter: '{b}',
|
||||
color: '#fff'
|
||||
},
|
||||
labelLine: {
|
||||
show: true
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: '#fff'
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
color: function(params) {
|
||||
// Custom bright colors
|
||||
const colorList = ['#37a2da', '#32c5e9', '#67e0e3', '#9fe6b8', '#ffdb5c', '#ff9f7f', '#fb7293', '#e062ae', '#e690d1', '#e7bcf3', '#9d96f5', '#8378ea', '#96bfff'];
|
||||
return colorList[params.dataIndex % colorList.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,22 +7,22 @@
|
||||
@handleSetLineChartData="handleSetLineChartData"
|
||||
/>
|
||||
|
||||
<el-row style="background:#fff;padding:16px 16px 0;margin-bottom:32px;border-radius:12px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);">
|
||||
<line-chart :chart-data="lineChartData" />
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="32">
|
||||
<el-col :xs="24" :sm="24" :lg="8">
|
||||
<el-col :xs="24" :sm="24" :lg="12">
|
||||
<div class="chart-wrapper">
|
||||
<pie-chart :chart-data="pieChartData" :legend-data="pieChartLegend" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :lg="8">
|
||||
<el-col :xs="24" :sm="24" :lg="12">
|
||||
<div class="chart-wrapper">
|
||||
<bar-chart :chart-data="barChartData" />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row class="map-chart-wrapper">
|
||||
<map-chart :chart-data="mapChartData" />
|
||||
</el-row>
|
||||
<div v-if="error" class="chart-wrapper">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,6 +31,7 @@
|
||||
<script>
|
||||
import PanelGroup from './components/PanelGroup'
|
||||
import LineChart from './components/LineChart'
|
||||
import MapChart from './components/MapChart'
|
||||
import PieChart from './components/PieChart'
|
||||
import BarChart from './components/BarChart'
|
||||
import { getDashboardStats } from '@/api/dashboard'
|
||||
@@ -40,6 +41,7 @@ export default {
|
||||
components: {
|
||||
PanelGroup,
|
||||
LineChart,
|
||||
MapChart,
|
||||
PieChart,
|
||||
BarChart
|
||||
},
|
||||
@@ -47,6 +49,7 @@ export default {
|
||||
return {
|
||||
loading: true,
|
||||
error: '',
|
||||
mapChartData: [],
|
||||
lineChartData: {
|
||||
expectedData: [],
|
||||
actualData: [],
|
||||
@@ -65,7 +68,7 @@ export default {
|
||||
pieChartData: [],
|
||||
pieChartLegend: [],
|
||||
barChartData: {
|
||||
title: '热门机构',
|
||||
title: '机构学员',
|
||||
names: [],
|
||||
counts: []
|
||||
}
|
||||
@@ -84,6 +87,7 @@ export default {
|
||||
this.allLineChartData = data.line_chart_data
|
||||
// Default to organizations line chart
|
||||
this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students
|
||||
this.mapChartData = data.map_chart_data
|
||||
this.pieChartData = data.pie_chart_data
|
||||
this.pieChartLegend = data.pie_chart_legend
|
||||
this.barChartData = data.bar_chart_data
|
||||
@@ -106,21 +110,40 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.dashboard-container {
|
||||
padding: 32px;
|
||||
background-color: rgb(240, 242, 245);
|
||||
background: radial-gradient(ellipse at bottom, #1b2735 0%, #090a0f 100%);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Subtle animated background elements could be added here if needed, but gradient is good for now */
|
||||
|
||||
.chart-wrapper {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 16px 16px 0;
|
||||
margin-bottom: 32px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.45);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.map-chart-wrapper {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 16px 16px 0;
|
||||
margin-bottom: 32px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:1024px) {
|
||||
|
||||
18
admin/server/apps/crm/migrations/0031_student_city.py
Normal file
18
admin/server/apps/crm/migrations/0031_student_city.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.23 on 2025-12-09 07:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('crm', '0030_auto_20251209_1207'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='city',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='城市'),
|
||||
),
|
||||
]
|
||||
@@ -132,6 +132,7 @@ class Student(models.Model):
|
||||
# parent = models.CharField(max_length=50, verbose_name="家长", null=True, blank=True)
|
||||
responsible_teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="负责老师", related_name="students")
|
||||
address = models.CharField(max_length=200, verbose_name="地址", null=True, blank=True)
|
||||
city = models.CharField(max_length=50, verbose_name="城市", null=True, blank=True)
|
||||
# 已经有avatar字段,对应微信头像
|
||||
avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde")
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否活跃")
|
||||
|
||||
@@ -77,7 +77,7 @@ class StudentSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['id', 'name', 'phone', 'age', 'address', 'avatar', 'wechat_nickname', 'openid', 'teacher', 'teacher_name', 'teaching_center', 'teaching_center_name', 'company_name', 'position', 'status', 'status_display', 'stats', 'enrolled_projects', 'coupons', 'created_at']
|
||||
fields = ['id', 'name', 'phone', 'age', 'city', 'address', 'avatar', 'wechat_nickname', 'openid', 'teacher', 'teacher_name', 'teaching_center', 'teaching_center_name', 'company_name', 'position', 'status', 'status_display', 'stats', 'enrolled_projects', 'coupons', 'created_at']
|
||||
read_only_fields = ['stats', 'enrolled_projects', 'coupons', 'teaching_center_name', 'teacher_name', 'status_display']
|
||||
|
||||
def get_stats(self, obj):
|
||||
|
||||
@@ -285,6 +285,35 @@ class DashboardStatsView(APIView):
|
||||
for item in coupon_status_counts
|
||||
]
|
||||
|
||||
# 6. Student City Distribution
|
||||
city_counts = Student.objects.values('city').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
def clean_city_name(name):
|
||||
if not name:
|
||||
return name
|
||||
|
||||
# Mapping from keyword to DataV standard name (Full Names required for DataV GeoJSON)
|
||||
mapping = {
|
||||
'北京': '北京市', '天津': '天津市', '上海': '上海市', '重庆': '重庆市',
|
||||
'河北': '河北省', '山西': '山西省', '辽宁': '辽宁省', '吉林': '吉林省', '黑龙江': '黑龙江省',
|
||||
'江苏': '江苏省', '浙江': '浙江省', '安徽': '安徽省', '福建': '福建省', '江西': '江西省', '山东': '山东省',
|
||||
'河南': '河南省', '湖北': '湖北省', '湖南': '湖南省', '广东': '广东省', '海南': '海南省',
|
||||
'四川': '四川省', '贵州': '贵州省', '云南': '云南省', '陕西': '陕西省', '甘肃': '甘肃省', '青海': '青海省', '台湾': '台湾省',
|
||||
'内蒙古': '内蒙古自治区', '广西': '广西壮族自治区', '西藏': '西藏自治区', '宁夏': '宁夏回族自治区', '新疆': '新疆维吾尔自治区',
|
||||
'香港': '香港特别行政区', '澳门': '澳门特别行政区'
|
||||
}
|
||||
|
||||
for key, full_name in mapping.items():
|
||||
if key in name:
|
||||
return full_name
|
||||
|
||||
return name
|
||||
|
||||
map_chart_data = [
|
||||
{'name': clean_city_name(item['city']), 'value': item['count']}
|
||||
for item in city_counts if item['city']
|
||||
]
|
||||
|
||||
return Response({
|
||||
'panel_data': {
|
||||
'students': student_count,
|
||||
@@ -298,6 +327,7 @@ class DashboardStatsView(APIView):
|
||||
'pie_chart_data': pie_chart_data,
|
||||
'pie_chart_legend': pie_chart_legend,
|
||||
'coupon_pie_chart_data': coupon_pie_chart_data,
|
||||
'map_chart_data': map_chart_data,
|
||||
'bar_chart_data': {
|
||||
'title': bar_title,
|
||||
'names': bar_names,
|
||||
@@ -560,7 +590,7 @@ class UserProfileView(APIView):
|
||||
return Response({'error': 'Not authenticated'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
payload = request.data or {}
|
||||
fields = ['name', 'phone', 'age', 'company_name', 'position', 'address', 'wechat_nickname', 'avatar']
|
||||
fields = ['name', 'phone', 'age', 'company_name', 'position', 'address', 'city', 'wechat_nickname', 'avatar']
|
||||
for f in fields:
|
||||
if f in payload:
|
||||
setattr(student, f, payload.get(f))
|
||||
|
||||
BIN
admin/server/media/2025/12/09/61cc289e253b3.jpg
Normal file
BIN
admin/server/media/2025/12/09/61cc289e253b3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -5,10 +5,12 @@ Page({
|
||||
user: {},
|
||||
isFormOpen: false,
|
||||
isDevtools: false,
|
||||
region: [],
|
||||
formData: {
|
||||
name: '',
|
||||
phone: '',
|
||||
age: '',
|
||||
city: '',
|
||||
company_name: '',
|
||||
position: '',
|
||||
wechat_nickname: '',
|
||||
@@ -17,8 +19,15 @@ Page({
|
||||
},
|
||||
onLoad(options) {
|
||||
try {
|
||||
const sys = wx.getSystemInfoSync()
|
||||
this.setData({ isDevtools: sys.platform === 'devtools' })
|
||||
let isDevtools = false
|
||||
if (wx.getDeviceInfo) {
|
||||
const info = wx.getDeviceInfo()
|
||||
isDevtools = info.platform === 'devtools'
|
||||
} else {
|
||||
const sys = wx.getSystemInfoSync()
|
||||
isDevtools = sys.platform === 'devtools'
|
||||
}
|
||||
this.setData({ isDevtools })
|
||||
} catch (e) {}
|
||||
// 检查是否已登录
|
||||
const app = getApp();
|
||||
@@ -64,10 +73,12 @@ Page({
|
||||
},
|
||||
initFormData(user) {
|
||||
this.setData({
|
||||
region: user.city ? user.city.split(' ') : [],
|
||||
formData: {
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
age: user.age,
|
||||
city: user.city,
|
||||
company_name: user.company_name,
|
||||
position: user.position,
|
||||
wechat_nickname: user.wechat_nickname,
|
||||
@@ -86,10 +97,12 @@ Page({
|
||||
.then((data) => {
|
||||
this.setData({
|
||||
user: data,
|
||||
region: data.city ? data.city.split(' ') : [],
|
||||
formData: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
age: data.age,
|
||||
city: data.city,
|
||||
company_name: data.company_name,
|
||||
position: data.position,
|
||||
wechat_nickname: data.wechat_nickname,
|
||||
@@ -114,6 +127,13 @@ Page({
|
||||
})
|
||||
})
|
||||
},
|
||||
onCityChange(e) {
|
||||
const val = e.detail.value;
|
||||
this.setData({
|
||||
region: val,
|
||||
'formData.city': val.join(' ')
|
||||
});
|
||||
},
|
||||
handleInput(e) {
|
||||
const field = e.currentTarget.dataset.field;
|
||||
const value = e.detail.value;
|
||||
|
||||
@@ -87,6 +87,17 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="label">城市</text>
|
||||
<view class="input-wrap">
|
||||
<picker mode="region" bindchange="onCityChange" value="{{region}}">
|
||||
<view class="picker" style="{{!region.length ? 'color:#808080' : ''}}">
|
||||
{{region.length ? region[0] + ' ' + region[1] + ' ' + region[2] : '请选择城市'}}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="label">公司名称</text>
|
||||
<view class="input-wrap">
|
||||
|
||||
Reference in New Issue
Block a user