Initial commit

This commit is contained in:
admin
2025-12-09 16:27:48 +08:00
parent 1384bb1d4a
commit 602ce92418
15 changed files with 488 additions and 129 deletions

View File

@@ -41,6 +41,11 @@
<span>{{ row.age }}</span> <span>{{ row.age }}</span>
</template> </template>
</el-table-column> </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"> <el-table-column label="已报项目" align="center" min-width="200">
<template slot-scope="{row}"> <template slot-scope="{row}">
<div v-if="row.enrolled_projects && row.enrolled_projects.length > 0" style="text-align: left;"> <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-form-item label="年龄" prop="age">
<el-input v-model.number="temp.age" /> <el-input v-model.number="temp.age" />
</el-form-item> </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-form-item label="地址" prop="address">
<el-input v-model="temp.address" /> <el-input v-model="temp.address" />
</el-form-item> </el-form-item>
@@ -219,6 +227,7 @@ export default {
name: '', name: '',
phone: '', phone: '',
age: undefined, age: undefined,
city: '',
address: '', address: '',
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde', avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
openid: '', openid: '',
@@ -276,6 +285,7 @@ export default {
name: '', name: '',
phone: '', phone: '',
age: undefined, age: undefined,
city: '',
address: '', address: '',
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde', avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde',
openid: '', openid: '',

View File

@@ -20,11 +20,11 @@ export default {
}, },
height: { height: {
type: String, type: String,
default: '300px' default: '350px'
}, },
chartData: { chartData: {
type: Object, type: Object,
default: () => ({ names: [], counts: [], title: '热门机构' }) default: () => ({ names: [], counts: [], title: '机构学员' })
} }
}, },
data() { data() {
@@ -50,9 +50,16 @@ export default {
const names = this.chartData.names || [] const names = this.chartData.names || []
const counts = this.chartData.counts || [] const counts = this.chartData.counts || []
const title = this.chartData.title || '热门机构' const title = this.chartData.title || '机构学员'
this.chart.setOption({ this.chart.setOption({
title: {
text: title,
textStyle: {
color: '#fff'
},
left: 'center'
},
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { // 坐标轴指示器坐标轴触发有效 axisPointer: { // 坐标轴指示器坐标轴触发有效
@@ -60,7 +67,7 @@ export default {
} }
}, },
grid: { grid: {
top: 10, top: 40,
left: '2%', left: '2%',
right: '2%', right: '2%',
bottom: '3%', bottom: '3%',
@@ -71,12 +78,34 @@ export default {
data: names, data: names,
axisTick: { axisTick: {
alignWithLabel: true alignWithLabel: true
},
axisLabel: {
color: '#fff'
},
axisLine: {
lineStyle: {
color: '#fff'
}
} }
}], }],
yAxis: [{ yAxis: [{
type: 'value', type: 'value',
minInterval: 1, // Ensure integer ticks
axisTick: { axisTick: {
show: false show: false
},
axisLabel: {
color: '#fff'
},
axisLine: {
lineStyle: {
color: '#fff'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
} }
}], }],
series: [{ series: [{
@@ -85,7 +114,34 @@ export default {
stack: 'vistors', stack: 'vistors',
barWidth: '60%', barWidth: '60%',
data: counts, 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)'
}
}
}] }]
}) })
} }

View File

@@ -68,6 +68,14 @@ export default {
boundaryGap: false, boundaryGap: false,
axisTick: { axisTick: {
show: false show: false
},
axisLabel: {
color: '#fff'
},
axisLine: {
lineStyle: {
color: '#fff'
}
} }
}, },
grid: { grid: {
@@ -87,10 +95,26 @@ export default {
yAxis: { yAxis: {
axisTick: { axisTick: {
show: false show: false
},
axisLabel: {
color: '#fff'
},
axisLine: {
lineStyle: {
color: '#fff'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
} }
}, },
legend: { legend: {
data: ['上周', '本周'] data: ['上周', '本周'],
textStyle: {
color: '#fff'
}
}, },
series: [{ series: [{
name: '上周', itemStyle: { name: '上周', itemStyle: {
@@ -98,7 +122,9 @@ export default {
color: '#FF005A', color: '#FF005A',
lineStyle: { lineStyle: {
color: '#FF005A', color: '#FF005A',
width: 2 width: 3,
shadowColor: 'rgba(255, 0, 90, 0.3)',
shadowBlur: 10
} }
} }
}, },
@@ -114,13 +140,27 @@ export default {
type: 'line', type: 'line',
itemStyle: { itemStyle: {
normal: { normal: {
color: '#3888fa', color: '#00f2fe',
lineStyle: { lineStyle: {
color: '#3888fa', color: '#00f2fe',
width: 2 width: 3,
shadowColor: 'rgba(0, 242, 254, 0.3)',
shadowBlur: 10
}, },
areaStyle: { 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
}
} }
} }
}, },

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

View File

@@ -2,8 +2,8 @@
<div> <div>
<el-row :gutter="40" class="panel-group"> <el-row :gutter="40" class="panel-group">
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col"> <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
<div class="card-panel" @click="handleSetLineChartData('students')"> <div class="card-panel card-panel-blue" @click="handleSetLineChartData('students')">
<div class="card-panel-icon-wrapper icon-people"> <div class="card-panel-icon-wrapper">
<svg-icon icon-class="peoples" class-name="card-panel-icon" /> <svg-icon icon-class="peoples" class-name="card-panel-icon" />
</div> </div>
<div class="card-panel-description"> <div class="card-panel-description">
@@ -17,8 +17,8 @@
</div> </div>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col"> <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
<div class="card-panel" @click="handleSetLineChartData('organizations')"> <div class="card-panel card-panel-purple" @click="handleSetLineChartData('organizations')">
<div class="card-panel-icon-wrapper icon-message"> <div class="card-panel-icon-wrapper">
<svg-icon icon-class="education" class-name="card-panel-icon" /> <svg-icon icon-class="education" class-name="card-panel-icon" />
</div> </div>
<div class="card-panel-description"> <div class="card-panel-description">
@@ -30,8 +30,8 @@
</div> </div>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col"> <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
<div class="card-panel" @click="handleSetLineChartData('projects')"> <div class="card-panel card-panel-orange" @click="handleSetLineChartData('projects')">
<div class="card-panel-icon-wrapper icon-money"> <div class="card-panel-icon-wrapper">
<svg-icon icon-class="component" class-name="card-panel-icon" /> <svg-icon icon-class="component" class-name="card-panel-icon" />
</div> </div>
<div class="card-panel-description"> <div class="card-panel-description">
@@ -43,8 +43,8 @@
</div> </div>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col"> <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
<div class="card-panel" @click="handleSetLineChartData('coupons')"> <div class="card-panel card-panel-green" @click="handleSetLineChartData('coupons')">
<div class="card-panel-icon-wrapper icon-shopping"> <div class="card-panel-icon-wrapper">
<svg-icon icon-class="money" class-name="card-panel-icon" /> <svg-icon icon-class="money" class-name="card-panel-icon" />
</div> </div>
<div class="card-panel-description"> <div class="card-panel-description">
@@ -60,8 +60,8 @@
</el-row> </el-row>
<el-row :gutter="40" class="panel-group"> <el-row :gutter="40" class="panel-group">
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col"> <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
<div class="card-panel" @click="handleSetLineChartData('projects')"> <div class="card-panel card-panel-red" @click="handleSetLineChartData('projects')">
<div class="card-panel-icon-wrapper icon-money"> <div class="card-panel-icon-wrapper">
<svg-icon icon-class="list" class-name="card-panel-icon" /> <svg-icon icon-class="list" class-name="card-panel-icon" />
</div> </div>
<div class="card-panel-description"> <div class="card-panel-description">
@@ -73,8 +73,8 @@
</div> </div>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col"> <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
<div class="card-panel" @click="handleSetLineChartData('coupons')"> <div class="card-panel card-panel-yellow" @click="handleSetLineChartData('coupons')">
<div class="card-panel-icon-wrapper icon-banner"> <div class="card-panel-icon-wrapper">
<svg-icon icon-class="tab" class-name="card-panel-icon" /> <svg-icon icon-class="tab" class-name="card-panel-icon" />
</div> </div>
<div class="card-panel-description"> <div class="card-panel-description">
@@ -86,8 +86,8 @@
</div> </div>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :lg="6" class="card-panel-col"> <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
<div class="card-panel" @click="handleSetLineChartData('coupons')"> <div class="card-panel card-panel-pink" @click="handleSetLineChartData('coupons')">
<div class="card-panel-icon-wrapper icon-video"> <div class="card-panel-icon-wrapper">
<svg-icon icon-class="star" class-name="card-panel-icon" /> <svg-icon icon-class="star" class-name="card-panel-icon" />
</div> </div>
<div class="card-panel-description"> <div class="card-panel-description">
@@ -148,96 +148,46 @@ export default {
font-size: 12px; font-size: 12px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
color: #666; color: #fff;
background: #fff; background: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); backdrop-filter: blur(10px);
border-color: rgba(0, 0, 0, .05); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-radius: 12px; border-radius: 12px;
transition: all 0.3s ease; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 24px; padding: 0 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover { &:hover {
transform: translateY(-5px); transform: translateY(-5px) scale(1.02);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
.card-panel-icon-wrapper { .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 { .card-panel-icon-wrapper {
float: none; float: none;
margin: 0; margin: 0;
padding: 16px; padding: 12px;
transition: all 0.38s ease-out; 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 { .card-panel-icon {
float: left; float: left;
font-size: 48px; font-size: 32px;
fill: #fff !important; /* Force white icon */
color: #fff; /* Some svgs use color */
} }
.card-panel-description { .card-panel-description {
@@ -250,16 +200,44 @@ export default {
.card-panel-text { .card-panel-text {
line-height: 18px; line-height: 18px;
color: rgba(0, 0, 0, 0.45); color: rgba(255, 255, 255, 0.85);
font-size: 16px; font-size: 16px;
margin-bottom: 12px; margin-bottom: 8px;
} }
.card-panel-num { .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) { @media (max-width:550px) {
@@ -272,6 +250,8 @@ export default {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0 !important; margin: 0 !important;
border-radius: 0 !important;
background: transparent !important;
.svg-icon { .svg-icon {
display: block; display: block;

View File

@@ -1,13 +1,5 @@
<template> <template>
<div :class="className" :style="{width:width}"> <div :class="className" :style="{height:height,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>
</template> </template>
<script> <script>
@@ -28,7 +20,7 @@ export default {
}, },
height: { height: {
type: String, type: String,
default: '300px' default: '350px'
}, },
chartData: { chartData: {
type: Array, type: Array,
@@ -58,15 +50,27 @@ export default {
}, },
methods: { methods: {
initChart() { initChart() {
this.chart = echarts.init(this.$refs.chart, 'macarons') this.chart = echarts.init(this.$el, 'macarons')
this.chart.setOption({ this.chart.setOption({
title: {
text: '项目人数',
left: 'center',
textStyle: {
color: '#fff'
}
},
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)' formatter: '{a} <br/>{b} : {c} ({d}%)'
}, },
legend: { legend: {
show: false left: 'center',
bottom: '10',
data: this.legendData && this.legendData.map(item => item.name),
textStyle: {
color: '#fff'
}
}, },
series: [ series: [
{ {
@@ -74,16 +78,27 @@ export default {
type: 'pie', type: 'pie',
roseType: 'radius', roseType: 'radius',
radius: [15, 95], radius: [15, 95],
center: ['50%', '38%'], center: ['50%', '45%'],
data: this.chartData || [], data: this.chartData || [],
animationEasing: 'cubicInOut', animationEasing: 'cubicInOut',
animationDuration: 2600, animationDuration: 2600,
label: { label: {
show: true, show: true,
formatter: '{b}' formatter: '{b}',
color: '#fff'
}, },
labelLine: { 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];
}
} }
} }
] ]

View File

@@ -7,22 +7,22 @@
@handleSetLineChartData="handleSetLineChartData" @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-row :gutter="32">
<el-col :xs="24" :sm="24" :lg="8"> <el-col :xs="24" :sm="24" :lg="12">
<div class="chart-wrapper"> <div class="chart-wrapper">
<pie-chart :chart-data="pieChartData" :legend-data="pieChartLegend" /> <pie-chart :chart-data="pieChartData" :legend-data="pieChartLegend" />
</div> </div>
</el-col> </el-col>
<el-col :xs="24" :sm="24" :lg="8"> <el-col :xs="24" :sm="24" :lg="12">
<div class="chart-wrapper"> <div class="chart-wrapper">
<bar-chart :chart-data="barChartData" /> <bar-chart :chart-data="barChartData" />
</div> </div>
</el-col> </el-col>
</el-row> </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 v-if="error" class="chart-wrapper">{{ error }}</div>
</div> </div>
</div> </div>
@@ -31,6 +31,7 @@
<script> <script>
import PanelGroup from './components/PanelGroup' import PanelGroup from './components/PanelGroup'
import LineChart from './components/LineChart' import LineChart from './components/LineChart'
import MapChart from './components/MapChart'
import PieChart from './components/PieChart' import PieChart from './components/PieChart'
import BarChart from './components/BarChart' import BarChart from './components/BarChart'
import { getDashboardStats } from '@/api/dashboard' import { getDashboardStats } from '@/api/dashboard'
@@ -40,6 +41,7 @@ export default {
components: { components: {
PanelGroup, PanelGroup,
LineChart, LineChart,
MapChart,
PieChart, PieChart,
BarChart BarChart
}, },
@@ -47,6 +49,7 @@ export default {
return { return {
loading: true, loading: true,
error: '', error: '',
mapChartData: [],
lineChartData: { lineChartData: {
expectedData: [], expectedData: [],
actualData: [], actualData: [],
@@ -65,7 +68,7 @@ export default {
pieChartData: [], pieChartData: [],
pieChartLegend: [], pieChartLegend: [],
barChartData: { barChartData: {
title: '热门机构', title: '机构学员',
names: [], names: [],
counts: [] counts: []
} }
@@ -84,6 +87,7 @@ export default {
this.allLineChartData = data.line_chart_data this.allLineChartData = data.line_chart_data
// Default to organizations line chart // Default to organizations line chart
this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students
this.mapChartData = data.map_chart_data
this.pieChartData = data.pie_chart_data this.pieChartData = data.pie_chart_data
this.pieChartLegend = data.pie_chart_legend this.pieChartLegend = data.pie_chart_legend
this.barChartData = data.bar_chart_data this.barChartData = data.bar_chart_data
@@ -106,21 +110,40 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.dashboard-container { .dashboard-container {
padding: 32px; padding: 32px;
background-color: rgb(240, 242, 245); background: radial-gradient(ellipse at bottom, #1b2735 0%, #090a0f 100%);
min-height: 100vh;
position: relative; position: relative;
overflow: hidden;
/* Subtle animated background elements could be added here if needed, but gradient is good for now */
.chart-wrapper { .chart-wrapper {
background: #fff; background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
padding: 16px 16px 0; padding: 16px 16px 0;
margin-bottom: 32px; margin-bottom: 32px;
border-radius: 12px; 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; transition: all 0.3s ease;
&:hover { &: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) { @media (max-width:1024px) {

View 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='城市'),
),
]

View File

@@ -132,6 +132,7 @@ class Student(models.Model):
# parent = models.CharField(max_length=50, verbose_name="家长", null=True, blank=True) # 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") 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) 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字段对应微信头像
avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde") avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde")
is_active = models.BooleanField(default=True, verbose_name="是否活跃") is_active = models.BooleanField(default=True, verbose_name="是否活跃")

View File

@@ -77,7 +77,7 @@ class StudentSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Student 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'] read_only_fields = ['stats', 'enrolled_projects', 'coupons', 'teaching_center_name', 'teacher_name', 'status_display']
def get_stats(self, obj): def get_stats(self, obj):

View File

@@ -285,6 +285,35 @@ class DashboardStatsView(APIView):
for item in coupon_status_counts 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({ return Response({
'panel_data': { 'panel_data': {
'students': student_count, 'students': student_count,
@@ -298,6 +327,7 @@ class DashboardStatsView(APIView):
'pie_chart_data': pie_chart_data, 'pie_chart_data': pie_chart_data,
'pie_chart_legend': pie_chart_legend, 'pie_chart_legend': pie_chart_legend,
'coupon_pie_chart_data': coupon_pie_chart_data, 'coupon_pie_chart_data': coupon_pie_chart_data,
'map_chart_data': map_chart_data,
'bar_chart_data': { 'bar_chart_data': {
'title': bar_title, 'title': bar_title,
'names': bar_names, 'names': bar_names,
@@ -560,7 +590,7 @@ class UserProfileView(APIView):
return Response({'error': 'Not authenticated'}, status=status.HTTP_401_UNAUTHORIZED) return Response({'error': 'Not authenticated'}, status=status.HTTP_401_UNAUTHORIZED)
payload = request.data or {} 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: for f in fields:
if f in payload: if f in payload:
setattr(student, f, payload.get(f)) setattr(student, f, payload.get(f))

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -5,10 +5,12 @@ Page({
user: {}, user: {},
isFormOpen: false, isFormOpen: false,
isDevtools: false, isDevtools: false,
region: [],
formData: { formData: {
name: '', name: '',
phone: '', phone: '',
age: '', age: '',
city: '',
company_name: '', company_name: '',
position: '', position: '',
wechat_nickname: '', wechat_nickname: '',
@@ -17,8 +19,15 @@ Page({
}, },
onLoad(options) { onLoad(options) {
try { try {
const sys = wx.getSystemInfoSync() let isDevtools = false
this.setData({ isDevtools: sys.platform === 'devtools' }) 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) {} } catch (e) {}
// 检查是否已登录 // 检查是否已登录
const app = getApp(); const app = getApp();
@@ -64,10 +73,12 @@ Page({
}, },
initFormData(user) { initFormData(user) {
this.setData({ this.setData({
region: user.city ? user.city.split(' ') : [],
formData: { formData: {
name: user.name, name: user.name,
phone: user.phone, phone: user.phone,
age: user.age, age: user.age,
city: user.city,
company_name: user.company_name, company_name: user.company_name,
position: user.position, position: user.position,
wechat_nickname: user.wechat_nickname, wechat_nickname: user.wechat_nickname,
@@ -86,10 +97,12 @@ Page({
.then((data) => { .then((data) => {
this.setData({ this.setData({
user: data, user: data,
region: data.city ? data.city.split(' ') : [],
formData: { formData: {
name: data.name, name: data.name,
phone: data.phone, phone: data.phone,
age: data.age, age: data.age,
city: data.city,
company_name: data.company_name, company_name: data.company_name,
position: data.position, position: data.position,
wechat_nickname: data.wechat_nickname, 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) { handleInput(e) {
const field = e.currentTarget.dataset.field; const field = e.currentTarget.dataset.field;
const value = e.detail.value; const value = e.detail.value;

View File

@@ -87,6 +87,17 @@
</view> </view>
</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"> <view class="form-group">
<text class="label">公司名称</text> <text class="label">公司名称</text>
<view class="input-wrap"> <view class="input-wrap">