基于SpringBoot + Vue3的导师双选系统设计实现

基于SpringBoot + Vue3的导师双选系统设计实现


1、选题背景

​ 在高校研究生培养体系中,导师双选环节是决定学生未来学术发展方向的关键步骤。传统的导师选择模式主要依靠纸质表格和人工沟通方式,存在信息不透明、匹配效率低下、公平性难以保障等突出问题。学生往往难以全面了解导师的研究方向和招生要求,而导师也无法有效评估学生的学术背景和研究潜力。这种信息不对称导致匹配效果不理想,既浪费了师生双方的时间精力,也可能影响后续的研究生培养质量。

2、技术选型

后端

  • Spring Boot 3: 主框架,提供依赖注入、AOP支持和自动化配置
  • MyBatis Plus: 数据持久层框架,简化数据库操作
  • Lombok: 简化JavaBean编写,自动生成getter/setter等方法
  • MySQL 8.0: 关系型数据库,存储系统所有业务数据
  • JWT: 用户认证与授权管理

前端

  • Vue 3: 主流前端框架,提供响应式数据绑定和组件化开发
  • Axios: 处理HTTP请求,实现前后端数据交互
  • Element Plus: UI组件库,提供丰富的界面元素
3、开发与部署环境
  • IDEA: 后端开发IDE
  • WebStorm: 前端开发IDE
  • Maven: 项目构建与管理
  • Node.js: 前端运行环境
4、系统功能模块设计

系统采用多角色权限设计,分为学生导师管理员三大角色,每个角色拥有不同的功能权限

image-20250905211418733

5、ER图

image-20250905211606690

6、系统实现与界面展示
6.1、学生端界面实现

学生个人信息页面

学生可以在此页面查看和编辑个人基本信息、学业成绩、研究意向等内容。系统还会显示竞争力六维分析图表,直观展示自己在各个能力维度上的评分和百分位排名

image-20250905211828990

导师信息查看页面

学生可以浏览导师列表,查看导师的基本信息、研究方向、招生要求等。系统支持按关键词搜索和分页浏览,帮助学生快速找到心仪的导师

image-20250905211911784

导师详细信息页面

点击特定导师后,学生可以查看该导师的详细资料,包括所属学院、研究领域、招生条件、联系方式、办公室地址以及学术成果列表

image-20250905212041991

我的申请页面

学生可以在此页面跟踪自己提交的所有导师申请,查看申请状态(待审核、已通过、未通过等)、申请时间和导师反馈

image-20250905212101585

我的面试页面

展示学生收到的所有面试安排,包括面试时间、会议链接、面试反馈和结果。学生可以通过该页面参加在线面试并查看导师的面试评价

image-20250905212227392

消息中心页面

系统通过消息中心向学生发送各类通知,包括系统公告、面试安排、导师消息等。消息会标记已读/未读状态,确保学生不会错过重要信息

image-20250905212256898

6.2、导师端界面实现

导师个人中心

导师可以维护个人信息,包括联系方式、职称、学院、办公室地址、招生要求和研究方向等。这些信息将对学生可见,帮助学生了解导师情况

image-20250905212443999

查看学生信息页面

导师可以浏览学生列表,查看学生的基本信息、学业成绩和研究意向。系统还会显示学生与导师研究方向的匹配度百分比,帮助导师快速识别合适的学生

image-20250905212515306

我的学生页面

展示已与导师匹配成功的学生列表,导师可以查看这些学生的详细信息和研究进展,并与学生进行互动交流

image-20250905212557918

面试管理页面

导师可以在此页面管理面试安排,包括安排面试时间、生成会议链接、填写面试反馈和确定面试结果

image-20250905212630929

导师消息中心

导师通过消息中心接收系统通知、学生面试确认通知等,所有消息都带有具体日期时间标记,确保导师能够及时处理重要事务

image-20250905212707757

6.3、管理员端界面实现

注册审核页面

管理员负责审核新注册的学生和导师账号,确保所有用户身份真实有效。页面支持按姓名/账号搜索,提高审核效率

image-20250905212829924

用户管理页面

管理员可以查看和管理所有学生和导师的账号信息,包括账号状态、审核状态、联系方式等,并支持编辑和搜索功能

image-20250905212904536

面试申请管理页面

管理员可以监控所有面试申请的状态,查看学生和导师的匹配情况,并在需要时介入处理异常情况

image-20250905213021018

7、核心代码

前端

<template>
    <div class="mentor-detail-container" v-loading="loading">
      <el-breadcrumb separator="/" class="breadcrumb">
        <el-breadcrumb-item :to="{ path: '/admin/mentor' }">导师列表</el-breadcrumb-item>
        <el-breadcrumb-item>导师详情</el-breadcrumb-item>
      </el-breadcrumb>
  
      <el-card v-if="mentorData" class="mentor-card" shadow="hover">
        <template #header>
          <div class="card-header">
            <div class="mentor-info">
              <el-avatar :size="80" :src="mentorData.user.avatar || ''" class="mentor-avatar">
                <i class="el-icon-user-solid" v-if="!mentorData.user.avatar" />
              </el-avatar>
              <div class="mentor-basic">
                <h1 class="mentor-name">{{ mentorData.user.full_name || '未填写' }}</h1>
                <div class="mentor-meta">
                  <el-tag type="success" effect="dark">{{ mentorData.user.role || '未填写' }}</el-tag>
                  <el-tag type="info" effect="plain">{{ mentorData.mentor.department || '未填写' }}</el-tag>
                  <el-tag type="warning">{{ mentorData.mentor.title || '未填写' }}</el-tag>
                </div>
              </div>
            </div>
          </div>
        </template>
  
        <el-row :gutter="20">
          <el-col :xs="24" :sm="12">
            <el-descriptions :column="1" border>
              <el-descriptions-item label="研究方向">
                <el-icon><Opportunity /></el-icon>
                {{ mentorData.mentor.research_direction || '未填写' }}
              </el-descriptions-item>
              <el-descriptions-item label="招生名额">
                <el-icon><User /></el-icon>
                {{ mentorData.mentor.enrollment_quota ? `${mentorData.mentor.enrollment_quota} 人` : '未填写' }}
              </el-descriptions-item>
              <el-descriptions-item label="筛选条件">
                <el-icon><Select /></el-icon>
                {{ mentorData.mentor.filter_condition || '未填写' }}
              </el-descriptions-item>
            </el-descriptions>
          </el-col>
          <el-col :xs="24" :sm="12">
            <el-descriptions :column="1" border>
              <el-descriptions-item label="办公地点">
                <el-icon><Location /></el-icon>
                {{ mentorData.mentor.office_location || '未填写' }}
              </el-descriptions-item>
              <el-descriptions-item label="联系方式">
                <el-link v-if="mentorData.user.email" type="primary" :href="`mailto:${mentorData.user.email}`">
                  <el-icon><Message /></el-icon>
                  {{ mentorData.user.email }}
                </el-link>
                <span v-else>
                  <el-icon><Message /></el-icon>
                  未填写
                </span>
                <div style="margin-top: 8px">
                  <el-link v-if="mentorData.user.phone" type="info" :href="`tel:${mentorData.user.phone}`">
                    <el-icon><Phone /></el-icon>
                    {{ mentorData.user.phone }}
                  </el-link>
                  <span v-else>
                    <el-icon><Phone /></el-icon>
                    未填写
                  </span>
                </div>
              </el-descriptions-item>
            </el-descriptions>
          </el-col>
        </el-row>
      </el-card>
  
      <el-card v-if="mentorData?.achievements?.length" class="achievements-card" shadow="hover">
        <template #header>
          <div class="achievements-header">
            <h2 class="section-title"><el-icon><Trophy /></el-icon> 学术成就</h2>
            <el-radio-group v-model="achievementFilter" size="small">
              <el-radio-button label="all">全部</el-radio-button>
              <el-radio-button label="paper">论文</el-radio-button>
              <el-radio-button label="project">项目</el-radio-button>
              <el-radio-button label="patent">专利</el-radio-button>
            </el-radio-group>
          </div>
        </template>
  
        <el-timeline>
          <el-timeline-item
            v-for="achievement in filteredAchievements"
            :key="achievement.id"
            :timestamp="formatDate(achievement.publish_date)"
            :type="getAchievementType(achievement.achievement_type)"
            placement="top"
            class="achievement-item"
          >
            <el-card shadow="never" :style="{ borderLeft: `4px solid ${typeColors[achievement.achievement_type]}` }">
              <div class="achievement-content">
                <div class="achievement-main">
                  <h3 class="achievement-title">
                    {{ achievement.title || '未填写' }}
                    <el-tag :color="typeColors[achievement.achievement_type]" effect="dark" size="small">
                      {{ achievementTypeNames[achievement.achievement_type] }}
                    </el-tag>
                  </h3>
                  <p class="achievement-desc">{{ achievement.description || '未填写' }}</p>
                </div>
                <el-button
                  v-if="achievement.file_path"
                  type="primary"
                  :icon="Download"
                  circle
                  @click="downloadFile(achievement.file_path)"
                />
              </div>
            </el-card>
          </el-timeline-item>
        </el-timeline>
      </el-card>
  
      <el-empty v-else-if="mentorData" description="暂无学术成就" :image-size="100" />
  
      <el-alert
        v-if="errorMessage"
        :title="errorMessage"
        type="error"
        show-icon
        :closable="false"
        class="error-alert"
      />
    </div>
  </template>
  
  <script setup>
  import { ref, computed, onMounted } from 'vue'
  import { useRoute } from 'vue-router'
  import { ElMessage } from 'element-plus'
  import { 
    Location,
    Message,
    Phone,
    Trophy,
    Download,
    User,
    Select,
    Opportunity
  } from '@element-plus/icons-vue'
  import { getMentorDetail } from '@/api/userApi'
  
  const route = useRoute()
  const mentorData = ref(null)
  const loading = ref(true)
  const errorMessage = ref('')
  const achievementFilter = ref('all')
  
  const typeColors = {
    paper: '#409EFF',
    project: '#67C23A',
    patent: '#E6A23C'
  }
  
  const achievementTypeNames = {
    paper: '学术论文',
    project: '科研项目',
    patent: '发明专利'
  }
  
  const filteredAchievements = computed(() => {
    if (achievementFilter.value === 'all') return mentorData.value?.achievements || []
    return mentorData.value?.achievements.filter(a => a.achievement_type === achievementFilter.value) || []
  })
  
  onMounted(async () => {
    try {
      const id = route.params.id
      if (!id) {
        throw new Error('缺少导师ID参数')
      }
      
      const response = await getMentorDetail(id)
      
      if (response.data.code === 200 && response.data.data) {
        mentorData.value = response.data.data
      } else {
        throw new Error(response.data.msg || '获取数据失败')
      }
    } catch (error) {
      console.error('请求错误:', error)
      errorMessage.value = error.message
    } finally {
      loading.value = false
    }
  })
  
  const formatDate = (dateArray) => {
    if (!dateArray || dateArray.length < 3) return '未填写'
    return `${dateArray[0]}-${String(dateArray[1]).padStart(2, '0')}-${String(dateArray[2]).padStart(2, '0')}`
  }
  
  const getAchievementType = (type) => {
    switch (type) {
      case 'paper': return 'primary'
      case 'project': return 'success'
      case 'patent': return 'warning'
      default: return 'info'
    }
  }
  
  const downloadFile = (filePath) => {
    // 这里应该实现文件下载逻辑
    ElMessage.success(`开始下载文件: ${filePath}`)
  }
  </script>

后端

@Service
@RequiredArgsConstructor
public class StudentServiceImpl implements StudentService {

    private final StudentMapper studentMapper;

    private final CompetencyAnalysisMapper competitionsMapper;

    private final UserMapper userMapper;

    private final MentorMapper mentionMapper;

    private final MentorAchievementMapper mentorAchievementMapper;

    private final ApplicationMapper applicationMapper;


    @Override
    public PageResult page(UserQueryPageDTO userQueryPageDTO) {
        PageHelper.startPage(userQueryPageDTO.getPageNum(), userQueryPageDTO.getPageSize());
        Page<User> page = userMapper.page(userQueryPageDTO);
        long total = page.getTotal();
        List<User> userList = page.getResult();

        if (userList == null || userList.isEmpty()) {
            return new PageResult(total, Collections.emptyList());
        }

        Long mentorId = BaseContext.getCurrentId();
        List<StudentDetailVO> studentDetailVOList = new ArrayList<>();

        for (User user : userList) {
            StudentDetailVO studentDetailVO = new StudentDetailVO();
            Student student = studentMapper.selectById(user.getId().longValue());
            CompetencyAnalysis competencyAnalysis = competitionsMapper.selectById(user.getId().longValue());

            studentDetailVO.setUser(user);
            studentDetailVO.setStudent(student);
            studentDetailVO.setCompetencyAnalysis(competencyAnalysis);

            Mentor mentor = mentionMapper.selectById(mentorId);
            List<MentorAchievement> achievements = null;
            if (mentor != null) {
                achievements = mentorAchievementMapper.getListByMentorId(mentorId);
            }

            int match = 50; // 默认值
            if (mentor != null && student != null) {
                String researchDirection = mentor.getResearchDirection();
                String filterCondition = mentor.getFilterCondition();
                String researchIntention = student.getResearchIntention();

                List<String> authorizations = new ArrayList<>();
                if (achievements != null && !achievements.isEmpty()) {
                    authorizations = achievements.stream()
                            .map(MentorAchievement::getTitle)
                            .collect(Collectors.toList());
                }

                // 使用改进后的匹配算法
                match += calculateMatch(researchIntention, researchDirection, filterCondition, authorizations);
            }

            match = Math.min(match, 100);
            studentDetailVO.setMatch(match);

            boolean isToMyApplication = false;
            if (student != null && mentor != null) {
                Application application = applicationMapper.selectStudentIdAndMentorIdAndApplicationStatus(student.getId(), mentorId, "accepted");
                if (application != null) {
                    isToMyApplication = true;
                }
            }
            studentDetailVO.setIsToMyApplication(isToMyApplication);

            studentDetailVOList.add(studentDetailVO);
        }
        return new PageResult(total, studentDetailVOList);
    }

    private int calculateMatch(String researchIntention, String researchDirection, String filterCondition, List<String> authorizations) {
        int matchScore = 0;
        final int BASE_AWARD = 15;
        // 权重可以根据实际情况调整
        final int DIRECTION_WEIGHT = BASE_AWARD;
        final int FILTER_WEIGHT = BASE_AWARD + 5;
        final int ARTICLE_WEIGHT = BASE_AWARD - 3;

        // 1. 研究方向匹配 (使用编辑距离)
        if (researchIntention != null && researchDirection != null) {
            int distance = LevenshteinDistance.getDefaultInstance().apply(researchIntention.toLowerCase(), researchDirection.toLowerCase());
            // 根据距离计算得分,距离越小,得分越高
            int maxLength = Math.max(researchIntention.length(), researchDirection.length());
            double similarity = (double) (maxLength - distance) / maxLength; // 相似度
            matchScore += (int) (similarity * DIRECTION_WEIGHT); // 根据相似度加权
        }

        // 2. 筛选条件匹配 (模糊匹配,但可以考虑多个关键词)
        if (researchIntention != null && filterCondition != null) {
            // 将筛选条件拆分为关键词,例如用逗号分隔
            String[] keywords = filterCondition.toLowerCase().split("[,;]");
            for (String keyword : keywords) {
                if (fuzzyMatch(researchIntention, keyword.trim())) {
                    matchScore += FILTER_WEIGHT; // 只要匹配到一个关键词,就增加分数
                    break; //避免重复加分
                }
            }
        }

        // 3. 发表文章匹配 (可以匹配多篇文章)
        if (researchIntention != null && authorizations != null) {
            for (String authorization : authorizations) {
                if (authorization != null && fuzzyMatch(researchIntention, authorization)) {
                    matchScore += ARTICLE_WEIGHT;
                }
            }
        }
        return matchScore;
    }

    // 保留模糊匹配方法
    private boolean fuzzyMatch(String text, String keyword) {
        if (text == null || keyword == null) {
            return false;
        }
        return text.toLowerCase().contains(keyword.toLowerCase());
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小林学习编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值