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:
@@ -161,4 +161,4 @@ piano-plan/
|
||||
|
||||
> **版本**:v1.2.0
|
||||
> **创建时间**:2026-04-17
|
||||
> **最后更新**:2026-04-21
|
||||
> **最后更新**:2026-04-23
|
||||
|
||||
+17
-10
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
<strong>练习时间:</strong>${data.content.practice_time}
|
||||
<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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
```
|
||||
|
||||
### 查询班级及其学员
|
||||
|
||||
+9
-10
@@ -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` - 练习方案
|
||||
|
||||
---
|
||||
@@ -276,4 +274,5 @@ 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 | 2026-04-18 | 添加用户管理、角色权限、班级管理 |
|
||||
| V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 |
|
||||
Reference in New Issue
Block a user