diff --git a/README.md b/README.md index 2af99f3..7bd3559 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,6 @@ piano-plan/ --- -> **版本**:v1.2.0 +> **版本**:v1.3.0 > **创建时间**:2026-04-17 -> **最后更新**:2026-04-23 +> **最后更新**:2026-04-25 diff --git a/app/__init__.py b/app/__init__.py index 67247c1..3d724dd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -170,6 +170,14 @@ def create_app(): if "comment" not in sg_columns: db.session.execute(text("ALTER TABLE student_goals ADD COLUMN comment TEXT")) db.session.commit() + + # 检查classes表的level字段 + result9 = db.session.execute(text("PRAGMA table_info(classes)")) + class_columns = [row[1] for row in result9] + if "level" not in class_columns: + db.session.execute(text("ALTER TABLE classes ADD COLUMN level VARCHAR(20) DEFAULT '启蒙'")) + db.session.commit() + # 删除不再使用的字段 # deadline 和 completed_at 已被 start_date, assessment_date, achievement_date 取代 # status 字段现在由日期计算,不再存储 diff --git a/app/models.py b/app/models.py index e2a8fe4..c09f3c9 100644 --- a/app/models.py +++ b/app/models.py @@ -17,6 +17,7 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), unique=True, nullable=False) + name = db.Column(db.String(50), nullable=True) # 姓名 password_hash = db.Column(db.String(200), nullable=False) role = db.Column(db.String(20), default="user") # admin / user created_at = db.Column(db.DateTime, default=datetime.now) @@ -54,6 +55,7 @@ class User(db.Model): return { "id": self.id, "username": self.username, + "name": self.name, "role": self.role, "created_at": self.created_at.strftime("%Y-%m-%d %H:%M") if self.created_at @@ -69,10 +71,14 @@ class Class(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text) + teacher_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) # 班主任/老师 + level = db.Column(db.String(20), default="启蒙") # 班级级别:启蒙/入门/进阶/熟练/精通 active = db.Column(db.Boolean, default=True) # 进行中 created_at = db.Column(db.DateTime, default=datetime.now) - # 关联 - 在Student模型中定义backref + # 关联 + teacher = db.relationship("User", foreign_keys=[teacher_id]) + def to_dict(self): # 直接查询学员数量,避免relationship问题 from app.models import Student @@ -82,6 +88,9 @@ class Class(db.Model): "id": self.id, "name": self.name, "description": self.description, + "teacher_id": self.teacher_id, + "level": self.level or "启蒙", + "teacher_name": self.teacher.name if self.teacher else None, "active": self.active if self.active is not None else True, "student_count": student_count, "created_at": self.created_at.strftime("%Y-%m-%d %H:%M") @@ -126,6 +135,10 @@ class Student(db.Model): # 通过关联获取问题名称 problem_names = [p.problem.name if p.problem else p.problem_name for p in problems_list] + # 获取目标统计 + goal_count = len(self.goal_records) if self.goal_records else 0 + completed_goal_count = sum(1 for g in self.goal_records if g.achievement_date) if self.goal_records else 0 + return { "id": self.id, "name": self.name, @@ -141,6 +154,8 @@ class Student(db.Model): "problem_count": self.problems.count(), "problem_names": problem_names, # 问题名称列表(按严重程度排序) "plan_count": self.plans.count(), + "goal_count": goal_count, + "completed_goal_count": completed_goal_count, } @@ -190,6 +205,7 @@ class StudentProblem(db.Model): "problem_no": self.problem.no if self.problem else None, "severity": self.severity, "level": self.level, + "created_at": self.created_at.isoformat() if self.created_at else None, } @@ -236,42 +252,81 @@ class StudentGoal(db.Model): student_id = db.Column(db.Integer, db.ForeignKey("students.id"), nullable=False) goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), nullable=False) start_date = db.Column(db.DateTime) # 开始日期 - assessment_date = db.Column(db.DateTime) # 评估日期 - mastery_level = db.Column(db.Integer, nullable=True) # 掌握程度 1-5(评估时填写) - achievement_date = db.Column(db.DateTime, nullable=True) # 达成日期 - comment = db.Column(db.Text, nullable=True) # 评语 + assessment_date = db.Column(db.DateTime) # 计划评估日期 + status = db.Column(db.String(20), default="进行中") # 状态:未开始/进行中/已完成/已过期 + mastery_level = db.Column(db.Integer, nullable=True) # 掌握程度(来自最新评估) + achievement_date = db.Column(db.DateTime, nullable=True) # 达成日期(来自最终评估) + comment = db.Column(db.Text, nullable=True) # 评语(来自最新评估) created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) student = db.relationship("Student", backref="goal_records") goal = db.relationship("Goal") - def get_status(self): - """根据日期自动计算状态""" + def compute_status(self): + """根据日期动态计算状态""" from datetime import datetime now = datetime.now() if self.start_date and now < self.start_date: return "未开始" + elif self.achievement_date: + return "已完成" elif self.assessment_date and now > self.assessment_date: - return "已结束" + return "已过期" else: return "进行中" + def sync_status(self): + """同步状态到数据库""" + self.status = self.compute_status() + def to_dict(self): - status = self.get_status() return { "id": self.id, "student_id": self.student_id, "goal_id": self.goal_id, "goal_name": self.goal.name if self.goal else None, + "goal_content": self.goal.content if self.goal else None, "goal_level": self.goal.level if self.goal else None, "goal_category": self.goal.category if self.goal else None, - "status": status, + "status": self.status, "start_date": self.start_date.isoformat() if self.start_date else None, "assessment_date": self.assessment_date.isoformat() if self.assessment_date else None, "mastery_level": self.mastery_level, "achievement_date": self.achievement_date.isoformat() if self.achievement_date else None, "comment": self.comment, "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class StudentGoalEvaluation(db.Model): + """学员目标评估记录表""" + __tablename__ = "student_goal_evaluations" + + id = db.Column(db.Integer, primary_key=True) + student_goal_id = db.Column(db.Integer, db.ForeignKey("student_goals.id"), nullable=False) + evaluator_id = db.Column(db.Integer, nullable=True) # 评估人(暂时用 nullable) + assessment_date = db.Column(db.DateTime, default=datetime.now) # 评估日期 + mastery_level = db.Column(db.Integer, nullable=False) # 掌握程度 1-5 + comment = db.Column(db.Text, nullable=True) # 评语 + is_final = db.Column(db.Boolean, default=False) # 是否最终评估 + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + student_goal = db.relationship("StudentGoal", backref="evaluations") + + def to_dict(self): + return { + "id": self.id, + "student_goal_id": self.student_goal_id, + "evaluator_id": self.evaluator_id, + "assessment_date": self.assessment_date.isoformat() if self.assessment_date else None, + "mastery_level": self.mastery_level, + "comment": self.comment, + "is_final": self.is_final, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } @@ -298,9 +353,14 @@ class PracticePlan(db.Model): except: pass - # 从 content 中提取问题列表 + # 从 content 中提取问题列表(含级别和严重程度) + # 兼容旧数据:旧数据用 problem_name,新数据用 name problems = content_obj.get("problems", []) - problem_names = [p.get("name", "") for p in problems] if problems else [] + problem_details = [ + {"name": p.get("name") or p.get("problem_name", ""), "level": p.get("level", ""), "severity": p.get("severity", "中等")} + for p in problems + ] if problems else [] + problem_names = [p["name"] for p in problem_details] # 获取模板名称 template_name = self.template.name if self.template else None @@ -319,6 +379,7 @@ class PracticePlan(db.Model): "template_name": template_name, "is_typical": self.is_typical, "problem_names": problem_names, + "problem_details": problem_details, "created_at": self.created_at.strftime("%Y-%m-%d %H:%M") if self.created_at else None, diff --git a/app/routes/classes.py b/app/routes/classes.py index d0ea809..90f3960 100644 --- a/app/routes/classes.py +++ b/app/routes/classes.py @@ -3,7 +3,7 @@ from flask import request, jsonify, render_template, session from datetime import datetime, timedelta from app.routes import main_bp -from app.models import db, Class, Student, StudentGoal, Goal +from app.models import db, Class, Student, StudentGoal, Goal, User from app.routes.auth import login_required_json, admin_required import logging @@ -17,12 +17,21 @@ def classes_page(): return render_template("classes.html", active_nav="classes") +@main_bp.route("/api/teachers", methods=["GET"]) +@login_required_json +def api_teachers_list(): + """用户列表(用于班主任选择,所有登录用户可访问)""" + users = User.query.order_by(User.name, User.username).all() + return jsonify([{"id": u.id, "name": u.name or u.username} for u in users]) + + @main_bp.route("/api/classes", methods=["GET"]) @login_required_json def api_classes_list(): """班级列表""" - # 支持筛选参数: active=true, active=false, 不传则返回全部 + # 支持筛选参数: active=true/false, mine=true/false active_filter = request.args.get("active") + mine_filter = request.args.get("mine") query = Class.query if active_filter is not None: @@ -31,6 +40,12 @@ def api_classes_list(): elif active_filter.lower() == "false": query = query.filter(Class.active == False) + # 我的班级筛选(当前用户作为老师的班级) + if mine_filter and mine_filter.lower() == "true": + user_id = session.get("user_id") + if user_id: + query = query.filter(Class.teacher_id == user_id) + classes = query.order_by(Class.created_at.desc()).all() return jsonify([c.to_dict() for c in classes]) @@ -42,6 +57,8 @@ def api_classes_create(): data = request.get_json() name = data.get("name", "").strip() description = data.get("description", "") + teacher_id = data.get("teacher_id") # 可以为空 + level = data.get("level", "启蒙") active = data.get("active", True) # 默认进行中 if not name: @@ -51,7 +68,7 @@ def api_classes_create(): return jsonify({"error": "班级名称已存在", "code": "DUPLICATE_NAME"}), 400 try: - cls = Class(name=name, description=description, active=active) + cls = Class(name=name, description=description, teacher_id=teacher_id, level=level, active=active) db.session.add(cls) db.session.commit() return jsonify(cls.to_dict()) @@ -78,6 +95,12 @@ def api_classes_update(class_id): if "description" in data: cls.description = data["description"] + if "teacher_id" in data: + cls.teacher_id = data["teacher_id"] if data["teacher_id"] else None + + if "level" in data: + cls.level = data["level"] + if "active" in data: cls.active = data["active"] diff --git a/app/routes/goals.py b/app/routes/goals.py index bbdb416..524b482 100644 --- a/app/routes/goals.py +++ b/app/routes/goals.py @@ -1,13 +1,13 @@ -from flask import Blueprint, request, jsonify, render_template -from app.models import db, Goal -from app.routes.auth import login_required_json, admin_required +from flask import Blueprint, request, jsonify, render_template, session +from app.models import db, Goal, StudentGoal, GoalRelation, StudentGoalEvaluation +from app.routes.auth import login_required_json, admin_required, login_required from app.routes import main_bp goals_bp = Blueprint("goals", __name__) # 目标管理页面路由 @main_bp.route("/goals") -@admin_required +@login_required def goals_page(): """目标管理页面""" return render_template("goals.html", active_nav="goals") @@ -57,7 +57,33 @@ def update_goal(goal_id): @goals_bp.route("/api/goals/", methods=["DELETE"]) @login_required_json def delete_goal(goal_id): + """删除目标模板(仅管理员,需检查依赖)""" + from app.models import User + user = User.query.get(session.get("user_id")) + if not user or user.role != "admin": + return jsonify({"error": "权限不足,仅管理员可操作"}), 403 + goal = Goal.query.get_or_404(goal_id) + + # 检查依赖关系 + deps = [] + + # 1. 检查是否有学员分配了此目标 + student_goal_count = StudentGoal.query.filter_by(goal_id=goal_id).count() + if student_goal_count > 0: + deps.append(f"已被 {student_goal_count} 名学员分配") + + # 2. 检查是否有目标关联(作为父目标或子目标) + as_parent = GoalRelation.query.filter_by(parent_goal_id=goal_id).count() + as_child = GoalRelation.query.filter_by(child_goal_id=goal_id).count() + if as_parent > 0: + deps.append(f"有 {as_parent} 个子目标关联") + if as_child > 0: + deps.append(f"作为子目标被 {as_child} 个目标引用") + + if deps: + return jsonify({"error": f"无法删除:{', '.join(deps)}"}), 400 + db.session.delete(goal) db.session.commit() return jsonify({"message": "删除成功"}) diff --git a/app/routes/plans.py b/app/routes/plans.py index 357b509..d4303f7 100644 --- a/app/routes/plans.py +++ b/app/routes/plans.py @@ -13,7 +13,7 @@ from flask import ( session, ) from app.routes import main_bp -from app.models import db, Student, PracticePlan, StudentProblem +from app.models import db, Student, PracticePlan, StudentProblem, StudentGoal 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, admin_required @@ -44,8 +44,10 @@ def get_all_plans(): - template_id: 模板ID - is_typical: 是否典型 (true/false) - student_name: 学员姓名(模糊匹配) + - mine: true/false(我的学员的方案) """ import json as json_module + from app.models import Class query = PracticePlan.query @@ -84,6 +86,13 @@ def get_all_plans(): ) ) + # 我的学员筛选(所在班级的老师是当前用户) + mine = request.args.get('mine') + if mine and mine.lower() == 'true': + user_id = session.get('user_id') + if user_id: + query = query.join(Student).join(Class).filter(Class.teacher_id == user_id) + plans = query.order_by(PracticePlan.created_at.desc()).all() return jsonify([p.to_dict() for p in plans]) @@ -153,6 +162,10 @@ def generate_plan(): student = Student.query.get_or_404(student_id) problems = student.problems.all() + # 获取学员的目标(只获取未完成的) + goals = StudentGoal.query.filter_by(student_id=student_id).all() + goal_data = [g.to_dict() for g in goals] + if not problems: return jsonify({"error": "请先记录学员的问题"}), 400 @@ -207,6 +220,7 @@ def generate_plan(): student_name=student.name, problems=problem_data, practice_time=practice_time, + goals=goal_data, ) yield sse_format( @@ -254,22 +268,27 @@ def generate_plan(): ) # 先用dry_run模式获取提示词并显示给用户 - prompt, _, error = generate_ai_report( + prompt, _, error, extra_info = generate_ai_report( student_name=student.name, wechat_nickname=student.wechat_nickname or "", problems=problem_data, practice_time=practice_time, time_config=time_config, template_id=template_id, - dry_run=True + dry_run=True, + goals=goal_data ) - # 发送提示词给前端显示 + # 发送提示词给前端显示(只发长度,不发完整内容避免SSE缓冲问题) + prompt_length = len(prompt) if prompt else 0 yield sse_format({ "step": "ai_prompt", - "message": "发送给AI的提示词:", + "message": "AI提示词已生成", "progress": 60, - "detail": prompt if prompt else "无" + "prompt_length": prompt_length, + "student_problems_length": extra_info.get("student_problems_length", 0) if extra_info else 0, + "problems_length": extra_info.get("problems_length", 0) if extra_info else 0, + "student_goals_length": extra_info.get("student_goals_length", 0) if extra_info else 0, }) if error: @@ -288,14 +307,15 @@ def generate_plan(): ) # 真正调用API生成报告 - _, ai_report, error = generate_ai_report( + _, ai_report, error, _ = generate_ai_report( student_name=student.name, wechat_nickname=student.wechat_nickname or "", problems=problem_data, practice_time=practice_time, time_config=time_config, template_id=template_id, - dry_run=False + dry_run=False, + goals=goal_data ) if error: @@ -375,6 +395,8 @@ def generate_plan(): "plan_id": plan.id if plan and hasattr(plan, 'id') else None, "content": plan_content, "ai_report": ai_report, + "prompt_length": prompt_length, + "ai_report_length": len(ai_report) if ai_report else 0, } ) except Exception as e: @@ -411,6 +433,7 @@ def get_plan(plan_id): "student_id": plan.student_id, "student_name": plan.student.name if plan.student else "", "created_at": plan.created_at.strftime("%Y-%m-%d %H:%M"), + "is_typical": plan.is_typical, "content": content, } ) @@ -423,14 +446,20 @@ def export_pdf(plan_id): plan = PracticePlan.query.get_or_404(plan_id) content = json.loads(plan.content) student_name = plan.student.name if plan.student else "未知学员" + template_id = request.args.get('template_id', type=int) - # 尝试使用数据库中的报告模板 + # 获取报告模板 report_template = None try: from app.models import Template - tmpl = Template.query.filter_by(type="report").first() - if tmpl: - report_template = tmpl.content + if template_id: + tmpl = Template.query.get(template_id) + if tmpl and tmpl.type == "report": + report_template = tmpl.content + else: + tmpl = Template.query.filter_by(type="report").order_by(Template.sort_order.asc()).first() + if tmpl: + report_template = tmpl.content except: pass @@ -442,6 +471,15 @@ def export_pdf(plan_id): rendered_report = rendered_report.replace("{practice_time}", content.get('practice_time', 'N/A')) rendered_report = rendered_report.replace("{total_minutes}", str(content.get('total_daily_minutes', 0))) rendered_report = rendered_report.replace("{generated_at}", content.get('generated_at', '')) + # 获取当前用户姓名 + from app.models import User + user_id = session.get('user_id') + user_name = '未知' + if user_id: + user = User.query.get(user_id) + if user and user.name: + user_name = user.name + rendered_report = rendered_report.replace("{generated_by}", user_name) if content.get('ai_report'): rendered_report = rendered_report.replace("{ai_report}", content['ai_report']) @@ -453,11 +491,6 @@ def export_pdf(plan_id): problem_tags += f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n" rendered_report = rendered_report.replace("{problem_tags}", problem_tags or "(无)") - schedule_table = "| 阶段 | 时长 | 内容 | 目的 |\n|------|------|------|------|\n" - for item in content.get('daily_schedule', []): - schedule_table += f"| {item.get('phase', '')} | {item.get('duration', '')} | {item.get('content', '')} | {item.get('purpose', '')} |\n" - rendered_report = rendered_report.replace("{schedule_table}", schedule_table) - pdf_path = generate_pdf( plan_id=plan_id, student_name=student_name, @@ -504,6 +537,16 @@ def export_md(plan_id): rendered = rendered.replace("{total_minutes}", str(content.get('total_daily_minutes', 0))) rendered = rendered.replace("{generated_at}", content.get('generated_at', '')) + # 获取当前用户姓名 + from app.models import User + user_id = session.get('user_id') + user_name = '未知' + if user_id: + user = User.query.get(user_id) + if user and user.name: + user_name = user.name + rendered = rendered.replace("{generated_by}", user_name) + # AI报告 if content.get('ai_report'): rendered = rendered.replace("{ai_report}", content['ai_report']) @@ -516,15 +559,9 @@ def export_md(plan_id): problem_tags += f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n" rendered = rendered.replace("{problem_tags}", problem_tags or "(无)") - # 每日计划表格 - schedule_table = "| 阶段 | 时长 | 内容 | 目的 |\n|------|------|------|------|\n" - for item in content.get('daily_schedule', []): - schedule_table += f"| {item.get('phase', '')} | {item.get('duration', '')} | {item.get('content', '')} | {item.get('purpose', '')} |\n" - rendered = rendered.replace("{schedule_table}", schedule_table) - md_content = rendered else: - # 兜底:生成完整内容 + # 兜底:生成完整内容(AI报告已包含所有内容) md_lines = [ f"# {student_name} - 个性化练习方案\n", f"**练习时间**: {content.get('practice_time', 'N/A')} (共{content.get('total_daily_minutes', 0)}分钟)\n", @@ -533,25 +570,7 @@ def export_md(plan_id): ] if content.get('ai_report'): - md_lines.append("## 📝 AI个性化报告\n") md_lines.append(content['ai_report']) - md_lines.append("\n---\n") - - md_lines.append("## 🔍 问题诊断\n") - for problem in content.get('problems', []): - md_lines.append(f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n") - md_lines.append("\n") - - if content.get('problem_details'): - md_lines.append("## 📚 问题详解\n") - for detail in content.get('problem_details', []): - md_lines.append(f"### {detail.get('name', '')} ({detail.get('severity', '')})\n") - md_lines.append(f"{detail.get('content', '')}\n\n") - - md_lines.append("## 📅 每日练习计划\n") - md_lines.append("| 阶段 | 时长 | 内容 | 目的 |\n|------|------|------|------|\n") - for item in content.get('daily_schedule', []): - md_lines.append(f"| {item.get('phase', '')} | {item.get('duration', '')} | {item.get('content', '')} | {item.get('purpose', '')} |\n") md_content = ''.join(md_lines) @@ -602,3 +621,72 @@ def update_plan_content(plan_id): plan.content = data["content"] db.session.commit() return jsonify({"message": "保存成功"}) + + +@main_bp.route("/api/generate-plan/preview", methods=["POST"]) +@login_required_json +def generate_plan_preview(): + """预览AI提示词 - 返回完整提示词内容""" + data = request.get_json() + student_id = data.get("student_id") + template_id = data.get("template_id") # AI提示词模板ID + + student = Student.query.get_or_404(student_id) + problems = student.problems.all() + + if not problems: + return jsonify({"error": "请先记录学员的问题"}), 400 + + # 获取学员的目标 + goals = StudentGoal.query.filter_by(student_id=student_id).all() + goal_data = [g.to_dict() for g in goals] + + # 学员的统一练习时间 + practice_time = student.practice_time or "30-60分钟" + + problem_data = [] + for p in problems: + problem_obj = p.problem + if problem_obj: + problem_data.append( + { + "problem_id": problem_obj.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}, + "45分钟": {"total": 45, "basic": 15, "tech": 10, "piece": 20}, + "60分钟": {"total": 60, "basic": 20, "tech": 15, "piece": 25}, + "90分钟": {"total": 90, "basic": 25, "tech": 25, "piece": 40}, + "120分钟": {"total": 120, "basic": 30, "tech": 35, "piece": 55}, + "150分钟以上": {"total": 150, "basic": 35, "tech": 45, "piece": 70}, + } + time_config = time_mapping.get(practice_time, time_mapping["30分钟"]) + + # 调用生成器获取提示词(dry_run模式) + from app.services.plan_generator import generate_ai_report + prompt, _, _, extra_info = generate_ai_report( + student_name=student.name, + wechat_nickname=student.wechat_nickname or "", + problems=problem_data, + practice_time=practice_time, + time_config=time_config, + template_id=template_id, + dry_run=True, + goals=goal_data + ) + + return jsonify({ + "prompt": prompt, + "prompt_length": len(prompt) if prompt else 0, + "student_problems_length": extra_info.get("student_problems_length", 0) if extra_info else 0, + "problems_length": extra_info.get("problems_length", 0) if extra_info else 0, + "student_goals_length": extra_info.get("student_goals_length", 0) if extra_info else 0, + }) diff --git a/app/routes/problems.py b/app/routes/problems.py index 27627ac..5a3a9f7 100644 --- a/app/routes/problems.py +++ b/app/routes/problems.py @@ -18,23 +18,15 @@ def get_student_problems(student_id): @main_bp.route("/api/students//problems", methods=["POST"]) @login_required_json def add_student_problem(student_id): - """为学员添加问题(同步模式:先删再加)""" + """为学员添加问题(增量模式:只添加或更新,不删除已有)""" student = Student.query.get_or_404(student_id) data = request.get_json() problems = data.get("problems", []) - submitted_ids = set() - # 删除旧的问题(如果勾选被取消) - existing = StudentProblem.query.filter_by(student_id=student_id).all() - for e in existing: - if e.problem_id not in [p.get("problem_id") for p in problems]: - db.session.delete(e) - - # 添加或更新问题 + # 添加或更新问题(不删除已有的) for p in problems: problem_id = p.get("problem_id") # 这是 problems.id - submitted_ids.add(problem_id) # 检查是否已存在 existing = StudentProblem.query.filter_by( diff --git a/app/routes/student_goals.py b/app/routes/student_goals.py index 6b857c2..4c41ad5 100644 --- a/app/routes/student_goals.py +++ b/app/routes/student_goals.py @@ -1,30 +1,22 @@ -from flask import Blueprint, request, jsonify -from app.models import db, StudentGoal, Goal +from flask import Blueprint, request, jsonify, session +from app.models import db, StudentGoal, StudentGoalEvaluation, Goal from app.routes.auth import login_required_json from datetime import datetime, timedelta student_goals_bp = Blueprint("student_goals", __name__) -def compute_status(start_date, assessment_date): - """根据日期计算状态""" - now = datetime.now() - if start_date and now < start_date: - return "未开始" - elif assessment_date and now > assessment_date: - return "已结束" - else: - return "进行中" - @student_goals_bp.route("/api/students//goals", methods=["GET"]) @login_required_json def get_student_goals(student_id): records = StudentGoal.query.filter_by(student_id=student_id).all() - # 计算状态并排序 + # 同步状态并排序 + for r in records: + r.sync_status() + def sort_key(r): - status_order = {"进行中": 1, "未开始": 2, "已结束": 3} - status = compute_status(r.start_date, r.assessment_date) - order = status_order.get(status, 4) + status_order = {"进行中": 1, "未开始": 2, "已过期": 3, "已完成": 4} + order = status_order.get(r.status, 5) assessment = r.assessment_date if r.assessment_date else datetime.min return (order, -assessment.timestamp() if assessment else 0) @@ -79,25 +71,58 @@ def update_student_goal(student_id, goal_id): record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404() data = request.get_json() - if "start_date" in data: - if data["start_date"]: - record.start_date = datetime.fromisoformat(data["start_date"]) + # 评估操作 + if "mastery_level" in data or "comment" in data: + is_final = data.get("is_final", False) + assessment_date = datetime.now() + if data.get("assessment_date"): + assessment_date = datetime.fromisoformat(data["assessment_date"]) + + # 如果提供了 evaluation_id,则是更新现有评估记录 + if data.get("evaluation_id"): + evaluation = StudentGoalEvaluation.query.get(data["evaluation_id"]) + if evaluation and evaluation.student_goal_id == record.id: + evaluation.mastery_level = data["mastery_level"] + evaluation.comment = data.get("comment") + evaluation.is_final = is_final + evaluation.updated_at = datetime.now() + # 如果是最终评估,同步到 StudentGoal + if is_final: + record.achievement_date = assessment_date + record.mastery_level = data["mastery_level"] + record.comment = data.get("comment") + record.sync_status() else: - record.start_date = None - if "assessment_date" in data: - if data["assessment_date"]: - record.assessment_date = datetime.fromisoformat(data["assessment_date"]) - else: - record.assessment_date = None - if "mastery_level" in data: - record.mastery_level = data["mastery_level"] - if "achievement_date" in data: - if data["achievement_date"]: - record.achievement_date = datetime.fromisoformat(data["achievement_date"]) - else: - record.achievement_date = None - if "comment" in data: - record.comment = data["comment"] + # 创建新评估记录 + evaluation = StudentGoalEvaluation( + student_goal_id=record.id, + evaluator_id=None, + assessment_date=assessment_date, + mastery_level=data["mastery_level"], + comment=data.get("comment"), + is_final=is_final + ) + db.session.add(evaluation) + + # 如果是最终评估,同步到 StudentGoal + if is_final: + record.achievement_date = assessment_date + record.mastery_level = data["mastery_level"] + record.comment = data.get("comment") + record.sync_status() + else: + # 调整操作:只更新日期 + if "start_date" in data: + if data["start_date"]: + record.start_date = datetime.fromisoformat(data["start_date"]) + else: + record.start_date = None + if "assessment_date" in data: + if data["assessment_date"]: + record.assessment_date = datetime.fromisoformat(data["assessment_date"]) + else: + record.assessment_date = None + record.sync_status() db.session.commit() return jsonify(record.to_dict()) @@ -105,7 +130,52 @@ def update_student_goal(student_id, goal_id): @student_goals_bp.route("/api/students//goals/", methods=["DELETE"]) @login_required_json def remove_student_goal(student_id, goal_id): + """移除学员的目标分配(仅管理员)""" + from app.models import User + user = User.query.get(session.get("user_id")) + if not user or user.role != "admin": + return jsonify({"error": "权限不足,仅管理员可操作"}), 403 + record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404() + + # 先删除关联的评估记录 + StudentGoalEvaluation.query.filter_by(student_goal_id=record.id).delete() + db.session.delete(record) db.session.commit() return jsonify({"message": "移除成功"}) + +@student_goals_bp.route("/api/evaluations/", methods=["DELETE"]) +@login_required_json +def delete_evaluation(evaluation_id): + """删除评估记录""" + evaluation = StudentGoalEvaluation.query.get_or_404(evaluation_id) + db.session.delete(evaluation) + db.session.commit() + return jsonify({"message": "删除成功"}) + + +@student_goals_bp.route("/api/students//evaluations", methods=["GET"]) +@login_required_json +def get_student_evaluations(student_id): + """获取学员所有目标的所有评估记录""" + evaluations = StudentGoalEvaluation.query.join(StudentGoal).filter( + StudentGoal.student_id == student_id + ).order_by(StudentGoalEvaluation.assessment_date.desc()).all() + + # 补充目标信息 + result = [] + for e in evaluations: + d = e.to_dict() + # 获取关联的目标信息 + sg = StudentGoal.query.get(e.student_goal_id) + if sg: + d["goal_name"] = sg.goal.name if sg.goal else None + d["goal_level"] = sg.goal.level if sg.goal else None + d["student_goal_id"] = sg.id + d["student_goal_goal_id"] = sg.goal_id + d["goal_start_date"] = sg.start_date.isoformat() if sg.start_date else None + d["goal_assessment_date"] = sg.assessment_date.isoformat() if sg.assessment_date else None + result.append(d) + + return jsonify(result) diff --git a/app/routes/students.py b/app/routes/students.py index b4d755b..a1b6718 100644 --- a/app/routes/students.py +++ b/app/routes/students.py @@ -71,9 +71,10 @@ def students_page(): @login_required_json def get_students(): """获取学员列表""" - # 支持筛选参数: class_id, name(模糊搜索) + # 支持筛选参数: class_id, name(模糊搜索), mine=true/false class_id = request.args.get("class_id") name = request.args.get("name") + mine_filter = request.args.get("mine") query = Student.query if class_id: @@ -81,6 +82,12 @@ def get_students(): if name: query = query.filter(Student.name.contains(name)) + # 我的学员筛选(所在班级的老师是当前用户) + if mine_filter and mine_filter.lower() == "true": + user_id = session.get("user_id") + if user_id: + query = query.join(Class, Student.class_id == Class.id).filter(Class.teacher_id == user_id) + students = query.order_by(Student.created_at.desc()).all() return jsonify([s.to_dict() for s in students]) diff --git a/app/routes/templates.py b/app/routes/templates.py index 266d353..e3b13ac 100644 --- a/app/routes/templates.py +++ b/app/routes/templates.py @@ -2,83 +2,15 @@ from flask import Blueprint, request, jsonify from app.models import db, Template -from app.routes.auth import admin_required +from app.routes.auth import admin_required, login_required_json templates_bp = Blueprint('templates', __name__, url_prefix='/templates') -# 默认模板 -DEFAULT_TEMPLATES = { - "ai_prompt": { - "name": "AI提示词模板", - "type": "ai_prompt", - "description": "生成练习方案时发送给AI的提示词", - "sort_order": 0, - "content": """你是一位资深的钢琴教师。请根据学员的具体问题详情,生成一份个性化练习方案报告。 - -## 学员基本信息 -- **姓名**: {student_name} -- **微信昵称**: {wechat_nickname} -- **每日可练习时间**: {practice_time} - -## 学员被诊断的问题 -{student_problems} - -## 每个问题的详细信息和练习方法(请务必基于这些内容生成方案) - -{problems} - -## 任务要求 -请根据上述学员的问题诊断和详细信息,生成一份针对性的练习方案报告: -1. 先简述该学员当前存在的主要问题 -2. 给出一个每日练习安排建议(你可以根据问题特点灵活安排热身、技术练习、曲目练习等环节) -3. 针对每个问题给出具体的日常练习方法 -4. 给出3-5条重点注意事项 - -请使用Markdown格式,语言专业、简洁、有鼓励性。""", - }, - "report": { - "name": "报告导出模板", - "type": "report", - "description": "导出方案时使用的Markdown模板", - "sort_order": 0, - "content": """# 钢琴练习方案 - {student_name} - -**练习时间**: {practice_time} (共{total_minutes}分钟) -**生成时间**: {generated_at} - ---- - -## AI个性化报告 -{ai_report} - -## 问题诊断 -{problem_tags} - -## 每日练习计划 -{schedule_table} - ---- - -*坚持练习 · 必有进步*""", - } -} - - def init_default_templates(): """初始化默认模板(如果不存在)""" - for key, tmpl in DEFAULT_TEMPLATES.items(): - existing = Template.query.filter_by(name=tmpl["name"]).first() - if not existing: - t = Template( - name=tmpl["name"], - type=tmpl["type"], - content=tmpl["content"], - description=tmpl["description"], - sort_order=tmpl.get("sort_order", 0) - ) - db.session.add(t) - db.session.commit() + # 默认模板从数据库获取,按 sort_order 排序 + pass # 不再使用代码中的硬编码模板 @templates_bp.route("/") @@ -90,7 +22,7 @@ def templates_page(): @templates_bp.route("/templates", methods=["GET"]) -@admin_required +@login_required_json def get_templates(): """获取所有模板(按sort_order排序,可按type筛选)""" query = Template.query.order_by(Template.sort_order.asc()) @@ -151,6 +83,13 @@ def update_template(template_id): def delete_template(template_id): """删除模板""" tmpl = Template.query.get_or_404(template_id) + template_type = tmpl.type + + # 检查该类型模板总数,删除后必须至少剩1个 + count = Template.query.filter_by(type=template_type).count() + if count <= 1: + return jsonify({"error": f"无法删除:{template_type}类型至少需要保留1个模板"}), 400 + db.session.delete(tmpl) db.session.commit() return jsonify({"message": "删除成功"}) @@ -159,9 +98,9 @@ def delete_template(template_id): @templates_bp.route("/templates//render", methods=["POST"]) @admin_required def render_template_preview(template_type): - """渲染模板(用于预览)""" + """渲染模板(用于预览)- 使用该类型中 sort_order 最小的模板""" data = request.get_json() - tmpl = Template.query.filter_by(type=template_type).first() + tmpl = Template.query.filter_by(type=template_type).order_by(Template.sort_order.asc()).first() if not tmpl: return jsonify({"error": "模板不存在"}), 404 diff --git a/app/routes/users.py b/app/routes/users.py index ec33af7..2f614ed 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -27,11 +27,14 @@ def api_users_create(): """新增用户""" data = request.get_json() username = data.get("username", "").strip() + name = data.get("name", "").strip() or None password = data.get("password", "") role = data.get("role", "user") - if not username or not password: - return jsonify({"error": "请输入用户名和密码"}), 400 + if not username: + return jsonify({"error": "请输入用户名"}), 400 + if not password: + return jsonify({"error": "请输入密码"}), 400 if User.query.filter_by(username=username).first(): return jsonify({"error": "用户名已存在"}), 400 @@ -40,7 +43,7 @@ def api_users_create(): return jsonify({"error": "无效的角色"}), 400 try: - user = User(username=username, role=role) + user = User(username=username, name=name, role=role) user.set_password(password) db.session.add(user) db.session.commit() @@ -55,13 +58,15 @@ def api_users_create(): @main_bp.route("/api/users/", methods=["PUT"]) @admin_required def api_users_update(user_id): - """编辑用户(仅管理员可改角色)""" + """编辑用户""" user = User.query.get_or_404(user_id) data = request.get_json() if "role" in data: if data["role"] in ["admin", "user"]: user.role = data["role"] + if "name" in data: + user.name = data["name"].strip() or None try: db.session.commit() diff --git a/app/services/pdf_generator.py b/app/services/pdf_generator.py index 3317a90..120b5a8 100644 --- a/app/services/pdf_generator.py +++ b/app/services/pdf_generator.py @@ -1,63 +1,62 @@ -# PDF生成服务 - 支持中文 +# PDF生成服务 - 支持中文和富文本 import os -import re from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.lib import colors from reportlab.platypus import ( - SimpleDocTemplate, - Paragraph, - Spacer, - Table, - TableStyle, - PageBreak, + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle ) from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont -from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from reportlab.lib.enums import TA_CENTER, TA_LEFT +# 注册中文字体(包含常规和粗体) +FONT_PATH = r"C:\Windows\Fonts\msyh.ttc" +FONT_BOLD_PATH = r"C:\Windows\Fonts\msyhbd.ttc" -def register_chinese_font(): - """注册中文字体""" - # 尝试注册系统自带的中文字体 - font_paths = [ - # Windows常用字体路径 - r"C:\Windows\Fonts\simsun.ttc", # 宋体 - r"C:\Windows\Fonts\msyh.ttc", # 微软雅黑 - r"C:\Windows\Fonts\STSONG.TTF", # 华文宋体 - r"C:\Windows\Fonts\simhei.ttf", # 黑体 - # Linux容器字体路径 - "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", - "/usr/share/fonts/wqy-microhei/wqy-microhei.ttc", - "/usr/share/fonts/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - ] +try: + pdfmetrics.registerFont(TTFont("Chinese", FONT_PATH)) + pdfmetrics.registerFont(TTFont("Chinese-Bold", FONT_BOLD_PATH)) + CHINESE_FONT_OK = True +except Exception as e: + CHINESE_FONT_OK = False - # 尝试注册一个可用的字体 - for font_path in font_paths: - if os.path.exists(font_path): - try: - pdfmetrics.registerFont(TTFont("Chinese", font_path)) - return True - except Exception as e: - continue - - return False - - -# 尝试注册中文字体 -_has_chinese_font = register_chinese_font() - - -def clean_text(text): - """清理文本,移除不支持的字符""" +def md_to_xml(text): + """将markdown转换为reportlab XML markup""" if not text: return "" - # 移除多余空白 - text = re.sub(r"\s+", " ", text) - return text.strip() + result = [] + i = 0 + while i < len(text): + # 处理 bold **text** - 使用中文字体粗体 + if text[i:i+2] == '**': + end = text.find('**', i+2) + if end != -1: + if CHINESE_FONT_OK: + result.append(f'{text[i+2:end]}') + else: + result.append(f'{text[i+2:end]}') + i = end + 2 + continue + # 处理 italic *text* + if text[i] == '*' and (i == 0 or text[i-1] not in '*_'): + end = text.find('*', i+1) + if end != -1 and text[end-1] != '*': + result.append(f'{text[i+1:end]}') + i = end + 1 + continue + # 处理 inline code `text` + if text[i] == '`': + end = text.find('`', i+1) + if end != -1: + result.append(f'{text[i+1:end]}') + i = end + 1 + continue + result.append(text[i]) + i += 1 + return ''.join(result) class PianoPDF: @@ -65,141 +64,117 @@ class PianoPDF: self.elements = [] self.styles = getSampleStyleSheet() - # 创建中文字体样式 - if _has_chinese_font: + if CHINESE_FONT_OK: self.base_font = "Chinese" + self.bold_font = "Chinese-Bold" else: self.base_font = "Helvetica" + self.bold_font = "Helvetica-Bold" # 标题样式 self.title_style = ParagraphStyle( "CustomTitle", parent=self.styles["Heading1"], - fontName=self.base_font, + fontName=self.bold_font, fontSize=18, - spaceAfter=10 * mm, + spaceAfter=10*mm, alignment=TA_CENTER, ) - # 副标题样式 + # 二级标题 self.heading_style = ParagraphStyle( "CustomHeading", parent=self.styles["Heading2"], - fontName=self.base_font, + fontName=self.bold_font, fontSize=14, - spaceAfter=6 * mm, - spaceBefore=6 * mm, + spaceAfter=6*mm, + spaceBefore=6*mm, textColor=colors.HexColor("#2c3e50"), ) + # 三级标题 + self.h3_style = ParagraphStyle( + "CustomH3", + parent=self.styles["Heading3"], + fontName=self.bold_font, + fontSize=12, + spaceAfter=4*mm, + spaceBefore=4*mm, + textColor=colors.HexColor("#34495e"), + ) + # 正文样式 self.body_style = ParagraphStyle( "CustomBody", parent=self.styles["Normal"], fontName=self.base_font, fontSize=10, - spaceAfter=4 * mm, + spaceAfter=4*mm, leading=14, ) # 表格样式 - self.table_style = TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#3498db")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("FONTNAME", (0, 0), (-1, 0), self.base_font), - ("FONTSIZE", (0, 0), (-1, 0), 10), - ("BOTTOMPADDING", (0, 0), (-1, 0), 8), - ("BACKGROUND", (0, 1), (-1, -1), colors.white), - ("FONTNAME", (0, 1), (-1, -1), self.base_font), - ("FONTSIZE", (0, 1), (-1, -1), 9), - ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), - ( - "ROWBACKGROUNDS", - (0, 1), - (-1, -1), - [colors.white, colors.HexColor("#f8f9fa")], - ), - ] - ) + self.table_style = TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#3498db")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("FONTNAME", (0, 0), (-1, 0), self.bold_font), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("BOTTOMPADDING", (0, 0), (-1, 0), 8), + ("BACKGROUND", (0, 1), (-1, -1), colors.white), + ("FONTNAME", (0, 1), (-1, -1), self.base_font), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8f9fa")]), + ]) def add_title(self, text): - """添加标题""" - self.elements.append(Paragraph(clean_text(text), self.title_style)) - self.elements.append(Spacer(1, 5 * mm)) + self.elements.append(Paragraph(md_to_xml(text), self.title_style)) + self.elements.append(Spacer(1, 5*mm)) - def add_heading(self, text): - """添加副标题""" - self.elements.append(Paragraph(clean_text(text), self.heading_style)) + def add_heading(self, text, level=2): + if level == 3: + self.elements.append(Paragraph(md_to_xml(text), self.h3_style)) + else: + self.elements.append(Paragraph(md_to_xml(text), self.heading_style)) def add_paragraph(self, text): - """添加段落""" if text: - self.elements.append(Paragraph(clean_text(text), self.body_style)) - self.elements.append(Spacer(1, 3 * mm)) + self.elements.append(Paragraph(md_to_xml(text), self.body_style)) + self.elements.append(Spacer(1, 3*mm)) def add_list(self, items): - """添加列表""" for item in items: if item: - self.elements.append( - Paragraph(f"• {clean_text(item)}", self.body_style) - ) - self.elements.append(Spacer(1, 3 * mm)) + self.elements.append(Paragraph(f"• {md_to_xml(item)}", self.body_style)) + self.elements.append(Spacer(1, 3*mm)) def add_table(self, data): - """添加表格""" if not data or len(data) < 2: return - - # 确保所有数据都是字符串 table_data = [] for row in data: - table_data.append([clean_text(str(cell)) for cell in row]) - + table_data.append([md_to_xml(str(cell)) if cell else "" for cell in row]) if table_data: table = Table(table_data) table.setStyle(self.table_style) self.elements.append(table) - self.elements.append(Spacer(1, 5 * mm)) - - def add_spacer(self, height=5 * mm): - """添加间距""" - self.elements.append(Spacer(1, height)) + self.elements.append(Spacer(1, 5*mm)) def generate_pdf(plan_id, student_name, content, output_dir, rendered_report=None): - """ - 使用reportlab生成PDF文件 - - Args: - plan_id: 方案ID - student_name: 学员姓名 - content: 方案内容字典 - output_dir: 输出目录 - rendered_report: 可选的预渲染报告(Markdown格式),如果提供则使用模板渲染整个报告 - - Returns: - str: PDF文件路径 - """ + """生成PDF文件""" os.makedirs(output_dir, exist_ok=True) - output_path = os.path.join(output_dir, f"plan_{plan_id}.pdf") - # 创建PDF文档 doc = SimpleDocTemplate( - output_path, - pagesize=A4, - rightMargin=20 * mm, - leftMargin=20 * mm, - topMargin=20 * mm, - bottomMargin=20 * mm, + output_path, pagesize=A4, + rightMargin=20*mm, leftMargin=20*mm, + topMargin=20*mm, bottomMargin=20*mm, ) pdf = PianoPDF() - # 如果提供了预渲染的报告,使用模板渲染整个报告 if rendered_report: # 解析Markdown并添加到PDF lines = rendered_report.split('\n') @@ -216,62 +191,47 @@ def generate_pdf(plan_id, student_name, content, output_dir, rendered_report=Non continue if line.startswith('# ') and not line.startswith('## '): - # 一级标题作为主标题 - title = line.replace('# ', '').replace('*', '') - pdf.add_title(title) - elif line.startswith('## '): - # 二级标题 if in_table and table_data: pdf.add_table(table_data) table_data = [] in_table = False - heading = line.replace('## ', '').replace('**', '') - pdf.add_heading(heading) + pdf.add_title(line.replace('# ', '')) + elif line.startswith('## '): + if in_table and table_data: + pdf.add_table(table_data) + table_data = [] + in_table = False + pdf.add_heading(line.replace('## ', '')) + elif line.startswith('### '): + pdf.add_heading(line.replace('### ', ''), level=3) elif line.startswith('|') and '|' in line[1:]: - # 表格行 in_table = True - cells = [c.strip() for c in line.split('|')[1:-1]] - if cells and any(cells): - # 跳过表头分隔行 - if not all(c.strip().startswith('-') for c in cells if c.strip()): - table_data.append(cells) + raw_cells = [c.strip() for c in line.split('|')[1:-1]] + cells = [md_to_xml(c) if c else "" for c in raw_cells] + if cells and not all(c and c.strip().startswith('-') for c in cells if c): + table_data.append(cells) elif line.startswith('- '): - # 列表项 - pdf.add_paragraph(line) + pdf.add_paragraph(f"• {line[2:]}") elif line and not in_table: - # 普通段落 - pdf.add_paragraph(line.replace('**', '').replace('*', '')) - # 表格收尾 + pdf.add_paragraph(line) + if in_table and table_data: pdf.add_table(table_data) else: - # 使用原有结构化方式生成PDF - # 标题 + # 使用结构化内容 pdf.add_title(f"钢琴练习方案 - {student_name}") - - # 学员信息 - pdf.add_heading("📋 学员信息") + pdf.add_heading("学员信息") pdf.add_paragraph(f"学员姓名:{student_name}") pdf.add_paragraph(f"每日练习时间:{content.get('practice_time', 'N/A')}") pdf.add_paragraph(f"生成时间:{content.get('generated_at', '')}") - # 问题诊断 - pdf.add_heading("🔍 问题诊断") - for problem in content.get("problems", []): - name = problem.get("name", "") - severity = problem.get("severity", "") - focus = problem.get("focus", {}) - pdf.add_paragraph( - f"• {name} ({severity}) - 基础练习: {focus.get('basic', 0)}分钟, 技术练习: {focus.get('tech', 0)}分钟" - ) - - # AI报告(如果有) if content.get("ai_report"): - pdf.add_heading("📝 AI个性化练习报告") - # 将AI报告分段添加 + pdf.add_heading("AI个性化练习报告") for line in content["ai_report"].split("\n"): line = line.strip() - if line.startswith("## "): + if line.startswith("### "): + pdf.add_heading(line.replace("### ", ""), level=3) + elif line.startswith("## "): pdf.add_heading(line.replace("## ", "")) elif line.startswith("# "): pdf.add_title(line.replace("# ", "")) @@ -280,36 +240,5 @@ def generate_pdf(plan_id, student_name, content, output_dir, rendered_report=Non elif line: pdf.add_paragraph(line) - # 每日练习计划 - pdf.add_heading("📅 每日练习计划") - total_minutes = content.get("total_daily_minutes", 0) - pdf.add_paragraph(f"总时长:{total_minutes} 分钟") - - # 表格 - table_data = [["阶段", "时长", "内容", "目的"]] - for item in content.get("daily_schedule", []): - table_data.append( - [ - item.get("phase", ""), - item.get("duration", ""), - item.get("content", ""), - item.get("purpose", ""), - ] - ) - pdf.add_table(table_data) - - # 练习建议 - pdf.add_heading("💡 练习建议") - tips = [ - "每天在固定时间练习,养成良好习惯", - "练习前先热身,放松手指", - "先难后易,先练习重点部分", - "录音回听,发现问题", - "保持耐心,进步需要时间", - ] - pdf.add_list(tips) - - # 生成PDF doc.build(pdf.elements) - - return output_path + return output_path \ No newline at end of file diff --git a/app/services/plan_generator.py b/app/services/plan_generator.py index c3acf71..6d4facf 100644 --- a/app/services/plan_generator.py +++ b/app/services/plan_generator.py @@ -3,11 +3,12 @@ import os import re import requests +from datetime import datetime from app.config import load_api_config def generate_practice_plan( - student_name, problems, practice_time="30-60分钟" + student_name, problems, practice_time="30-60分钟", goals=None ): """ 根据学员问题和练习时间生成针对性练习方案 @@ -16,6 +17,7 @@ def generate_practice_plan( student_name: 学员姓名 problems: 问题列表 [{problem_id, problem_name, severity, level, content}] practice_time: 练习时间描述 + goals: 目标列表 [{goal_id, goal_name, goal_content, status, mastery_level}],默认为空列表 Returns: dict: 包含方案内容的字典 @@ -41,6 +43,7 @@ def generate_practice_plan( { "name": p["problem_name"], "severity": p["severity"], + "level": p.get("level", ""), "content": _extract_key_sections(content) if content else {"problem": f"针对{p['problem_name']}的练习"}, "time_allocation": _calculate_time_allocation( p["severity"], time_config @@ -48,9 +51,6 @@ def generate_practice_plan( } ) - # 生成每日练习计划 - daily_plan = _generate_daily_schedule(time_config, problem_contents) - # 生成方案 plan = { "student_name": student_name, @@ -60,12 +60,12 @@ def generate_practice_plan( { "name": p["name"], "severity": p["severity"], + "level": p.get("level", ""), "focus": p["time_allocation"], } for p in problem_contents ], - "daily_schedule": daily_plan, - "generated_at": "2026-04-17", + "generated_at": datetime.now().strftime("%Y-%m-%d"), } return plan @@ -143,85 +143,6 @@ def _calculate_time_allocation(severity, time_config): } -def _generate_daily_schedule(time_config, problem_contents): - """生成每日练习计划""" - total = time_config["total"] - - # 基础练习(哈农+音阶) - basic_time = time_config["basic"] - - # 技术练习(针对问题) - tech_time = time_config["tech"] - - # 曲目练习 - piece_time = time_config["piece"] - - schedule = [] - - # 1. 热身 - schedule.append( - { - "phase": "热身", - "duration": "3分钟", - "content": "手部放松操 + 呼吸调节", - "purpose": "放松肌肉,进入状态", - } - ) - - # 2. 基础练习 - if basic_time > 0: - schedule.append( - { - "phase": "基础练习", - "duration": f"{basic_time}分钟", - "content": "哈农练习曲 + 音阶练习", - "purpose": "建立基本功", - } - ) - - # 3. 技术练习(针对问题) - if tech_time > 0 and problem_contents: - tech_items = [] - for p in problem_contents: - practices = p.get("content", {}).get("practices", []) - if practices: - # 取前2个练习 - for practice in practices[:2]: - tech_items.append(practice["name"]) - - schedule.append( - { - "phase": "技术练习", - "duration": f"{tech_time}分钟", - "content": "、".join(tech_items[:3]) if tech_items else "针对性练习", - "purpose": "解决具体问题", - } - ) - - # 4. 曲目练习 - if piece_time > 0: - schedule.append( - { - "phase": "曲目练习", - "duration": f"{piece_time}分钟", - "content": "复习所学曲目 + 预习新曲目", - "purpose": "提升演奏能力", - } - ) - - # 5. 总结 - schedule.append( - { - "phase": "总结", - "duration": "3分钟", - "content": "练习记录 + 问题检查 + 明日计划", - "purpose": "巩固学习成果", - } - ) - - return schedule - - def _get_severity_advice(severity): """根据严重程度给出建议""" advice = { @@ -236,7 +157,7 @@ def _get_severity_advice(severity): def generate_ai_report( - student_name, wechat_nickname, problems, practice_time, time_config, template_id=None, dry_run=False + student_name, wechat_nickname, problems, practice_time, time_config, template_id=None, dry_run=False, goals=None ): """ 使用AI生成个性化的练习报告 @@ -249,6 +170,11 @@ def generate_ai_report( time_config: 时间配置 {total, basic, tech, piece} template_id: 可选的AI提示词模板ID dry_run: 如果为True,只返回提示词不调用API + goals: 目标列表 [{goal_id, goal_name, goal_content, status, mastery_level}] + practice_time: 练习时间描述 + time_config: 时间配置 {total, basic, tech, piece} + template_id: 可选的AI提示词模板ID + dry_run: 如果为True,只返回提示词不调用API Returns: tuple: (prompt, ai_report, error) - 提示词、AI报告内容、错误信息 @@ -324,14 +250,25 @@ def generate_ai_report( f"**详细内容和练习方法**:\n{content}" ) + # 构建学员未达成目标列表(只包含未完成的目标) + student_goals = [] + if goals: + for g in goals: + # 只处理未完成的目标 + if g.get("status") != "已完成": + student_goals.append( + f"- **{g['goal_name']}**\n 内容:{g.get('goal_content', '未提供具体内容')}" + ) + # 使用配置的模板,如果为空则使用默认模板 if prompt_template: prompt = prompt_template.format( student_name=student_name, wechat_nickname=wechat_nickname or "未设置", practice_time=practice_time, - student_problems="\n".join(student_problems), - problems="\n\n".join(problems_full), + student_problems="\n".join(student_problems) if student_problems else "无", + problems="\n\n".join(problems_full) if problems_full else "无", + student_goals="\n".join(student_goals) if student_goals else "无", ) else: # 默认模板(向后兼容) @@ -363,7 +300,15 @@ def generate_ai_report( # 如果是 dry_run(预览模式),直接返回提示词不调用API if dry_run: - return prompt, None, None + # 计算各部分字数 + student_problems_text = "\n".join(student_problems) if student_problems else "无" + problems_text = "\n\n".join(problems_full) if problems_full else "无" + student_goals_text = "\n".join(student_goals) if student_goals else "无" + return prompt, None, None, { + "student_problems_length": len(student_problems_text), + "problems_length": len(problems_text), + "student_goals_length": len(student_goals_text), + } # 调用API headers = { @@ -407,7 +352,7 @@ def generate_ai_report( content = ( result.get("choices", [{}])[0].get("message", {}).get("content", "") ) - return prompt, content, None + return prompt, content, None, None else: error_msg = f"API错误: {response.status_code}" try: @@ -415,9 +360,9 @@ def generate_ai_report( error_msg = error_detail.get("error", {}).get("message", error_msg) except: pass - return prompt, None, error_msg + return prompt, None, error_msg, None except requests.exceptions.Timeout: - return prompt, None, "API请求超时,请稍后重试" + return prompt, None, "API请求超时,请稍后重试", None except Exception as e: - return prompt, None, f"调用API失败: {str(e)}" + return prompt, None, f"调用API失败: {str(e)}", None diff --git a/app/static/js/plan_common.js b/app/static/js/plan_common.js index 1ff58be..6d32c67 100644 --- a/app/static/js/plan_common.js +++ b/app/static/js/plan_common.js @@ -35,19 +35,6 @@ async function viewPlan(planId) { `; } - html += ` -
每日练习计划(共${data.content.total_daily_minutes}分钟)
- - - - `; - - data.content.daily_schedule.forEach(item => { - html += ``; - }); - - html += '
环节时长内容目的
${item.phase}${item.duration}${item.content}${item.purpose}
'; - document.getElementById('planDetailContent').innerHTML = html; new bootstrap.Modal(document.getElementById('planDetailModal')).show(); } diff --git a/app/templates/base.html b/app/templates/base.html index dc9d975..b3d05f5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -98,7 +98,7 @@ 问题配置 - + 班级管理 + {% block sidebar_extra %}
diff --git a/app/templates/classes.html b/app/templates/classes.html index 3132df7..9b93898 100644 --- a/app/templates/classes.html +++ b/app/templates/classes.html @@ -11,6 +11,9 @@ + - ${isAdmin ? ` + ${isAdmin ? ` ` : ''} @@ -194,7 +234,9 @@ function loadClasses() { document.getElementById('saveClassBtn').onclick = () => { const id = document.getElementById('classId').value; const name = document.getElementById('className').value.trim(); + const teacherId = document.getElementById('classTeacher').value; const description = document.getElementById('classDesc').value; + const level = document.getElementById('classLevel').value; const active = document.getElementById('classActive').checked; if (!name) { @@ -206,7 +248,7 @@ document.getElementById('saveClassBtn').onclick = () => { fetch('/api/classes' + (id ? '/' + id : ''), { method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description, active }) + body: JSON.stringify({ name, description, level, active, teacher_id: teacherId ? parseInt(teacherId) : null }) }).then(r => r.json()).then(data => { if (data.error) { showToast(data.error); @@ -221,12 +263,14 @@ document.getElementById('saveClassBtn').onclick = () => { }; // 编辑班级 -function editClass(id, name, desc, active) { +function editClass(id, name, teacherId, desc, active, level) { document.getElementById('classId').value = id; document.getElementById('className').value = name; document.getElementById('classDesc').value = desc; document.getElementById('classActive').checked = active !== false; + document.getElementById('classLevel').value = level || '启蒙'; document.getElementById('classModalTitle').textContent = '编辑班级'; + loadTeacherOptions(teacherId); new bootstrap.Modal(document.getElementById('classModal')).show(); } @@ -289,6 +333,18 @@ document.getElementById('confirmAssignBtn').onclick = () => { }); }; +// 加载班主任选项 +function loadTeacherOptions(selectedId) { + fetch('/api/teachers').then(r => r.json()).then(users => { + const select = document.getElementById('classTeacher'); + select.innerHTML = ''; + users.forEach(u => { + const selected = u.id === selectedId ? 'selected' : ''; + select.innerHTML += ``; + }); + }); +} + // 新增班级按钮 document.getElementById('addClassBtn').onclick = () => { document.getElementById('classId').value = ''; @@ -296,6 +352,7 @@ document.getElementById('addClassBtn').onclick = () => { document.getElementById('classDesc').value = ''; document.getElementById('classActive').checked = true; document.getElementById('classModalTitle').textContent = '新增班级'; + loadTeacherOptions(null); new bootstrap.Modal(document.getElementById('classModal')).show(); }; @@ -348,6 +405,22 @@ document.getElementById('assign-assessment-date').addEventListener('change', fun } }); +// 开始日期联动:修改开始日期后,如果使用"XX天后"评估,自动重新计算评估日期 +document.getElementById('assign-start-date').addEventListener('change', function() { + const startDateStr = this.value; + const daysStr = document.getElementById('assign-assessment-days').value; + if (startDateStr && daysStr) { + const days = parseInt(daysStr); + const [y, m, d] = startDateStr.split('-').map(Number); + const startDate = new Date(y, m - 1, d); + startDate.setDate(startDate.getDate() + days); + const yy = startDate.getFullYear(); + const mm = String(startDate.getMonth() + 1).padStart(2, '0'); + const dd = String(startDate.getDate()).padStart(2, '0'); + document.getElementById('assign-assessment-date').value = `${yy}-${mm}-${dd}`; + } +}); + // 确认分配目标 document.getElementById('confirm-assign-goal').addEventListener('click', async () => { const goalId = document.getElementById('assign-goal-select').value; diff --git a/app/templates/goals.html b/app/templates/goals.html index 09f09f6..4cbdbc8 100644 --- a/app/templates/goals.html +++ b/app/templates/goals.html @@ -339,8 +339,13 @@ function editGoal(id) { } async function deleteGoal(id) { - if (!confirm('确定删除此目标?')) return; - await fetch(`${API_BASE}/${id}`, {method: 'DELETE'}); + if (!confirm('确定删除此目标?此操作不可恢复。')) return; + const resp = await fetch(`${API_BASE}/${id}`, {method: 'DELETE'}); + const data = await resp.json(); + if (!resp.ok) { + alert('删除失败:' + data.error); + return; + } loadGoals(); } diff --git a/app/templates/index.html b/app/templates/index.html index bfb3fe5..98fd86a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -68,6 +68,9 @@ +
@@ -835,9 +857,9 @@ function renderPlanList(plans) { 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}个` : ''; + if (p.problem_details && p.problem_details.length > 0) { + const problems = p.problem_details.slice(0, 3).map(d => `${d.name}[${d.level}/${d.severity}]`).join('、'); + const more = p.problem_details.length > 3 ? `等${p.problem_details.length}个` : ''; problemText = `${problems}${more}`; } diff --git a/app/templates/plan_detail.html b/app/templates/plan_detail.html index a9f52ae..f7d9195 100644 --- a/app/templates/plan_detail.html +++ b/app/templates/plan_detail.html @@ -6,7 +6,7 @@

方案详情

-
@@ -40,16 +40,24 @@ async function loadPlan() { 生成时间:${data.created_at}    模板:${data.template_name || '无'}
- + 查看学员 - +
+ + +
+
编辑 - -
@@ -78,20 +86,8 @@ async function loadPlan() { `; } - html += ` -
每日练习计划(共${data.content.total_daily_minutes}分钟)
- - - - `; - - data.content.daily_schedule.forEach(item => { - html += ``; - }); - - html += '
环节时长内容目的
${item.phase}${item.duration}${item.content}${item.purpose}
'; - document.getElementById('planContent').innerHTML = html; + loadTemplates(); } catch (e) { document.getElementById('planContent').innerHTML = `
@@ -102,12 +98,57 @@ async function loadPlan() { } } -function downloadPDF() { - window.open(`/api/plans/${currentPlanId}/pdf`, '_blank'); +function updateDownloadLinks() { + // No longer needed - buttons now use downloadPDFWithTemplate/downloadMDWithTemplate directly } -function downloadMD() { - window.open(`/api/plans/${currentPlanId}/md`, '_blank'); +function downloadPDFWithTemplate() { + const templateId = document.getElementById('reportTemplateSelect')?.value; + const suffix = templateId ? `?template_id=${templateId}` : ''; + window.open(`/api/plans/${currentPlanId}/pdf${suffix}`, '_blank'); +} + +function downloadMDWithTemplate() { + const templateId = document.getElementById('reportTemplateSelect')?.value; + const suffix = templateId ? `?template_id=${templateId}` : ''; + window.open(`/api/plans/${currentPlanId}/md${suffix}`, '_blank'); +} + +async function loadTemplates() { + try { + const resp = await fetch('/templates/templates?type=report'); + if (resp.ok) { + const templates = await resp.json(); + const select = document.getElementById('reportTemplateSelect'); + select.innerHTML = templates.map(t => ``).join(''); + } + } catch (e) { + console.error('加载模板失败:', e); + } +} + +// 设为典型 +async function toggleTypical(planId, isTypical) { + try { + await fetch(`/api/plans/${planId}/typical`, {method: 'POST'}); + } catch (e) { + alert('设置失败: ' + e.message); + } +} + +// 返回按钮处理:如果是编辑页返回的,跳过编辑页 +function goBack() { + if (sessionStorage.getItem('fromEdit') === 'true') { + sessionStorage.removeItem('fromEdit'); + history.go(-2); // 跳过编辑页 + } else { + history.back(); + } +} + +// 标记来源为编辑页(编辑页点击"返回详情"前设置) +function markFromEdit() { + sessionStorage.setItem('fromEdit', 'true'); } window.currentStudentId = null; diff --git a/app/templates/plan_edit.html b/app/templates/plan_edit.html index 7c3d563..52aeb07 100644 --- a/app/templates/plan_edit.html +++ b/app/templates/plan_edit.html @@ -6,7 +6,7 @@

编辑方案

@@ -42,7 +42,7 @@