Python 从入门到实战(十八):学生成绩系统高级功能实战(实时通知与数据看板)

文章目录

    • 一、为什么需要这些高级功能?教学场景的痛点解决
      • [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新增))
        • 关键说明:
      • [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))
      • [3. 前端看板页面开发:集成 ECharts](#3. 前端看板页面开发:集成 ECharts)
        • [步骤 1:创建看板模板(`templates/dashboard.html`)](#步骤 1:创建看板模板(templates/dashboard.html))
        • [步骤 2:添加看板导航链接](#步骤 2:添加看板导航链接)
        • [步骤 3:添加看板路由(`app.py`)](#步骤 3:添加看板路由(app.py))
        • 效果验证:
    • 五、高级功能优化与最佳实践
      • [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">
效果验证:
  1. 启动服务器(确保 Gunicorn 和 Nginx 正常运行);
  2. 学生用户登录系统(如用户名 "小明");
  3. 管理员登录另一浏览器窗口,编辑 "小明" 的数学成绩;
  4. 学生窗口会实时弹出 "成绩更新通知" 弹窗,导航栏通知数从 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')
效果验证:
  1. 管理员或老师登录系统,点击导航栏 "数据看板";
  2. 页面会加载三个统计卡片(总学生数、总课程数、优秀率、及格率)和两个图表;
  3. 当有新成绩添加或修改时,5 分钟后页面会自动更新数据(或手动刷新页面),图表实时变化。

五、高级功能优化与最佳实践

新增功能后,需要进行优化,确保系统稳定、高效运行。

1. 实时通知优化:减少不必要的推送

  • 用户权限过滤:仅向相关学生推送成绩通知,避免向所有用户广播;
  • 消息持久化 :用户不在线时,将通知存储到数据库(新增Notification模型),用户下次登录时加载未读通知;
  • 限流控制:同一用户 1 分钟内最多接收 5 条通知,避免恶意操作导致通知轰炸。

2. 数据看板优化:提升加载速度

  • 数据缓存 :用Flask-Caching缓存看板 API 结果(如 5 分钟缓存),减少数据库查询;
  • 懒加载:图表按优先级加载(先加载统计卡片,再加载图表),提升页面首屏加载速度;
  • 数据抽样:当数据量过大(如 1000 + 成绩),采用抽样统计,平衡精度和速度。

3. 安全加固:防止 WebSocket 滥用

  • 连接认证 :除了current_user,还可验证 JWT 令牌(在 Socket 连接时传递令牌,服务器验证);
  • 消息校验:服务器接收客户端消息时,校验消息格式和内容,避免恶意数据;
  • 跨域限制 :生产环境中,cors_allowed_origins指定具体域名(如["https://your-domain.com"]),禁止所有跨域。

六、小结与后续扩展方向

本篇核心收获

  1. 实时通知系统:基于 Flask-SocketIO 实现 WebSocket 通信,在成绩变动时定向推送通知,提升用户体验;
  2. 数据可视化看板:用 ECharts 开发成绩分布、各科对比等图表,结合后端 API 提供数据,辅助教学决策;
  3. 功能集成:无缝衔接之前的用户认证、数据库和 API 逻辑,确保系统一致性;
  4. 优化实践:通过缓存、权限控制、限流等手段,确保高级功能稳定高效。

后续进阶方向

  1. 多维度看板:新增 "学生进步趋势""薄弱知识点分析"(需新增试题与知识点关联模型);
  2. 实时协作:支持多位老师同时编辑成绩,通过 WebSocket 同步编辑状态,避免冲突;
  3. 移动端适配:优化看板和通知界面,适配手机屏幕,支持触屏操作;
  4. 智能预警:基于成绩趋势,自动预警 "成绩下滑严重的学生""平均分低于年级水平的科目",主动推送建议。

通过本篇的高级功能实战,学生成绩系统从 "功能完整" 升级为 "体验优秀、智能辅助",真正满足教学场景的核心需求。如果你跟着完成了所有步骤,不仅掌握了 WebSocket 和 ECharts 的实战用法,还理解了 "用户体验优化" 和 "数据驱动决策" 的核心思想,为后续开发更复杂的 Web 系统打下坚实基础~

相关推荐
weixin_4624462314 小时前
Python 使用 pypdf 按指定页码范围批量拆分 PDF(分章节)
python·pdf·pdf分割
亮子AI14 小时前
【JavaScript】forEach 是按数组顺序执行吗?
开发语言·javascript·ecmascript
菩提祖师_14 小时前
基于Docker的微服务自动化部署系统
开发语言·javascript·flutter·docker
廋到被风吹走14 小时前
【Java】【JVM】内存模型
java·开发语言·jvm
拾贰_C14 小时前
【无标题】
运维·服务器·数据库·pytorch·python·考研·学习方法
leiming614 小时前
c++ for_each算法
开发语言·c++·算法
阿蔹14 小时前
UI测试自动化-Web-Python-Appium
前端·python·ui·appium·自动化
zh_xuan14 小时前
kotlin的when表达式、数组循环等
开发语言·kotlin