1295 lines
44 KiB
Markdown
1295 lines
44 KiB
Markdown
# 用户管理与班级管理实现计划
|
||
|
||
> **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. 普通用户修改自己密码 |