基于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、系统功能模块设计
系统采用多角色权限设计,分为学生、导师和管理员三大角色,每个角色拥有不同的功能权限
5、ER图
6、系统实现与界面展示
6.1、学生端界面实现
学生个人信息页面
学生可以在此页面查看和编辑个人基本信息、学业成绩、研究意向等内容。系统还会显示竞争力六维分析图表,直观展示自己在各个能力维度上的评分和百分位排名
导师信息查看页面
学生可以浏览导师列表,查看导师的基本信息、研究方向、招生要求等。系统支持按关键词搜索和分页浏览,帮助学生快速找到心仪的导师
导师详细信息页面
点击特定导师后,学生可以查看该导师的详细资料,包括所属学院、研究领域、招生条件、联系方式、办公室地址以及学术成果列表
我的申请页面
学生可以在此页面跟踪自己提交的所有导师申请,查看申请状态(待审核、已通过、未通过等)、申请时间和导师反馈
我的面试页面
展示学生收到的所有面试安排,包括面试时间、会议链接、面试反馈和结果。学生可以通过该页面参加在线面试并查看导师的面试评价
消息中心页面
系统通过消息中心向学生发送各类通知,包括系统公告、面试安排、导师消息等。消息会标记已读/未读状态,确保学生不会错过重要信息
6.2、导师端界面实现
导师个人中心
导师可以维护个人信息,包括联系方式、职称、学院、办公室地址、招生要求和研究方向等。这些信息将对学生可见,帮助学生了解导师情况
查看学生信息页面
导师可以浏览学生列表,查看学生的基本信息、学业成绩和研究意向。系统还会显示学生与导师研究方向的匹配度百分比,帮助导师快速识别合适的学生
我的学生页面
展示已与导师匹配成功的学生列表,导师可以查看这些学生的详细信息和研究进展,并与学生进行互动交流
面试管理页面
导师可以在此页面管理面试安排,包括安排面试时间、生成会议链接、填写面试反馈和确定面试结果
导师消息中心
导师通过消息中心接收系统通知、学生面试确认通知等,所有消息都带有具体日期时间标记,确保导师能够及时处理重要事务
6.3、管理员端界面实现
注册审核页面
管理员负责审核新注册的学生和导师账号,确保所有用户身份真实有效。页面支持按姓名/账号搜索,提高审核效率
用户管理页面
管理员可以查看和管理所有学生和导师的账号信息,包括账号状态、审核状态、联系方式等,并支持编辑和搜索功能
面试申请管理页面
管理员可以监控所有面试申请的状态,查看学生和导师的匹配情况,并在需要时介入处理异常情况
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());
}
}