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

1295 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 用户管理与班级管理实现计划
> **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` 字段:
```python
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 字段**
```python
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` 类之前添加:
```python
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` 字段后添加:
```python
class_id = db.Column(db.Integer, db.ForeignKey("classes.id")) # 班级
```
- [ ] **Step 5: 更新 Student.to_dict() 添加班级信息**
```python
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 常量**
在文件末尾添加:
```python
ROLE_OPTIONS = ["admin", "user"]
```
---
## Task 2: 权限装饰器
**Files:**
- Modify: `app/routes/auth.py:94-105`
- [ ] **Step 1: 修改登录状态检查,返回 role 信息**
修改 `check_login` 函数:
```python
@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 装饰器**
在文件末尾添加:
```python
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: 添加获取当前用户角色的函数**
在文件末尾添加:
```python
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`
```python
# 用户管理路由
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 蓝图已注册:
```python
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`
```python
# 班级管理路由
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
```python
__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 添加装饰器:
```python
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 添加登录验证**
```python
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 添加登录验证**
```python
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 添加管理员权限**
```python
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`
```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`
```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">` 部分,修改为:
```html
<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>` 后添加:
```html
<!-- 修改密码弹窗 -->
<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>` 部分:
```javascript
// 检查登录状态并初始化
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>` 中添加:
```javascript
// 修改密码
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>` 中添加:
```html
<th>班级</th>
```
`<tbody>` 渲染时添加:
```javascript
// 在 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. 普通用户修改自己密码