feat: 问题数据迁移到数据库;学员详情页URL导航改造;侧边栏统一

- 问题从文件系统迁移到数据库 problems 表
- 移除 PROBLEMS_DIR 配置和文件读取逻辑
- student.html 完整重写:编辑/添加/删除问题,生成方案进度显示
- 学员详情页支持独立URL访问 (/student/<id>)
- 统一侧边栏到 base.html
- 更新文档:DEPLOYMENT_SOP, MODELS, STRUCTURE, FRONTEND_ARCH
- 部署到生产环境 v1.2.0
This commit is contained in:
hmo
2026-04-23 06:35:32 +08:00
parent fd593bddf4
commit 18351212e8
18 changed files with 857 additions and 488 deletions
+1 -1
View File
@@ -161,4 +161,4 @@ piano-plan/
> **版本**v1.2.0
> **创建时间**2026-04-17
> **最后更新**2026-04-21
> **最后更新**2026-04-23
+17 -10
View File
@@ -25,14 +25,6 @@ def create_app():
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# 问题文件目录
is_docker = os.environ.get("FLASK_ENV") == "production"
if is_docker:
app.config["PROBLEMS_DIR"] = BASE_DIR / "个性化方案"
else:
# 本地开发:问题文件在父目录的 个性化方案/针对性练习(拆分为单独文件)
app.config["PROBLEMS_DIR"] = BASE_DIR.parent / "个性化方案" / "针对性练习(拆分为单独文件)"
app.config["PDF_OUTPUT_DIR"] = BASE_DIR / "output"
app.config["API_CONFIG_FILE"] = BASE_DIR / "config" / "api_config.json"
@@ -109,11 +101,26 @@ def create_app():
if "sort_order" not in template_columns:
db.session.execute(text("ALTER TABLE templates ADD COLUMN sort_order INTEGER DEFAULT 0"))
db.session.commit()
# 检查practice_plans表是否有template_id字段
result5 = db.session.execute(text("PRAGMA table_info(practice_plans)"))
plan_columns = [row[1] for row in result5]
if "template_id" not in plan_columns:
db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN template_id INTEGER REFERENCES templates(id)"))
db.session.commit()
# 检查practice_plans表是否有is_typical字段
result6 = db.session.execute(text("PRAGMA table_info(practice_plans)"))
plan_columns2 = [row[1] for row in result6]
if "is_typical" not in plan_columns2:
db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN is_typical INTEGER DEFAULT 0"))
db.session.commit()
except Exception as e:
print(f"数据库迁移: {e}")
# 初始化默认模板(必须在迁移之后)
from app.routes.templates import init_default_templates
init_default_templates()
# 已禁用:如果需要默认模板,请手动创建
# from app.routes.templates import init_default_templates
# init_default_templates()
return app
+69 -4
View File
@@ -114,6 +114,15 @@ class Student(db.Model):
class_obj = db.relationship("Class", backref="students")
def to_dict(self):
# 获取问题列表,按严重程度排序(严重 > 中等 > 轻微)
severity_order = {"严重": 0, "中等": 1, "轻微": 2}
problems_list = sorted(
self.problems.all(),
key=lambda p: (severity_order.get(p.severity, 1), p.created_at)
)
# 通过关联获取问题名称
problem_names = [p.problem.name if p.problem else p.problem_name for p in problems_list]
return {
"id": self.id,
"name": self.name,
@@ -127,10 +136,33 @@ class Student(db.Model):
if self.created_at
else None,
"problem_count": self.problems.count(),
"problem_names": problem_names, # 问题名称列表(按严重程度排序)
"plan_count": self.plans.count(),
}
class Problem(db.Model):
"""问题表"""
__tablename__ = "problems"
id = db.Column(db.Integer, primary_key=True)
no = db.Column(db.String(10), unique=True, nullable=False) # 编号:01, 02...
name = db.Column(db.String(100), nullable=False) # 问题名称
category = db.Column(db.String(50), default="技术类") # 分类
content = db.Column(db.Text) # 问题详细内容
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
def to_dict(self):
return {
"id": self.id,
"no": self.no,
"name": self.name,
"category": self.category,
}
class StudentProblem(db.Model):
"""学员问题记录表"""
@@ -138,17 +170,21 @@ class StudentProblem(db.Model):
id = db.Column(db.Integer, primary_key=True)
student_id = db.Column(db.Integer, db.ForeignKey("students.id"), nullable=False)
problem_id = db.Column(db.String(50), nullable=False) # 如 "01_手小"
problem_name = db.Column(db.String(100), nullable=False) # 如 "手小"
problem_id = db.Column(db.Integer, db.ForeignKey("problems.id"), nullable=False) # 外键
severity = db.Column(db.String(10), nullable=False) # 轻微/中等/严重
level = db.Column(db.String(20)) # 启蒙/入门/进阶/熟练/精通
created_at = db.Column(db.DateTime, default=datetime.now)
# 关联到 Problem
problem = db.relationship("Problem", foreign_keys=[problem_id])
def to_dict(self):
return {
"id": self.id,
"problem_id": self.problem_id,
"problem_name": self.problem_name,
"student_id": self.student_id,
"problem_id": self.problem_id, # 外键关联到 problems.id
"problem_name": self.problem.name if self.problem else None,
"problem_no": self.problem.no if self.problem else None,
"severity": self.severity,
"level": self.level,
}
@@ -161,14 +197,43 @@ class PracticePlan(db.Model):
id = db.Column(db.Integer, primary_key=True)
student_id = db.Column(db.Integer, db.ForeignKey("students.id"), nullable=False)
template_id = db.Column(db.Integer, db.ForeignKey("templates.id"), nullable=True) # 使用的AI提示词模板
is_typical = db.Column(db.Boolean, default=False, nullable=False) # 是否为典型方案
content = db.Column(db.Text, nullable=False) # JSON格式存储方案内容
created_at = db.Column(db.DateTime, default=datetime.now)
# 关联
template = db.relationship("Template", foreign_keys=[template_id])
def to_dict(self):
import json as json_module
content_obj = {}
try:
content_obj = json_module.loads(self.content) if self.content else {}
except:
pass
# 从 content 中提取问题列表
problems = content_obj.get("problems", [])
problem_names = [p.get("name", "") for p in problems] if problems else []
# 获取模板名称
template_name = self.template.name if self.template else None
# 获取学员班级
class_name = None
if self.student and self.student.class_obj:
class_name = self.student.class_obj.name
return {
"id": self.id,
"student_id": self.student_id,
"student_name": self.student.name if self.student else "",
"class_name": class_name,
"template_id": self.template_id,
"template_name": template_name,
"is_typical": self.is_typical,
"problem_names": problem_names,
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M")
if self.created_at
else None,
+1 -1
View File
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
@login_required_json
def classes_page():
"""班级管理页面"""
return render_template("classes.html")
return render_template("classes.html", active_nav="classes")
@main_bp.route("/api/classes", methods=["GET"])
+124 -22
View File
@@ -13,10 +13,10 @@ from flask import (
session,
)
from app.routes import main_bp
from app.models import db, Student, PracticePlan
from app.models import db, Student, PracticePlan, StudentProblem
from app.services.plan_generator import generate_practice_plan, generate_ai_report
from app.services.pdf_generator import generate_pdf
from app.routes.auth import login_required_json
from app.routes.auth import login_required_json, admin_required
def sse_format(data):
@@ -33,6 +33,114 @@ def get_student_plans(student_id):
return jsonify([p.to_dict() for p in plans])
@main_bp.route("/api/plans", methods=["GET"])
@login_required_json
def get_all_plans():
"""获取所有方案(支持多条件筛选)
查询参数:
- class_id: 班级ID
- problem_ids: Problem.id 列表,逗号分隔
- template_id: 模板ID
- is_typical: 是否典型 (true/false)
- student_name: 学员姓名(模糊匹配)
"""
import json as json_module
query = PracticePlan.query
# 按班级筛选
class_id = request.args.get('class_id', type=int)
if class_id:
query = query.join(Student).filter(Student.class_id == class_id)
# 按模板筛选
template_id = request.args.get('template_id', type=int)
if template_id:
query = query.filter(PracticePlan.template_id == template_id)
# 按典型状态筛选
is_typical = request.args.get('is_typical')
if is_typical and is_typical.lower() == 'true':
query = query.filter(PracticePlan.is_typical == True)
# 按学员姓名模糊筛选
student_name = request.args.get('student_name')
if student_name:
query = query.join(Student).filter(Student.name.like(f'%%{student_name}%%'))
# 按问题筛选(通过 problem_id 关联到学员的问题)
problem_ids = request.args.get('problem_ids')
if problem_ids:
problem_id_list = [int(pid.strip()) for pid in problem_ids.split(',') if pid.strip()]
if problem_id_list:
# 筛选:方案对应的学员有指定问题之一的
# 使用子查询避免笛卡尔积导致的重复
from sqlalchemy import exists
query = query.join(Student).filter(
exists().where(
(StudentProblem.student_id == Student.id) &
(StudentProblem.problem_id.in_(problem_id_list))
)
)
plans = query.order_by(PracticePlan.created_at.desc()).all()
return jsonify([p.to_dict() for p in plans])
@main_bp.route("/api/plans/<int:plan_id>/typical", methods=["POST"])
@login_required_json
def toggle_plan_typical(plan_id):
"""切换方案的典型状态"""
plan = PracticePlan.query.get_or_404(plan_id)
plan.is_typical = not plan.is_typical
db.session.commit()
return jsonify({"success": True, "is_typical": plan.is_typical})
@main_bp.route("/plans")
@login_required_json
def plans_page():
"""方案管理页面"""
return render_template("plans.html", active_nav="plans")
@main_bp.route("/api/admin/fix-plan-templates", methods=["GET"])
@admin_required
def fix_plan_templates():
"""临时接口:修复所有方案的模板关联"""
from app.models import Template, PracticePlan
simple_template = Template.query.filter_by(name='简单文字版').first()
formal_template = Template.query.filter_by(name='正式报告版').first()
if not simple_template or not formal_template:
return jsonify({"error": "模板没找到"}), 400
plans = PracticePlan.query.order_by(PracticePlan.created_at.desc()).all()
if plans:
plans[0].template_id = simple_template.id
for plan in plans[1:]:
plan.template_id = formal_template.id
db.session.commit()
return jsonify({"ok": True, "updated": len(plans)})
@main_bp.route("/plan/<int:plan_id>")
@login_required_json
def plan_detail_page(plan_id):
"""方案详情页面"""
return render_template("plan_detail.html", active_nav="plans")
@main_bp.route("/plan/<int:plan_id>/edit")
@login_required_json
def plan_edit_page(plan_id):
"""方案编辑页面"""
return render_template("plan_edit.html", active_nav="plans", plan_id=plan_id)
@main_bp.route("/api/generate-plan", methods=["POST"])
@login_required_json
def generate_plan():
@@ -49,30 +157,24 @@ def generate_plan():
return jsonify({"error": "请先记录学员的问题"}), 400
# 预先收集所有数据,避免在generator中访问数据库
problems_dir = current_app.config["PROBLEMS_DIR"]
# 学员的统一练习时间
practice_time = student.practice_time or "30-60分钟"
problem_data = []
for p in problems:
# problem_id 已经是完整标识(如 "01_手小"),直接用作文件名
problem_file = os.path.join(problems_dir, f"{p.problem_id}.md")
content = ""
if os.path.exists(problem_file):
with open(problem_file, "r", encoding="utf-8") as f:
content = f.read()
problem_data.append(
{
"problem_id": p.problem_id,
"problem_name": p.problem_name,
"severity": p.severity,
"level": p.level,
"content": content,
}
)
# 使用 Problem 关联获取问题信息
problem_obj = p.problem
if problem_obj:
problem_data.append(
{
"problem_id": problem_obj.id, # 使用 Problem.id
"problem_name": problem_obj.name,
"problem_no": problem_obj.no,
"severity": p.severity,
"level": p.level,
"content": problem_obj.content or "",
}
)
time_mapping = {
"15分钟": {"total": 15, "basic": 10, "tech": 2, "piece": 3},
"30分钟": {"total": 30, "basic": 15, "tech": 5, "piece": 10},
@@ -104,7 +206,6 @@ def generate_plan():
plan_content = generate_practice_plan(
student_name=student.name,
problems=problem_data,
problems_dir=problems_dir,
practice_time=practice_time,
)
@@ -241,6 +342,7 @@ def generate_plan():
try:
plan = PracticePlan(
student_id=student_id,
template_id=template_id,
content=json.dumps(plan_content, ensure_ascii=False),
)
db.session.add(plan)
+29 -5
View File
@@ -33,7 +33,7 @@ def add_student_problem(student_id):
# 添加或更新问题
for p in problems:
problem_id = p.get("problem_id")
problem_id = p.get("problem_id") # 这是 problems.id
submitted_ids.add(problem_id)
# 检查是否已存在
@@ -50,7 +50,6 @@ def add_student_problem(student_id):
problem = StudentProblem(
student_id=student_id,
problem_id=problem_id,
problem_name=p.get("problem_name"),
severity=p.get("severity"),
level=p.get("level"),
)
@@ -70,13 +69,38 @@ def clear_student_problems(student_id):
@main_bp.route(
"/api/students/<int:student_id>/problems/<problem_id>", methods=["DELETE"]
"/api/students/<int:student_id>/problems/<int:student_problem_id>", methods=["DELETE"]
)
@login_required_json
def delete_single_problem(student_id, problem_id):
def delete_single_problem(student_id, student_problem_id):
"""删除学员的单个问题"""
StudentProblem.query.filter_by(
student_id=student_id, problem_id=problem_id
id=student_problem_id
).delete()
db.session.commit()
return jsonify({"message": "删除成功"})
@main_bp.route(
"/api/students/<int:student_id>/problems/<int:student_problem_id>", methods=["PUT"]
)
@login_required_json
def update_single_problem(student_id, student_problem_id):
"""更新学员的单个问题(严重程度和级别)"""
student = Student.query.get_or_404(student_id)
data = request.get_json()
problem = StudentProblem.query.filter_by(
id=student_problem_id
).first()
if not problem:
return jsonify({"error": "问题记录不存在"}), 404
if "severity" in data:
problem.severity = data["severity"]
if "level" in data:
problem.level = data["level"]
db.session.commit()
return jsonify({"message": "更新成功", "problem": problem.to_dict()})
+57 -198
View File
@@ -5,6 +5,7 @@ import shutil
from datetime import datetime
from flask import request, jsonify, render_template, current_app, session, redirect
from app.routes import main_bp
from app.models import db, Problem, StudentProblem
from app.config import load_api_config, save_api_config
from app.routes.auth import login_required_json, admin_required
@@ -13,14 +14,14 @@ from app.routes.auth import login_required_json, admin_required
@login_required_json
def settings():
"""问题配置页面 - 所有登录用户可访问"""
return render_template("settings.html")
return render_template("settings.html", active_nav="settings")
@main_bp.route("/api-settings")
@admin_required
def api_settings_page():
"""API设置页面 - 仅管理员"""
return render_template("api_settings.html")
return render_template("api_settings.html", active_nav="api-settings")
# ==================== API配置接口 ====================
@@ -81,241 +82,99 @@ def update_api_config():
@main_bp.route("/api/problems", methods=["GET"])
@login_required_json
def get_problems():
"""获取问题列表(从文件夹动态读取)"""
problems_dir = current_app.config.get("PROBLEMS_DIR")
"""获取问题列表(从数据库读取)"""
from app.models import Problem
problems = []
if problems_dir and os.path.exists(problems_dir):
for filename in sorted(os.listdir(problems_dir)):
if (
filename.endswith(".md")
and not filename.startswith("模板")
and not filename.startswith("针对性练习建议")
):
name = filename.replace(".md", "")
if "汇总" in name:
continue
parts = name.split("_", 1)
if len(parts) == 2:
problem_id = parts[0]
problem_name = parts[1]
category = "技术类"
filepath = os.path.join(problems_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
if "认知类" in content:
category = "认知类"
elif "节奏类" in content:
category = "节奏类"
elif "表现类" in content:
category = "表现类"
elif "习惯类" in content:
category = "习惯类"
elif "综合类" in content:
category = "综合类"
except:
pass
try:
mtime = os.path.getmtime(filepath)
except:
mtime = 0
problems.append(
{
"id": problem_id,
"name": problem_name,
"category": category,
"file": filename,
"mtime": mtime,
}
)
return jsonify(sorted(problems, key=lambda x: x["id"]))
problems = Problem.query.order_by(Problem.no).all()
return jsonify([p.to_dict() for p in problems])
@main_bp.route("/api/problems/<problem_id>", methods=["GET"])
@main_bp.route("/api/problems/<int:problem_id>", methods=["GET"])
@login_required_json
def get_problem_detail(problem_id):
"""获取单个问题的详细信息"""
problems_dir = current_app.config.get("PROBLEMS_DIR")
from app.models import Problem
if problems_dir and os.path.exists(problems_dir):
for filename in os.listdir(problems_dir):
if filename.startswith(f"{problem_id}_") and filename.endswith(".md"):
filepath = os.path.join(problems_dir, filename)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
return jsonify(
{"id": problem_id, "filename": filename, "content": content}
)
problem = Problem.query.get(problem_id)
if not problem:
return jsonify({"error": "问题不存在"}), 404
return jsonify({"error": "问题不存在"}), 404
return jsonify({
"id": problem.id,
"no": problem.no,
"name": problem.name,
"category": problem.category,
"content": problem.content,
})
@main_bp.route("/api/problems", methods=["POST"])
@admin_required
def create_problem():
"""创建新问题 - 仅管理员"""
"""创建新问题"""
from app.models import Problem
data = request.get_json()
problem_id = data.get("id", "").strip()
problem_name = data.get("name", "").strip()
no = data.get("no", "").strip()
name = data.get("name", "").strip()
category = data.get("category", "技术类")
content = data.get("content", "")
if not problem_id or not problem_name:
return jsonify({"error": "ID和名称不能为空"}), 400
if not no or not name:
return jsonify({"error": "编号和名称不能为空"}), 400
# 格式化ID
problem_id = problem_id.zfill(2)
# 检查编号是否已存在
existing = Problem.query.filter_by(no=no).first()
if existing:
return jsonify({"error": "该编号已存在"}), 400
problems_dir = current_app.config.get("PROBLEMS_DIR")
filename = f"{problem_id}_{problem_name}.md"
filepath = os.path.join(problems_dir, filename)
# 创建问题
problem = Problem(no=no, name=name, category=category, content=content)
db.session.add(problem)
db.session.commit()
if os.path.exists(filepath):
return jsonify({"error": "问题已存在"}), 400
# 生成默认内容
content = f"""# {problem_name}
> 所属系列:钢琴学习常见问题针对性练习建议
> 配合目标体系使用,针对性补齐短板
---
## 问题表现
- 请描述具体表现症状
## 原因分析
- 可能的原因1
- 可能的原因2
## 针对性练习方案
### 日常基础练习
| 练习名称 | 时长 | 频率 | 目的 |
|---------|------|------|------|
| 练习1 | 10分钟 | 每天 | 目的1 |
| 练习2 | 5分钟 | 每天 | 目的2 |
### 具体操作
```
练习1:练习名称
- 步骤1
- 步骤2
- 步骤3
```
## 练习提醒
### ⚠️ 禁忌
- 禁忌1
- 禁忌2
### ✓ 正确做法
- 正确做法1
- 正确做法2
## 评估标准
| 等级 | 标准 |
|------|------|
| 入门 | 达到的标准 |
| 进阶 | 达到的标准 |
| 熟练 | 达到的标准 |
| 精通 | 达到的标准 |
---
> **版本**V1.0
> **创建时间**{datetime.now().strftime("%Y-%m-%d")}
> **适用场景**:成人钢琴集体课学员个性化辅导"""
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return jsonify({"message": "创建成功", "id": problem_id, "name": problem_name})
return jsonify({"message": "创建成功", "id": problem.id, "no": problem.no, "name": problem.name})
@main_bp.route("/api/problems/<problem_id>", methods=["PUT"])
@main_bp.route("/api/problems/<int:problem_id>", methods=["PUT"])
@login_required_json
def update_problem(problem_id):
"""更新问题"""
data = request.get_json()
new_name = data.get("name", "").strip()
new_content = data.get("content", "").strip()
from app.models import Problem
if not new_name:
return jsonify({"error": "名称不能为空"}), 400
problems_dir = current_app.config.get("PROBLEMS_DIR")
# 找到旧文件
old_filename = None
for f in os.listdir(problems_dir):
if f.startswith(f"{problem_id}_") and f.endswith(".md"):
old_filename = f
break
if not old_filename:
problem = Problem.query.get(problem_id)
if not problem:
return jsonify({"error": "问题不存在"}), 404
old_filepath = os.path.join(problems_dir, old_filename)
new_filename = f"{problem_id}_{new_name}.md"
new_filepath = os.path.join(problems_dir, new_filename)
# 如果名称改变,需要重命名文件
if old_filename != new_filename:
if os.path.exists(new_filepath):
return jsonify({"error": "同名问题已存在"}), 400
os.rename(old_filepath, new_filepath)
filepath = new_filepath
else:
filepath = old_filepath
# 更新内容
with open(filepath, "w", encoding="utf-8") as f:
f.write(new_content)
data = request.get_json()
if "name" in data:
problem.name = data["name"].strip()
if "category" in data:
problem.category = data["category"]
if "content" in data:
problem.content = data["content"]
db.session.commit()
return jsonify({"message": "更新成功"})
@main_bp.route("/api/problems/<problem_id>", methods=["DELETE"])
@main_bp.route("/api/problems/<int:problem_id>", methods=["DELETE"])
@admin_required
def delete_problem(problem_id):
"""删除问题"""
problems_dir = current_app.config.get("PROBLEMS_DIR")
from app.models import Problem
# 找到文件
filename = None
for f in os.listdir(problems_dir):
if f.startswith(f"{problem_id}_") and f.endswith(".md"):
filename = f
break
if not filename:
problem = Problem.query.get(problem_id)
if not problem:
return jsonify({"error": "问题不存在"}), 404
filepath = os.path.join(problems_dir, filename)
# 检查是否有关联数据
from app.models import StudentProblem
if StudentProblem.query.filter_by(problem_db_id=problem_id).first():
return jsonify({"error": "该问题已被学员使用,无法删除"}), 400
# 移动到备份目录
trash_dir = os.path.join(problems_dir, "bk")
os.makedirs(trash_dir, exist_ok=True)
import time
backup_name = f"{filename.replace('.md', '')}_{int(time.time())}.md"
shutil.move(filepath, os.path.join(trash_dir, backup_name))
db.session.delete(problem)
db.session.commit()
return jsonify({"message": "删除成功"})
+31 -2
View File
@@ -15,18 +15,46 @@ from app.routes import main_bp
from app.models import (
db,
Student,
Class,
PracticePlan,
PROBLEM_LIST,
SEVERITY_LEVELS,
PRACTICE_TIME_OPTIONS,
User,
Class,
)
from app.routes.auth import login_required_json, check_login
@main_bp.route("/")
def index():
"""首页 - 学员列表"""
"""首页"""
if not check_login():
return redirect("/login")
student_count = Student.query.count()
class_count = Class.query.count()
plan_count = PracticePlan.query.count()
return render_template(
"home.html",
student_count=student_count,
class_count=class_count,
plan_count=plan_count,
active_nav="home",
)
@main_bp.route("/student/<int:student_id>")
@login_required_json
def student_detail_page(student_id):
"""学员详情页"""
student = Student.query.get_or_404(student_id)
return render_template("student.html", student=student, active_nav="students")
@main_bp.route("/students")
def students_page():
"""学员列表页"""
if not check_login():
return redirect("/login")
@@ -35,6 +63,7 @@ def index():
problem_list=PROBLEM_LIST,
severity_levels=SEVERITY_LEVELS,
practice_time_options=PRACTICE_TIME_OPTIONS,
active_nav="students",
)
+1 -1
View File
@@ -86,7 +86,7 @@ def init_default_templates():
def templates_page():
"""模板管理页面"""
from flask import render_template
return render_template("templates.html")
return render_template("templates.html", active_nav="templates")
@templates_bp.route("/templates", methods=["GET"])
+1 -1
View File
@@ -10,7 +10,7 @@ from app.routes.auth import login_required_json, admin_required
@admin_required
def users_page():
"""用户管理页面"""
return render_template("users.html")
return render_template("users.html", active_nav="users")
@main_bp.route("/api/users", methods=["GET"])
+14 -34
View File
@@ -7,15 +7,14 @@ from app.config import load_api_config
def generate_practice_plan(
student_name, problems, problems_dir, practice_time="30-60分钟"
student_name, problems, practice_time="30-60分钟"
):
"""
根据学员问题和练习时间生成针对性练习方案
Args:
student_name: 学员姓名
problems: 问题列表 [{problem_id, problem_name, severity}]
problems_dir: 问题文件所在目录
problems: 问题列表 [{problem_id, problem_name, severity, level, content}]
practice_time: 练习时间描述
Returns:
@@ -34,39 +33,20 @@ def generate_practice_plan(
}
time_config = time_mapping.get(practice_time, time_mapping["30分钟"])
# 读取问题文件内容
# 从数据库问题内容构建
problem_contents = []
for p in problems:
problem_file = os.path.join(problems_dir, f"{p['problem_id']}.md")
if os.path.exists(problem_file):
with open(problem_file, "r", encoding="utf-8") as f:
content = f.read()
# 提取关键部分
problem_contents.append(
{
"name": p["problem_name"],
"severity": p["severity"],
"content": _extract_key_sections(content),
"time_allocation": _calculate_time_allocation(
p["severity"], time_config
),
}
)
else:
# 问题文件不存在时,使用默认内容
problem_contents.append(
{
"name": p["problem_name"],
"severity": p["severity"],
"content": {
"problem": f"针对{p['problem_name']}的练习",
"suggestion": "建议每天进行针对性练习",
},
"time_allocation": _calculate_time_allocation(
p["severity"], time_config
),
}
)
content = p.get("content", "") or ""
problem_contents.append(
{
"name": p["problem_name"],
"severity": p["severity"],
"content": _extract_key_sections(content) if content else {"problem": f"针对{p['problem_name']}的练习"},
"time_allocation": _calculate_time_allocation(
p["severity"], time_config
),
}
)
# 生成每日练习计划
daily_plan = _generate_daily_schedule(time_config, problem_contents)
+97 -1
View File
@@ -8,6 +8,8 @@
<!-- 公共CSS -->
<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">
<link href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/tabulator-tables@5/dist/css/tabulator.min.css" rel="stylesheet">
{% block extra_css %}{% endblock %}
<style>
@@ -84,7 +86,33 @@
<small id="currentUserDisplay" class="text-light"></small>
</div>
<nav class="nav flex-column">
{% block sidebar_nav %}{% endblock %}
<a class="nav-link {% if active_nav == 'students' %}active{% endif %}" href="/students">
<i class="bi bi-people"></i> 学员管理
</a>
<a class="nav-link {% if active_nav == 'plans' %}active{% endif %}" href="/plans">
<i class="bi bi-clipboard-check"></i> 方案管理
</a>
<a class="nav-link {% if active_nav == 'settings' %}active{% endif %}" href="/settings">
<i class="bi bi-gear"></i> 问题配置
</a>
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes">
<i class="bi bi-collection"></i> 班级管理
</a>
<a class="nav-link {% if active_nav == 'api-settings' %}active{% endif %}" href="/api-settings" id="apiSettingsNav" style="display:none;">
<i class="bi bi-key"></i> API设置
</a>
<a class="nav-link {% if active_nav == 'templates' %}active{% endif %}" href="/templates" id="templatesNav" style="display:none;">
<i class="bi bi-file-earmark-text"></i> 模板管理
</a>
{% block sidebar_extra %}
<hr>
<a class="nav-link" href="#" onclick="showChangePasswordModal(); return false;">
<i class="bi bi-key"></i> 修改密码
</a>
<a class="nav-link" href="#" onclick="logout(); return false;">
<i class="bi bi-box-arrow-right"></i> 退出登录
</a>
{% endblock %}
</nav>
</div>
@@ -97,7 +125,51 @@
<!-- 公共JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@5/dist/js/tabulator.min.js"></script>
<script src="/static/js/plan_common.js"></script>
<script>
// 统一登录检查和权限处理
window.addEventListener('DOMContentLoaded', function() {
fetch('/api/check-login').then(r => r.json()).then(data => {
if (!data.logged_in) {
window.location.href = '/login';
return;
}
// 显示用户名
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
const userDisplay = data.username + ' (' + (ROLE_LABELS[data.role] || data.role) + ')';
const currentUserEl = document.getElementById('currentUserDisplay');
if (currentUserEl) currentUserEl.textContent = userDisplay;
const mobileUserEl = document.getElementById('mobileUserDisplay');
if (mobileUserEl) mobileUserEl.textContent = userDisplay;
// 侧边栏权限控制
const setDisplay = (id, val) => {
const el = document.getElementById(id);
if (el) el.style.display = val;
};
if (data.role === 'admin') {
setDisplay('apiSettingsNav', '');
setDisplay('templatesNav', '');
setDisplay('usersNav', '');
setDisplay('classesNav', '');
setDisplay('settingsNav', '');
} else {
setDisplay('settingsNav', '');
setDisplay('classesNav', '');
}
// 调用页面初始化函数(如果定义了)
if (typeof window.pageInit === 'function') {
window.pageInit(data);
}
}).catch(() => {
window.location.href = '/login';
});
});
// 移动端导航切换
function toggleMobileNav() {
const sidebar = document.getElementById('sidebar');
@@ -131,6 +203,30 @@
</script>
{% block extra_js %}{% endblock %}
<!-- 方案详情弹窗 -->
<div class="modal fade" id="planDetailModal" tabindex="-1">
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
<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" id="planDetailContent">
<!-- 方案内容将通过JS动态生成 -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-warning" onclick="editPlanContent()">
<i class="bi bi-edit"></i> 编辑内容
</button>
<button type="button" class="btn btn-primary" onclick="downloadPDF()">
<i class="bi bi-download"></i> 下载PDF
</button>
</div>
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div class="modal fade" id="changePwdModal" tabindex="-1">
<div class="modal-dialog">
+286 -139
View File
@@ -24,33 +24,36 @@
.markdown-body th { background: #f8f9fa; }
.markdown-body code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
.markdown-body blockquote { border-left: 4px solid #dee2e6; margin: 1rem 0; padding: 0.5rem 1rem; background: #f8f9fa; }
/* 方案列表样式 */
.plan-problem-text { font-weight: 600; color: #2c3e50; font-size: 0.95rem; }
.plan-meta-text { color: #95a5a6; font-size: 0.8rem; }
</style>
{% endblock %}
{% block sidebar_nav %}
<a class="nav-link active" href="#" onclick="showStudentList()">
<a class="nav-link {% if active_nav == 'students' %}active{% endif %}" href="/students">
<i class="bi bi-people"></i> 学员管理
</a>
<a class="nav-link" href="#" onclick="showSettings()" id="settingsNav">
<a class="nav-link {% if active_nav == 'plans' %}active{% endif %}" href="/plans">
<i class="bi bi-clipboard-check"></i> 方案管理
</a>
<a class="nav-link {% if active_nav == 'settings' %}active{% endif %}" href="/settings">
<i class="bi bi-gear"></i> 问题配置
</a>
<a class="nav-link" href="/api-settings" id="apiSettingsNav" style="display:none;">
<i class="bi bi-key"></i> API设置
</a>
<a class="nav-link" href="/templates" id="templatesNav" style="display:none;">
<i class="bi bi-file-earmark-text"></i> 模板管理
</a>
<a class="nav-link" href="/classes" id="classesNav" style="display:none;">
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes">
<i class="bi bi-collection"></i> 班级管理
</a>
<a class="nav-link" href="/users" id="usersNav" style="display:none;">
<i class="bi bi-person-badge"></i> 用户管理
<a class="nav-link {% if active_nav == 'api-settings' %}active{% endif %}" href="/api-settings" id="apiSettingsNav" style="display:none;">
<i class="bi bi-key"></i> API设置
</a>
<a class="nav-link {% if active_nav == 'templates' %}active{% endif %}" href="/templates" id="templatesNav" style="display:none;">
<i class="bi bi-file-earmark-text"></i> 模板管理
</a>
<hr>
<a class="nav-link" href="#" onclick="showChangePasswordModal()">
<a class="nav-link" href="#" onclick="showChangePasswordModal(); return false;">
<i class="bi bi-key"></i> 修改密码
</a>
<a class="nav-link" href="#" onclick="logout()">
<a class="nav-link" href="#" onclick="logout(); return false;">
<i class="bi bi-box-arrow-right"></i> 退出登录
</a>
{% endblock %}
@@ -90,8 +93,8 @@
<!-- 学员详情页面 -->
<div id="studentDetailPage" style="display: none;">
<button class="btn btn-link mb-3" onclick="showStudentList()">
<i class="bi bi-arrow-left"></i> 返回列表
<button class="btn btn-link mb-3" onclick="goBack()">
<i class="bi bi-arrow-left"></i> <span id="backBtnText">返回列表</span>
</button>
<div class="row">
@@ -223,7 +226,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-secondary" id="cancelProblemsBtn">取消</button>
<button type="button" class="btn btn-primary" onclick="saveProblems()">保存</button>
</div>
</div>
@@ -232,7 +235,7 @@
<!-- 方案查看模态框 -->
<div class="modal fade" id="planDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">练习方案</h5>
@@ -322,7 +325,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-secondary" id="cancelEditPlanBtn">取消</button>
<button type="button" class="btn btn-primary" onclick="savePlanContent()">
<i class="bi bi-save"></i> 保存
</button>
@@ -373,7 +376,6 @@
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@5/dist/js/tabulator.min.js"></script>
<script>
let currentStudentId = null;
@@ -384,41 +386,21 @@ const problemList = {{ problem_list | tojson }};
const severityLevels = {{ severity_levels | tojson }};
const practiceTimeOptions = {{ practice_time_options | tojson }};
// 初始化
document.addEventListener('DOMContentLoaded', function() {
fetch('/api/check-login').then(r => r.json()).then(data => {
if (!data.logged_in) {
window.location.href = '/login';
return;
}
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
const userDisplay = data.username + ' (' + (ROLE_LABELS[data.role] || data.role) + ')';
document.getElementById('currentUserDisplay').textContent = userDisplay;
const mobileDisplay = document.getElementById('mobileUserDisplay');
if (mobileDisplay) mobileDisplay.textContent = userDisplay;
if (data.role === 'admin') {
document.getElementById('usersNav').style.display = '';
document.getElementById('settingsNav').style.display = '';
document.getElementById('apiSettingsNav').style.display = '';
document.getElementById('templatesNav').style.display = '';
} else {
document.getElementById('settingsNav').style.display = '';
document.getElementById('apiSettingsNav').style.display = 'none';
document.getElementById('templatesNav').style.display = 'none';
}
document.getElementById('classesNav').style.display = '';
loadAiTemplates();
loadReportTemplates();
}).catch(() => {
window.location.href = '/login';
});
// 页面初始化(base.html 统一登录检查后调用)
window.pageInit = function(data) {
loadAiTemplates();
loadReportTemplates();
loadClassFilter();
loadStudents();
initProblemCheckboxes();
});
// 检查 URL 参数,自动打开学员详情
const urlParams = new URLSearchParams(window.location.search);
const studentId = urlParams.get('student_id');
if (studentId) {
showStudentDetail(parseInt(studentId));
}
};
// 加载AI提示词模板列表
async function loadAiTemplates() {
@@ -532,23 +514,39 @@ async function loadClassFilter() {
// 渲染学员列表
function renderStudentList(students) {
const container = document.getElementById('studentList');
if (students.length === 0) {
if (!container) { console.error('studentList element not found'); return; }
console.log('renderStudentList called with', students?.length, 'students');
if (!students || students.length === 0) {
container.innerHTML = '<div class="col-12 text-center text-muted py-5"><p>暂无学员,请添加</p></div>';
return;
}
let html = '';
students.forEach(s => {
// 构建问题显示文本
let problemText = '';
if (s.problem_names && s.problem_names.length > 0) {
if (s.problem_names.length >= 3) {
problemText = `${s.problem_names[0]}${s.problem_names[1]}${s.problem_names.length}`;
} else {
problemText = s.problem_names.join('、');
}
} else {
problemText = `${s.problem_count} 个问题`;
}
html += `
<div class="col-md-4 col-sm-6 mb-3">
<div class="card student-card" onclick="showStudentDetail(${s.id})">
<div class="card-body">
<h5 class="card-title">${s.name}</h5>
<p class="card-text text-muted small">${s.wechat_nickname || ''} ${s.phone ? '| ' + s.phone : ''}</p>
<span class="badge bg-info">${s.practice_time}</span>
<span class="badge bg-secondary">${s.problem_count} 个问题</span>
<span class="badge bg-primary">${s.plan_count} 个方案</span>
</div>
<div class="card">
<a href="/student/${s.id}" class="text-decoration-none">
<div class="card-body">
<h5 class="card-title">${s.name}</h5>
<p class="card-text text-muted small">${s.wechat_nickname || ''} ${s.phone ? '| ' + s.phone : ''}</p>
<span class="badge bg-info">${s.practice_time}</span>
<span class="badge bg-secondary">${problemText}</span>
<span class="badge bg-primary">${s.plan_count} 个方案</span>
</div>
</a>
</div>
</div>
`;
@@ -684,30 +682,57 @@ async function saveStudent() {
// 显示学员详情
async function showStudentDetail(studentId) {
currentStudentId = studentId;
try {
currentStudentId = studentId;
const response = await fetch(`/api/students/${studentId}`);
const data = await response.json();
// 检查来源页面
const urlParams = new URLSearchParams(window.location.search);
window.returnUrl = urlParams.get('from');
document.getElementById('backBtnText').textContent = window.returnUrl ? '返回' : '返回列表';
document.getElementById('detailName').textContent = data.student.name;
document.getElementById('detailWechatNickname').textContent = data.student.wechat_nickname || '未填写';
document.getElementById('detailPhone').textContent = data.student.phone || '未填写';
document.getElementById('detailPracticeTime').textContent = data.student.practice_time || '30分钟';
const response = await fetch(`/api/students/${studentId}`);
const data = await response.json();
const classEl = document.getElementById('detailClass');
if (data.student.class_name) {
classEl.innerHTML = '<span class="badge bg-secondary">' + data.student.class_name + '</span>';
} else {
classEl.innerHTML = '<span class="text-muted">未分配班级</span>';
document.getElementById('detailName').textContent = data.student.name;
document.getElementById('detailWechatNickname').textContent = data.student.wechat_nickname || '未填写';
document.getElementById('detailPhone').textContent = data.student.phone || '未填写';
document.getElementById('detailPracticeTime').textContent = data.student.practice_time || '30分钟';
const classEl = document.getElementById('detailClass');
if (data.student.class_name) {
classEl.innerHTML = '<span class="badge bg-secondary">' + data.student.class_name + '</span>';
} else {
classEl.innerHTML = '<span class="text-muted">未分配班级</span>';
}
document.getElementById('detailNotes').textContent = data.student.notes || '无备注';
renderProblemList(data.problems);
renderPlanList(data.plans);
document.getElementById('studentListPage').style.display = 'none';
document.getElementById('studentDetailPage').style.display = 'block';
// 检查是否需要自动打开方案编辑模态框
const action = urlParams.get('action');
const planIdFromUrl = urlParams.get('plan_id');
if (action === 'edit' && planIdFromUrl) {
currentPlanId = parseInt(planIdFromUrl);
// 延迟一下确保 DOM 已渲染
setTimeout(() => editPlanContent(), 100);
}
} catch (err) {
console.error('showStudentDetail error:', err);
}
}
document.getElementById('detailNotes').textContent = data.student.notes || '无备注';
renderProblemList(data.problems);
renderPlanList(data.plans);
document.getElementById('studentListPage').style.display = 'none';
document.getElementById('studentDetailPage').style.display = 'block';
// 返回
function goBack() {
if (window.returnUrl) {
window.location.href = window.returnUrl;
} else {
showStudentList();
}
}
// 返回学员列表
@@ -808,10 +833,29 @@ function renderPlanList(plans) {
let html = '';
plans.forEach(p => {
// 构建显示文本:问题【模板 | 时间】
let problemText = '';
if (p.problem_names && p.problem_names.length > 0) {
const problems = p.problem_names.slice(0, 3).join('、');
const more = p.problem_names.length > 3 ? `${p.problem_names.length}` : '';
problemText = `${problems}${more}`;
}
const template = p.template_name || '无模板';
const time = p.created_at || '';
const metaText = `${template}${time ? ' | ' + time : ''}`;
html += `
<div class="d-flex justify-content-between align-items-center mb-2">
<span>生成于 ${p.created_at}</span>
<div>
<div class="d-flex align-items-center mb-2 gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="typical_${p.id}"
${p.is_typical ? 'checked' : ''}
onchange="toggleTypical(${p.id}, this.checked)">
<label class="form-check-label small text-muted" for="typical_${p.id}">典型</label>
</div>
<span class="plan-problem-text">${problemText}</span>
<span class="plan-meta-text">${metaText}</span>
<div class="ms-auto">
<button class="btn btn-sm btn-outline-primary" onclick="viewPlan(${p.id})">查看</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">删除</button>
</div>
@@ -821,7 +865,25 @@ function renderPlanList(plans) {
container.innerHTML = html;
}
// 切换典型方案状态
async function toggleTypical(planId, isTypical) {
try {
const resp = await fetch(`/api/plans/${planId}/typical`, { method: 'POST' });
if (!resp.ok) {
// 恢复原状态
document.getElementById('typical_' + planId).checked = !isTypical;
alert('设置失败');
}
} catch (e) {
// 恢复原状态
document.getElementById('typical_' + planId).checked = !isTypical;
alert('设置失败: ' + e.message);
}
}
// 显示问题记录模态框
let problemsModalOriginalState = []; // 记录原始状态用于检测修改
function showProblemsModal() {
fetch(`/api/students/${currentStudentId}/problems`)
.then(r => r.json())
@@ -829,14 +891,14 @@ function showProblemsModal() {
const currentProblemIds = currentProblems.map(p => p.problem_id);
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
const isChecked = currentProblemIds.includes(cb.value);
const isChecked = currentProblemIds.includes(parseInt(cb.value));
cb.checked = isChecked;
const severityDiv = document.getElementById(`severity_${cb.value}`);
severityDiv.style.display = isChecked ? 'block' : 'none';
if (isChecked) {
const problem = currentProblems.find(p => p.problem_id === cb.value);
const problem = currentProblems.find(p => p.problem_id === parseInt(cb.value));
if (problem) {
const levelRadios = document.querySelectorAll(`input[name="level_${cb.value}"]`);
levelRadios.forEach(r => {
@@ -849,9 +911,82 @@ function showProblemsModal() {
}
}
});
// 记录加载后的原始状态
problemsModalOriginalState = getProblemsModalState();
});
new bootstrap.Modal(document.getElementById('problemsModal')).show();
const modal = new bootstrap.Modal(document.getElementById('problemsModal'), {
keyboard: false // 禁用 ESC 关闭,由我们手动处理
});
const modalEl = document.getElementById('problemsModal');
// 监听取消按钮 - 如果有修改则确认
document.getElementById('cancelProblemsBtn').onclick = () => {
if (isProblemsModalDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modal.hide();
}
} else {
modal.hide();
}
};
// 监听键盘事件处理 ESC
const handleEscape = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (isProblemsModalDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
} else {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
}
};
modalEl.addEventListener('keydown', handleEscape);
modal.show();
}
// 获取当前问题弹窗的状态
function getProblemsModalState() {
const state = [];
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
if (cb.checked) {
const problemId = cb.value;
const levelEl = document.querySelector(`input[name="level_${problemId}"]:checked`);
const severityEl = document.querySelector(`input[name="severity_${problemId}"]:checked`);
state.push({
problem_id: problemId,
level: levelEl ? levelEl.value : '入门',
severity: severityEl ? severityEl.value : '中等'
});
}
});
return state;
}
// 检测问题弹窗是否有修改
function isProblemsModalDirty() {
const currentState = getProblemsModalState();
// 比较数量
if (currentState.length !== problemsModalOriginalState.length) return true;
// 比较每个问题的状态
for (const current of currentState) {
const original = problemsModalOriginalState.find(o => o.problem_id === current.problem_id);
if (!original) return true;
if (original.level !== current.level || original.severity !== current.severity) return true;
}
return false;
}
// 保存问题
@@ -859,10 +994,10 @@ async function saveProblems() {
const problems = [];
document.querySelectorAll('.problem-checkbox-input:checked').forEach(cb => {
const problemId = cb.value;
const problemId = parseInt(cb.value);
const problemName = cb.dataset.name;
const severityEl = document.querySelector(`input[name="severity_${problemId}"]:checked`);
const levelEl = document.querySelector(`input[name="level_${problemId}"]:checked`);
const severityEl = document.querySelector(`input[name="severity_${cb.value}"]:checked`);
const levelEl = document.querySelector(`input[name="level_${cb.value}"]:checked`);
problems.push({
problem_id: problemId,
@@ -970,56 +1105,9 @@ async function generatePlan() {
}
}
// 查看方案
async function viewPlan(planId) {
currentPlanId = planId;
const response = await fetch(`/api/plans/${planId}`);
const data = await response.json();
let html = `
<div class="mb-3">
<strong>学员:</strong>${data.student_name} &nbsp;&nbsp;
<strong>练习时间:</strong>${data.content.practice_time} &nbsp;&nbsp;
<strong>生成时间:</strong>${data.created_at}
</div>
<h6>问题诊断</h6>
<div class="mb-3">
`;
data.content.problems.forEach(p => {
html += `<span class="problem-tag severity-${p.severity}">${p.name}${p.severity}</span> `;
});
html += `</div>`;
if (data.content.ai_report) {
const aiReportHtml = marked.parse(data.content.ai_report);
html += `
<h6>AI个性化练习报告</h6>
<div class="mb-3 p-3 bg-light rounded" style="max-height: 500px; overflow-y: auto;">${aiReportHtml}</div>
`;
} else if (data.content.ai_report_error) {
html += `
<h6>AI报告</h6>
<div class="mb-3 p-3 bg-warning rounded">AI生成失败: ${data.content.ai_report_error}</div>
`;
}
html += `
<h6>每日练习计划(共${data.content.total_daily_minutes}分钟)</h6>
<table class="table table-sm">
<thead><tr><th>环节</th><th>时长</th><th>内容</th><th>目的</th></tr></thead>
<tbody>
`;
data.content.daily_schedule.forEach(item => {
html += `<tr><td>${item.phase}</td><td>${item.duration}</td><td>${item.content}</td><td>${item.purpose}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('planDetailContent').innerHTML = html;
new bootstrap.Modal(document.getElementById('planDetailModal')).show();
// 查看方案 - 跳转到方案详情页
function viewPlan(planId) {
window.location.href = `/plan/${planId}`;
}
// 下载PDF
@@ -1061,6 +1149,8 @@ async function previewReportTemplate() {
// 编辑方案内容
let planContentEditor = null;
let scheduleTable = null;
let editPlanOriginalState = { ai_report: '', scheduleData: [] }; // 记录原始状态
async function editPlanContent() {
const resp = await fetch(`/api/plans/${currentPlanId}`);
const data = await resp.json();
@@ -1070,6 +1160,9 @@ async function editPlanContent() {
document.getElementById('editGeneratedAt').value = content.generated_at || '';
document.getElementById('editProblemsCount').value = (content.problems || []).length + ' 个问题';
// 记录原始状态
editPlanOriginalState.ai_report = content.ai_report || '';
if (planContentEditor) {
planContentEditor.toTextArea();
planContentEditor = null;
@@ -1095,6 +1188,9 @@ async function editPlanContent() {
purpose: item.purpose || ''
}));
// 记录原始表格数据
editPlanOriginalState.scheduleData = JSON.parse(JSON.stringify(scheduleData));
scheduleTable = new Tabulator("#editDailyScheduleTable", {
data: scheduleData,
layout: "fitDataFill",
@@ -1119,7 +1215,58 @@ async function editPlanContent() {
]
});
new bootstrap.Modal(document.getElementById('editPlanContentModal')).show();
const modal = new bootstrap.Modal(document.getElementById('editPlanContentModal'), {
keyboard: false // 禁用 ESC 关闭,由我们手动处理
});
const modalEl = document.getElementById('editPlanContentModal');
// 取消按钮点击处理
const cancelBtn = document.getElementById('cancelEditPlanBtn');
cancelBtn.onclick = () => {
if (isEditPlanDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modal.hide();
}
} else {
modal.hide();
}
};
// ESC 键处理
const handleEscape = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (isEditPlanDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
} else {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
}
};
modalEl.addEventListener('keydown', handleEscape);
modal.show();
}
// 检测编辑方案内容是否有修改
function isEditPlanDirty() {
// 检查 AI 报告
const currentAiReport = planContentEditor ? planContentEditor.value() : document.getElementById('editAiReport').value;
if (currentAiReport !== editPlanOriginalState.ai_report) return true;
// 检查表格数据
if (scheduleTable) {
const currentData = scheduleTable.getData();
if (JSON.stringify(currentData) !== JSON.stringify(editPlanOriginalState.scheduleData)) return true;
}
return false;
}
// 添加一行
+60 -44
View File
@@ -1,7 +1,7 @@
# 钢琴练习方案系统 - 部署 SOP
> 版本:v1.1
> 日期:2026-04-21
> 版本:v1.2
> 日期:2026-04-23
> 核心原则:**不删除,只备份后新增/替换**
---
@@ -46,11 +46,12 @@
| 类型 | 源 | 容器内 | 说明 |
|------|-----|--------|------|
| Bind Mount | `/opt/piano-plan/个性化方案` | `/app/个性化方案` | 问题文件(15个md |
| Volume | `piano-plan-data` | `/app/data` | SQLite 数据库 |
| Volume | `piano-plan-output` | `/app/output` | PDF 输出 |
| Bind Mount | `/opt/piano-plan/config` | `/app/config` | API 配置 |
> ⚠️ **已移除**`/opt/piano-plan/个性化方案` 挂载点(问题文件已迁移到数据库 `problems` 表)
---
## 三、部署步骤
@@ -81,22 +82,18 @@ docker save piano-plan:latest -o piano-plan.tar
scp -i ~/.ssh/id_rsa piano-plan.tar root@47.106.65.108:/opt/piano-plan/
```
### 3.3 服务器部署(使用脚本!)
### 3.3 服务器部署
```bash
# 7. SSH 到服务器
ssh -i ~/.ssh/id_rsa root@47.106.65.108
# 8. 使用自动化部署脚本(会自动完成所有步骤并验证)
bash /path/to/deploy.sh /opt/piano-plan/piano-plan.tar
# 8. 创建带时间戳的备份目录
mkdir -p /opt/piano-plan/backups/backup_$(date +%Y%m%d)
# 或者手动部署(不推荐):
# 8a. 确认当前容器挂载配置
docker inspect piano-plan --format '{{json .Mounts}}'
# 9. 创建备份
mkdir -p /opt/piano-plan/backups
docker cp piano-plan:/app/data/piano_plans.db /opt/piano-plan/backups/piano_plans.db.bak
# 9. 备份当前数据库和配置
docker cp piano-plan:/app/data/piano_plans.db /opt/piano-plan/backups/backup_$(date +%Y%m%d)/
cp /opt/piano-plan/config/api_config.json /opt/piano-plan/backups/backup_$(date +%Y%m%d)/
# 10. 停止旧容器
docker stop piano-plan
@@ -105,38 +102,52 @@ docker rm piano-plan
# 11. 加载新镜像
docker load -i /opt/piano-plan/piano-plan.tar
# 12. 启动新容器(挂载配置必须完全正确!)
# 12. 启动新容器(无个性化方案挂载!)
docker run -d \
--name piano-plan \
-p 5001:5001 \
--restart unless-stopped \
-e FLASK_ENV=production \
-v /opt/piano-plan/个性化方案:/app/个性化方案 \
-v piano-plan-data:/app/data \
-v piano-plan-output:/app/output \
-v /opt/piano-plan/config:/app/config \
piano-plan:latest
```
### 3.4 验证
### 3.4 数据同步(特殊情况下从开发环境覆盖生产数据库)
> ⚠️ **警告**:这是**特殊处理**,仅在开发环境和生产环境数据结构需要统一时执行。正常部署不应覆盖生产数据库。
```bash
# 13. 检查容器状态
# 13. 停止容器
docker stop piano-plan
# 14. 上传开发环境数据库到服务器(在本地执行)
scp -i ~/.ssh/id_rsa data/piano_plans.db root@47.106.65.108:/opt/piano-plan/backups/
# 15. 覆盖生产数据库
docker cp /opt/piano-plan/backups/piano_plans.db piano-plan:/app/data/piano_plans.db
# 16. 重启容器
docker start piano-plan
```
### 3.5 验证
```bash
# 17. 检查容器状态
docker ps --filter name=piano-plan
# 14. 检查日志
# 18. 检查日志
docker logs piano-plan --tail 20
# 15. 验证服务
# 19. 验证服务
curl -I http://localhost:5001/
# 16. 验证问题文件(应该看到15个md文件)
docker exec piano-plan ls /app/个性化方案/
# 20. 验证数据库表
docker exec piano-plan ls /app/data/
# 17. 验证数据库(应该看到 templates 表)
docker exec piano-plan python -c "import sqlite3; conn=sqlite3.connect('/app/data/piano_plans.db'); print([r[0] for r in conn.execute('SELECT name FROM sqlite_master WHERE type=\"table\"')])"
# 18. 验证 API 配置
# 21. 验证 API 配置
docker exec piano-plan cat /app/config/api_config.json
```
@@ -152,7 +163,7 @@ docker exec piano-plan cat /app/config/api_config.json
| 学员数据 | piano-plan-data:/app/data | students, student_problems 表 |
| 班级数据 | piano-plan-data:/app/data | classes 表 |
| 练习方案 | piano-plan-data:/app/data | practice_plans 表 |
| 问题文件 | /opt/piano-plan/个性化方案 | 15个md文件 |
| 问题数据 | piano-plan-data:/app/data | problems 表(已从文件迁移到数据库) |
### 4.2 新增/更新的数据
@@ -221,32 +232,40 @@ ssh -i ~/.ssh/id_rsa root@47.106.65.108 "docker cp /tmp/update_templates.py pian
## 五、回滚流程
### 5.1 快速回滚(推荐)
### 5.1 从备份恢复数据库
```bash
# 停止当前容器
# 停止容器
docker stop piano-plan
# 从备份目录恢复(替换日期)
docker cp /opt/piano-plan/backups/backup_20260423/piano_plans.db piano-plan:/app/data/piano_plans.db
# 重启容器
docker start piano-plan
```
### 5.2 完整回滚(恢复旧镜像+数据库)
```bash
# 停止并删除当前容器
docker stop piano-plan
docker rm piano-plan
# 使用旧镜像重新启动(如果镜像还在)
# 使用旧镜像重新启动(如果镜像还在本地
docker run -d \
--name piano-plan \
-p 5001:5001 \
--restart unless-stopped \
-e FLASK_ENV=production \
-v /opt/piano-plan/个性化方案:/app/个性化方案 \
-v piano-plan-data:/app/data \
-v piano-plan-output:/app/output \
-v /opt/piano-plan/config:/app/config \
piano-plan:latest
```
### 5.2 从备份恢复
```bash
# 恢复数据库
# 从备份恢复数据库
docker stop piano-plan
cp /opt/piano-plan/backups/piano_plans.db.bak /var/lib/docker/volumes/piano-plan-data/_data/piano_plans.db
docker cp /opt/piano-plan/backups/backup_YYYYMMDD/piano_plans.db piano-plan:/app/data/piano_plans.db
docker start piano-plan
```
@@ -275,9 +294,10 @@ docker start piano-plan
| 源 | 容器内路径 | 说明 |
|-----|------------|------|
| /opt/piano-plan/个性化方案 | /app/个性化方案 | 问题文件 |
| /opt/piano-plan/config | /app/config | API 配置 |
> ⚠️ **已移除**`/opt/piano-plan/个性化方案` 挂载(问题文件已迁移到数据库)
---
## 七、API 配置说明
@@ -300,9 +320,6 @@ docker start piano-plan
## 八、常见问题
### Q: 部署后问题文件看不到?
A: 检查挂载 `/opt/piano-plan/个性化方案:/app/个性化方案` 是否正确
### Q: 数据库是空的?
A: 检查 volume `piano-plan-data` 是否被错误覆盖,尝试从备份恢复
@@ -335,8 +352,7 @@ location /api/generate-plan {
```
[ ] 容器状态:running
[ ] 服务响应:HTTP 200/302
[ ] 问题文件数量:15个 md 文件
[ ] 数据库记录:users, students, classes, student_problems, practice_plans 完整
[ ] 数据库记录:users, students, classes, student_problems, practice_plans, problems 完整
[ ] templates 表存在且包含 AI提示词模板、报告导出模板
[ ] API 配置:provider, model, api_key 正确
[ ] 功能验证:能生成练习方案
@@ -344,5 +360,5 @@ location /api/generate-plan {
---
> **最后更新**2026-04-21
> **更新原因**更新部署流程,添加数据保护规范,明确挂载点配置;添加 SSE 问题排查
> **最后更新**2026-04-23
> **更新原因**v1.2 部署更新;移除个性化方案挂载(问题已迁移到数据库);更新备份和回滚流程
+18 -7
View File
@@ -79,15 +79,15 @@ piano-plan/
### 发布流程
1. **开发完成** → 本地测试通过
2. **构建镜像**`docker build -t piano-plan:v1.2.0 .`
2. **构建镜像**`docker build -t piano-plan:latest .`
3. **打包部署文件** → 创建 `releases/v1.2.0/` 目录,放入:
- `piano-plan-v1.2.0.tar.gz` - Docker镜像
- `piano-nginx.conf` - Nginx配置(从服务器获取最新)
- `docker-compose.yml` - 部署编排
4. **上传** → 传到服务器 load 镜像
5. **部署** → docker-compose up -d
- `piano-plan.tar` - Docker镜像
4. **上传** → scp 到服务器 `/opt/piano-plan/`
5. **部署** → 按照 DEPLOYMENT_SOP.md 执行
6. **清理** → 本地 tar 包可删除(git已管理版本)
> ⚠️ Nginx 配置在服务器上:`/srv/nginx/conf/conf.d/piano.yoin.fun.conf`
### 版本化部署包命名
```
@@ -143,4 +143,15 @@ deploy: v1.2.0 生产环境部署
---
*最后更新:2026-04-21*
## 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| V1.0 | 2026-04-17 | 初始版本:学员管理、问题记录、方案生成 |
| V1.1 | 2026-04-17 | 添加用户登录认证系统 |
| V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 |
| V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 |
---
*最后更新:2026-04-23*
+20 -2
View File
@@ -11,6 +11,9 @@
```
base.html (基础模板)
├── index.html (学员管理)
├── home.html (默认首页)
├── student.html (学员详情)
├── plan_edit.html (方案编辑)
├── settings.html (问题配置)
├── classes.html (班级管理)
├── users.html (用户管理)
@@ -218,8 +221,11 @@ base.html 已包含修改密码弹窗 HTML 和 JS。各页面不需要重复定
```
app/templates/
├── base.html # 基础模板(核心)
├── base.html # 基础模板(核心,统一侧边栏
├── index.html # 学员管理
├── home.html # 默认首页(统计信息)
├── student.html # 学员详情(URL导航)
├── plan_edit.html # 方案编辑(URL导航)
├── settings.html # 问题配置
├── classes.html # 班级管理
├── users.html # 用户管理
@@ -232,8 +238,20 @@ app/templates/
> 注意:`login.html`、`setup.html`、`wechat_card.html` 是独立页面,不继承 base.html。
## 7. 更新日志
## 7. URL 导航模式
系统支持两种导航模式:
| 模式 | 说明 | 示例 |
|------|------|------|
| SPA 模式 | 点击学员卡片弹窗查看详情 | 原 index.html 模式 |
| URL 模式 | 通过 URL 直接访问 | `/student/<id>`, `/plan/<id>/edit` |
推荐使用 URL 模式,便于分享和书签。
## 8. 更新日志
| 日期 | 版本 | 变更内容 |
|------|------|----------|
| 2026-04-21 | v1.0 | 初始文档,定义 base.html 模板继承模式 |
| 2026-04-23 | v1.1 | 添加 URL 导航模式说明;新增 home.html, student.html, plan_edit.html |
+22 -6
View File
@@ -10,6 +10,22 @@
## 数据表
### 0. Problem (问题定义)
系统预定义的15种常见钢琴学习问题。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Integer | 主键,自增 |
| code | String(50) | 问题编号,如 "05_掌关节支撑差" |
| name | String(100) | 问题名称,如 "掌关节支撑差" |
| category | String(20) | 分类:技术类/认知类/节奏类/习惯类/综合类 |
| created_at | DateTime | 创建时间 |
> ⚠️ 问题数据已从文件系统迁移到数据库。student_problems 表通过 `problem_id` 外键关联到此表。
---
### 1. User (用户)
系统用户,用于登录认证和权限管理。
@@ -58,12 +74,13 @@
|------|------|------|
| id | Integer | 主键,自增 |
| student_id | Integer | 外键,关联 Student |
| problem_id | String(50) | 问题编号,如 "01_手小" |
| problem_name | String(100) | 问题名称,如 "手小" |
| problem_id | Integer | 外键,关联 Problem.id |
| severity | String(10) | 严重程度:轻微/中等/严重 |
| level | String(20) | 级别:启蒙/入门/进阶/熟练/精通 |
| created_at | DateTime | 创建时间 |
> ⚠️ `problem_id` 现为数字外键,关联 `Problem.id`。通过 `student_problem.problem` 关系获取问题名称。
---
### 4. Class (班级)
@@ -139,8 +156,7 @@
├──────────────────┤ │
│ id │ │
│ student_id ─────┘ │
│ problem_id │
│ problem_name │
│ problem_id │◄─┼──► Problem
│ severity │
│ level │
│ created_at │
@@ -173,8 +189,8 @@ print(user.role) # "admin" or "user"
```python
student = Student.query.get(1)
for problem in student.problems:
print(problem.problem_name, problem.severity, problem.level)
for sp in student.problems:
print(sp.problem.name, sp.problem.code, sp.severity, sp.level)
```
### 查询班级及其学员
+8 -9
View File
@@ -28,8 +28,11 @@
│ │ └── pdf_generator.py # PDF生成器
│ │
│ └── templates/ # 前端模板
│ ├── base.html # 基础模板(所有页面继承)
│ ├── base.html # 基础模板(所有页面继承,统一侧边栏
│ ├── index.html # 学员管理页面(继承base)
│ ├── home.html # 默认首页(显示统计信息)
│ ├── student.html # 学员详情页(URL导航)
│ ├── plan_edit.html # 方案编辑页(URL导航)
│ ├── settings.html # 问题配置页面(继承base)
│ ├── login.html # 登录页面(独立)
│ ├── setup.html # 初始设置页面(独立)
@@ -47,12 +50,6 @@
├── config/ # 配置目录(运行时创建)
│ └── api_config.json # API配置文件
├── 个性化方案/ # 练习方案内容
│ └── 针对性练习(拆分为单独文件)/
│ ├── 01_手小.md
│ ├── 02_识谱慢.md
│ └── ...
├── run.py # 应用入口
├── run.bat # 启动脚本
├── requirements.txt # Python依赖
@@ -96,8 +93,9 @@ def create_app():
数据库模型定义:
- `User` - 用户(登录认证、权限管理)
- `Student` - 学员
- `StudentProblem` - 问题记录
- `Class` - 班级(新增
- `StudentProblem` - 问题记录(关联 Problem 表)
- `Problem` - 问题定义(15种预定义问题,已从文件迁移到数据库
- `Class` - 班级
- `PracticePlan` - 练习方案
---
@@ -277,3 +275,4 @@ generate_pdf(plan_id, student_name, content, output_dir)
| V1.0 | 2026-04-17 | 初始版本:学员管理、问题记录、方案生成 |
| V1.1 | 2026-04-17 | 添加用户登录认证系统 |
| V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 |
| V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 |