更新:models/routes/services/templates/docs
This commit is contained in:
@@ -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 字段现在由日期计算,不再存储
|
||||
|
||||
+73
-12
@@ -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,
|
||||
|
||||
+26
-3
@@ -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"]
|
||||
|
||||
|
||||
+30
-4
@@ -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/<int:goal_id>", 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": "删除成功"})
|
||||
|
||||
+130
-42
@@ -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,
|
||||
})
|
||||
|
||||
+2
-10
@@ -18,23 +18,15 @@ def get_student_problems(student_id):
|
||||
@main_bp.route("/api/students/<int:student_id>/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(
|
||||
|
||||
+104
-34
@@ -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/<int:student_id>/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/<int:student_id>/goals/<int:goal_id>", 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/<int:evaluation_id>", 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/<int:student_id>/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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
+13
-74
@@ -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/<string:template_type>/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
|
||||
|
||||
+9
-4
@@ -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/<int:user_id>", methods=["PUT"])
|
||||
@admin_required
|
||||
def api_users_update(user_id):
|
||||
"""编辑用户(仅管理员可改角色)"""
|
||||
"""编辑用户"""
|
||||
user = User.query.get_or_404(user_id)
|
||||
data = request.get_json()
|
||||
|
||||
if "role" in data:
|
||||
if data["role"] in ["admin", "user"]:
|
||||
user.role = data["role"]
|
||||
if "name" in data:
|
||||
user.name = data["name"].strip() or None
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
|
||||
+118
-189
@@ -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'<font name="Chinese-Bold">{text[i+2:end]}</font>')
|
||||
else:
|
||||
result.append(f'<b>{text[i+2:end]}</b>')
|
||||
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'<i>{text[i+1:end]}</i>')
|
||||
i = end + 1
|
||||
continue
|
||||
# 处理 inline code `text`
|
||||
if text[i] == '`':
|
||||
end = text.find('`', i+1)
|
||||
if end != -1:
|
||||
result.append(f'<font name="Courier">{text[i+1:end]}</font>')
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -35,19 +35,6 @@ async function viewPlan(planId) {
|
||||
`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<a class="nav-link {% if active_nav == 'problems' %}active{% endif %}" href="/problems">
|
||||
<i class="bi bi-gear"></i> 问题配置
|
||||
</a>
|
||||
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes">
|
||||
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes" id="classesNav">
|
||||
<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;">
|
||||
@@ -107,6 +107,9 @@
|
||||
<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>
|
||||
<a class="nav-link {% if active_nav == 'users' %}active{% endif %}" href="/users" id="usersNav" style="display:none;">
|
||||
<i class="bi bi-person"></i> 用户管理
|
||||
</a>
|
||||
{% block sidebar_extra %}
|
||||
<hr>
|
||||
<a class="nav-link" href="#" onclick="showChangePasswordModal(); return false;">
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<option value="true" selected>进行中</option>
|
||||
<option value="false">已结束</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm active" id="mineFilterBtn" onclick="toggleMineFilter()">
|
||||
<i class="bi bi-person"></i> 我的
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="addClassBtn" style="display:none;">
|
||||
<i class="bi bi-plus-circle"></i> 新增班级
|
||||
@@ -24,6 +27,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>班级名称</th>
|
||||
<th>级别</th>
|
||||
<th>描述</th>
|
||||
<th>进行中</th>
|
||||
<th>学员数</th>
|
||||
@@ -64,10 +68,26 @@
|
||||
<label class="form-label">班级名称</label>
|
||||
<input type="text" class="form-control" id="className" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">班主任</label>
|
||||
<select class="form-select" id="classTeacher">
|
||||
<option value="">未指定</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea class="form-control" id="classDesc" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">级别</label>
|
||||
<select class="form-select" id="classLevel">
|
||||
<option value="启蒙">启蒙</option>
|
||||
<option value="启蒙" selected>启蒙</option>
|
||||
<option value="进阶">进阶</option>
|
||||
<option value="熟练">熟练</option>
|
||||
<option value="精通">精通</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="classActive" checked>
|
||||
<label class="form-check-label" for="classActive">进行中</label>
|
||||
@@ -165,10 +185,29 @@ window.pageInit = function(data) {
|
||||
loadClasses();
|
||||
};
|
||||
|
||||
// 我的班级筛选
|
||||
function toggleMineFilter() {
|
||||
const btn = document.getElementById('mineFilterBtn');
|
||||
btn.classList.toggle('active');
|
||||
if (btn.classList.contains('active')) {
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-primary');
|
||||
} else {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
loadClasses();
|
||||
}
|
||||
|
||||
// 加载班级列表
|
||||
function loadClasses() {
|
||||
const activeFilter = document.getElementById('activeFilter').value;
|
||||
const url = activeFilter ? '/api/classes?active=' + activeFilter : '/api/classes';
|
||||
const mineFilter = document.getElementById('mineFilterBtn').classList.contains('active');
|
||||
let url = '/api/classes?';
|
||||
if (activeFilter) url += 'active=' + activeFilter + '&';
|
||||
if (mineFilter) url += 'mine=true&';
|
||||
url = url.endsWith('&') ? url.slice(0, -1) : url;
|
||||
url = url.endsWith('?') ? '/api/classes' : url;
|
||||
fetch(url).then(r => r.json()).then(classes => {
|
||||
const tbody = document.querySelector('#classesTable tbody');
|
||||
const isAdmin = currentUserRole === 'admin';
|
||||
@@ -176,13 +215,14 @@ function loadClasses() {
|
||||
<tr>
|
||||
<td>${c.id}</td>
|
||||
<td>${c.name}</td>
|
||||
<td>${c.level || '启蒙'}</td>
|
||||
<td>${c.description || '-'}</td>
|
||||
<td>${c.active ? '<span class="badge bg-success">进行中</span>' : '<span class="badge bg-secondary">已结束</span>'}</td>
|
||||
<td><a href="#" onclick="viewClassStudents(${c.id})">${c.student_count}</a></td>
|
||||
<td>${c.created_at}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-success me-1" onclick="openAssignGoalModal(${c.id}, '${c.name}')">分配目标</button>
|
||||
${isAdmin ? `<button type="button" class="btn btn-sm btn-primary me-1" onclick="editClass(${c.id}, '${c.name}', '${c.description || ''}', ${c.active})">编辑</button>
|
||||
${isAdmin ? `<button type="button" class="btn btn-sm btn-primary me-1" onclick="editClass(${c.id}, '${c.name}', ${c.teacher_id || 'null'}, '${c.description || ''}', ${c.active}, '${c.level || '启蒙'}')">编辑</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteClass(${c.id})">删除</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 = '<option value="">未指定</option>';
|
||||
users.forEach(u => {
|
||||
const selected = u.id === selectedId ? 'selected' : '';
|
||||
select.innerHTML += `<option value="${u.id}" ${selected}>${u.name}</option>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 新增班级按钮
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
<option value="">全部班级</option>
|
||||
</select>
|
||||
<input type="text" class="form-control form-control-sm" style="width:150px;" placeholder="搜索姓名..." id="nameFilter" oninput="loadStudents()">
|
||||
<button class="btn btn-primary btn-sm active" id="mineStudentFilterBtn" onclick="toggleMineStudentFilter()">
|
||||
<i class="bi bi-person"></i> 我的
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-secondary" onclick="downloadTemplate()">
|
||||
@@ -480,10 +483,14 @@ function importStudents(input) {
|
||||
async function loadStudents() {
|
||||
const classId = document.getElementById('classFilter').value;
|
||||
const name = document.getElementById('nameFilter').value;
|
||||
const mineFilter = document.getElementById('mineStudentFilterBtn').classList.contains('active');
|
||||
|
||||
let url = '/api/students?';
|
||||
if (classId) url += 'class_id=' + classId + '&';
|
||||
if (name) url += 'name=' + encodeURIComponent(name);
|
||||
if (name) url += 'name=' + encodeURIComponent(name) + '&';
|
||||
if (mineFilter) url += 'mine=true&';
|
||||
|
||||
url = url.endsWith('&') ? url.slice(0, -1) : url;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.status === 401) {
|
||||
@@ -494,6 +501,20 @@ async function loadStudents() {
|
||||
renderStudentList(students);
|
||||
}
|
||||
|
||||
// 我的学员筛选
|
||||
function toggleMineStudentFilter() {
|
||||
const btn = document.getElementById('mineStudentFilterBtn');
|
||||
btn.classList.toggle('active');
|
||||
if (btn.classList.contains('active')) {
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-primary');
|
||||
} else {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
loadStudents();
|
||||
}
|
||||
|
||||
// 加载班级筛选选项
|
||||
async function loadClassFilter() {
|
||||
try {
|
||||
@@ -545,6 +566,7 @@ function renderStudentList(students) {
|
||||
<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>
|
||||
${s.goal_count > 0 ? `<span class="badge bg-success">${s.goal_count}个目标(${s.completed_goal_count}已达成)</span>` : ''}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4><i class="bi bi-file-text"></i> 方案详情</h4>
|
||||
<div>
|
||||
<button onclick="history.back()" class="btn btn-outline-secondary">
|
||||
<button onclick="goBack()" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> 返回
|
||||
</button>
|
||||
</div>
|
||||
@@ -40,16 +40,24 @@ async function loadPlan() {
|
||||
<strong>生成时间:</strong>${data.created_at}
|
||||
<strong>模板:</strong>${data.template_name || '无'}
|
||||
<div class="mt-2">
|
||||
<a href="/?student_id=${data.student_id}&from=${encodeURIComponent(window.location.href)}" class="btn btn-sm btn-outline-primary">
|
||||
<a href="/student/${data.student_id}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-person"></i> 查看学员
|
||||
</a>
|
||||
<a href="/plan/${currentPlanId}/edit" class="btn btn-sm btn-warning">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="typicalToggle" ${data.is_typical ? 'checked' : ''} onchange="toggleTypical(${currentPlanId}, this.checked)">
|
||||
<label class="form-check-label" for="typicalToggle">典型方案</label>
|
||||
</div>
|
||||
<a href="/plan/${currentPlanId}/edit" class="btn btn-sm btn-warning" onclick="markFromEdit()">
|
||||
<i class="bi bi-edit"></i> 编辑
|
||||
</a>
|
||||
<button onclick="downloadPDF()" class="btn btn-sm btn-primary">
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="reportTemplateSelect" class="form-select form-select-sm" style="width: auto;" onchange="updateDownloadLinks()">
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="downloadPDFWithTemplate()" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-download"></i> 下载PDF
|
||||
</button>
|
||||
<button onclick="downloadMD()" class="btn btn-sm btn-outline-primary">
|
||||
<button onclick="downloadMDWithTemplate()" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-markdown"></i> 下载MD
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,20 +86,8 @@ async function loadPlan() {
|
||||
`;
|
||||
}
|
||||
|
||||
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('planContent').innerHTML = html;
|
||||
loadTemplates();
|
||||
} catch (e) {
|
||||
document.getElementById('planContent').innerHTML = `
|
||||
<div class="card-body text-center text-danger py-5">
|
||||
@@ -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 => `<option value="${t.id}">${t.name}</option>`).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;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4><i class="bi bi-edit"></i> 编辑方案</h4>
|
||||
<div>
|
||||
<a href="/plan/{{ plan_id }}" class="btn btn-outline-secondary">
|
||||
<a href="/plan/{{ plan_id }}" class="btn btn-outline-secondary" onclick="location.replace(this.href); return false;">
|
||||
<i class="bi bi-arrow-left"></i> 返回详情
|
||||
</a>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="history.back()">取消</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="location.replace('/plan/{{ plan_id }}')">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="savePlanContent()">
|
||||
<i class="bi bi-save"></i> 保存
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button class="btn btn-primary active w-100" id="minePlansBtn" onclick="toggleMinePlans()">我的</button>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">班级</label>
|
||||
<select class="form-select" id="filterClass" onchange="loadPlans()">
|
||||
@@ -44,7 +47,7 @@
|
||||
<option value="true">仅典型</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">学员姓名</label>
|
||||
<input type="text" class="form-control" id="filterStudentName" placeholder="模糊搜索..." oninput="debounceLoad()">
|
||||
</div>
|
||||
@@ -148,6 +151,11 @@ async function loadPlans() {
|
||||
params.append('problem_ids', parseInt(problemId));
|
||||
}
|
||||
|
||||
const mineBtn = document.getElementById('minePlansBtn');
|
||||
if (mineBtn && mineBtn.classList.contains('active')) {
|
||||
params.append('mine', 'true');
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/plans?${params}`);
|
||||
const plans = await resp.json();
|
||||
|
||||
@@ -173,8 +181,9 @@ async function loadPlans() {
|
||||
`;
|
||||
|
||||
plans.forEach(p => {
|
||||
const problems = (p.problem_names || []).slice(0, 3).join('、');
|
||||
const moreProblems = (p.problem_names || []).length > 3 ? `等${p.problem_names.length}个` : '';
|
||||
const details = p.problem_details || [];
|
||||
const problemText = details.slice(0, 3).map(d => `${d.name}[${d.level}/${d.severity}]`).join('、');
|
||||
const moreProblems = details.length > 3 ? `等${details.length}个` : '';
|
||||
const template = p.template_name || '无模板';
|
||||
const studentName = p.student_name || '未知';
|
||||
const className = p.class_name || '-';
|
||||
@@ -182,9 +191,9 @@ async function loadPlans() {
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td><strong>${studentName}</strong></td>
|
||||
<td><a href="/student/${p.student_id}" class="text-decoration-none"><strong>${studentName}</strong></a></td>
|
||||
<td>${className}</td>
|
||||
<td><span class="plan-problem-text">${problems}${moreProblems}</span></td>
|
||||
<td><span class="plan-problem-text">${problemText}${moreProblems}</span></td>
|
||||
<td class="text-muted small">${template}</td>
|
||||
<td class="text-center">${isTypical ? '<span class="text-warning">★</span>' : ''}</td>
|
||||
<td class="text-muted small">${p.created_at || ''}</td>
|
||||
@@ -210,6 +219,26 @@ function clearFilters() {
|
||||
document.getElementById('filterTemplate').value = '';
|
||||
document.getElementById('filterTypical').value = '';
|
||||
document.getElementById('filterStudentName').value = '';
|
||||
const mineBtn = document.getElementById('minePlansBtn');
|
||||
if (mineBtn) {
|
||||
mineBtn.classList.remove('active', 'btn-primary');
|
||||
mineBtn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
loadPlans();
|
||||
}
|
||||
|
||||
// 我的筛选
|
||||
function toggleMinePlans() {
|
||||
const btn = document.getElementById('minePlansBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('active');
|
||||
if (btn.classList.contains('active')) {
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-primary');
|
||||
} else {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
loadPlans();
|
||||
}
|
||||
|
||||
|
||||
+284
-84
@@ -156,8 +156,8 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">级别 *</label>
|
||||
<select class="form-select" id="addProblemLevel" required>
|
||||
<option value="启蒙">启蒙</option>
|
||||
<option value="入门" selected>入门</option>
|
||||
<option value="启蒙" selected>启蒙</option>
|
||||
<option value="入门">入门</option>
|
||||
<option value="进阶">进阶</option>
|
||||
<option value="熟练">熟练</option>
|
||||
<option value="精通">精通</option>
|
||||
@@ -225,12 +225,10 @@
|
||||
<label class="form-label">学员:{{ student.name }}</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="useAiReport" checked>
|
||||
<label class="form-check-label" for="useAiReport">
|
||||
生成AI个性化报告(需要配置API)
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-label">AI提示词模板</label>
|
||||
<select class="form-select" id="aiTemplateSelect">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="progress" style="height: 25px;">
|
||||
@@ -296,10 +294,15 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="assess-goal-id">
|
||||
<input type="hidden" id="assess-evaluation-id">
|
||||
<p class="fw-bold mb-3" id="assess-goal-name"></p>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">评估日期</label>
|
||||
<input type="date" class="form-control" id="assess-date">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">掌握程度</label>
|
||||
<select class="form-select" id="assess-mastery">
|
||||
<option value="1">⭐ 入门</option>
|
||||
@@ -309,14 +312,18 @@
|
||||
<option value="5">⭐⭐⭐⭐⭐ 精通</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">达成日期</label>
|
||||
<input type="date" class="form-control" id="assess-achievement-date">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">当前状态</label>
|
||||
<input type="text" class="form-control" id="assess-current-status" readonly>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="assess-is-final">
|
||||
<label class="form-check-label" for="assess-is-final">
|
||||
最终评估
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">评语</label>
|
||||
@@ -407,6 +414,7 @@ function renderProblemList(problems) {
|
||||
<strong>${p.problem_name}</strong>
|
||||
<span class="badge bg-${p.severity === '严重' ? 'danger' : p.severity === '中等' ? 'warning' : 'info'} ms-2">${p.severity}</span>
|
||||
<span class="badge bg-secondary ms-1">${p.level}</span>
|
||||
<span class="text-muted ms-2" style="font-size: 0.8em;">添加: ${p.created_at ? p.created_at.split('T')[0] : ''}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" onclick="showEditProblemModal(${p.id}, '${p.problem_name}', '${p.severity}', '${p.level}')">
|
||||
@@ -427,39 +435,56 @@ async function loadPlans() {
|
||||
|
||||
// 学习历程时间线
|
||||
async function loadTimeline() {
|
||||
const [plansRes, goalsRes] = await Promise.all([
|
||||
fetch(`/api/students/${currentStudentId}/plans`),
|
||||
fetch(`/api/students/${currentStudentId}/goals`)
|
||||
]);
|
||||
const plans = await plansRes.json();
|
||||
const goals = await goalsRes.json();
|
||||
|
||||
// 构建时间线条目
|
||||
const timeline = [];
|
||||
|
||||
// 添加目标开始记录
|
||||
goals.forEach(g => {
|
||||
if (g.start_date) {
|
||||
const startDate = new Date(g.start_date);
|
||||
const endDate = g.assessment_date ? new Date(g.assessment_date) : null;
|
||||
const days = endDate ? Math.ceil((endDate - startDate) / (1000*60*60*24)) : null;
|
||||
timeline.push({
|
||||
date: startDate,
|
||||
type: 'goal_start',
|
||||
goal: g,
|
||||
days: days,
|
||||
endDate: endDate
|
||||
});
|
||||
try {
|
||||
const [plansRes, goalsRes, evalsRes] = await Promise.all([
|
||||
fetch(`/api/students/${currentStudentId}/plans`),
|
||||
fetch(`/api/students/${currentStudentId}/goals`),
|
||||
fetch(`/api/students/${currentStudentId}/evaluations`)
|
||||
]);
|
||||
if (!plansRes.ok || !goalsRes.ok || !evalsRes.ok) {
|
||||
throw new Error('API request failed');
|
||||
}
|
||||
// 添加目标达成记录
|
||||
if (g.achievement_date) {
|
||||
const plans = await plansRes.json();
|
||||
const goals = await goalsRes.json();
|
||||
const evaluations = await evalsRes.json();
|
||||
|
||||
// 构建时间线条目
|
||||
const timeline = [];
|
||||
const today = new Date();
|
||||
|
||||
// 添加所有评估记录
|
||||
evaluations.forEach(e => {
|
||||
if (!e.assessment_date) return;
|
||||
timeline.push({
|
||||
date: new Date(g.achievement_date),
|
||||
type: 'goal_achieved',
|
||||
goal: g
|
||||
date: new Date(e.assessment_date),
|
||||
type: 'evaluation',
|
||||
evaluation: e,
|
||||
goalName: e.goal_name,
|
||||
goalLevel: e.goal_level
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 添加目标开始记录
|
||||
goals.forEach(g => {
|
||||
if (g.start_date) {
|
||||
const startDate = new Date(g.start_date);
|
||||
const assessmentDate = g.assessment_date ? new Date(g.assessment_date) : null;
|
||||
const days = assessmentDate ? Math.ceil((assessmentDate - startDate) / (1000*60*60*24)) : null;
|
||||
// 计算尚余天数(对于未完成的目标)
|
||||
let daysRemaining = null;
|
||||
if (days && g.status !== '已完成') {
|
||||
daysRemaining = Math.ceil((assessmentDate - today) / (1000*60*60*24));
|
||||
}
|
||||
timeline.push({
|
||||
date: startDate,
|
||||
type: 'goal_start',
|
||||
goal: g,
|
||||
days: days,
|
||||
daysRemaining: daysRemaining,
|
||||
assessmentDate: assessmentDate
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 添加方案生成记录
|
||||
plans.forEach(p => {
|
||||
@@ -474,6 +499,10 @@ async function loadTimeline() {
|
||||
timeline.sort((a, b) => b.date - a.date);
|
||||
|
||||
renderTimeline(timeline);
|
||||
} catch (err) {
|
||||
console.error('loadTimeline error:', err);
|
||||
document.getElementById('planList').innerHTML = '<p class="text-danger">加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline(timeline) {
|
||||
@@ -486,6 +515,17 @@ function renderTimeline(timeline) {
|
||||
container.innerHTML = timeline.map(entry => {
|
||||
if (entry.type === 'goal_start') {
|
||||
const g = entry.goal;
|
||||
const isCompleted = g.status === '已完成';
|
||||
let durationBadge = '';
|
||||
if (entry.days) {
|
||||
if (isCompleted) {
|
||||
durationBadge = `<span class="badge bg-secondary">预期 ${entry.days} 天</span>`;
|
||||
} else if (entry.daysRemaining !== null) {
|
||||
durationBadge = `<span class="badge bg-secondary">预期 ${entry.days} 天</span> <span class="badge bg-info">尚余 ${entry.daysRemaining} 天</span>`;
|
||||
} else {
|
||||
durationBadge = `<span class="badge bg-secondary">预期 ${entry.days} 天</span>`;
|
||||
}
|
||||
}
|
||||
return `
|
||||
<div class="d-flex align-items-start mb-2 p-2 border rounded border-primary">
|
||||
<div class="me-2">
|
||||
@@ -497,52 +537,78 @@ function renderTimeline(timeline) {
|
||||
<strong>${escapeHtml(g.goal_name)}</strong>
|
||||
<span class="text-muted ms-2">${formatDate(entry.date)}</span>
|
||||
</div>
|
||||
<span class="badge bg-secondary">预期 ${entry.days} 天</span>
|
||||
${durationBadge}
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
${g.goal_level || '入门'} | ${g.goal_category || '综合'} | 评估日期: ${entry.endDate ? formatDate(entry.endDate) : '未设置'}
|
||||
${g.goal_level || '入门'} | ${g.goal_category || '综合'} | 评估日期: ${entry.assessmentDate ? formatDate(entry.assessmentDate) : '未设置'}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (entry.type === 'goal_achieved') {
|
||||
const g = entry.goal;
|
||||
const stars = '⭐'.repeat(g.mastery_level || 1);
|
||||
} else if (entry.type === 'evaluation') {
|
||||
const e = entry.evaluation;
|
||||
const stars = '⭐'.repeat(e.mastery_level || 1);
|
||||
const badge = e.is_final
|
||||
? '<span class="badge bg-warning text-dark">最终评估</span>'
|
||||
: '<span class="badge bg-info">阶段评估</span>';
|
||||
let timingBadge = '';
|
||||
if (e.is_final && e.goal_start_date && e.goal_assessment_date) {
|
||||
const startDate = new Date(e.goal_start_date);
|
||||
const achievementDate = new Date(e.assessment_date);
|
||||
const assessmentDate = new Date(e.goal_assessment_date);
|
||||
const actualDays = Math.ceil((achievementDate - startDate) / (1000*60*60*24));
|
||||
const expectedDays = Math.ceil((assessmentDate - startDate) / (1000*60*60*24));
|
||||
const diff = actualDays - expectedDays;
|
||||
let timingInfo;
|
||||
if (diff < 0) {
|
||||
timingInfo = `提前${Math.abs(diff)}天达成`;
|
||||
} else if (diff > 0) {
|
||||
timingInfo = `延迟${diff}天达成`;
|
||||
} else {
|
||||
timingInfo = '按期达成';
|
||||
}
|
||||
timingBadge = `<div class="mt-1"><span class="badge bg-success">${timingInfo},共耗时 ${actualDays} 天</span></div>`;
|
||||
}
|
||||
return `
|
||||
<div class="d-flex align-items-start mb-2 p-2 border rounded border-success">
|
||||
<div class="d-flex align-items-start mb-2 p-2 border rounded ${e.is_final ? 'border-success' : 'border-info'}">
|
||||
<div class="me-2">
|
||||
<span class="badge bg-success">目标达成</span>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1" style="cursor:pointer" onclick='openAssessGoalFromEvaluation(${JSON.stringify(e)})'>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>${escapeHtml(g.goal_name)}</strong>
|
||||
<strong>${escapeHtml(e.goal_name || '未知目标')}</strong>
|
||||
<span class="text-muted ms-2">${formatDate(entry.date)}</span>
|
||||
</div>
|
||||
<span>${stars}</span>
|
||||
</div>
|
||||
${g.comment ? `<div class="small text-muted mt-1"><em>"${escapeHtml(g.comment)}"</em></div>` : ''}
|
||||
${timingBadge}
|
||||
${e.comment ? `<div class="small text-muted mt-1"><em>"${escapeHtml(e.comment)}"</em></div>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger ms-2" onclick="event.stopPropagation(); deleteEvaluation(${e.id})" title="删除">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
} else {
|
||||
const p = entry.plan;
|
||||
return `
|
||||
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}">
|
||||
<div class="form-check me-2">
|
||||
<input class="form-check-input" type="checkbox" id="typical-${p.id}" ${p.is_typical ? 'checked' : ''} onchange="toggleTypical(${p.id})">
|
||||
<label class="form-check-label" for="typical-${p.id}" title="设为典型"></label>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<a href="/plan/${p.id}" class="fw-bold text-decoration-none">${formatDate(entry.date)}</a>
|
||||
${p.is_typical ? '<span class="badge bg-warning text-dark ms-1">典型</span>' : ''}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary" title="查看">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})" title="删除">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
${p.problem_names && p.problem_names.length > 0 ? '问题: ' + p.problem_names.join(', ') : ''}
|
||||
${p.problem_details && p.problem_details.length > 0 ? '问题: ' + p.problem_details.map(d => d.name + '[' + d.level + '/' + d.severity + ']').join(', ') : ''}
|
||||
${p.template_name ? ' | 模板: ' + p.template_name : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -551,11 +617,6 @@ function renderTimeline(timeline) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleTypical(planId) {
|
||||
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
|
||||
loadTimeline();
|
||||
}
|
||||
|
||||
function showEditStudentModal() {
|
||||
editStudentModal.show();
|
||||
}
|
||||
@@ -596,7 +657,7 @@ function deleteStudent() {
|
||||
function showAddProblemModal() {
|
||||
document.getElementById('addProblemSelect').value = '';
|
||||
document.getElementById('addProblemSeverity').value = '中等';
|
||||
document.getElementById('addProblemLevel').value = '入门';
|
||||
document.getElementById('addProblemLevel').value = '启蒙';
|
||||
problemModal.show();
|
||||
}
|
||||
|
||||
@@ -695,14 +756,80 @@ function generatePlan() {
|
||||
progressBar.textContent = '0%';
|
||||
progressText.textContent = '准备中...';
|
||||
progressLog.innerHTML = '';
|
||||
|
||||
// 加载AI模板列表
|
||||
loadAiTemplates();
|
||||
|
||||
generateModal.show();
|
||||
}
|
||||
|
||||
// 模态框显示后加载提示词预览
|
||||
document.getElementById('generatePlanModal').addEventListener('shown.bs.modal', function () {
|
||||
loadPromptPreview();
|
||||
});
|
||||
|
||||
// 模板切换时更新预览
|
||||
document.getElementById('aiTemplateSelect').addEventListener('change', function () {
|
||||
loadPromptPreview();
|
||||
});
|
||||
|
||||
// 加载提示词预览
|
||||
async function loadPromptPreview() {
|
||||
const progressLog = document.getElementById('progressLog');
|
||||
const progressText = document.getElementById('progressText');
|
||||
|
||||
progressText.textContent = '正在生成提示词预览...';
|
||||
progressLog.innerHTML = '<div class="text-muted small">加载中...</div>';
|
||||
|
||||
try {
|
||||
const templateId = document.getElementById('aiTemplateSelect').value || null;
|
||||
const resp = await fetch('/api/generate-plan/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ student_id: currentStudentId, template_id: templateId })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// 显示完整提示词
|
||||
progressLog.innerHTML = `<div class="small" style="white-space: pre-wrap; font-family: monospace;">${escapeHtml(data.prompt)}</div>`;
|
||||
progressText.textContent = `提示词预览(共${data.prompt_length}字)`;
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
progressLog.innerHTML = `<div class="text-danger small">加载失败: ${err.error || '未知错误'}</div>`;
|
||||
progressText.textContent = '加载失败';
|
||||
}
|
||||
} catch (e) {
|
||||
progressLog.innerHTML = `<div class="text-danger small">加载失败: ${e.message}</div>`;
|
||||
progressText.textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// 加载AI模板列表
|
||||
async function loadAiTemplates() {
|
||||
try {
|
||||
const resp = await fetch('/templates/templates?type=ai_prompt');
|
||||
if (resp.ok) {
|
||||
const templates = await resp.json();
|
||||
const select = document.getElementById('aiTemplateSelect');
|
||||
select.innerHTML = templates.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
|
||||
// 模板加载完成后自动显示预览
|
||||
loadPromptPreview();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载AI模板失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startGeneratePlan() {
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const progressLog = document.getElementById('progressLog');
|
||||
const useAi = document.getElementById('useAiReport').checked;
|
||||
const startBtn = document.getElementById('startGenerateBtn');
|
||||
|
||||
// 禁用开始按钮
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = '生成中...';
|
||||
|
||||
progressBar.style.width = '0%';
|
||||
progressText.textContent = '准备中...';
|
||||
@@ -718,7 +845,7 @@ async function startGeneratePlan() {
|
||||
const response = await fetch('/api/generate-plan', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({student_id: currentStudentId, use_ai: useAi})
|
||||
body: JSON.stringify({student_id: currentStudentId, use_ai: true, template_id: document.getElementById('aiTemplateSelect').value || null})
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -742,19 +869,35 @@ async function startGeneratePlan() {
|
||||
|
||||
let logMsg = data.message;
|
||||
if (data.detail) {
|
||||
if (data.step === 'ai_prompt') {
|
||||
logMsg = '【AI提示词】\n' + data.detail;
|
||||
addLog(logMsg);
|
||||
continue;
|
||||
} else {
|
||||
logMsg += '\n └ ' + data.detail.replace(/\n/g, '\n └ ');
|
||||
}
|
||||
logMsg += '\n └ ' + data.detail.replace(/\n/g, '\n └ ');
|
||||
}
|
||||
addLog(logMsg);
|
||||
|
||||
// AI报告完成后显示字数统计
|
||||
if (data.step === 'complete') {
|
||||
addLog('─'.repeat(40));
|
||||
if (data.prompt_length) {
|
||||
addLog(`📤 提示词:${data.prompt_length} 字`);
|
||||
}
|
||||
if (data.student_problems_length) {
|
||||
addLog(` └ 问题摘要:${data.student_problems_length} 字`);
|
||||
}
|
||||
if (data.problems_length) {
|
||||
addLog(` └ 问题详情:${data.problems_length} 字`);
|
||||
}
|
||||
if (data.student_goals_length) {
|
||||
addLog(` └ 学员目标:${data.student_goals_length} 字`);
|
||||
}
|
||||
if (data.ai_report_length) {
|
||||
addLog(`📥 AI报告:${data.ai_report_length} 字`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.step === 'complete') {
|
||||
setTimeout(() => {
|
||||
generateModal.hide();
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '开始生成';
|
||||
alert('方案生成成功!');
|
||||
loadPlans();
|
||||
}, 500);
|
||||
@@ -762,6 +905,8 @@ async function startGeneratePlan() {
|
||||
|
||||
if (data.error) {
|
||||
addLog(data.error, true);
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '开始生成';
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -770,6 +915,8 @@ async function startGeneratePlan() {
|
||||
} catch (err) {
|
||||
addLog('请求失败:' + err.message, true);
|
||||
progressText.textContent = '生成失败';
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '开始生成';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,6 +996,7 @@ document.getElementById('remove-assigned-goal').addEventListener('click', () =>
|
||||
.then(() => {
|
||||
bootstrap.Modal.getInstance(document.getElementById('adjustGoalModal')).hide();
|
||||
loadStudentGoals();
|
||||
loadTimeline();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -876,35 +1024,69 @@ async function openAssessGoal(goalId) {
|
||||
if (!goal) return;
|
||||
|
||||
document.getElementById('assess-goal-id').value = goalId;
|
||||
document.getElementById('assess-evaluation-id').value = '';
|
||||
document.getElementById('assess-goal-name').textContent = goal.goal_name;
|
||||
document.getElementById('assess-date').value = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('assess-mastery').value = goal.mastery_level || '1';
|
||||
document.getElementById('assess-achievement-date').value = goal.achievement_date ? goal.achievement_date.split('T')[0] : '';
|
||||
document.getElementById('assess-current-status').value = goal.status;
|
||||
document.getElementById('assess-comment').value = goal.comment || '';
|
||||
document.getElementById('assess-is-final').checked = false;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('assessGoalModal')).show();
|
||||
}
|
||||
|
||||
function openAssessGoalFromEvaluation(evaluation) {
|
||||
// evaluation has: id, student_goal_id, mastery_level, comment, is_final, goal_name, assessment_date, etc.
|
||||
document.getElementById('assess-goal-id').value = evaluation.student_goal_goal_id;
|
||||
document.getElementById('assess-evaluation-id').value = evaluation.id;
|
||||
document.getElementById('assess-goal-name').textContent = evaluation.goal_name || '未知目标';
|
||||
document.getElementById('assess-date').value = evaluation.assessment_date ? evaluation.assessment_date.split('T')[0] : new Date().toISOString().split('T')[0];
|
||||
document.getElementById('assess-mastery').value = evaluation.mastery_level || '1';
|
||||
document.getElementById('assess-comment').value = evaluation.comment || '';
|
||||
document.getElementById('assess-is-final').checked = evaluation.is_final || false;
|
||||
|
||||
// Status cannot be edited from evaluation modal, show current status
|
||||
document.getElementById('assess-current-status').value = evaluation.is_final ? '已完成' : '进行中';
|
||||
|
||||
new bootstrap.Modal(document.getElementById('assessGoalModal')).show();
|
||||
}
|
||||
|
||||
document.getElementById('confirm-assess-goal').addEventListener('click', async () => {
|
||||
const goalId = document.getElementById('assess-goal-id').value;
|
||||
const evaluationId = document.getElementById('assess-evaluation-id').value;
|
||||
const assessmentDate = document.getElementById('assess-date').value;
|
||||
const masteryLevel = document.getElementById('assess-mastery').value;
|
||||
const achievementDate = document.getElementById('assess-achievement-date').value;
|
||||
const comment = document.getElementById('assess-comment').value;
|
||||
const isFinal = document.getElementById('assess-is-final').checked;
|
||||
|
||||
const body = {
|
||||
mastery_level: parseInt(masteryLevel),
|
||||
comment: comment,
|
||||
is_final: isFinal,
|
||||
assessment_date: assessmentDate || null
|
||||
};
|
||||
|
||||
if (evaluationId) {
|
||||
body.evaluation_id = parseInt(evaluationId);
|
||||
}
|
||||
|
||||
await fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
mastery_level: parseInt(masteryLevel),
|
||||
achievement_date: achievementDate || null,
|
||||
comment: comment
|
||||
})
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('assessGoalModal')).hide();
|
||||
loadStudentGoals();
|
||||
loadTimeline();
|
||||
});
|
||||
|
||||
async function deleteEvaluation(evaluationId) {
|
||||
if (!confirm('确定要删除这条评估记录吗?')) return;
|
||||
await fetch(`/api/evaluations/${evaluationId}`, { method: 'DELETE' });
|
||||
loadTimeline();
|
||||
}
|
||||
|
||||
// 加载可选目标列表到 Modal
|
||||
async function loadGoalOptions() {
|
||||
const res = await fetch('/api/goals');
|
||||
@@ -946,6 +1128,24 @@ document.getElementById('assign-assessment-date').addEventListener('change', fun
|
||||
}
|
||||
});
|
||||
|
||||
// 开始日期联动:修改开始日期后,如果使用"XX天后"评估,自动重新计算评估日期
|
||||
function updateAssessmentDateFromStartDate() {
|
||||
const startDateStr = document.getElementById('assign-start-date').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('assign-start-date').addEventListener('change', updateAssessmentDateFromStartDate);
|
||||
document.getElementById('assign-start-date').addEventListener('input', updateAssessmentDateFromStartDate);
|
||||
|
||||
document.getElementById('confirm-assign-goal').addEventListener('click', async () => {
|
||||
const goalId = document.getElementById('assign-goal-select').value;
|
||||
const assessmentDays = document.getElementById('assign-assessment-days').value;
|
||||
|
||||
@@ -145,18 +145,36 @@ function renderTemplateList() {
|
||||
list.innerHTML = '<div class="p-3 text-muted">暂无模板</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = templates.map(t => `
|
||||
<div class="template-card card m-2 p-2 ${currentTemplate && currentTemplate.id === t.id ? 'active' : ''}"
|
||||
onclick="selectTemplate(${t.id})">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${t.name}</strong>
|
||||
<br><small class="text-muted">${t.type === 'ai_prompt' ? 'AI提示词' : '报告模板'}</small>
|
||||
|
||||
// 按类型分组
|
||||
const groups = {};
|
||||
templates.forEach(t => {
|
||||
if (!groups[t.type]) groups[t.type] = [];
|
||||
groups[t.type].push(t);
|
||||
});
|
||||
|
||||
const typeNames = { 'ai_prompt': 'AI提示词模板', 'report': '报告导出模板' };
|
||||
|
||||
let html = '';
|
||||
Object.keys(groups).forEach(type => {
|
||||
html += `<div class="mb-3">
|
||||
<h6 class="text-muted border-bottom pb-1 mb-2">${typeNames[type] || type}</h6>`;
|
||||
groups[type].forEach(t => {
|
||||
html += `
|
||||
<div class="template-card card m-2 p-2 ${currentTemplate && currentTemplate.id === t.id ? 'active' : ''}"
|
||||
onclick="selectTemplate(${t.id})">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${t.name}</strong>
|
||||
<br><small class="text-muted">${t.description || ''}</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
async function selectTemplate(id) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4><i class="bi bi-person-badge"></i> 用户管理</h4>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#userModal">
|
||||
<button class="btn btn-primary" onclick="openAddUser()">
|
||||
<i class="bi bi-plus-circle"></i> 新增用户
|
||||
</button>
|
||||
</div>
|
||||
@@ -17,6 +17,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>姓名</th>
|
||||
<th>角色</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
@@ -42,6 +43,10 @@
|
||||
<label class="form-label">用户名</label>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">姓名</label>
|
||||
<input type="text" class="form-control" id="userName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" id="passwordLabel">初始密码</label>
|
||||
<input type="password" class="form-control" id="password">
|
||||
@@ -93,6 +98,8 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
|
||||
|
||||
window.pageInit = function() {
|
||||
loadUsers();
|
||||
};
|
||||
@@ -125,17 +132,19 @@ function loadUsers() {
|
||||
.then(users => {
|
||||
const tbody = document.querySelector('#usersTable tbody');
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">暂无用户</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">暂无用户</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td>${u.username}</td>
|
||||
<td>${u.name || '-'}</td>
|
||||
<td><span class="badge ${u.role === 'admin' ? 'bg-danger' : 'bg-secondary'}">${ROLE_LABELS[u.role]}</span></td>
|
||||
<td>${u.created_at}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary me-1" onclick="openEditUser(${u.id}, '${u.username}', '${u.name || ''}', '${u.role}')">编辑</button>
|
||||
<button class="btn btn-sm btn-warning me-1" onclick="openResetPwd(${u.id})">重置密码</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">删除</button>
|
||||
${u.role === 'admin' ? `<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">删除</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -147,23 +156,60 @@ function loadUsers() {
|
||||
});
|
||||
}
|
||||
|
||||
// 打开新增用户
|
||||
function openAddUser() {
|
||||
document.getElementById('userId').value = '';
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('username').readOnly = false;
|
||||
document.getElementById('userName').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('password').parentElement.querySelector('.form-text').textContent = '8位以上,包含大小写字母、数字和特殊字符';
|
||||
document.getElementById('role').value = 'user';
|
||||
document.getElementById('role').disabled = false;
|
||||
document.getElementById('userModalTitle').textContent = '新增用户';
|
||||
new bootstrap.Modal(document.getElementById('userModal')).show();
|
||||
}
|
||||
|
||||
// 打开编辑用户
|
||||
function openEditUser(id, username, name, role) {
|
||||
document.getElementById('userId').value = id;
|
||||
document.getElementById('username').value = username;
|
||||
document.getElementById('username').readOnly = true;
|
||||
document.getElementById('userName').value = name;
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('password').parentElement.querySelector('.form-text').textContent = '不填则保持不变';
|
||||
document.getElementById('role').value = role;
|
||||
document.getElementById('role').disabled = true;
|
||||
document.getElementById('userModalTitle').textContent = '编辑用户';
|
||||
new bootstrap.Modal(document.getElementById('userModal')).show();
|
||||
}
|
||||
|
||||
// 保存用户
|
||||
document.getElementById('saveUserBtn').onclick = () => {
|
||||
const id = document.getElementById('userId').value;
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const name = document.getElementById('userName').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const role = document.getElementById('role').value;
|
||||
|
||||
if (!username || !password) {
|
||||
alert('请填写完整');
|
||||
if (!username) {
|
||||
alert('请填写用户名');
|
||||
return;
|
||||
}
|
||||
if (!id && !password) {
|
||||
alert('请填写密码');
|
||||
return;
|
||||
}
|
||||
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const body = { username, role };
|
||||
if (name) body.name = name;
|
||||
if (password) body.password = password;
|
||||
|
||||
fetch('/api/users' + (id ? '/' + id : ''), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, role })
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.error) {
|
||||
showToast(data.error);
|
||||
|
||||
@@ -155,20 +155,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">📅 每日练习计划</div>
|
||||
{% for item in content.daily_schedule %}
|
||||
<div class="schedule-item">
|
||||
<div class="phase">{{ item.phase }}</div>
|
||||
<div class="details">
|
||||
<div class="content">{{ item.content }}</div>
|
||||
<div class="purpose">{{ item.purpose }}</div>
|
||||
</div>
|
||||
<div class="duration">{{ item.duration }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
|
||||
Reference in New Issue
Block a user