- 新增成绩分析页面,包含GPA趋势图、成绩分布图和学分进度 - 实现学生密码修改功能,包括前端表单和后端验证逻辑 - 添加课程类别分析功能,展示不同类别课程的GPA表现 - 优化学生仪表板和课程页面导航链接 - 增加数据加载状态提示和错误处理
449 lines
18 KiB
JavaScript
449 lines
18 KiB
JavaScript
/**
|
|
* 学生端功能管理
|
|
*/
|
|
class StudentManager {
|
|
constructor() {
|
|
this.apiBase = '/api/student';
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.initDashboard();
|
|
this.initMyCourses();
|
|
this.initGradeAnalysis();
|
|
this.updateCurrentTime();
|
|
setInterval(() => this.updateCurrentTime(), 1000);
|
|
|
|
// 绑定刷新按钮
|
|
const refreshBtn = document.getElementById('refreshCourses');
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => this.initMyCourses());
|
|
}
|
|
|
|
const refreshTrendBtn = document.getElementById('refreshTrend');
|
|
if (refreshTrendBtn) {
|
|
refreshTrendBtn.addEventListener('click', () => this.initGradeAnalysis());
|
|
}
|
|
}
|
|
|
|
updateCurrentTime() {
|
|
const timeElement = document.getElementById('currentTime');
|
|
if (timeElement) {
|
|
const now = new Date();
|
|
const options = {
|
|
year: 'numeric', month: 'long', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
|
};
|
|
timeElement.textContent = now.toLocaleString('zh-CN', options);
|
|
}
|
|
}
|
|
|
|
async initDashboard() {
|
|
// 检查是否在仪表板页面
|
|
if (!document.getElementById('gradesTableBody')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/grades`);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
this.renderDashboard(result.data);
|
|
} else {
|
|
if (window.authManager) {
|
|
window.authManager.showNotification(result.message || '获取数据失败', 'error');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fetch dashboard data failed:', error);
|
|
}
|
|
}
|
|
|
|
async initMyCourses() {
|
|
// 检查是否在我的课程页面
|
|
const tbody = document.getElementById('coursesTableBody');
|
|
if (!tbody) return;
|
|
|
|
// 显示加载状态
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="text-center py-5 text-muted">
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
数据加载中...
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/courses`);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
this.renderCourses(result.data);
|
|
} else {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="text-center py-5 text-danger">
|
|
<i class="fas fa-exclamation-circle me-2"></i>
|
|
${result.message || '获取课程失败'}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
if (window.authManager) {
|
|
window.authManager.showNotification(result.message || '获取课程失败', 'error');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fetch courses data failed:', error);
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="text-center py-5 text-danger">
|
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
网络错误,请稍后重试
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
}
|
|
|
|
renderCourses(courses) {
|
|
const tbody = document.getElementById('coursesTableBody');
|
|
if (!tbody) return;
|
|
|
|
if (!courses || courses.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">暂无课程记录</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = courses.map(course => `
|
|
<tr>
|
|
<td>${course.course_code}</td>
|
|
<td>${course.course_name}</td>
|
|
<td>${course.credit}</td>
|
|
<td>${course.teacher_name}</td>
|
|
<td>${course.semester || '2023-2024 下学期'}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="studentManager.viewCourseDetails(${course.id})">
|
|
<i class="fas fa-info-circle"></i> 课程详情
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async viewCourseDetails(courseId) {
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/courses/${courseId}`);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
const course = result.data;
|
|
this.updateElement('modalCourseName', course.course_name);
|
|
this.updateElement('modalCourseCode', course.course_code);
|
|
this.updateElement('modalCourseCredit', course.credit);
|
|
this.updateElement('modalTeacherName', course.teacher_name);
|
|
this.updateElement('modalClassName', course.class_name);
|
|
this.updateElement('modalSemester', course.semester || '2023-2024 下学期');
|
|
this.updateElement('modalTeacherEmail', course.teacher_email || '暂无');
|
|
|
|
const modalEl = document.getElementById('courseDetailsModal');
|
|
if (modalEl) {
|
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
modal.show();
|
|
} else {
|
|
console.error('Course details modal element not found');
|
|
if (window.authManager) {
|
|
window.authManager.showNotification('无法打开课程详情:模态框组件缺失', 'error');
|
|
}
|
|
}
|
|
} else {
|
|
if (window.authManager) {
|
|
window.authManager.showNotification(result.message || '获取课程详情失败', 'error');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fetch course details failed:', error);
|
|
if (window.authManager) {
|
|
window.authManager.showNotification('获取课程详情失败:网络错误', 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
renderDashboard(data) {
|
|
const { grades, statistics } = data;
|
|
|
|
// 渲染统计数据
|
|
if (statistics) {
|
|
this.updateElement('gpaValue', statistics.gpa);
|
|
this.updateElement('courseCount', statistics.totalCourses);
|
|
this.updateElement('creditTotal', statistics.totalCredits);
|
|
// 班级排名如果后端没提供,可以显示为 '-' 或固定值
|
|
this.updateElement('classRank', statistics.classRank || '-');
|
|
}
|
|
|
|
// 渲染成绩表格
|
|
const tbody = document.getElementById('gradesTableBody');
|
|
if (tbody) {
|
|
if (!grades || grades.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">暂无成绩记录</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = grades.map(grade => `
|
|
<tr>
|
|
<td>${grade.course_name}</td>
|
|
<td>${grade.course_code || '-'}</td>
|
|
<td>${grade.credit}</td>
|
|
<td>${grade.score}</td>
|
|
<td>${grade.grade_level || '-'}</td>
|
|
<td>${grade.grade_point || '-'}</td>
|
|
<td>
|
|
<span class="badge ${this.getScoreBadgeClass(grade.score)}">
|
|
${this.getScoreText(grade.score)}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="studentManager.viewDetails(${grade.id})">
|
|
<i class="fas fa-eye"></i> 详情
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
updateElement(id, value) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = value;
|
|
}
|
|
|
|
getScoreBadgeClass(score) {
|
|
const s = parseFloat(score);
|
|
if (s >= 90) return 'bg-success';
|
|
if (s >= 80) return 'bg-info';
|
|
if (s >= 60) return 'bg-warning';
|
|
return 'bg-danger';
|
|
}
|
|
|
|
getScoreText(score) {
|
|
const s = parseFloat(score);
|
|
if (s >= 60) return '及格';
|
|
return '不及格';
|
|
}
|
|
|
|
async viewDetails(id) {
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/grades/${id}`);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
const grade = result.data.grade;
|
|
this.updateElement('modalGradeCourseName', grade.course_name);
|
|
this.updateElement('modalGradeCourseCode', grade.course_code);
|
|
this.updateElement('modalTotalScore', grade.score);
|
|
this.updateElement('modalUsualScore', grade.usual_score || '-');
|
|
this.updateElement('modalMidtermScore', grade.midterm_score || '-');
|
|
this.updateElement('modalFinalScore', grade.final_score || '-');
|
|
this.updateElement('modalGradeCredit', grade.credit);
|
|
this.updateElement('modalGradeLevel', grade.grade_level || '-');
|
|
this.updateElement('modalGradePoint', grade.grade_point || '-');
|
|
this.updateElement('modalGradeTeacher', grade.teacher_name);
|
|
this.updateElement('modalGradeTime', new Date(grade.created_at).toLocaleString());
|
|
|
|
const remarkContainer = document.getElementById('modalRemarkContainer');
|
|
if (grade.remark) {
|
|
remarkContainer.style.display = 'block';
|
|
this.updateElement('modalRemark', grade.remark);
|
|
} else {
|
|
remarkContainer.style.display = 'none';
|
|
}
|
|
|
|
const modalEl = document.getElementById('gradeDetailsModal');
|
|
if (modalEl) {
|
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
modal.show();
|
|
} else {
|
|
console.error('Grade details modal element not found');
|
|
if (window.authManager) {
|
|
window.authManager.showNotification('无法打开成绩详情:模态框组件缺失', 'error');
|
|
}
|
|
}
|
|
} else {
|
|
if (window.authManager) {
|
|
window.authManager.showNotification(result.message || '获取成绩详情失败', 'error');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fetch grade details failed:', error);
|
|
if (window.authManager) {
|
|
window.authManager.showNotification('获取成绩详情失败:网络错误', 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
async initGradeAnalysis() {
|
|
if (!document.getElementById('gpaTrendChart')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${this.apiBase}/statistics`);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
this.renderAnalysisCharts(result.data);
|
|
this.renderCreditsInfo(result.data.credits, result.data.semesterCredits);
|
|
} else {
|
|
if (window.authManager) {
|
|
window.authManager.showNotification(result.message || '获取统计数据失败', 'error');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fetch statistics failed:', error);
|
|
}
|
|
}
|
|
|
|
renderAnalysisCharts(data) {
|
|
try {
|
|
// 1. GPA 趋势图
|
|
const ctxGPA = document.getElementById('gpaTrendChart');
|
|
if (ctxGPA) {
|
|
const ctx = ctxGPA.getContext('2d');
|
|
if (this.gpaChart) this.gpaChart.destroy();
|
|
this.gpaChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: (data.trend || []).map(t => t.semester),
|
|
datasets: [{
|
|
label: '学期 GPA',
|
|
data: (data.trend || []).map(t => t.gpa),
|
|
borderColor: '#4e73df',
|
|
backgroundColor: 'rgba(78, 115, 223, 0.05)',
|
|
tension: 0.3,
|
|
fill: true
|
|
}]
|
|
},
|
|
options: {
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: { beginAtZero: false, min: 0, max: 4.0 }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 2. 成绩分布饼图
|
|
const ctxDist = document.getElementById('gradeDistributionChart');
|
|
if (ctxDist) {
|
|
const ctx = ctxDist.getContext('2d');
|
|
if (this.distChart) this.distChart.destroy();
|
|
this.distChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['A (优秀)', 'B (良好)', 'C (中等)', 'D (及格)', 'F (不及格)'],
|
|
datasets: [{
|
|
data: [
|
|
data.distribution.A || 0,
|
|
data.distribution.B || 0,
|
|
data.distribution.C || 0,
|
|
data.distribution.D || 0,
|
|
data.distribution.F || 0
|
|
],
|
|
backgroundColor: ['#1cc88a', '#36b9cc', '#f6c23e', '#fd7e14', '#e74a3b']
|
|
}]
|
|
},
|
|
options: {
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { position: 'bottom' } }
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Render charts failed:', error);
|
|
}
|
|
|
|
// 3. 课程类别表现 (使用进度条列表展示)
|
|
this.renderCategoryPerformance(data.categories);
|
|
}
|
|
|
|
renderCategoryPerformance(categories) {
|
|
const container = document.getElementById('categoryPerformanceList');
|
|
if (!container || !categories) return;
|
|
|
|
container.innerHTML = categories.map(cat => {
|
|
const gpaValue = parseFloat(cat.gpa);
|
|
const percentage = (gpaValue / 4.0) * 100;
|
|
const totalCredits = parseFloat(cat.totalCredits || 0);
|
|
|
|
// 根据绩点选择颜色
|
|
let bgColor = 'bg-primary';
|
|
if (gpaValue >= 3.5) bgColor = 'bg-success';
|
|
else if (gpaValue < 2.0) bgColor = 'bg-danger';
|
|
else if (gpaValue < 3.0) bgColor = 'bg-warning';
|
|
|
|
return `
|
|
<div class="mb-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
<div>
|
|
<span class="fw-bold">${cat.category}</span>
|
|
<span class="text-muted small ms-2">(${totalCredits.toFixed(1)} 学分)</span>
|
|
</div>
|
|
<span class="fw-bold text-primary">${cat.gpa} GPA</span>
|
|
</div>
|
|
<div class="progress" style="height: 10px;">
|
|
<div class="progress-bar ${bgColor}" role="progressbar"
|
|
style="width: ${percentage}%"
|
|
aria-valuenow="${percentage}" aria-valuemin="0" aria-valuemax="100">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
renderCreditsInfo(credits, semesterCredits) {
|
|
// 总进度
|
|
const percent = Math.min(100, Math.round((credits.earned / credits.target) * 100));
|
|
const progressBar = document.getElementById('creditProgressBar');
|
|
const percentText = document.getElementById('creditPercent');
|
|
|
|
if (progressBar && percentText) {
|
|
progressBar.style.width = `${percent}%`;
|
|
percentText.textContent = `${percent}%`;
|
|
|
|
// 更新数值
|
|
this.updateElement('earnedCredits', credits.earned);
|
|
this.updateElement('targetCredits', credits.target);
|
|
}
|
|
|
|
// 学期学分列表
|
|
const listContainer = document.getElementById('semesterCreditsList');
|
|
if (listContainer) {
|
|
listContainer.innerHTML = semesterCredits.map(item => `
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span class="text-secondary small">${item.semester}</span>
|
|
<span class="fw-bold">${item.credits} 学分</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 初始化
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.studentManager = new StudentManager();
|
|
|
|
// 从 Session 获取用户信息并更新 UI
|
|
fetch('/api/auth/me')
|
|
.then(res => res.json())
|
|
.then(result => {
|
|
if (result.success && result.data.user) {
|
|
const user = result.data.user;
|
|
const nameEl = document.getElementById('userName');
|
|
const studentNameEl = document.getElementById('studentName');
|
|
const studentClassEl = document.getElementById('studentClass');
|
|
|
|
if (nameEl) nameEl.textContent = user.name;
|
|
if (studentNameEl) studentNameEl.textContent = user.name;
|
|
if (studentClassEl) studentClassEl.textContent = user.class || '未分配';
|
|
}
|
|
});
|
|
});
|