Django+Vue+机器学习:毕业生就业数据分析系统设计与实现
又是一个毕业设计“top级”选题。Django + Vue 这套前后端分离的组合,配上机器学习和可视化,基本是近两年毕设和项目实战里出场率最高的技术栈之一了。毕业生就业数据分析这个切入点也选得很聪明——数据来源相对明确(学校就业系统、招聘平台、问卷调查),业务逻辑清晰(统计、预测、展示),做出来的东西既能看到技术含量,又能实际落地到就业指导工作中。
我刚完整做完一个同类项目,从数据清洗到模型训练,再到可视化大屏和部署上线,所有坑都踩了一遍。这篇文章就把整个系统的设计与实现过程完整拆解给你,从架构设计到每个模块的具体实现,包括我实测下来的参数配置和避坑经验,照着做,你也能复现一套可用性很高的就业数据分析系统。
1. 整体架构设计与技术选型思路
1.1 核心需求拆解:这个系统到底要做什么
先别急着敲代码,动手之前想清楚一件事——这个系统解决什么问题。
如果你只是把数据导进Excel再生成几张图表,那不叫系统,那叫报告。既然挂上了“系统”的名字,就必须具备三个能力: 数据能进得来、分析能看得懂、结果能查得到 。
拆解下来,毕业生就业数据分析系统的核心需求其实是这四个维度:
数据管理端 :管理员录入或批量导入毕业生基本信息、就业去向、薪资水平、企业信息等原始数据。这部分对应Django后台和REST API接口。
统计分析端 :按学院、专业、学历层次、生源地等维度,统计就业率、薪资分布、行业流向、地域分布等核心指标。这部分是数据分析模块的主战场。
预测研判端 :基于历史数据训练机器学习模型,预测下一年度的就业趋势、薪资水平,或者根据学生特征预测其可能的就业方向。这部分是系统的技术亮点,也是论文里最能“讲故事”的部分。
可视化展示端 :用图表、大屏、仪表盘等形式直观呈现分析结果,让用户(教师、辅导员、就业指导中心)能快速读懂数据背后的含义。这部分是Vue + ECharts的主场。
整个系统做成一个闭环:管理员维护数据 → Django完成统计和模型预测 → API输出结构化结果 → Vue前端渲染可视化 → 用户看到结论指导决策。
1.2 技术栈选型的深层逻辑
选型这件事,很多人是“因为别人用了所以我用”,但答辩的时候老师十个追问里有八个都在问“你为什么选这个”。把选型逻辑想通,项目就站得住脚。
后端为什么选Django而不是Flask? 因为这类系统的核心价值不在接口性能,而在业务完整性。Django自带的ORM、Admin后台、认证授权、Admin管理界面,能帮你在最短时间内搭建一套可维护的后台管理系统。对于就业数据这类结构化程度高、表关系明确的数据,Django的ORM写起来比裸SQL舒服十倍。更重要的是,Django自带的Admin站点可以直接拿来当数据录入后台,毕设演示的时候省一大半开发量。Flask虽然轻量,但论坛、认证、权限这些都要自己搭,做毕设纯属浪费时间。
前端为什么选Vue而不是React? 中文生态、上手难度、ECharts的融合度,这三个维度上都占优势。Vue3的组合式API配合Element Plus组件库,写后台管理界面和中屏大屏都很快。更重要的是,Vue的单文件组件机制对封装ECharts图表非常友好——一个图表封装成一个组件,传入配置项就渲染,数据变了自动更新,逻辑非常清晰。
机器学习部分选什么框架? 直接用scikit-learn就够了。这个场景下的数据量级通常是几千到几万条,SKL的模型完全够用。你不需要上TensorFlow或PyTorch,除非你打算用深度学习做一些文本类分析(比如就业评价的情感分析),但这对毕设来说属于“再无必要”。我实际的选型是:随机森林和XGBoost用于薪资预测,逻辑回归用于就业方向分类,K-Means用于学生聚类分析。这些模型训练快、效果稳定、可解释性也够。
可视化部分选ECharts而不是AntV或D3? ECharts在中国地图、地图热力、大屏适配上的成熟度和中文文档质量无人能比。就业数据系统里不可避免地要用到“毕业去向地域分布图”——按省份着色,ECharts的地图组件一套上去就是满分效果,别的库做不到这么省心。
1.3 系统架构分层设计
把这个系统的架构分成四层来理解:
数据接入层 → 数据存储与处理层 → 业务逻辑与模型层 → 可视化展示层
- 数据接入层 :Django Admin后台手动录入、Excel批量导入、CSV导入三种方式。实际做的时候Excel批量导入最重要——学校的就业数据通常以Excel表格的形式提供,要支持按模板导入。
- 数据存储与处理层 :MySQL(或SQLite)存储业务数据,Pandas做数据清洗和转换,Django ORM做对象关系映射。这一层的核心任务是“把混乱的源数据变成干净的、可分析的表格”。
- 业务逻辑与模型层 :统计分析(Django ORM聚合 + Pandas分组计算)、机器学习预测(SKL)。这一层的输出是JSON格式的统计结果和预测结果。
- 展示层 :Vue3 + ECharts + Element Plus,通过Axios调用API,渲染交互式可视化界面。
每一层之间通过API边界分开,好处是前端可以并行开发,后端改数据结构不会影响前端页面,部署时也可以前后端分离部署。
2. 数据库设计与Django模型实现
2.1 核心表结构设计
数据库设计是这类系统的地基。设计得好,后面所有查询都能很顺;设计得烂,那你在ORM里各种join调优的日子会非常难熬。
毕业生就业分析系统至少需要这几张核心表: 学生信息表、就业信息表、企业信息表、专业/学院维度表 ,外加一个 用户表 用于后台登录和权限控制。
我实际使用的模型设计精简后如下:
# students/models.py
from django.db import models
class Student(models.Model):
"""毕业生基本信息表"""
student_id = models.CharField('学号', max_length=20, unique=True)
name = models.CharField('姓名', max_length=50)
gender = models.CharField('性别', max_length=10, choices=[('male', '男'), ('female', '女')])
birth_date = models.DateField('出生年月', null=True, blank=True)
native_place = models.CharField('生源地', max_length=100, null=True, blank=True)
college = models.CharField('学院', max_length=100)
major = models.CharField('专业', max_length=100)
education = models.CharField('学历层次', max_length=20, choices=[('bachelor', '本科'), ('master', '硕士'), ('doctor', '博士')])
graduation_year = models.IntegerField('毕业年份')
is_graduate = models.BooleanField('是否应届毕业', default=True)
created_time = models.DateTimeField('创建时间', auto_now_add=True)
class Meta:
db_table = 'student'
ordering = ['graduation_year']
indexes = [
models.Index(fields=['major']),
models.Index(fields=['graduation_year']),
]
def __str__(self):
return f"{self.student_id}-{self.name}"
class Employment(models.Model):
"""就业信息表"""
student = models.OneToOneField(Student, on_delete=models.CASCADE, related_name='employment')
employment_status = models.CharField('毕业去向', max_length=20, choices=[
('company', '企业就业'), ('civil', '考公务员'), ('postgrad', '升学深造'),
('self', '自主创业'), ('unemployed', '待就业')
])
industry = models.CharField('行业领域', max_length=50, null=True, blank=True)
position = models.CharField('工作岗位', max_length=100, null=True, blank=True)
monthly_salary = models.DecimalField('月薪(元)', max_digits=8, decimal_places=2, null=True, blank=True)
work_city = models.CharField('工作城市', max_length=100, null=True, blank=True)
company_name = models.CharField('签约/就业单位', max_length=200, null=True, blank=True)
company_type = models.CharField('单位性质', max_length=30, choices=[('国企', '国企'), ('私企', '私企'), ('外企', '外企'), ('事业单位', '事业单位')], null=True, blank=True)
is_professional_match = models.BooleanField('是否专业对口', null=True, blank=True)
confirm_time = models.DateField('就业时间', null=True, blank=True)
class Meta:
db_table = 'employment'
注意一个细节:
Employment
和
Student
是
一对一关系
。一个毕业生对应一条唯一的就业记录,这是符合实际的。如果想做“就业过程留痕”功能,再单独建一张
就业记录流水表
,但常规统计分析用一对一就够了。
2.2 Excel批量导入:效率拉满的关键操作
手录一万条毕业生数据不现实,真实场景下数据都在Excel表里躺着。Django后台单条录入只是一个补充手段,批量导入才是核心能力。
我的方案是先在Django中做一个Excel导入管理命令,然后在视图层提供导入接口。具体步骤如下:
# students/management/commands/import_students.py
import pandas as pd
from django.core.management.base import BaseCommand
from django.db import transaction
from students.models import Student, Employment
class Command(BaseCommand):
help = '从Excel批量导入毕业生数据'
def add_arguments(self, parser):
parser.add_argument('--file', required=True, help='Excel文件路径')
@transaction.atomic
def handle(self, *args, **options):
file_path = options['file']
df = pd.read_excel(file_path, dtype={'学号': str})
imported_count = 0
for _, row in df.iterrows():
# 字段映射 & 数据清洗
student_id = row['学号'].strip()
if not student_id:
continue
if Student.objects.filter(student_id=student_id).exists():
continue
student = Student(
student_id=student_id,
name=row['姓名'],
gender='male' if row['性别'] == '男' else 'female',
native_place=row.get('生源地', None),
college=row['学院'],
major=row['专业'],
education=row.get('学历层次', '本科'),
graduation_year=int(row['毕业年份']),
)
student.save()
# 就业信息
if '毕业去向' in row and pd.notna(row.get('毕业去向')):
Employment.objects.create(
student=student,
employment_status=row['毕业去向'],
industry=row.get('行业', None),
position=row.get('岗位', None),
monthly_salary=row.get('月薪', None),
work_city=row.get('工作城市', None),
)
imported_count += 1
self.stdout.write(self.style.SUCCESS(f'成功导入 {imported_count} 条记录'))
实际操作中注意三个坑 :
一是
学号必须转成字符串
。Pandas读取数值型学号默认会带
.0
,我在上面对
学号
列强制设了
dtype={'学号': str}
,但绝杀是末尾还是可能残留全角空格或换行符,所以必要时用
strip()
清理。
二是 数据校验的兜底 。不可能每次都保证Excel模板规范,字段缺失、日期格式错乱、学院名称缩写混杂(“计科院”vs“计算机学院”)都是常见问题。导入逻辑里加一层默认值和异常捕获,宁可跳过也不要在中途报错崩掉——批量导入一旦中断,恢复成本很高。
三是
事务保证
。用
@transaction.atomic
包住循环,要么全部成功,要么全部回滚。这避免了导入一半之后发现数据混乱,再手动清理几十条脏数据的窘境。
2.3 Django REST Framework接口设计
前后端分离的话,后端必须提供完整的REST API。接口设计直接参考资源语义来:
# students/urls.py
urlpatterns = [
path('api/students/', views.StudentListView.as_view()),
path('api/students/<int:pk>/', views.StudentDetailView.as_view()),
path('api/employment/', views.EmploymentListView.as_view()),
path('api/statistics/overview/', views.OverviewStatisticsView.as_view()),
path('api/statistics/trend/', views.TrendStatisticsView.as_view()),
path('api/statistics/major/', views.MajorStatisticsView.as_view()),
path('api/statistics/industry/', views.IndustryStatisticsView.as_view()),
path('api/statistics/geography/', views.GeographyStatisticsView.as_view()),
path('api/predict/salary/', views.PredictSalaryView.as_view()),
path('api/predict/direction/', views.PredictDirectionView.as_view()),
]
在视图层,计算统计结果的时候我强烈建议直接写原生聚合查询,而不是先把数据全部读到Python里再循环算。Django的
aggregate
和
annotate
就是干这个的:
from django.db.models import Count, Avg, F, Q
from rest_framework.views import APIView
from rest_framework.response import Response
from students.models import Student, Employment
class OverviewStatisticsView(APIView):
"""总览统计数据"""
def get(self, request):
total = Student.objects.count()
employed = Employment.objects.exclude(employment_status='unemployed').count()
employment_rate = round(employed / total * 100, 2) if total else 0
avg_salary = Employment.objects.filter(monthly_salary__isnull=False).aggregate(
avg=Avg('monthly_salary')
)['avg'] or 0
status_distribution = Employment.objects.values('employment_status').annotate(
cnt=Count('id')
)
return Response({
'total': total,
'employed': employed,
'employment_rate': employment_rate,
'avg_salary': round(float(avg_salary), 2),
'status_distribution': list(status_distribution),
})
这种写法比遍历对象再找平均值快出一个数量级,而且代码可读性高,答辩的时候讲起来也清晰。
3. 数据分析指标体系设计
3.1 核心指标拆解:哪些数据真正有分析价值
数据量不等于信息量。几千条学生数据导进去之后,你需要先想清楚一个问题:要告诉用户什么结论?我最终把指标收敛成了三组:
就业总体情况 :
- 就业率(高优先级):已就业人数 / 全体毕业生人数。
- 深造率:升学研读 / 总数,反映学校的学术培养导向。
- 待就业率:重点关注指标,待就业人数过多说明专业市场供需失衡。
薪资与岗位质量 :
- 平均薪资、最低薪资、最高薪资。
- 薪资中位数(比平均薪资更能抵抗异常值影响)。
-
专业对口率:
is_professional_match标记为True的比例。 - 单位性质分布(国企/私企/外企/事业单位的比例)。
行业与地域流向 :
- 行业TOP10排名(计算机软件、制造业、金融、教育等等)。
- 流入省市TOP10(观察人才虹吸效应)。
- 各学院/专业的就业去向跨度。
各个指标之间不是孤立的。比如“某专业就业率90%但专业对口率只有40%”——这说明这个专业的学生虽然能找到工作,但干的多半与专业无关,这反映出 专业培养方案和市场需求的错位 。这种关联解读正是系统“分析”二字的灵魂,也是你论文里可以和指导老师深度探讨的点。
3.2 基于Pandas的数据加工流水线
Django ORM适合做基础查询,但要做多维度分组对比、透视表、时间序列对齐,直接用ORM会写到怀疑人生。我的做法是: ORM查原始数据 → Pandas转DataFrame → 加工聚合 → 转JSON输出 。这套流水线是数据分析类Django项目的通用范式。
# analytics/services.py
import pandas as pd
from django.forms.models import model_to_dict
from students.models import Student, Employment
def get_salary_by_major(graduation_year=None):
"""按专业统计平均薪资"""
qs = Employment.objects.select_related('student').filter(
monthly_salary__isnull=False
)
if graduation_year:
qs = qs.filter(student__graduation_year=graduation_year)
df = pd.DataFrame(list(qs.values(
'monthly_salary', 'student__major', 'student__graduation_year'
)))
if df.empty:
return {}
result = df.groupby(['student__graduation_year', 'student__major']).agg(
avg_salary=('monthly_salary', 'mean'),
median_salary=('monthly_salary', 'median'),
cnt=('monthly_salary', 'count')
)
# 转成前端友好的JSON结构
return result.reset_index().to_dict(orient='records')
你可能会问:为什么不在Django里直接玩转ORML即可?你有没有搜过随机事09“如果后面数据分析还涉及到多维下钻分析”—“按学院×专业×年份×学历看薪资变化”——ORM filter嵌套到第三层你就要疯,Pandas一个
groupby([col1, col2, col3])
就解决问题。Pandas的长项是灵活分析,Django的长项是模型管理,两者配合才是数据分析项目的正确姿势。
3.3 可视化图表的选型逻辑
图表不是越炫越好,而是越“切题”越好。拿到ECharts之后,我一开始的倾向是这样的——“来一组3D柱状图震一震”。后来发现花哨的图表信息传达效率极低,看的人不知道你在表达什么,自己也解释不清。
最终我定的图表方案是:
| 分析维度 | 图表类型 | 为什么选它 |
|---|---|---|
| 就业率趋势(多年对比) | 折线图 | 时间序列的最优表达,变趋势清晰可见 |
| 各专业就业率对比 | 横向条形图 | 专业名称长,横排更易读,按数值排序突出高低 |
| 行业分布占比 | 环形图 | 占比场景最优解,中心可放总计 |
| 地区流向热度 | 中国地图 + 视觉映射 | 地域维度首选,颜色深浅表达热度差异 |
| 薪资分布频率 | 直方图/箱线图 | 观察集中趋势和离群值,箱线图还能看四分位 |
| 就业方向构成 | 玫瑰饼图 | 升学/考公/创业/企业就业的比例,玫瑰图更有冲击力 |
| 专业就业质量综合对比 | 雷达图 | 就业率、对口率、平均薪资等多个维度投影对比专业强弱 |
把图表选型对应到需求上,而不是为图而图,系统呈现出来的专业感就完全不一样。
4. 机器学习模块的构建与集成
4.1 模型定位与选型:预测什么才有价值
机器学习在这个系统里的角色一定要想清楚——它不是摆着炫技的,而是要能回答实际的就业问题。
我最终保留了三个预测功能:
薪资预测(回归) :输入学生特征(学历、专业、生源地、性别、是否专业对口等),输出预测月薪。这对在校生选专业方向、应届生谈薪资期望都有参考价值。
就业方向预测(分类) :输入特征后,预测该学生最可能走:企业就业 / 考公 / 升学 / 创业 / 待就业。这个对辅导员做精准就业指导很有用。
学生特征聚类(无监督) :把毕业生按特征聚成几类(比如“高薪技术类”、“稳定体制类”、“深造学术类”、“就业困难类”),让管理员从群体层面看清学生构成的底层规律。
选型依据很简单: 数据是结构化表格数据,样本量不大(几千~几万条),特征以类别型字段为主,目标诉求是可解释性 ——scikit-learn里的树模型就是最优解。随机森林能给出特征重要性排序,这在答辩环节是加分项;XGBoost精度更高;逻辑回归可以看每个特征对方向的权重方向,适合做归因分析。
4.2 特征工程与数据预处理
特征决定上限,模型只是逼近这个上限。这个项目里特征工程比调参重要得多。
从原始数据里衍生出可用的特征,我推荐下面这一套方案:
# ml/features.py
import pandas as pd
import numpy as np
def build_features():
"""从原始数据构建模型特征集"""
df = pd.DataFrame(list(
Employment.objects.select_related('student').values(
'monthly_salary',
'employment_status',
'student__gender',
'student__education',
'student__graduation_year',
'student__college',
'student__major',
'student__native_place',
'industry',
)
))
# 处理缺失薪资——仅保留有薪资标签的样本用于训练回归模型
df_salary = df[df['monthly_salary'].notna()].copy()
# 类别特征编码
df_salary['gender_code'] = df_salary['student__gender'].map({'male': 1, 'female': 0})
df_salary['edu_code'] = df_salary['student__education'].map({'bachelor': 1, 'master': 2, 'doctor': 3})
# 生源地经济水平分组编码(依据省份GDP水平粗略分组)
high_gdp = ['北京', '上海', '广东', '江苏', '浙江', '山东', '福建', '四川']
mid_gdp = ['湖北', '湖南', '河南', '河北', '安徽', '陕西', '重庆', '辽宁', '天津']
df_salary['native_economy'] = df_salary['student__native_place'].apply(
lambda x: 2 if x in high_gdp else (1 if x in mid_gdp else 0)
)
return df_salary
注意一个非常关键的细节:特征一致性。
模型训练时用的特征顺序和字段名,必须和预测时接口接收到的数据完全一致。训练时特征顺序是
gender_code → edu_code → native_economy → graduation_year
,预测的时候传参顺序也得一样,否则预测结果就是乱码级别的错误。这个看似不起眼的问题,是我联调时排查了两天的“大坑”。
实操建议:把特征处理逻辑封装成同一个函数,训练和预测都用它。不要训练时用一套代码、预测时另写一套,这是我踩过最深的坑。
4.3 模型训练与持久化部署
模型训练部分不多说了(直接上代码),重点是模型训练完之后的持久化和集成方式。
# ml/train_salary_model.py
import joblib
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import r2_score, mean_absolute_error
from features import build_features
df = build_features()
# 特征与标签分离
X = df[['gender_code', 'edu_code', 'native_economy', 'student__graduation_year']]
y = df['monthly_salary'].astype(float)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = RandomForestRegressor(
n_estimators=300,
max_depth=10,
min_samples_split=5,
min_samples_leaf=2,
random_state=42
)
model.fit(X_train, y_train)
# 预测测试
y_pred = model.predict(X_test)
print(f'R2: {r2_score(y_test, y_pred):.4f}')
print(f'MAE: {mean_absolute_error(y_test, y_pred):.2f}元')
# 持久化模型
joblib.dump(model, 'ml_model/salary_model.pkl')
# 查看特征重要性
for name, imp in zip(X.columns, model.feature_importances_):
print(f'{name}: {imp:.4f}')
训练中要验证“数据拆成回归赛”
:如果数据量特别少(不到2000条),建议用回归算法或带正则化的线性回归,别硬上复杂树模型,否则很容易过拟合。我在用到学校某学院1500多条数据时测试过,随机森林的
max_depth
超过15之后,R2反而下降,这是典型的过拟合信号。
模型训练好之后保存为
pkl
文件,在Django启动时加载一次,预测时直接调用:
# ml/predict.py
import joblib
from django.conf import settings
_model = None
def get_model():
global _model
if _model is None:
model_path = settings.BASE_DIR / 'ml_model' / 'salary_model.pkl'
_model = joblib.load(model_path)
return _model
def predict_salary(gender_code, edu_code, native_economy, graduation_year):
model = get_model()
features = [[gender_code, edu_code, native_economy, graduation_year]]
return float(model.predict(features)[0])
这里有个性能陷阱要提一下: 模型不要每次请求都从磁盘加载 。第一次加载之后放到内存里全局复用,不然接口耗时直接从30毫秒干到300毫秒。用全局变量做懒惰加载,是我实测下来最简洁可靠的方案。
4.4 模型效果验证的几个注意点
R2分数不要盲目追求“大于90%” 。就业数据本身噪声很大——薪资受到大量未知因素影响(行业周期、个人谈判能力、企业规模),能达到0.6~0.7就有很好的参考价值了。导师要是问“为什么预测精度不高”,你要说“薪资本质上是高噪声变量,模型提取的是系统性规律而非个体精确值”,这个表述反而是专业的证明。
MAE比R2更符合业务逻辑 。R2波动对异常值很敏感,而MAE直接告诉你“平均误差在2000元以内”。跟非技术背景的就业指导老师解释时,说“平均偏差1500元”比“R2=0.67”更有说服力。
模型可解释性要做在前面
:把
feature_importances_
的结果做成一个柱状图放在前端“模型解读”页,让用户看到“学历贡献最大、占38%,专业贡献22%,生源地经济水平贡献15%”,机器学习模块的落地感完全不一样。
5. Vue前端与可视化大屏实现
5.1 前端项目结构设计
Vue端我用的Vue3 (Composition API) + Vite + Element Plus + ECharts。项目结构按功能模块拆分,保证后续扩展不受限:
frontend/
├── src/
│ ├── api/ # 接口请求封装
│ │ ├── statistics.js
│ │ ├── predict.js
│ │ └── student.js
│ ├── components/
│ │ ├── charts/ # 图表封装组件
│ │ │ ├── LineChart.vue
│ │ │ ├── BarChart.vue
│ │ │ ├── PieChart.vue
│ │ │ ├── MapChart.vue
│ │ │ └── RadarChart.vue
│ │ └── layout/ # 布局组件
│ ├── views/
│ │ ├── Dashboard.vue # 数据总览大屏
│ │ ├── SalaryAnalysis.vue # 薪资分析页
│ │ ├── MajorAnalysis.vue # 专业就业分析页
│ │ ├── TrendAnalysis.vue # 趋势分析页
│ │ ├── PredictSalary.vue # 薪资预测页
│ │ └── DataManage.vue # 数据管理页
│ ├── router/index.js
│ └── main.js
5.2 图表组件封装的关键做法
ECharts在Vue里的正确使用方式不是每个页面
init
来
init
去,而是抽一个通用的图表基础组件,所有图表都复用同一个
BaseChart
,通过
option
参数完成配置。这样表格、折线图、地图都统一了生命周期管理。
这里直接给一份我调试好的
BaseChart.vue
——
容器高度不设导致图表不显示
,这个是ECharts新手十大坑之首:
<!-- components/charts/BaseChart.vue -->
<template>
<div ref="chartRef" :style="{ width: '100%', height: height }"></div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
option: { type: Object, required: true },
height: { type: String, default: '350px' }
})
const chartRef = ref(null)
let chartInstance = null
const renderChart = () => {
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value)
}
chartInstance.setOption(props.option, true) // 第二个参数true表示全覆盖
}
const resizeChart = () => {
chartInstance && chartInstance.resize()
}
onMounted(() => {
nextTick(() => {
renderChart()
})
window.addEventListener('resize', resizeChart)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chartInstance && chartInstance.dispose()
})
// 数据变化时重新渲染
watch(() => props.option, () => {
renderChart()
}, { deep: true })
</script>
封装之后的用法 就是传入不同的option。比如薪资趋势折线图:
<template>
<BaseChart :option="salaryTrendOption" height="380px" />
</template>
<script setup>
import { computed } from 'vue'
import BaseChart from '../components/charts/BaseChart.vue'
const props = defineProps({
salaryData: Array
})
const salaryTrendOption = computed(() => {
return {
tooltip: { trigger: 'axis' },
legend: { data: ['平均薪资', '中位薪资'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: props.salaryData.map(d => `${d.graduation_year}届`)
},
yAxis: {
type: 'value',
name: '月薪(元)',
axisLabel: { formatter: '¥{value}' }
},
series: [
{
name: '平均薪资',
type: 'line',
smooth: true,
data: props.salaryData.map(d => d.avg_salary)
},
{
name: '中位薪资',
type: 'line',
smooth: true,
data: props.salaryData.map(d => d.median_salary)
}
]
}
})
</script>
5.3 大屏布局与数据联动
大屏页面是整个系统展示的“门面”,也是答辩时最先给人留下印象的部分。我的Dashboard布局方案是经典的三栏式,网格布局,适应1920x1080的标准屏:
┌─────────────┬──────────────────┬─────────────┐
│ 就业总览指标 │ 中国地图 │ 就业方向 │
│ (4个KPI卡片) │ (地域流向热度) │ (玫瑰饼图) │
├─────────────┼──────────────────┼─────────────┤
│ 近五年就业率 │ 专业就业率TOP10 │ 薪资区间分布 │
│ 趋势折线 │ 横向条形图 │ 直方图 │
└─────────────┴──────────────────┴─────────────┘
大屏核心不只是放图表, 联动交互 才是关键。我做了两个联动:年份筛选器和学院下拉框。用户切换年份,所有图表组件会同时刷新;点击地图上某个省份,右侧饼图同步切换为该省份的就业方向分布。这个交互的代码典型实现是:
// Dashboard.vue中的联动逻辑
const selectedYear = ref('')
const selectedProvince = ref('')
const loadAllCharts = async () => {
const params = {
year: selectedYear.value,
province: selectedProvince.value
}
// 并行请求所有图表数据
const [overviewRes, trendRes, geoRes, majorRes, salaryRes] = await Promise.all([
fetchOverview(params),
fetchTrend(params),
fetchGeography(params),
fetchMajor(params),
fetchSalaryDist(params)
])
// 更新各图表option(此处通过响应式数据自动触发子组件重新渲染)
overviewData.value = overviewRes
trendOption.value = buildTrendOption(trendRes)
salaryDistOption.value = buildSalaryDistOption(salaryRes)
}
// 地图点击事件绑定
const handleMapClick = (params) => {
selectedProvince.value = params.name
loadAllCharts()
}
5.4 路由与权限控制
前端路由常规配置不做过多讲解,重点说一下权限控制。这类系统有一个核心角色划分:普通用户和超级管理员。普通用户看数据,管理员管数据。我直接用Django DRF自带的Token认证:
# settings.py
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
前端通过Axios拦截器统一携带Token:
// api/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: '/api',
timeout: 15000
})
// 请求拦截:携带token
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Token ${token}`
}
return config
})
// 响应拦截:统一错误处理
request.interceptors.response.use(
response => response.data,
error => {
if (error.response && error.response.status === 401) {
ElMessage.error('登录过期,请重新登录')
localStorage.removeItem('token')
window.location.href = '/login'
} else {
ElMessage.error('请求失败,请稍后再试')
}
return Promise.reject(error)
}
)
预测结果页面、统计分析页面都是仅登录用户可访问,数据管理页面仅管理员可访问。前端路由守卫配合后端鉴权双保险,基本满足毕设需求。
6. 部署上线与环境配置实战
6.1 前后端分离部署方案
开发环境跑通之后,部署上线时踩的坑往往比开发还多。我最终采用的方案是: Nginx + Gunicorn + Django + Node构建Vue静态文件 。
后端用Gunicorn跑:
cd /server/project_backend
# 安装依赖
pip install -r requirements.txt
# 迁移数据库
python manage.py makemigrations
python manage.py migrate
# 收集静态文件
python manage.py collectstatic --noinput
# 用gunicorn启动
gunicorn config.wsgi:application \
--bind 0.0.0.0:8000 \
--workers 4 \
--timeout 120
前端构建:
cd /server/project_frontend
npm install
npm run build
# 构建产物在dist/目录
Nginx配置同时承担两件事:反向代理API请求到Django,托管Vue构建后的静态文件:
server {
listen 80;
server_name your_domain.com;
# 前端静态文件
root /server/project_frontend/dist;
index index.html;
# Vue history模式路由重定向
location / {
try_files $uri $uri/ /index.html;
}
# API请求反向代理到Django
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Django Admin后台
location /admin/ {
proxy_pass http://127.0.0.1:8000/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
6.2 跨域与接口调试的终极解法
前后端分离开发中,
跨域问题是新人第一次联调必踩的坑
。前端跑在8080端口,后端跑在8000端口,前端请求
http://localhost:8000/api/...
时必然触发CORS拦截。
常规解法是在Django安装
django-cors-headers
:
pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
# ...
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
# ...
]
CORS_ALLOWED_ORIGINS = [
'http://localhost:8080',
'http://127.0.0.1:8080',
]
但多做一步:
开发环境的Vite配置代理
,让前端直接请求
/api
走Vite代理转发到后端,一是避免CORS,二是生产环境路径不用改:
// vite.config.js
export default defineConfig({
plugins: [vue()],
server: {
port: 8080,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true
}
}
}
})
这样前端代码里所有请求都用相对路径
/api/...
,开发环境走Vite代理,生产环境走Nginx代理,代码不用改一行。
6.3 部署时的环境配置与依赖锁定
部署环境经常出现“我本地能跑,服务器不能跑”的情况,根因就是 依赖没有锁定版本 。务必使用虚拟环境,并把requirements.txt锁定具体版本:
pip freeze > requirements.txt
以我这次项目为例,核心依赖版本如下:
| 依赖 | 版本 | 说明 |
|---|---|---|
| Django | 4.2.x | 稳定版本,LTS |
| djangorestframework | 3.14.x | REST框架 |
| django-cors-headers | 4.3.x | 跨域解决 |
| mysqlclient | 2.2.x | MySQL驱动 |
| pandas | 2.0.x | 数据处理 |
| scikit-learn | 1.3.x | 机器学习 |
| joblib | 1.3.x | 模型持久化 |
| gunicorn | 21.2.x | 生产服务器 |
还有一个低概率但高杀伤力的坑: 服务器上MySQL字符集没配utf8mb4,中文数据插入直接报错 。创建数据库时一定要明确指定字符集:
CREATE DATABASE employment_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
7. 常见问题与排查技巧
7.1 前后端联调高频问题速查
这个部分是实打实的踩坑总结,我尽量把毕设阶段遇到的高频问题一次性给你梳理清楚:
| 问题现象 | 排查方向 | 最终解法 |
|---|---|---|
| 前端请求接口报CORS错误 | 后端cors配置 | 安装django-cors-headers,在CORS_ALLOWED_ORIGINS加前端地址 |
| ECharts图表空白不渲染 | 容器高度 |
图表父级必须有明确高度,
height: 80vh
或固定像素值
|
| ECharts图例/表格数据不更新 | option未触发setOption |
BaseChart组件watch option用
deep: true
深监听
|
| 加载模型接口报错 | pkl文件路径不对 |
检查BASE_DIR拼接路径,或使用
settings.BASE_DIR / 'ml_model'
|
| Vue打包后刷新404 | history路由缺配置 |
Nginx配置
try_files $uri $uri/ /index.html
|
| 中文数据乱码 | 数据库字符集 |
MySQL建库指定utf8mb4,Django配置
default-character-set=utf8mb4
|
| 批量导入时学号带.0 | pandas读取类型 |
读取Excel时指定
dtype={'学号': str}
|
| 训练和预测结果差很远 | 特征不一致 | 训练和预测统一用同一个特征处理函数 |
7.2 那个折腾我最久的Bug:建模预测的接口乱序
这个Bug值得单独拎出来讲,因为它的教训价值很大。
场景 :薪资预测接口上线之后,前端拿到结果,怎么测都不对——明明输入“研究生学历、计算机专业、上海生源”,预测薪资比很多本科生还低,完全违背常识。
排查过程 :
- 先怀疑模型本身,回训练集测试,R2 0.72,测试集预测效果正常。排除模型问题。
- 再怀疑数据传参错误,前端console打印请求payload,特征值没问题。排除入参问题。
-
最后直接在后端打印进入
model.predict()之前的特征矩阵,一眼发现问题—— 特征列顺序错位了 。
问题在于Django接口代码里重新写了特征构造逻辑,字段顺序和训练时的不一致:训练时是
[gender, edu, native_economy, year]
,预测接口里写成了
[gender, year, edu, native_economy]
。模型接收到的东西完全变了意思,预测结果自然荒谬。
经验教训 :特征工程的代码在训练和预测中必须共享,同一个函数、同一个顺序。永远不要在两处各写一套。这个教训价值远超这个Bug本身——团队协作或者后续维护时,改动一处的逻辑另一处不知道,就会出这种“看起来没坏但结果全错”的隐形Bug。也正是通过这个教训,我更确信一个结论:数据类项目里,出错往往是错在流程一致性,而不是算法本身。
8. 写在最后:项目交付的实际心得
整个项目从开发到上线,一个多月的时间主要集中在三个方面: 数据清洗真的占了40%的工作量 、 模型调优和业务解释花了30% 、 剩余的才是前后端联调和部署 。如果你也打算复现类似项目,把这60%的时间预留出来,否则后面节奏会非常赶。
关于毕业论文/报告里怎么写,一个核心建议:不要只写“我实现了技术栈ABC”,而要从业务问题切入—— 学校就业指导面临什么难点、现有方案有什么不足、系统如何用数据驱动决策 ——把逻辑链条写清楚,导师的阅读体验会好很多。
最后分享一个小技巧,也是深有体会的一点: 模型预测部分做一个人性化的输入界面会很加分 。我做了个“我的薪资预测”页面,用户选几个选项就能看到预测月薪区间和影响因素贡献占比,用实际可感知的交互把“机器学习”这个抽象名词转成了每个人都能体验的功能。这种将技术转化为用户可感知价值的设计,远比堆叠技术名词更能打动答辩老师。
如果你照着这套架构和实现方案走一遍,你得到的不只是能跑通的一套系统,更是一个逻辑自洽、有深度也有落地感的完整项目。
更多推荐

所有评论(0)