又是一个毕业设计“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值得单独拎出来讲,因为它的教训价值很大。

场景 :薪资预测接口上线之后,前端拿到结果,怎么测都不对——明明输入“研究生学历、计算机专业、上海生源”,预测薪资比很多本科生还低,完全违背常识。

排查过程 :

  1. 先怀疑模型本身,回训练集测试,R2 0.72,测试集预测效果正常。排除模型问题。
  2. 再怀疑数据传参错误,前端console打印请求payload,特征值没问题。排除入参问题。
  3. 最后直接在后端打印进入 model.predict() 之前的特征矩阵,一眼发现问题—— 特征列顺序错位了 。

问题在于Django接口代码里重新写了特征构造逻辑,字段顺序和训练时的不一致:训练时是 [gender, edu, native_economy, year] ,预测接口里写成了 [gender, year, edu, native_economy] 。模型接收到的东西完全变了意思,预测结果自然荒谬。

经验教训 :特征工程的代码在训练和预测中必须共享,同一个函数、同一个顺序。永远不要在两处各写一套。这个教训价值远超这个Bug本身——团队协作或者后续维护时,改动一处的逻辑另一处不知道,就会出这种“看起来没坏但结果全错”的隐形Bug。也正是通过这个教训,我更确信一个结论:数据类项目里,出错往往是错在流程一致性,而不是算法本身。

8. 写在最后:项目交付的实际心得

整个项目从开发到上线,一个多月的时间主要集中在三个方面: 数据清洗真的占了40%的工作量 、 模型调优和业务解释花了30% 、 剩余的才是前后端联调和部署 。如果你也打算复现类似项目,把这60%的时间预留出来,否则后面节奏会非常赶。

关于毕业论文/报告里怎么写,一个核心建议:不要只写“我实现了技术栈ABC”,而要从业务问题切入—— 学校就业指导面临什么难点、现有方案有什么不足、系统如何用数据驱动决策 ——把逻辑链条写清楚,导师的阅读体验会好很多。

最后分享一个小技巧,也是深有体会的一点: 模型预测部分做一个人性化的输入界面会很加分 。我做了个“我的薪资预测”页面,用户选几个选项就能看到预测月薪区间和影响因素贡献占比,用实际可感知的交互把“机器学习”这个抽象名词转成了每个人都能体验的功能。这种将技术转化为用户可感知价值的设计,远比堆叠技术名词更能打动答辩老师。

如果你照着这套架构和实现方案走一遍,你得到的不只是能跑通的一套系统,更是一个逻辑自洽、有深度也有落地感的完整项目。

更多推荐