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:
+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;
|
||||
}
|
||||
|
||||
// 添加一行
|
||||
|
||||
Reference in New Issue
Block a user