Files
piano-plan/docs/superpowers/plans/2026-04-18-rbac-class-mgmt.md
T

44 KiB
Raw Blame History

用户管理与班级管理实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 扩展钢琴练习方案系统,增加用户管理、角色权限管理、班级管理功能

Architecture: 在现有 User 模型上增加 role 字段,新建 Class 表,Student 表增加 class_id 外键。前端动态渲染导航栏,后端通过装饰器实现权限控制。

Tech Stack: Flask + SQLAlchemy + SQLite + Bootstrap5


文件修改概览

操作 文件 说明
修改 app/models.py User 增加 role 字段,新建 Class 模型,Student 增加 class_id
修改 app/routes/auth.py 增加权限装饰器,修改密码API
新建 app/routes/classes.py 班级管理 API
新建 app/routes/users.py 用户管理 API
修改 app/templates/index.html 导航栏动态渲染,学员列表增加班级列
新建 app/templates/users.html 用户管理页面
新建 app/templates/classes.html 班级管理页面
修改 app/templates/login.html 登录后返回 role 信息

Task 1: 数据库模型扩展

Files:

  • Modify: app/models.py:1-180

  • Step 1: 修改 User 模型,添加 role 字段

app/models.py 中找到 class User 定义,在 created_at 字段后添加 role 字段:

class User(db.Model):
    """管理员用户表"""

    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(50), unique=True, nullable=False)
    password_hash = db.Column(db.String(200), nullable=False)
    role = db.Column(db.String(20), default="user")  # admin / user
    created_at = db.Column(db.DateTime, default=datetime.now)
  • Step 2: 在 User.to_dict() 中添加 role 字段
def to_dict(self):
    return {
        "id": self.id,
        "username": self.username,
        "role": self.role,
        "created_at": self.created_at.strftime("%Y-%m-%d %H:%M")
        if self.created_at
        else None,
    }
  • Step 3: 新建 Class 模型

User 类之后、Student 类之前添加:

class Class(db.Model):
    """班级表"""

    __tablename__ = "classes"

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    description = db.Column(db.Text)
    created_at = db.Column(db.DateTime, default=datetime.now)

    def to_dict(self):
        return {
            "id": self.id,
            "name": self.name,
            "description": self.description,
            "student_count": self.students.count(),
            "created_at": self.created_at.strftime("%Y-%m-%d %H:%M")
            if self.created_at
            else None,
        }
  • Step 4: 修改 Student 模型,添加 class_id 字段

Student 类中找到 notes 字段后添加:

class_id = db.Column(db.Integer, db.ForeignKey("classes.id"))  # 班级
  • Step 5: 更新 Student.to_dict() 添加班级信息
def to_dict(self):
    return {
        "id": self.id,
        "name": self.name,
        "phone": self.phone,
        "wechat_nickname": self.wechat_nickname,
        "practice_time": self.practice_time,
        "notes": self.notes,
        "class_id": self.class_id,
        "class_name": self.class_obj.name if self.class_obj else None,
        "created_at": self.created_at.strftime("%Y-%m-%d %H:%M")
        if self.created_at
        else None,
        "problem_count": self.problems.count(),
        "plan_count": self.plans.count(),
    }
  • Step 6: 添加 ROLE_OPTIONS 常量

在文件末尾添加:

ROLE_OPTIONS = ["admin", "user"]

Task 2: 权限装饰器

Files:

  • Modify: app/routes/auth.py:94-105

  • Step 1: 修改登录状态检查,返回 role 信息

修改 check_login 函数:

@main_bp.route("/api/check-login", methods=["GET"])
def check_login():
    """检查登录状态"""
    if session.get("user_id"):
        user = User.query.get(session.get("user_id"))
        return jsonify({
            "logged_in": True, 
            "username": session.get("username"),
            "role": user.role if user else "user"
        })
    return jsonify({"logged_in": False})
  • Step 2: 添加 admin_required 装饰器

在文件末尾添加:

def admin_required(f):
    """管理员权限装饰器"""
    from functools import wraps

    @wraps(f)
    def decorated(*args, **kwargs):
        if not session.get("user_id"):
            return jsonify({"error": "请先登录"}), 401
        user = User.query.get(session.get("user_id"))
        if not user or user.role != "admin":
            return jsonify({"error": "权限不足"}), 403
        return f(*args, **kwargs)

    return decorated


def login_required_json(f):
    """登录验证装饰器(返回JSON"""
    from functools import wraps

    @wraps(f)
    def decorated(*args, **kwargs):
        if not session.get("user_id"):
            return jsonify({"error": "请先登录"}), 401
        return f(*args, **kwargs)

    return decorated
  • Step 3: 添加获取当前用户角色的函数

在文件末尾添加:

def get_current_user():
    """获取当前登录用户"""
    user_id = session.get("user_id")
    if user_id:
        return User.query.get(user_id)
    return None


def is_admin():
    """判断当前用户是否是管理员"""
    user = get_current_user()
    return user and user.role == "admin"

Task 3: 用户管理 API

Files:

  • Create: app/routes/users.py

  • Modify: app/routes/__init__.py

  • Step 1: 创建 users.py

新建文件 app/routes/users.py

# 用户管理路由

from flask import request, jsonify, render_template, session
from app.routes import main_bp
from app.models import db, User, login_required_json, admin_required


@main_bp.route("/users")
@admin_required
def users_page():
    """用户管理页面"""
    return render_template("users.html")


@main_bp.route("/api/users", methods=["GET"])
@admin_required
def api_users_list():
    """用户列表"""
    users = User.query.order_by(User.created_at.desc()).all()
    return jsonify([u.to_dict() for u in users])


@main_bp.route("/api/users", methods=["POST"])
@admin_required
def api_users_create():
    """新增用户"""
    data = request.get_json()
    username = data.get("username", "").strip()
    password = data.get("password", "")
    role = data.get("role", "user")

    if not username or not password:
        return jsonify({"error": "请输入用户名和密码"}), 400

    if User.query.filter_by(username=username).first():
        return jsonify({"error": "用户名已存在"}), 400

    if role not in ["admin", "user"]:
        return jsonify({"error": "无效的角色"}), 400

    try:
        user = User(username=username, role=role)
        user.set_password(password)
        db.session.add(user)
        db.session.commit()
        return jsonify(user.to_dict())
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "创建失败: " + str(e)}), 500


@main_bp.route("/api/users/<int:user_id>", methods=["PUT"])
@admin_required
def api_users_update(user_id):
    """编辑用户(仅管理员可改角色)"""
    user = User.query.get_or_404(user_id)
    data = request.get_json()

    if "role" in data:
        if data["role"] in ["admin", "user"]:
            user.role = data["role"]

    try:
        db.session.commit()
        return jsonify(user.to_dict())
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "更新失败: " + str(e)}), 500


@main_bp.route("/api/users/<int:user_id>", methods=["DELETE"])
@admin_required
def api_users_delete(user_id):
    """删除用户"""
    if user_id == session.get("user_id"):
        return jsonify({"error": "不能删除自己"}), 400

    user = User.query.get_or_404(user_id)
    try:
        db.session.delete(user)
        db.session.commit()
        return jsonify({"message": "删除成功"})
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "删除失败: " + str(e)}), 500


@main_bp.route("/api/users/<int:user_id>/reset-password", methods=["POST"])
@admin_required
def api_users_reset_password(user_id):
    """重置用户密码(管理员无需知道原密码)"""
    user = User.query.get_or_404(user_id)
    data = request.get_json()
    new_password = data.get("new_password", "")

    if not new_password:
        return jsonify({"error": "请输入新密码"}), 400

    try:
        user.set_password(new_password)
        db.session.commit()
        return jsonify({"message": "密码重置成功"})
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "重置失败: " + str(e)}), 500


@main_bp.route("/api/users/change-password", methods=["POST"])
@login_required_json
def api_users_change_password():
    """修改当前用户密码"""
    user = User.query.get(session.get("user_id"))
    data = request.get_json()
    old_password = data.get("old_password", "")
    new_password = data.get("new_password", "")
    confirm_password = data.get("confirm_password", "")

    if not old_password or not new_password:
        return jsonify({"error": "请填写完整"}), 400

    if not user.check_password(old_password):
        return jsonify({"error": "原密码错误"}), 400

    if new_password != confirm_password:
        return jsonify({"error": "两次密码输入不一致"}), 400

    try:
        user.set_password(new_password)
        db.session.commit()
        return jsonify({"message": "密码修改成功"})
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "修改失败: " + str(e)}), 500
  • Step 2: 注册蓝图

修改 app/routes/__init__.py,确保 users 蓝图已注册:

from flask import Blueprint

main_bp = Blueprint("main", __name__)

# 导入所有路由
from app.routes import students, problems, plans, settings, auth, classes, users

Task 4: 班级管理 API

Files:

  • Create: app/routes/classes.py

  • Step 1: 创建 classes.py

新建文件 app/routes/classes.py

# 班级管理路由

from flask import request, jsonify, render_template, session
from app.routes import main_bp
from app.models import db, Class, Student, User, login_required_json, admin_required


@main_bp.route("/classes")
@login_required_json
def classes_page():
    """班级管理页面"""
    return render_template("classes.html")


@main_bp.route("/api/classes", methods=["GET"])
@login_required_json
def api_classes_list():
    """班级列表"""
    classes = Class.query.order_by(Class.created_at.desc()).all()
    return jsonify([c.to_dict() for c in classes])


@main_bp.route("/api/classes", methods=["POST"])
@admin_required
def api_classes_create():
    """新增班级"""
    data = request.get_json()
    name = data.get("name", "").strip()
    description = data.get("description", "")

    if not name:
        return jsonify({"error": "请输入班级名称"}), 400

    if Class.query.filter_by(name=name).first():
        return jsonify({"error": "班级名称已存在"}), 400

    try:
        cls = Class(name=name, description=description)
        db.session.add(cls)
        db.session.commit()
        return jsonify(cls.to_dict())
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "创建失败: " + str(e)}), 500


@main_bp.route("/api/classes/<int:class_id>", methods=["PUT"])
@admin_required
def api_classes_update(class_id):
    """编辑班级"""
    cls = Class.query.get_or_404(class_id)
    data = request.get_json()

    if "name" in data and data["name"]:
        # 检查名称是否与其他班级冲突
        existing = Class.query.filter_by(name=data["name"]).first()
        if existing and existing.id != class_id:
            return jsonify({"error": "班级名称已存在"}), 400
        cls.name = data["name"]

    if "description" in data:
        cls.description = data["description"]

    try:
        db.session.commit()
        return jsonify(cls.to_dict())
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "更新失败: " + str(e)}), 500


@main_bp.route("/api/classes/<int:class_id>", methods=["DELETE"])
@admin_required
def api_classes_delete(class_id):
    """删除班级(不删除学员,仅解除关联)"""
    cls = Class.query.get_or_404(class_id)
    try:
        # 解除学员与班级的关联
        Student.query.filter_by(class_id=class_id).update({"class_id": None})
        db.session.delete(cls)
        db.session.commit()
        return jsonify({"message": "删除成功"})
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "删除失败: " + str(e)}), 500


@main_bp.route("/api/classes/<int:class_id>/students", methods=["GET"])
@login_required_json
def api_classes_students(class_id):
    """班级学员列表"""
    cls = Class.query.get_or_404(class_id)
    students = cls.students.all()
    return jsonify([{
        "id": s.id,
        "name": s.name,
        "phone": s.phone,
        "practice_time": s.practice_time
    } for s in students])


@main_bp.route("/api/classes/<int:class_id>/assign", methods=["POST"])
@login_required_json
def api_classes_assign(class_id):
    """分配学员到班级"""
    cls = Class.query.get_or_404(class_id)
    data = request.get_json()
    student_ids = data.get("student_ids", [])

    try:
        # 先清除这些学员的班级关联
        Student.query.filter(Student.id.in_(student_ids)).update({"class_id": None})
        # 再设置新的班级关联
        Student.query.filter(Student.id.in_(student_ids)).update({"class_id": class_id})
        db.session.commit()
        return jsonify({"message": "分配成功"})
    except Exception as e:
        db.session.rollback()
        return jsonify({"error": "分配失败: " + str(e)}), 500
  • Step 2: 导出 Class 模型

app/models.py 文件末尾确保导出 Class

__all__ = ["db", "User", "Student", "StudentProblem", "PracticePlan", "Class"]

Task 5: 修改现有路由添加权限控制

Files:

  • Modify: app/routes/students.py

  • Modify: app/routes/problems.py

  • Modify: app/routes/plans.py

  • Modify: app/routes/settings.py

  • Step 1: 修改 students.py 添加登录验证

app/routes/students.py 顶部导入装饰器,并在每个 API 添加装饰器:

from app.models import db, Student, StudentProblem, login_required_json

为以下路由添加 @login_required_json

  • api_students_list

  • api_students_create

  • api_students_detail

  • api_students_update

  • api_students_delete

  • Step 2: 修改 problems.py 添加登录验证

from app.models import db, StudentProblem, login_required_json

为以下路由添加 @login_required_json

  • api_problems_list

  • api_problems_add

  • api_problems_delete

  • Step 3: 修改 plans.py 添加登录验证

from app.models import db, PracticePlan, login_required_json

为以下路由添加 @login_required_json

  • api_generate_plan

  • api_plans_detail

  • api_plans_delete

  • Step 4: 修改 settings.py 添加管理员权限

from app.models import db, admin_required

将以下路由的装饰器从 @login_required 改为 @admin_required

  • settings_page
  • api_problems_list(创建/编辑/删除需要管理员)
  • api_problems_create
  • api_problems_update
  • api_problems_delete
  • api_config_get
  • api_config_set
  • api_config_test

Task 6: 前端 - 用户管理页面

Files:

  • Create: app/templates/users.html

  • Step 1: 创建用户管理页面

新建 app/templates/users.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>用户管理 - 钢琴练习方案系统</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container-fluid">
            <a class="navbar-brand" href="/">钢琴练习方案系统</a>
            <div class="navbar-nav ms-auto">
                <span class="nav-item nav-link text-light" id="currentUser"></span>
                <a class="nav-link" href="#" id="logoutBtn">退出</a>
            </div>
        </div>
    </nav>

    <div class="container mt-4">
        <div class="d-flex justify-content-between align-items-center mb-4">
            <h2>用户管理</h2>
            <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#userModal">
                <i class="bi bi-plus-circle"></i> 新增用户
            </button>
        </div>

        <div class="card">
            <div class="card-body">
                <table class="table table-hover" id="usersTable">
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>用户名</th>
                            <th>角色</th>
                            <th>创建时间</th>
                            <th>操作</th>
                        </tr>
                    </thead>
                    <tbody></tbody>
                </table>
            </div>
        </div>
    </div>

    <!-- 新增/编辑用户弹窗 -->
    <div class="modal fade" id="userModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="userModalTitle">新增用户</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="userForm">
                        <input type="hidden" id="userId">
                        <div class="mb-3">
                            <label class="form-label">用户名</label>
                            <input type="text" class="form-control" id="username" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label" id="passwordLabel">初始密码</label>
                            <input type="password" class="form-control" id="password">
                            <div class="form-text">8位以上,包含大小写字母、数字和特殊字符</div>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">角色</label>
                            <select class="form-select" id="role">
                                <option value="user">普通用户</option>
                                <option value="admin">管理员</option>
                            </select>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-primary" id="saveUserBtn">保存</button>
                </div>
            </div>
        </div>
    </div>

    <!-- 重置密码弹窗 -->
    <div class="modal fade" id="resetPwdModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">重置密码</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="resetPwdForm">
                        <input type="hidden" id="resetUserId">
                        <div class="mb-3">
                            <label class="form-label">新密码</label>
                            <input type="password" class="form-control" id="newPassword" required>
                            <div class="form-text">8位以上,包含大小写字母、数字和特殊字符</div>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-warning" id="confirmResetBtn">重置密码</button>
                </div>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <script>
        const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
        
        // 检查登录状态
        fetch('/api/check-login').then(r => r.json()).then(data => {
            if (!data.logged_in || data.role !== 'admin') {
                window.location.href = '/login';
                return;
            }
            document.getElementById('currentUser').textContent = data.username + ' (' + ROLE_LABELS[data.role] + ')';
            loadUsers();
        });

        // 加载用户列表
        function loadUsers() {
            fetch('/api/users').then(r => r.json()).then(users => {
                const tbody = document.querySelector('#usersTable tbody');
                tbody.innerHTML = users.map(u => `
                    <tr>
                        <td>${u.id}</td>
                        <td>${u.username}</td>
                        <td><span class="badge ${u.role === 'admin' ? 'bg-danger' : 'bg-secondary'}">${ROLE_LABELS[u.role]}</span></td>
                        <td>${u.created_at}</td>
                        <td>
                            <button class="btn btn-sm btn-warning me-1" onclick="openResetPwd(${u.id})">重置密码</button>
                            <button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">删除</button>
                        </td>
                    </tr>
                `).join('');
            });
        }

        // 保存用户
        document.getElementById('saveUserBtn').onclick = () => {
            const id = document.getElementById('userId').value;
            const username = document.getElementById('username').value.trim();
            const password = document.getElementById('password').value;
            const role = document.getElementById('role').value;

            if (!username || !password) {
                alert('请填写完整');
                return;
            }

            const method = id ? 'PUT' : 'POST';
            fetch('/api/users' + (id ? '/' + id : ''), {
                method,
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ username, password, role })
            }).then(r => r.json()).then(data => {
                if (data.error) {
                    alert(data.error);
                } else {
                    bootstrap.Modal.getInstance(document.getElementById('userModal')).hide();
                    loadUsers();
                }
            });
        };

        // 重置密码
        function openResetPwd(id) {
            document.getElementById('resetUserId').value = id;
            document.getElementById('newPassword').value = '';
            new bootstrap.Modal(document.getElementById('resetPwdModal')).show();
        }

        document.getElementById('confirmResetBtn').onclick = () => {
            const id = document.getElementById('resetUserId').value;
            const newPassword = document.getElementById('newPassword').value;
            if (!newPassword) {
                alert('请输入新密码');
                return;
            }
            fetch('/api/users/' + id + '/reset-password', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ new_password: newPassword })
            }).then(r => r.json()).then(data => {
                if (data.error) {
                    alert(data.error);
                } else {
                    alert('密码重置成功');
                    bootstrap.Modal.getInstance(document.getElementById('resetPwdModal')).hide();
                }
            });
        };

        // 删除用户
        function deleteUser(id) {
            if (!confirm('确定要删除该用户吗?')) return;
            fetch('/api/users/' + id, { method: 'DELETE' }).then(r => r.json()).then(data => {
                if (data.error) {
                    alert(data.error);
                } else {
                    loadUsers();
                }
            });
        }

        // 退出登录
        document.getElementById('logoutBtn').onclick = () => {
            fetch('/api/logout', { method: 'POST' }).then(() => window.location.href = '/login');
        };
    </script>
</body>
</html>

Task 7: 前端 - 班级管理页面

Files:

  • Create: app/templates/classes.html

  • Step 1: 创建班级管理页面

新建 app/templates/classes.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>班级管理 - 钢琴练习方案系统</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container-fluid">
            <a class="navbar-brand" href="/">钢琴练习方案系统</a>
            <div class="navbar-nav ms-auto">
                <span class="nav-item nav-link text-light" id="currentUser"></span>
                <a class="nav-link" href="#" id="logoutBtn">退出</a>
            </div>
        </div>
    </nav>

    <div class="container mt-4">
        <div class="d-flex justify-content-between align-items-center mb-4">
            <h2>班级管理</h2>
            <button class="btn btn-primary" id="addClassBtn" style="display:none;">
                <i class="bi bi-plus-circle"></i> 新增班级
            </button>
        </div>

        <div class="card">
            <div class="card-body">
                <table class="table table-hover" id="classesTable">
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>班级名称</th>
                            <th>描述</th>
                            <th>学员数</th>
                            <th>创建时间</th>
                            <th>操作</th>
                        </tr>
                    </thead>
                    <tbody></tbody>
                </table>
            </div>
        </div>
    </div>

    <!-- 新增/编辑班级弹窗 -->
    <div class="modal fade" id="classModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="classModalTitle">新增班级</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="classForm">
                        <input type="hidden" id="classId">
                        <div class="mb-3">
                            <label class="form-label">班级名称</label>
                            <input type="text" class="form-control" id="className" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">描述</label>
                            <textarea class="form-control" id="classDesc" rows="2"></textarea>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-primary" id="saveClassBtn">保存</button>
                </div>
            </div>
        </div>
    </div>

    <!-- 班级学员弹窗 -->
    <div class="modal fade" id="classStudentsModal" tabindex="-1">
        <div class="modal-dialog modal-lg">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">班级学员</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-3">
                        <button class="btn btn-sm btn-success" id="assignStudentsBtn">分配学员</button>
                    </div>
                    <table class="table" id="classStudentsTable">
                        <thead>
                            <tr>
                                <th>姓名</th>
                                <th>手机</th>
                                <th>练习时间</th>
                            </tr>
                        </thead>
                        <tbody></tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>

    <!-- 分配学员弹窗 -->
    <div class="modal fade" id="assignModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">分配学员到班级</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <div id="studentCheckboxes"></div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-primary" id="confirmAssignBtn">确认分配</button>
                </div>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <script>
        let currentUserRole = 'user';
        let currentClassId = null;
        let allStudents = [];

        // 检查登录状态
        fetch('/api/check-login').then(r => r.json()).then(data => {
            if (!data.logged_in) {
                window.location.href = '/login';
                return;
            }
            currentUserRole = data.role;
            const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
            document.getElementById('currentUser').textContent = data.username + ' (' + ROLE_LABELS[data.role] + ')';
            if (data.role === 'admin') {
                document.getElementById('addClassBtn').style.display = 'inline-block';
            }
            loadClasses();
        });

        // 加载班级列表
        function loadClasses() {
            fetch('/api/classes').then(r => r.json()).then(classes => {
                const tbody = document.querySelector('#classesTable tbody');
                const isAdmin = currentUserRole === 'admin';
                tbody.innerHTML = classes.map(c => `
                    <tr>
                        <td>${c.id}</td>
                        <td>${c.name}</td>
                        <td>${c.description || '-'}</td>
                        <td><a href="#" onclick="viewClassStudents(${c.id})">${c.student_count}</a></td>
                        <td>${c.created_at}</td>
                        <td>
                            ${isAdmin ? `<button class="btn btn-sm btn-primary me-1" onclick="editClass(${c.id}, '${c.name}', '${c.description || ''}')">编辑</button>
                            <button class="btn btn-sm btn-danger" onclick="deleteClass(${c.id})">删除</button>` : ''}
                        </td>
                    </tr>
                `).join('');
            });
        }

        // 保存班级
        document.getElementById('saveClassBtn').onclick = () => {
            const id = document.getElementById('classId').value;
            const name = document.getElementById('className').value.trim();
            const description = document.getElementById('classDesc').value;

            if (!name) {
                alert('请输入班级名称');
                return;
            }

            const method = id ? 'PUT' : 'POST';
            fetch('/api/classes' + (id ? '/' + id : ''), {
                method,
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ name, description })
            }).then(r => r.json()).then(data => {
                if (data.error) {
                    alert(data.error);
                } else {
                    bootstrap.Modal.getInstance(document.getElementById('classModal')).hide();
                    loadClasses();
                }
            });
        };

        // 编辑班级
        function editClass(id, name, desc) {
            document.getElementById('classId').value = id;
            document.getElementById('className').value = name;
            document.getElementById('classDesc').value = desc;
            document.getElementById('classModalTitle').textContent = '编辑班级';
            new bootstrap.Modal(document.getElementById('classModal')).show();
        }

        // 删除班级
        function deleteClass(id) {
            if (!confirm('确定要删除该班级吗?学员不会被删除。')) return;
            fetch('/api/classes/' + id, { method: 'DELETE' }).then(r => r.json()).then(data => {
                if (data.error) {
                    alert(data.error);
                } else {
                    loadClasses();
                }
            });
        }

        // 查看班级学员
        function viewClassStudents(classId) {
            currentClassId = classId;
            fetch('/api/classes/' + classId + '/students').then(r => r.json()).then(students => {
                const tbody = document.querySelector('#classStudentsTable tbody');
                tbody.innerHTML = students.length ? students.map(s => `
                    <tr><td>${s.name}</td><td>${s.phone || '-'}</td><td>${s.practice_time}</td></tr>
                `).join('') : '<tr><td colspan="3" class="text-center">暂无学员</td></tr>';
                new bootstrap.Modal(document.getElementById('classStudentsModal')).show();
            });
        }

        // 分配学员
        document.getElementById('assignStudentsBtn').onclick = () => {
            fetch('/api/students').then(r => r.json()).then(students => {
                allStudents = students;
                const container = document.getElementById('studentCheckboxes');
                container.innerHTML = students.map(s => `
                    <div class="form-check">
                        <input class="form-check-input" type="checkbox" value="${s.id}" ${s.class_id == currentClassId ? 'checked' : ''}>
                        <label class="form-check-label">${s.name} (${s.practice_time})</label>
                    </div>
                `).join('');
                new bootstrap.Modal(document.getElementById('assignModal')).show();
            });
        };

        document.getElementById('confirmAssignBtn').onclick = () => {
            const checked = Array.from(document.querySelectorAll('#studentCheckboxes input:checked')).map(c => parseInt(c.value));
            fetch('/api/classes/' + currentClassId + '/assign', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ student_ids: checked })
            }).then(r => r.json()).then(data => {
                if (data.error) {
                    alert(data.error);
                } else {
                    bootstrap.Modal.getInstance(document.getElementById('assignModal')).hide();
                    loadClasses();
                    viewClassStudents(currentClassId);
                }
            });
        };

        // 新增班级按钮
        document.getElementById('addClassBtn').onclick = () => {
            document.getElementById('classId').value = '';
            document.getElementById('className').value = '';
            document.getElementById('classDesc').value = '';
            document.getElementById('classModalTitle').textContent = '新增班级';
            new bootstrap.Modal(document.getElementById('classModal')).show();
        };

        // 退出登录
        document.getElementById('logoutBtn').onclick = () => {
            fetch('/api/logout', { method: 'POST' }).then(() => window.location.href = '/login');
        };
    </script>
</body>
</html>

Task 8: 前端 - 导航栏动态渲染

Files:

  • Modify: app/templates/index.html

  • Step 1: 修改导航栏,动态显示菜单

找到 <nav class="navbar"> 部分,修改为:

<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">钢琴练习方案系统</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
            <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="/settings">问题配置</a>
                </li>
                <li class="nav-item" id="classesNav" style="display:none;">
                    <a class="nav-link" href="/classes">班级管理</a>
                </li>
                <li class="nav-item" id="usersNav" style="display:none;">
                    <a class="nav-link" href="/users">用户管理</a>
                </li>
            </ul>
            <ul class="navbar-nav">
                <li class="nav-item dropdown">
                    <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
                        <span id="currentUserName"></span>
                    </a>
                    <ul class="dropdown-menu dropdown-menu-end">
                        <li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#changePwdModal">修改密码</a></li>
                        <li><hr class="dropdown-divider"></li>
                        <li><a class="dropdown-item" href="#" id="logoutBtn">退出</a></li>
                    </ul>
                </li>
            </ul>
        </div>
    </div>
</nav>
  • Step 2: 添加修改密码弹窗

</nav> 后添加:

<!-- 修改密码弹窗 -->
<div class="modal fade" id="changePwdModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">修改密码</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body">
                <form id="changePwdForm">
                    <div class="mb-3">
                        <label class="form-label">原密码</label>
                        <input type="password" class="form-control" id="oldPassword" required>
                    </div>
                    <div class="mb-3">
                        <label class="form-label">新密码</label>
                        <input type="password" class="form-control" id="newPassword" required>
                        <div class="form-text">8位以上,包含大小写字母、数字和特殊字符</div>
                    </div>
                    <div class="mb-3">
                        <label class="form-label">确认新密码</label>
                        <input type="password" class="form-control" id="confirmPassword" required>
                    </div>
                </form>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                <button type="button" class="btn btn-primary" id="changePwdBtn">确认修改</button>
            </div>
        </div>
    </div>
</div>
  • Step 3: 修改 JavaScript,动态控制菜单显示

修改 <script> 部分:

// 检查登录状态并初始化
fetch('/api/check-login').then(r => r.json()).then(data => {
    if (!data.logged_in) {
        window.location.href = '/login';
        return;
    }
    
    const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
    document.getElementById('currentUserName').textContent = data.username + ' (' + ROLE_LABELS[data.role] + ')';
    
    // 根据角色显示菜单
    if (data.role === 'admin') {
        document.getElementById('usersNav').style.display = '';
    }
    document.getElementById('classesNav').style.display = '';
    
    // 加载数据
    loadStudents();
});
  • Step 4: 添加修改密码功能

<script> 中添加:

// 修改密码
document.getElementById('changePwdBtn').onclick = () => {
    const oldPassword = document.getElementById('oldPassword').value;
    const newPassword = document.getElementById('newPassword').value;
    const confirmPassword = document.getElementById('confirmPassword').value;

    if (!oldPassword || !newPassword || !confirmPassword) {
        alert('请填写完整');
        return;
    }

    fetch('/api/users/change-password', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ old_password: oldPassword, new_password: newPassword, confirm_password: confirmPassword })
    }).then(r => r.json()).then(data => {
        if (data.error) {
            alert(data.error);
        } else {
            alert('密码修改成功,请重新登录');
            fetch('/api/logout', { method: 'POST' }).then(() => window.location.href = '/login');
        }
    });
};

// 退出登录
document.getElementById('logoutBtn').onclick = (e) => {
    e.preventDefault();
    fetch('/api/logout', { method: 'POST' }).then(() => window.location.href = '/login');
};
  • Step 5: 学员列表增加班级列

在学员表格的 <thead> 中添加:

<th>班级</th>

<tbody> 渲染时添加:

// 在 renderStudents 函数中
`<td>${s.class_name || '-'}</td>`

Task 9: 测试与验证

Files:

  • Test: 启动应用测试所有功能

  • Step 1: 测试登录和初始设置

  1. 启动应用 python run.py
  2. 访问 /setup 创建管理员
  3. 登录验证
  • Step 2: 测试用户管理
  1. 访问 /users(仅管理员可见)
  2. 新增普通用户
  3. 重置用户密码
  4. 删除用户
  • Step 3: 测试班级管理
  1. 访问 /classes
  2. 管理员:新增/编辑/删除班级
  3. 分配学员到班级
  • Step 4: 测试权限控制
  1. 用普通用户登录
  2. 验证无法访问 /users
  3. 验证无法访问 /settings
  4. 验证可以访问 /classes 但无法增删
  • Step 5: 测试修改密码
  1. 管理员修改自己密码
  2. 普通用户修改自己密码