
文章目录
-
- 一、为什么需要这些高级功能?教学场景的痛点解决
-
- [1. 现有系统的痛点](#1. 现有系统的痛点)
- [2. 新增功能的价值](#2. 新增功能的价值)
- [二、技术选型:贴合 Flask 生态,降低学习成本](#二、技术选型:贴合 Flask 生态,降低学习成本)
- [三、实战 1:实时通知系统(基于 Flask-SocketIO)](#三、实战 1:实时通知系统(基于 Flask-SocketIO))
-
- [1. 环境准备:安装依赖](#1. 环境准备:安装依赖)
- [2. 服务器端配置:集成 SocketIO 到现有系统](#2. 服务器端配置:集成 SocketIO 到现有系统)
-
- [步骤 1:初始化 SocketIO(`app.py`新增)](#步骤 1:初始化 SocketIO(
app.py新增)) - 关键说明:
- [步骤 1:初始化 SocketIO(`app.py`新增)](#步骤 1:初始化 SocketIO(
- [3. 客户端实现:接收并显示实时通知](#3. 客户端实现:接收并显示实时通知)
-
- [步骤 1:在基础模板中引入 SocketIO 客户端库](#步骤 1:在基础模板中引入 SocketIO 客户端库)
- [步骤 2:在导航栏添加通知数显示](#步骤 2:在导航栏添加通知数显示)
- 效果验证:
- [四、实战 2:数据可视化看板(基于 ECharts)](#四、实战 2:数据可视化看板(基于 ECharts))
-
- [1. 技术原理:前后端协作流程](#1. 技术原理:前后端协作流程)
- [2. 后端 API 开发:提供看板数据](#2. 后端 API 开发:提供看板数据)
-
- [步骤 1:新增看板 API 命名空间(`app.py`)](#步骤 1:新增看板 API 命名空间(
app.py))
- [步骤 1:新增看板 API 命名空间(`app.py`)](#步骤 1:新增看板 API 命名空间(
- [3. 前端看板页面开发:集成 ECharts](#3. 前端看板页面开发:集成 ECharts)
-
- [步骤 1:创建看板模板(`templates/dashboard.html`)](#步骤 1:创建看板模板(
templates/dashboard.html)) - [步骤 2:添加看板导航链接](#步骤 2:添加看板导航链接)
- [步骤 3:添加看板路由(`app.py`)](#步骤 3:添加看板路由(
app.py)) - 效果验证:
- [步骤 1:创建看板模板(`templates/dashboard.html`)](#步骤 1:创建看板模板(
- 五、高级功能优化与最佳实践
-
- [1. 实时通知优化:减少不必要的推送](#1. 实时通知优化:减少不必要的推送)
- [2. 数据看板优化:提升加载速度](#2. 数据看板优化:提升加载速度)
- [3. 安全加固:防止 WebSocket 滥用](#3. 安全加固:防止 WebSocket 滥用)
- 六、小结与后续扩展方向
欢迎回到「Python 从入门到实战」系列专栏。上一篇咱们完成了 Flask API 开发,让学生系统支持 Web/APP/ 小程序多端访问,但在实际教学场景中,还有两个关键需求没解决:一是 "信息同步不及时"------ 老师修改成绩后,学生需要手动刷新页面才能看到更新;二是 "数据洞察难"------ 老师需要手动统计才能知道班级成绩分布、薄弱科目,缺乏直观的可视化工具。
今天咱们聚焦「高级功能实战」,在现有系统基础上新增两大核心功能:实时通知系统 (基于 WebSocket,成绩变动时学生 / 家长实时收到提醒)和数据可视化看板(基于 ECharts,直观展示班级成绩分布、趋势和统计指标)。这两个功能既衔接前面的数据库、API 和用户认证逻辑,又引入新的技术点(WebSocket、ECharts),让系统从 "能用" 升级为 "好用、智能",真正辅助教学决策和提升用户体验。
一、为什么需要这些高级功能?教学场景的痛点解决
在讲技术实现前,先明确新增功能的价值 ------ 解决前面系统未覆盖的教学痛点:
1. 现有系统的痛点
- 信息滞后:老师修改成绩后,学生需反复刷新页面才能发现更新;家长无法及时知晓孩子成绩变动,只能靠老师手动通知;
- 数据统计繁琐:想知道 "数学成绩 80-90 分的学生有多少""班级平均分是否呈上升趋势",需要手动导出 Excel 计算,效率低;
- 缺乏决策依据:无法快速定位班级薄弱科目(如英语平均分低于年级水平),教学改进缺乏数据支撑。
2. 新增功能的价值
| 功能模块 | 解决的痛点 | 受益角色 |
|---|---|---|
| 实时通知系统 | 成绩变动 / 系统公告实时推送,无需刷新页面 | 学生、家长、老师 |
| 数据可视化看板 | 直观展示成绩分布、趋势、薄弱科目,自动统计指标 | 老师、管理员 |
举个实际场景:老师在系统中把小明的数学成绩从 78 分改为 85 分,系统会实时推送通知到小明的浏览器和家长的手机 APP,同时数据看板自动更新 "数学成绩 80-90 分段人数",老师不用手动统计就能看到变化 ------ 这就是从 "被动查询" 到 "主动服务" 的升级。
二、技术选型:贴合 Flask 生态,降低学习成本
为了保持技术栈的连贯性,避免引入过于复杂的框架,选择以下工具组合,确保和现有 Flask 系统无缝集成:
| 功能需求 | 技术选择 | 选择理由 |
|---|---|---|
| 实时通知 | Flask-SocketIO | 与 Flask 原生兼容,支持 WebSocket 协议,开发简单 |
| 数据可视化看板 | ECharts(前端)+ Flask API(后端) | ECharts 开源免费、文档丰富;后端复用现有 API,无需新增复杂逻辑 |
| 前端界面 | Bootstrap + jQuery | 与现有系统前端风格一致,降低适配成本 |
核心原理说明:
- WebSocket:不同于 HTTP 的 "请求 - 响应" 模式,它是 "双向通信" 协议,服务器可主动向客户端推送消息(如成绩变动通知),实现实时性;
- ECharts:前端可视化库,通过 JavaScript 渲染图表,数据从后端 API 获取(复用前面开发的成绩统计 API),前后端分离,灵活易维护。
三、实战 1:实时通知系统(基于 Flask-SocketIO)
首先实现实时通知功能,核心是 "成绩变动时触发通知→服务器通过 WebSocket 推送→客户端接收并显示" 的全流程。
1. 环境准备:安装依赖
在服务器或本地虚拟环境中安装 Flask-SocketIO(支持 WebSocket):
bash
bash
# 激活虚拟环境(服务器或本地)
source ~/student_system/venv/bin/activate # 服务器
# 或本地(Windows):venv\Scripts\activate
# 安装Flask-SocketIO(指定版本,避免兼容性问题)
pip install flask-socketio==5.3.6 python-socketio==5.8.0
flask-socketio:Flask 的 SocketIO 扩展,处理服务器端 WebSocket 连接;python-socketio:SocketIO 的 Python 客户端库,确保服务器和客户端通信兼容。
2. 服务器端配置:集成 SocketIO 到现有系统
修改app.py,初始化 SocketIO,配置连接事件和通知触发逻辑,确保和现有用户认证、数据库操作衔接。
步骤 1:初始化 SocketIO(app.py新增)
python
python
# app.py(新增SocketIO配置,放在API配置后面)
from flask_socketio import SocketIO, emit, join_room, leave_room
from flask_login import current_user # 复用现有用户认证
# 初始化SocketIO(允许跨域,适配前端页面)
socketio = SocketIO(
app,
cors_allowed_origins="*", # 开发环境允许所有跨域,生产环境需指定域名
async_mode="eventlet" # 使用eventlet异步模式,支持高并发
)
# 全局存储用户与SocketID的映射(用于定向推送通知)
# 结构:{user_id: socket_id}
user_socket_map = {}
# ------------------------------
# SocketIO事件:连接与断开
# ------------------------------
@socketio.on('connect')
def handle_connect():
"""客户端连接时触发:绑定用户ID与SocketID"""
# 验证用户是否登录(通过Cookie中的Flask-Login会话)
if not current_user.is_authenticated:
emit('connect_failed', {'msg': '请先登录'})
return False # 拒绝未登录用户连接
# 存储用户ID与SocketID的映射(覆盖旧连接,确保同一用户只有一个有效连接)
user_id = current_user.id
user_socket_map[user_id] = request.sid
print(f"用户{current_user.username}(ID:{user_id})已连接,SocketID:{request.sid}")
# 发送连接成功通知
emit('connect_success', {
'msg': f'欢迎回来,{current_user.username}!实时通知已开启',
'username': current_user.username
})
@socketio.on('disconnect')
def handle_disconnect():
"""客户端断开连接时触发:删除用户与SocketID的映射"""
if current_user.is_authenticated:
user_id = current_user.id
# 从映射中删除
if user_id in user_socket_map:
del user_socket_map[user_id]
print(f"用户{current_user.username}(ID:{user_id})已断开连接")
# ------------------------------
# 自定义SocketIO事件:发送通知
# ------------------------------
def send_notification(user_id, title, content, type="info"):
"""
向指定用户发送实时通知
:param user_id: 接收通知的用户ID
:param title: 通知标题
:param content: 通知内容
:param type: 通知类型(info/success/warning/error)
"""
# 检查用户是否在线(是否有Socket连接)
if user_id not in user_socket_map:
print(f"用户ID:{user_id} 不在线,无法发送实时通知")
return False
# 获取用户的SocketID
socket_id = user_socket_map[user_id]
# 发送通知事件(事件名:'new_notification')
emit(
'new_notification',
{
'title': title,
'content': content,
'type': type,
'time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
},
to=socket_id # 定向发送给指定SocketID
)
print(f"向用户ID:{user_id} 发送通知:{title} - {content}")
return True
# ------------------------------
# 集成到现有业务逻辑:成绩修改时触发通知
# ------------------------------
# 修改之前的CourseDetail.put方法(编辑成绩时发送通知)
@course_ns.route('/<int:course_id>')
class CourseDetail(Resource):
@token_required
@api_admin_required
@course_ns.expect(course_model)
def put(self, course_id, **kwargs):
data = request.get_json()
score = data.get('score')
username = kwargs['current_username']
with app.app_context():
course = Course.query.get(course_id)
if not course:
return {'code': 404, 'msg': f'ID为{course_id}的成绩不存在'}, 404
if isinstance(score, int) and 0 <= score <= 100:
old_score = course.score
new_score = score
course.score = new_score
db.session.commit()
log_operation(username, f'API编辑成绩:ID{course_id}({course.name})- 从{old_score}分改为{new_score}分')
# 新增:发送实时通知给学生(假设学生用户名为学生姓名,需提前创建学生用户)
# 实际场景:学生用户与Student模型关联,可通过course.student.name获取用户名
student_user = User.query.filter_by(username=course.student.name).first()
if student_user:
# 调用send_notification发送通知
send_notification(
user_id=student_user.id,
title='成绩更新通知',
content=f'你的{course.name}成绩已更新:{old_score}分 → {new_score}分',
type='success'
)
# 同时发送邮件通知(复用之前的功能)
parent_email = "parent@example.com" # 实际从数据库获取
if parent_email:
send_score_notify(course.student.name, course.name, old_score, new_score, parent_email)
else:
return {'code': 400, 'msg': '成绩需为0-100的整数'}, 400
return {
'code': 200,
'msg': '编辑成绩成功',
'data': {
'id': course.id,
'student_name': course.student.name,
'course_name': course.name,
'score': course.score
}
}, 200
关键说明:
- 用户连接管理 :
user_socket_map存储用户 ID 与 SocketID 的映射,确保能定向给某个用户发通知; - 事件驱动 :
connect/disconnect是 SocketIO 内置事件,处理连接与断开;new_notification是自定义事件,用于推送通知; - 业务集成 :在编辑成绩的 API 中调用
send_notification,实现 "成绩修改→实时通知" 的自动化触发,无需手动操作。
3. 客户端实现:接收并显示实时通知
在现有前端页面(如学生列表、成绩查询页)中添加 SocketIO 客户端代码,实现 "接收通知→显示通知弹窗→更新通知列表" 的功能。
步骤 1:在基础模板中引入 SocketIO 客户端库
修改templates/base.html,在<head>标签中引入 SocketIO 和 jQuery(客户端需要 jQuery 支持):
html
html
<!-- templates/base.html(新增) -->
<head>
<!-- 原有代码:Bootstrap CSS等 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- 引入SocketIO客户端库(与服务器版本兼容) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.min.js"></script>
<!-- 引入jQuery(SocketIO客户端依赖) -->
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
</head>
<body>
<!-- 原有代码:导航栏、内容区域等 -->
<!-- 新增:实时通知弹窗(默认隐藏) -->
<div id="notificationToast" class="toast position-fixed top-20 end-0 m-3" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong id="toastTitle" class="me-auto">通知标题</strong>
<small id="toastTime">刚刚</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div id="toastContent" class="toast-body">
通知内容
</div>
</div>
<!-- 原有代码:Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- 新增:SocketIO客户端逻辑 -->
<script>
// 初始化SocketIO连接(连接当前域名的WebSocket服务)
const socket = io();
// 初始化通知弹窗
const toast = new bootstrap.Toast(document.getElementById('notificationToast'));
// 1. 处理连接成功事件
socket.on('connect_success', function(data) {
console.log(data.msg);
// 可在这里更新页面上的连接状态(如显示"实时通知已开启")
});
// 2. 处理连接失败事件(未登录)
socket.on('connect_failed', function(data) {
console.error(data.msg);
// 跳转到登录页
// window.location.href = '/login';
});
// 3. 处理新通知事件(核心:接收并显示通知)
socket.on('new_notification', function(notification) {
// 更新弹窗内容
document.getElementById('toastTitle').textContent = notification.title;
document.getElementById('toastContent').textContent = notification.content;
document.getElementById('toastTime').textContent = notification.time;
// 根据通知类型设置弹窗颜色(success/warning/error/info)
const toastHeader = document.querySelector('#notificationToast .toast-header');
toastHeader.className = 'toast-header'; // 重置类名
if (notification.type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (notification.type === 'warning') {
toastHeader.classList.add('bg-warning', 'text-white');
} else if (notification.type === 'error') {
toastHeader.classList.add('bg-danger', 'text-white');
} else {
toastHeader.classList.add('bg-info', 'text-white');
}
// 显示弹窗
toast.show();
// (可选)更新页面上的通知列表(如导航栏通知数)
// 这里假设导航栏有一个#notificationCount元素显示未读通知数
const countElem = document.getElementById('notificationCount');
if (countElem) {
let currentCount = parseInt(countElem.textContent) || 0;
countElem.textContent = currentCount + 1;
countElem.classList.remove('d-none'); // 显示通知数
}
});
// 4. 处理连接断开事件
socket.on('disconnect', function() {
console.log('实时通知连接已断开,正在尝试重连...');
// SocketIO会自动重连,无需手动处理
});
</script>
</body>
步骤 2:在导航栏添加通知数显示
修改templates/base.html的导航栏,添加一个显示未读通知数的徽章:
html
html
<!-- templates/base.html(导航栏部分新增) -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<!-- 原有代码:品牌名称、导航链接等 -->
<a class="navbar-brand" href="/">学生成绩系统</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<!-- 原有导航链接 -->
</ul>
<!-- 新增:用户信息与通知 -->
<ul class="navbar-nav">
{% if current_user.is_authenticated %}
<!-- 通知图标与未读计数 -->
<li class="nav-item me-3">
<a class="nav-link position-relative" href="/notifications">
<i class="bi bi-bell"></i> <!-- 引入Bootstrap图标,需添加图标CSS -->
<span id="notificationCount" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger d-none">0</span>
</a>
</li>
<!-- 原有代码:用户名、登出按钮 -->
<li class="nav-item me-3">
<span class="nav-link text-white">欢迎,{{ current_user.username }}</span>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout">登出</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="/login">登录</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- 引入Bootstrap图标CSS(在base.html的<head>中) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
效果验证:
- 启动服务器(确保 Gunicorn 和 Nginx 正常运行);
- 学生用户登录系统(如用户名 "小明");
- 管理员登录另一浏览器窗口,编辑 "小明" 的数学成绩;
- 学生窗口会实时弹出 "成绩更新通知" 弹窗,导航栏通知数从 0 变为 1,无需刷新页面。
四、实战 2:数据可视化看板(基于 ECharts)
数据看板是老师的 "教学辅助工具",需要展示成绩分布 (如分数段人数)、各科对比 (平均分、优秀率)、趋势变化(月考 / 期中 / 期末成绩趋势)等核心指标。我们用 ECharts 实现前端可视化,后端通过 API 提供看板所需数据。
1. 技术原理:前后端协作流程
- 后端:新增看板专用 API,从数据库查询统计数据(如各科平均分、成绩分布),返回 JSON 格式;
- 前端:开发看板页面,引入 ECharts 库,通过 AJAX 调用后端 API 获取数据,渲染折线图、柱状图、饼图等;
- 集成:将看板页面添加到现有系统导航栏,仅管理员和老师可访问(通过权限控制)。
2. 后端 API 开发:提供看板数据
在app.py中新增看板专用 API,复用之前的数据库模型和查询逻辑,返回结构化的统计数据。
步骤 1:新增看板 API 命名空间(app.py)
python
python
# app.py(新增数据看板API命名空间)
# 1. 定义看板命名空间(路径前缀:/api/dashboard)
dashboard_ns = api.namespace('dashboard', description='数据看板相关API', path='/api/dashboard')
# 2. 看板数据API视图类
@dashboard_ns.route('/score-distribution')
class ScoreDistribution(Resource):
"""获取成绩分布数据(分数段人数,用于饼图/柱状图)"""
@token_required
@dashboard_ns.doc(responses={
200: '获取成绩分布成功',
401: '未登录',
403: '无权限访问'
})
def get(self, **kwargs):
"""
获取所有成绩的分数段分布
分数段:0-59(不及格)、60-79(及格)、80-89(良好)、90-100(优秀)
"""
with app.app_context():
# 查询所有成绩
courses = Course.query.all()
scores = [course.score for course in courses]
# 统计各分数段人数
distribution = {
'不及格(0-59)': sum(1 for s in scores if 0 <= s < 60),
'及格(60-79)': sum(1 for s in scores if 60 <= s < 80),
'良好(80-89)': sum(1 for s in scores if 80 <= s < 90),
'优秀(90-100)': sum(1 for s in scores if 90 <= s <= 100)
}
# 计算总计和各段占比
total = sum(distribution.values())
percentage = {
segment: round((count / total * 100), 1) if total > 0 else 0
for segment, count in distribution.items()
}
return {
'code': 200,
'msg': '获取成绩分布成功',
'data': {
'segments': list(distribution.keys()), # 分数段名称
'counts': list(distribution.values()), # 各段人数
'percentages': list(percentage.values()), # 各段占比(%)
'total': total # 总成绩数
}
}, 200
@dashboard_ns.route('/course-average')
class CourseAverage(Resource):
"""获取各科平均分数据(用于柱状图/折线图)"""
@token_required
def get(self, **kwargs):
"""获取所有课程的平均分、最高分、最低分"""
with app.app_context():
# 按课程名分组统计
course_stats = db.session.query(
Course.name,
db.func.avg(Course.score).label('avg'),
db.func.max(Course.score).label('max'),
db.func.min(Course.score).label('min'),
db.func.count(Course.id).label('count')
).group_by(Course.name).all()
# 整理数据
courses = []
averages = []
max_scores = []
min_scores = []
counts = []
for stat in course_stats:
courses.append(stat.name)
averages.append(round(stat.avg, 1))
max_scores.append(stat.max)
min_scores.append(stat.min)
counts.append(stat.count)
return {
'code': 200,
'msg': '获取各科平均分成功',
'data': {
'courses': courses, # 课程名称
'averages': averages, # 平均分
'max_scores': max_scores, # 最高分
'min_scores': min_scores, # 最低分
'counts': counts # 选课人数
}
}, 200
@dashboard_ns.route('/grade-rate')
class GradeRate(Resource):
"""获取优秀率、及格率数据(用于仪表盘)"""
@token_required
def get(self, **kwargs):
"""计算整体优秀率(90+)、及格率(60+)"""
with app.app_context():
courses = Course.query.all()
total = len(courses)
if total == 0:
return {
'code': 200,
'msg': '暂无成绩数据',
'data': {'excellent_rate': 0, 'pass_rate': 0}
}, 200
# 统计优秀和及格人数
excellent_count = sum(1 for c in courses if c.score >= 90)
pass_count = sum(1 for c in courses if c.score >= 60)
# 计算比率
excellent_rate = round((excellent_count / total * 100), 1)
pass_rate = round((pass_count / total * 100), 1)
return {
'code': 200,
'msg': '获取优秀率及格率成功',
'data': {
'excellent_rate': excellent_rate, # 优秀率(%)
'pass_rate': pass_rate, # 及格率(%)
'total': total # 总成绩数
}
}, 200
3. 前端看板页面开发:集成 ECharts
创建templates/dashboard.html,开发数据看板页面,包含三个核心图表:成绩分布饼图、各科平均分柱状图、优秀率及格率仪表盘,通过 AJAX 调用后端 API 获取数据。
步骤 1:创建看板模板(templates/dashboard.html)
html
html
{% extends "base.html" %}
{% block title %}数据看板 - 学生成绩管理系统{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>学生成绩数据看板</h2>
<p class="text-muted mb-4">实时展示班级成绩分布、各科对比及统计指标(数据每5分钟更新一次)</p>
<!-- 第一行:统计指标卡片 -->
<div class="row g-4 mb-4">
<!-- 总学生数 -->
<div class="col-md-3">
<div class="card bg-primary text-white h-100">
<div class="card-body">
<h5 class="card-title">总学生数</h5>
<h2 id="totalStudents" class="display-4">0</h2>
</div>
<div class="card-footer d-flex align-items-center justify-content-between">
<a class="small text-white stretched-link" href="/student/list">查看详情</a>
<i class="bi bi-people"></i>
</div>
</div>
</div>
<!-- 总课程数 -->
<div class="col-md-3">
<div class="card bg-success text-white h-100">
<div class="card-body">
<h5 class="card-title">总课程数</h5>
<h2 id="totalCourses" class="display-4">0</h2>
</div>
<div class="card-footer d-flex align-items-center justify-content-between">
<a class="small text-white stretched-link" href="/student/list">查看详情</a>
<i class="bi bi-book"></i>
</div>
</div>
</div>
<!-- 优秀率 -->
<div class="col-md-3">
<div class="card bg-warning text-white h-100">
<div class="card-body">
<h5 class="card-title">优秀率(90+)</h5>
<h2 id="excellentRate" class="display-4">0.0%</h2>
</div>
<div class="card-footer d-flex align-items-center justify-content-between">
<a class="small text-white stretched-link" href="/report">查看报告</a>
<i class="bi bi-trophy"></i>
</div>
</div>
</div>
<!-- 及格率 -->
<div class="col-md-3">
<div class="card bg-danger text-white h-100">
<div class="card-body">
<h5 class="card-title">及格率(60+)</h5>
<h2 id="passRate" class="display-4">0.0%</h2>
</div>
<div class="card-footer d-flex align-items-center justify-content-between">
<a class="small text-white stretched-link" href="/report">查看报告</a>
<i class="bi bi-check-circle"></i>
</div>
</div>
</div>
</div>
<!-- 第二行:图表区域 -->
<div class="row g-4">
<!-- 成绩分布饼图 -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">成绩分布(分数段人数)</h5>
</div>
<div class="card-body">
<div id="scoreDistributionChart" style="width:100%; height:400px;"></div>
</div>
</div>
</div>
<!-- 各科平均分柱状图 -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">各科平均分对比(含最高分/最低分)</h5>
</div>
<div class="card-body">
<div id="courseAverageChart" style="width:100%; height:400px;"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 引入ECharts库 -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script>
// 页面加载完成后初始化图表
$(document).ready(function() {
// 1. 初始化成绩分布饼图
const scoreChart = echarts.init(document.getElementById('scoreDistributionChart'));
// 2. 初始化各科平均分柱状图
const courseChart = echarts.init(document.getElementById('courseAverageChart'));
// ------------------------------
// 加载看板数据的函数
// ------------------------------
function loadDashboardData() {
// 1. 获取成绩分布数据
$.ajax({
url: '/api/dashboard/score-distribution',
type: 'GET',
headers: {
// 从Cookie中获取CSRF令牌(Flask-WTF需要)
'X-CSRFToken': getCookie('csrftoken')
},
success: function(result) {
if (result.code === 200) {
const data = result.data;
// 更新饼图配置
scoreChart.setOption({
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 10,
top: 20
},
series: [
{
name: '人数',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: data.counts[0], name: data.segments[0] },
{ value: data.counts[1], name: data.segments[1] },
{ value: data.counts[2], name: data.segments[2] },
{ value: data.counts[3], name: data.segments[3] }
],
color: ['#dc3545', '#ffc107', '#28a745', '#007bff'] // 红、黄、绿、蓝
}
]
});
}
},
error: function(xhr) {
console.error('获取成绩分布失败:', xhr.responseText);
}
});
// 2. 获取各科平均分数据
$.ajax({
url: '/api/dashboard/course-average',
type: 'GET',
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
success: function(result) {
if (result.code === 200) {
const data = result.data;
// 更新柱状图配置
courseChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['平均分', '最高分', '最低分'],
top: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.courses,
axisLabel: {
rotate: 30 // 课程名太长,旋转30度
}
},
yAxis: {
type: 'value',
min: 0,
max: 100,
name: '分数'
},
series: [
{
name: '平均分',
type: 'bar',
data: data.averages,
itemStyle: {
color: '#007bff'
}
},
{
name: '最高分',
type: 'line',
data: data.max_scores,
itemStyle: {
color: '#28a745'
},
marker: {
size: 6
}
},
{
name: '最低分',
type: 'line',
data: data.min_scores,
itemStyle: {
color: '#dc3545'
},
marker: {
size: 6
}
}
]
});
}
},
error: function(xhr) {
console.error('获取各科平均分失败:', xhr.responseText);
}
});
// 3. 获取优秀率、及格率及总人数
$.ajax({
url: '/api/dashboard/grade-rate',
type: 'GET',
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
success: function(result) {
if (result.code === 200) {
const rateData = result.data;
// 更新优秀率和及格率
$('#excellentRate').text(rateData.excellent_rate + '%');
$('#passRate').text(rateData.pass_rate + '%');
// 获取总学生数和总课程数(复用之前的API)
$.ajax({
url: '/api/students',
type: 'GET',
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
success: function(studentResult) {
if (studentResult.code === 200) {
// 去重获取总学生数(因为学生列表可能有重复)
const studentNames = new Set(studentResult.data.map(s => s.name));
$('#totalStudents').text(studentNames.size);
}
}
});
$.ajax({
url: '/api/courses',
type: 'GET',
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
success: function(courseResult) {
if (courseResult.code === 200) {
$('#totalCourses').text(courseResult.data.length);
}
}
});
}
},
error: function(xhr) {
console.error('获取优秀率及格率失败:', xhr.responseText);
}
});
}
// ------------------------------
// 辅助函数:获取Cookie(用于CSRF令牌)
// ------------------------------
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// ------------------------------
// 初始化与定时更新
// ------------------------------
// 首次加载数据
loadDashboardData();
// 定时更新(每5分钟更新一次)
setInterval(loadDashboardData, 5 * 60 * 1000);
// 窗口大小变化时,重绘图表
window.addEventListener('resize', function() {
scoreChart.resize();
courseChart.resize();
});
});
</script>
{% endblock %}
步骤 2:添加看板导航链接
修改templates/base.html的导航栏,添加 "数据看板" 链接,仅管理员和老师可见:
html
html
<!-- templates/base.html(导航栏部分新增) -->
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/">首页</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/student/list">学生列表</a>
</li>
<!-- 新增:数据看板链接 -->
{% if current_user.is_authenticated and (current_user.is_admin() or current_user.role == 'teacher') %}
<li class="nav-item">
<a class="nav-link" href="/dashboard">数据看板</a>
</li>
{% endif %}
<!-- 原有其他链接 -->
</ul>
步骤 3:添加看板路由(app.py)
python
python
# app.py(新增看板页面路由)
@app.route('/dashboard')
@login_required
def dashboard():
"""数据看板页面(仅管理员和老师可访问)"""
# 权限控制:仅管理员和老师可访问
if not (current_user.is_admin() or current_user.role == 'teacher'):
flash('无权限访问数据看板', 'danger')
return redirect(url_for('index'))
return render_template('dashboard.html')
效果验证:
- 管理员或老师登录系统,点击导航栏 "数据看板";
- 页面会加载三个统计卡片(总学生数、总课程数、优秀率、及格率)和两个图表;
- 当有新成绩添加或修改时,5 分钟后页面会自动更新数据(或手动刷新页面),图表实时变化。
五、高级功能优化与最佳实践
新增功能后,需要进行优化,确保系统稳定、高效运行。
1. 实时通知优化:减少不必要的推送
- 用户权限过滤:仅向相关学生推送成绩通知,避免向所有用户广播;
- 消息持久化 :用户不在线时,将通知存储到数据库(新增
Notification模型),用户下次登录时加载未读通知; - 限流控制:同一用户 1 分钟内最多接收 5 条通知,避免恶意操作导致通知轰炸。
2. 数据看板优化:提升加载速度
- 数据缓存 :用
Flask-Caching缓存看板 API 结果(如 5 分钟缓存),减少数据库查询; - 懒加载:图表按优先级加载(先加载统计卡片,再加载图表),提升页面首屏加载速度;
- 数据抽样:当数据量过大(如 1000 + 成绩),采用抽样统计,平衡精度和速度。
3. 安全加固:防止 WebSocket 滥用
- 连接认证 :除了
current_user,还可验证 JWT 令牌(在 Socket 连接时传递令牌,服务器验证); - 消息校验:服务器接收客户端消息时,校验消息格式和内容,避免恶意数据;
- 跨域限制 :生产环境中,
cors_allowed_origins指定具体域名(如["https://your-domain.com"]),禁止所有跨域。
六、小结与后续扩展方向
本篇核心收获
- 实时通知系统:基于 Flask-SocketIO 实现 WebSocket 通信,在成绩变动时定向推送通知,提升用户体验;
- 数据可视化看板:用 ECharts 开发成绩分布、各科对比等图表,结合后端 API 提供数据,辅助教学决策;
- 功能集成:无缝衔接之前的用户认证、数据库和 API 逻辑,确保系统一致性;
- 优化实践:通过缓存、权限控制、限流等手段,确保高级功能稳定高效。
后续进阶方向
- 多维度看板:新增 "学生进步趋势""薄弱知识点分析"(需新增试题与知识点关联模型);
- 实时协作:支持多位老师同时编辑成绩,通过 WebSocket 同步编辑状态,避免冲突;
- 移动端适配:优化看板和通知界面,适配手机屏幕,支持触屏操作;
- 智能预警:基于成绩趋势,自动预警 "成绩下滑严重的学生""平均分低于年级水平的科目",主动推送建议。
通过本篇的高级功能实战,学生成绩系统从 "功能完整" 升级为 "体验优秀、智能辅助",真正满足教学场景的核心需求。如果你跟着完成了所有步骤,不仅掌握了 WebSocket 和 ECharts 的实战用法,还理解了 "用户体验优化" 和 "数据驱动决策" 的核心思想,为后续开发更复杂的 Web 系统打下坚实基础~