feat: v1.4.0 - 典型方案采纳、推荐方案列表、审计字段、导航优化
- 添加典型方案采纳功能 (POST /api/plans/<id>/adopt) - 添加推荐方案列表 (GET /api/students/<id>/recommended-plans) - PracticePlan 新增 created_by/updated_by/updated_at 审计字段 - 方案编辑/详情页导航优化 (bfcache 处理、pageshow 事件) - 方案列表支持删除功能 - 学员列表'暂无方案/问题'样式统一 - 更新文档:问题文件已废弃(迁移到数据库) - 更新部署脚本和验证清单
This commit is contained in:
@@ -125,6 +125,17 @@ def create_app():
|
||||
db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN is_typical INTEGER DEFAULT 0"))
|
||||
db.session.commit()
|
||||
|
||||
# 检查practice_plans表是否有created_by字段
|
||||
if "created_by" not in plan_columns2:
|
||||
db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN created_by INTEGER REFERENCES users(id)"))
|
||||
db.session.commit()
|
||||
if "updated_by" not in plan_columns2:
|
||||
db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN updated_by INTEGER REFERENCES users(id)"))
|
||||
db.session.commit()
|
||||
if "updated_at" not in plan_columns2:
|
||||
db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN updated_at TIMESTAMP"))
|
||||
db.session.commit()
|
||||
|
||||
# 检查goals表是否有level字段
|
||||
result7 = db.session.execute(text("PRAGMA table_info(goals)"))
|
||||
goal_columns = [row[1] for row in result7]
|
||||
|
||||
@@ -340,10 +340,15 @@ class PracticePlan(db.Model):
|
||||
template_id = db.Column(db.Integer, db.ForeignKey("templates.id"), nullable=True) # 使用的AI提示词模板
|
||||
is_typical = db.Column(db.Boolean, default=False, nullable=False) # 是否为典型方案
|
||||
content = db.Column(db.Text, nullable=False) # JSON格式存储方案内容
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) # 创建人
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) # 更新人
|
||||
updated_at = db.Column(db.DateTime, nullable=True) # 更新人,只在更新时设置
|
||||
|
||||
# 关联
|
||||
template = db.relationship("Template", foreign_keys=[template_id])
|
||||
creator = db.relationship("User", foreign_keys=[created_by])
|
||||
updater = db.relationship("User", foreign_keys=[updated_by])
|
||||
|
||||
def to_dict(self):
|
||||
import json as json_module
|
||||
@@ -370,6 +375,9 @@ class PracticePlan(db.Model):
|
||||
if self.student and self.student.class_obj:
|
||||
class_name = self.student.class_obj.name
|
||||
|
||||
# 采纳来源信息
|
||||
adopted_from = content_obj.get("adopted_from")
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"student_id": self.student_id,
|
||||
@@ -380,10 +388,16 @@ class PracticePlan(db.Model):
|
||||
"is_typical": self.is_typical,
|
||||
"problem_names": problem_names,
|
||||
"problem_details": problem_details,
|
||||
"created_by": self.creator.name if self.creator else None,
|
||||
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M")
|
||||
if self.created_at
|
||||
else None,
|
||||
"updated_by": self.updater.name if self.updater else None,
|
||||
"updated_at": self.updated_at.strftime("%Y-%m-%d %H:%M")
|
||||
if self.updated_at
|
||||
else None,
|
||||
"content": self.content,
|
||||
"adopted_from": adopted_from,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+198
-15
@@ -13,7 +13,7 @@ from flask import (
|
||||
session,
|
||||
)
|
||||
from app.routes import main_bp
|
||||
from app.models import db, Student, PracticePlan, StudentProblem, StudentGoal
|
||||
from app.models import db, Student, PracticePlan, StudentProblem, StudentGoal, Class
|
||||
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
|
||||
@@ -48,13 +48,30 @@ def get_all_plans():
|
||||
"""
|
||||
import json as json_module
|
||||
from app.models import Class
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy import exists
|
||||
|
||||
query = PracticePlan.query
|
||||
|
||||
# 按班级筛选
|
||||
# 检查是否需要 join Student
|
||||
needs_student_join = False
|
||||
needs_class_join = False
|
||||
|
||||
class_id = request.args.get('class_id', type=int)
|
||||
student_name = request.args.get('student_name')
|
||||
problem_ids = request.args.get('problem_ids')
|
||||
mine = request.args.get('mine')
|
||||
|
||||
if class_id or student_name or problem_ids or (mine and mine.lower() == 'true'):
|
||||
needs_student_join = True
|
||||
query = query.join(Student)
|
||||
if mine and mine.lower() == 'true':
|
||||
needs_class_join = True
|
||||
query = query.join(Class)
|
||||
|
||||
# 按班级筛选
|
||||
if class_id:
|
||||
query = query.join(Student).filter(Student.class_id == class_id)
|
||||
query = query.filter(Student.class_id == class_id)
|
||||
|
||||
# 按模板筛选
|
||||
template_id = request.args.get('template_id', type=int)
|
||||
@@ -67,19 +84,14 @@ def get_all_plans():
|
||||
query = query.filter(PracticePlan.is_typical == True)
|
||||
|
||||
# 按学员姓名模糊筛选
|
||||
student_name = request.args.get('student_name')
|
||||
if student_name:
|
||||
query = query.join(Student).filter(Student.name.like(f'%%{student_name}%%'))
|
||||
query = query.filter(Student.name.like(f'%%{student_name}%%'))
|
||||
|
||||
# 按问题筛选(通过 problem_id 关联到学员的问题)
|
||||
problem_ids = request.args.get('problem_ids')
|
||||
if problem_ids:
|
||||
problem_id_list = [int(pid.strip()) for pid in problem_ids.split(',') if pid.strip()]
|
||||
if problem_id_list:
|
||||
# 筛选:方案对应的学员有指定问题之一的
|
||||
# 使用子查询避免笛卡尔积导致的重复
|
||||
from sqlalchemy import exists
|
||||
query = query.join(Student).filter(
|
||||
query = query.filter(
|
||||
exists().where(
|
||||
(StudentProblem.student_id == Student.id) &
|
||||
(StudentProblem.problem_id.in_(problem_id_list))
|
||||
@@ -87,11 +99,10 @@ def get_all_plans():
|
||||
)
|
||||
|
||||
# 我的学员筛选(所在班级的老师是当前用户)
|
||||
mine = request.args.get('mine')
|
||||
if mine and mine.lower() == 'true':
|
||||
if needs_class_join:
|
||||
user_id = session.get('user_id')
|
||||
if user_id:
|
||||
query = query.join(Student).join(Class).filter(Class.teacher_id == user_id)
|
||||
query = query.filter(Class.teacher_id == user_id)
|
||||
|
||||
plans = query.order_by(PracticePlan.created_at.desc()).all()
|
||||
return jsonify([p.to_dict() for p in plans])
|
||||
@@ -107,6 +118,155 @@ def toggle_plan_typical(plan_id):
|
||||
return jsonify({"success": True, "is_typical": plan.is_typical})
|
||||
|
||||
|
||||
@main_bp.route("/api/students/<int:student_id>/recommended-plans", methods=["GET"])
|
||||
@login_required_json
|
||||
def get_recommended_plans(student_id):
|
||||
"""获取推荐方案 - 当前学员问题与典型方案问题有交集的方案
|
||||
|
||||
查询参数:
|
||||
- mine: true/false(我的学员的典型方案)
|
||||
|
||||
返回字段:
|
||||
- can_adopt: 问题集合是否完全一致,可采纳
|
||||
"""
|
||||
try:
|
||||
student = Student.query.get_or_404(student_id)
|
||||
|
||||
# 获取当前学员的问题名称集合
|
||||
student_problems = StudentProblem.query.filter_by(student_id=student_id).all()
|
||||
# 通过 Problem 关联获取问题名称
|
||||
student_problem_names = set()
|
||||
for sp in student_problems:
|
||||
if sp.problem:
|
||||
student_problem_names.add(sp.problem.name)
|
||||
|
||||
# 获取所有典型方案,排除当前学员自己的方案
|
||||
query = PracticePlan.query.filter(
|
||||
PracticePlan.is_typical == True,
|
||||
PracticePlan.student_id != student_id
|
||||
)
|
||||
|
||||
# 我的筛选:只显示当前用户创建的学员的典型方案
|
||||
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)
|
||||
|
||||
typical_plans = query.order_by(PracticePlan.created_at.desc()).all()
|
||||
|
||||
# 筛选:方案的问题与当前学员的问题有交集
|
||||
import json as json_module
|
||||
recommended = []
|
||||
for plan in typical_plans:
|
||||
try:
|
||||
content = json_module.loads(plan.content) if plan.content else {}
|
||||
except:
|
||||
continue
|
||||
|
||||
plan_problems = content.get('problems', [])
|
||||
# 提取方案中的问题名称
|
||||
plan_problem_names = set()
|
||||
for p in plan_problems:
|
||||
name = p.get('name') or p.get('problem_name', '')
|
||||
if name:
|
||||
plan_problem_names.add(name)
|
||||
|
||||
# 检查交集
|
||||
if student_problem_names & plan_problem_names:
|
||||
# 计算交集问题
|
||||
matched_problems = student_problem_names & plan_problem_names
|
||||
plan_dict = plan.to_dict()
|
||||
plan_dict['matched_problems'] = list(matched_problems)
|
||||
plan_dict['matched_count'] = len(matched_problems)
|
||||
# 检查问题集合是否完全一致(可采纳)
|
||||
plan_dict['can_adopt'] = (student_problem_names == plan_problem_names)
|
||||
recommended.append(plan_dict)
|
||||
|
||||
# 按匹配数量降序排序
|
||||
recommended.sort(key=lambda x: x['matched_count'], reverse=True)
|
||||
|
||||
return jsonify(recommended)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@main_bp.route("/api/students/<int:student_id>/plans/from-typical/<int:plan_id>", methods=["POST"])
|
||||
@login_required_json
|
||||
def adopt_typical_plan(student_id, plan_id):
|
||||
"""采纳典型方案 - 复制该方案给当前学员
|
||||
|
||||
前端已判断 can_adopt,后端直接采纳并记录来源
|
||||
"""
|
||||
student = Student.query.get_or_404(student_id)
|
||||
typical_plan = PracticePlan.query.get_or_404(plan_id)
|
||||
|
||||
if not typical_plan.is_typical:
|
||||
return jsonify({"error": "只能采纳典型方案"}), 400
|
||||
|
||||
# 获取典型方案的问题名称集合并验证一致性
|
||||
try:
|
||||
content = json.loads(typical_plan.content) if typical_plan.content else {}
|
||||
except:
|
||||
content = {}
|
||||
|
||||
plan_problems = content.get('problems', [])
|
||||
plan_problem_names = set()
|
||||
for p in plan_problems:
|
||||
name = p.get('name') or p.get('problem_name', '')
|
||||
if name:
|
||||
plan_problem_names.add(name)
|
||||
|
||||
# 获取当前学员的问题名称集合并验证一致性
|
||||
student_problems = StudentProblem.query.filter_by(student_id=student_id).all()
|
||||
student_problem_names = set()
|
||||
for sp in student_problems:
|
||||
if sp.problem:
|
||||
student_problem_names.add(sp.problem.name)
|
||||
|
||||
# 检查问题名称集合是否完全一致
|
||||
if student_problem_names != plan_problem_names:
|
||||
return jsonify({
|
||||
"error": "采纳失败:方案的问题与当前学员的问题不一致",
|
||||
"student_problems": list(student_problem_names),
|
||||
"plan_problems": list(plan_problem_names)
|
||||
}), 400
|
||||
|
||||
# 替换内容中的原学员姓名为当前学员姓名
|
||||
old_name = typical_plan.student.name if typical_plan.student else ""
|
||||
if old_name and old_name in str(content):
|
||||
content_str = json.dumps(content, ensure_ascii=False)
|
||||
content_str = content_str.replace(old_name, student.name)
|
||||
content = json.loads(content_str)
|
||||
|
||||
# 添加采纳来源信息
|
||||
from datetime import datetime
|
||||
content['adopted_from'] = {
|
||||
'student_name': old_name,
|
||||
'plan_id': typical_plan.id,
|
||||
'adopted_at': datetime.now().strftime('%Y-%m-%d')
|
||||
}
|
||||
|
||||
# 创建新方案
|
||||
new_plan = PracticePlan(
|
||||
student_id=student_id,
|
||||
template_id=typical_plan.template_id,
|
||||
content=json.dumps(content, ensure_ascii=False),
|
||||
is_typical=False, # 采纳的方案不再是典型
|
||||
created_by=session.get('user_id')
|
||||
)
|
||||
db.session.add(new_plan)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
"message": "方案已采纳",
|
||||
"plan_id": new_plan.id,
|
||||
"plan": new_plan.to_dict()
|
||||
}), 201
|
||||
|
||||
|
||||
@main_bp.route("/plans")
|
||||
@login_required_json
|
||||
def plans_page():
|
||||
@@ -364,6 +524,7 @@ def generate_plan():
|
||||
student_id=student_id,
|
||||
template_id=template_id,
|
||||
content=json.dumps(plan_content, ensure_ascii=False),
|
||||
created_by=session.get('user_id')
|
||||
)
|
||||
db.session.add(plan)
|
||||
db.session.commit()
|
||||
@@ -433,6 +594,8 @@ 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"),
|
||||
"updated_at": plan.updated_at.strftime("%Y-%m-%d %H:%M") if plan.updated_at else None,
|
||||
"updated_by_name": plan.updater.name if plan.updated_by and plan.updater else None,
|
||||
"is_typical": plan.is_typical,
|
||||
"content": content,
|
||||
}
|
||||
@@ -613,12 +776,32 @@ def delete_plan(plan_id):
|
||||
@login_required_json
|
||||
def update_plan_content(plan_id):
|
||||
"""更新方案内容(用于编辑)"""
|
||||
from datetime import datetime
|
||||
plan = PracticePlan.query.get_or_404(plan_id)
|
||||
data = request.get_json()
|
||||
|
||||
# 更新content字段
|
||||
# 合并content字段 - 保留原有字段,只更新ai_report和daily_schedule
|
||||
if "content" in data:
|
||||
plan.content = data["content"]
|
||||
new_content_str = data["content"]
|
||||
existing_content = json.loads(plan.content) if plan.content else {}
|
||||
|
||||
# 解析新content(可能是字符串或对象)
|
||||
if isinstance(new_content_str, str):
|
||||
new_content = json.loads(new_content_str)
|
||||
else:
|
||||
new_content = new_content_str
|
||||
|
||||
# 合并:保留existing中的所有字段,用new_content覆盖ai_report和daily_schedule
|
||||
merged = existing_content.copy()
|
||||
merged.update({
|
||||
"ai_report": new_content.get("ai_report", ""),
|
||||
"daily_schedule": new_content.get("daily_schedule", [])
|
||||
})
|
||||
|
||||
plan.content = json.dumps(merged, ensure_ascii=False)
|
||||
|
||||
plan.updated_by = session.get('user_id')
|
||||
plan.updated_at = datetime.now()
|
||||
db.session.commit()
|
||||
return jsonify({"message": "保存成功"})
|
||||
|
||||
|
||||
@@ -182,6 +182,90 @@ window.pageInit = function(data) {
|
||||
const addClassBtn = document.getElementById('addClassBtn');
|
||||
if (addClassBtn) addClassBtn.style.display = 'inline-block';
|
||||
}
|
||||
restoreClassFilterState();
|
||||
loadClasses();
|
||||
};
|
||||
|
||||
// 班级筛选状态管理
|
||||
const CLASS_FILTER_KEY = 'class_filters';
|
||||
|
||||
function saveClassFilterState() {
|
||||
const state = {
|
||||
activeFilter: document.getElementById('activeFilter').value,
|
||||
mineActive: document.getElementById('mineFilterBtn').classList.contains('active')
|
||||
};
|
||||
sessionStorage.setItem(CLASS_FILTER_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function restoreClassFilterState() {
|
||||
const saved = sessionStorage.getItem(CLASS_FILTER_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const state = JSON.parse(saved);
|
||||
document.getElementById('activeFilter').value = state.activeFilter || '';
|
||||
const btn = document.getElementById('mineFilterBtn');
|
||||
if (btn) {
|
||||
if (state.mineActive) {
|
||||
btn.classList.add('active', 'btn-primary');
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
} else {
|
||||
btn.classList.remove('active', 'btn-primary');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
}
|
||||
saveClassFilterState();
|
||||
} catch (e) {
|
||||
console.error('恢复班级筛选状态失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 我的班级筛选
|
||||
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');
|
||||
}
|
||||
saveClassFilterState();
|
||||
loadClasses();
|
||||
}
|
||||
|
||||
// 加载班级列表
|
||||
function loadClasses() {
|
||||
saveClassFilterState();
|
||||
|
||||
const activeFilter = document.getElementById('activeFilter').value;
|
||||
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';
|
||||
tbody.innerHTML = classes.map(c => `
|
||||
<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.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>
|
||||
`).join('');
|
||||
});
|
||||
}
|
||||
loadClasses();
|
||||
};
|
||||
|
||||
|
||||
@@ -174,8 +174,34 @@
|
||||
{{ super() }}
|
||||
<script>
|
||||
const API_BASE = '/api/goals';
|
||||
const GOAL_FILTER_KEY = 'goal_filters';
|
||||
let allGoals = []; // 缓存所有目标数据
|
||||
|
||||
// 保存筛选状态
|
||||
function saveGoalFilterState() {
|
||||
const state = {
|
||||
filterLevel: document.getElementById('filter-level').value,
|
||||
filterCategory: document.getElementById('filter-category').value,
|
||||
groupBy: document.getElementById('group-by').value
|
||||
};
|
||||
sessionStorage.setItem(GOAL_FILTER_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
// 恢复筛选状态
|
||||
function restoreGoalFilterState() {
|
||||
const saved = sessionStorage.getItem(GOAL_FILTER_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const state = JSON.parse(saved);
|
||||
if (state.filterLevel) document.getElementById('filter-level').value = state.filterLevel;
|
||||
if (state.filterCategory) document.getElementById('filter-category').value = state.filterCategory;
|
||||
if (state.groupBy) document.getElementById('group-by').value = state.groupBy;
|
||||
saveGoalFilterState();
|
||||
} catch (e) {
|
||||
console.error('恢复目标筛选状态失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载目标列表
|
||||
async function loadGoals() {
|
||||
const res = await fetch(API_BASE);
|
||||
@@ -188,11 +214,14 @@ async function loadGoals() {
|
||||
return {...g, children: children};
|
||||
}));
|
||||
|
||||
restoreGoalFilterState();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// 应用筛选和分组
|
||||
function applyFilters() {
|
||||
saveGoalFilterState();
|
||||
|
||||
const filterLevel = document.getElementById('filter-level').value;
|
||||
const filterCategory = document.getElementById('filter-category').value;
|
||||
const groupBy = document.getElementById('group-by').value;
|
||||
|
||||
@@ -389,12 +389,49 @@ const problemList = {{ problem_list | tojson }};
|
||||
const severityLevels = {{ severity_levels | tojson }};
|
||||
const practiceTimeOptions = {{ practice_time_options | tojson }};
|
||||
|
||||
// 学员列表筛选状态管理
|
||||
const STUDENT_FILTER_KEY = 'index_student_filters';
|
||||
|
||||
function saveStudentFilterState() {
|
||||
const state = {
|
||||
classId: document.getElementById('classFilter').value,
|
||||
name: document.getElementById('nameFilter').value,
|
||||
mineActive: document.getElementById('mineStudentFilterBtn').classList.contains('active')
|
||||
};
|
||||
sessionStorage.setItem(STUDENT_FILTER_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function restoreStudentFilterState() {
|
||||
const saved = sessionStorage.getItem(STUDENT_FILTER_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const state = JSON.parse(saved);
|
||||
if (state.classId) document.getElementById('classFilter').value = state.classId;
|
||||
if (state.name) document.getElementById('nameFilter').value = state.name;
|
||||
const btn = document.getElementById('mineStudentFilterBtn');
|
||||
if (btn) {
|
||||
if (state.mineActive) {
|
||||
btn.classList.add('active', 'btn-primary');
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
} else {
|
||||
btn.classList.remove('active', 'btn-primary');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
}
|
||||
saveStudentFilterState();
|
||||
} catch (e) {
|
||||
console.error('恢复学员筛选状态失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面初始化(base.html 统一登录检查后调用)
|
||||
window.pageInit = function(data) {
|
||||
loadAiTemplates();
|
||||
loadReportTemplates();
|
||||
loadClassFilter();
|
||||
loadStudents();
|
||||
loadClassFilter().then(() => {
|
||||
restoreStudentFilterState();
|
||||
loadStudents();
|
||||
});
|
||||
initProblemCheckboxes();
|
||||
|
||||
// 检查 URL 参数,自动打开学员详情
|
||||
@@ -481,6 +518,8 @@ function importStudents(input) {
|
||||
|
||||
// 加载学员列表
|
||||
async function loadStudents() {
|
||||
saveStudentFilterState();
|
||||
|
||||
const classId = document.getElementById('classFilter').value;
|
||||
const name = document.getElementById('nameFilter').value;
|
||||
const mineFilter = document.getElementById('mineStudentFilterBtn').classList.contains('active');
|
||||
@@ -552,10 +591,17 @@ function renderStudentList(students) {
|
||||
} else {
|
||||
problemText = s.problem_names.join('、');
|
||||
}
|
||||
} else {
|
||||
} else if (s.problem_count > 0) {
|
||||
problemText = `${s.problem_count} 个问题`;
|
||||
} else {
|
||||
problemText = '<span class="text-muted">暂无问题</span>';
|
||||
}
|
||||
|
||||
// 构建方案数量显示(样式与问题一致)
|
||||
const planCount = s.plan_count > 0;
|
||||
const planBadgeText = planCount ? `${s.plan_count} 个方案` : '暂无方案';
|
||||
const planBadgeClass = planCount ? 'bg-primary' : 'bg-light text-muted';
|
||||
|
||||
html += `
|
||||
<div class="col-md-4 col-sm-6 mb-3">
|
||||
<div class="card">
|
||||
@@ -564,8 +610,8 @@ function renderStudentList(students) {
|
||||
<h5 class="card-title">${s.name}</h5>
|
||||
<p class="card-text text-muted small">${s.wechat_nickname || ''} ${s.phone ? '| ' + s.phone : ''}</p>
|
||||
<span class="badge bg-info">${s.practice_time}</span>
|
||||
<span class="badge bg-secondary">${problemText}</span>
|
||||
<span class="badge bg-primary">${s.plan_count} 个方案</span>
|
||||
<span class="badge ${s.problem_count > 0 ? 'bg-secondary' : 'bg-light text-muted'}">${problemText}</span>
|
||||
<span class="badge ${planBadgeClass}">${planBadgeText}</span>
|
||||
${s.goal_count > 0 ? `<span class="badge bg-success">${s.goal_count}个目标(${s.completed_goal_count}已达成)</span>` : ''}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -28,16 +28,40 @@ var currentPlanId = null;
|
||||
|
||||
async function loadPlan() {
|
||||
currentPlanId = window.location.pathname.split('/').pop();
|
||||
// 清除编辑页标记(从编辑页返回后不要再跳回去)
|
||||
sessionStorage.removeItem('fromEdit');
|
||||
// 记录来源页面
|
||||
const referrer = document.referrer;
|
||||
if (referrer.includes('/student/')) {
|
||||
sessionStorage.setItem('plan_detail_referrer', 'student');
|
||||
} else if (referrer.includes('/plans')) {
|
||||
sessionStorage.setItem('plan_detail_referrer', 'plans');
|
||||
} else {
|
||||
sessionStorage.setItem('plan_detail_referrer', 'unknown');
|
||||
}
|
||||
|
||||
// 如果是从编辑页返回(plan_detail_reload被设置),强制刷新
|
||||
const needsReload = sessionStorage.getItem('plan_detail_reload') === 'true';
|
||||
if (needsReload) {
|
||||
sessionStorage.removeItem('plan_detail_reload');
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/plans/${currentPlanId}`);
|
||||
const data = await resp.json();
|
||||
window.currentStudentId = data.student_id;
|
||||
|
||||
let editInfo = '';
|
||||
if (data.updated_at) {
|
||||
const editor = data.updated_by_name ? ` by ${data.updated_by_name}` : '';
|
||||
editInfo = `<span class="text-muted">(于${data.updated_at}${editor}编辑)</span>`;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="mb-3">
|
||||
<strong>学员:</strong>${data.student_name}
|
||||
<strong>练习时间:</strong>${data.content.practice_time}
|
||||
<strong>生成时间:</strong>${data.created_at}
|
||||
<strong>生成时间:</strong>${data.created_at} ${editInfo}
|
||||
<strong>模板:</strong>${data.template_name || '无'}
|
||||
<div class="mt-2">
|
||||
<a href="/student/${data.student_id}" class="btn btn-sm btn-outline-primary">
|
||||
@@ -131,21 +155,28 @@ async function loadTemplates() {
|
||||
async function toggleTypical(planId, isTypical) {
|
||||
try {
|
||||
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
|
||||
// 标记需要刷新方案列表
|
||||
sessionStorage.setItem('plans_needs_refresh', 'true');
|
||||
} catch (e) {
|
||||
alert('设置失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回按钮处理:如果是编辑页返回的,跳过编辑页
|
||||
// 返回按钮处理
|
||||
function goBack() {
|
||||
if (sessionStorage.getItem('fromEdit') === 'true') {
|
||||
sessionStorage.removeItem('fromEdit');
|
||||
history.go(-2); // 跳过编辑页
|
||||
} else {
|
||||
history.back();
|
||||
}
|
||||
// 标记需要刷新推荐方案列表
|
||||
sessionStorage.setItem('needs_refresh_recommended', 'true');
|
||||
history.back();
|
||||
}
|
||||
|
||||
// 处理 bfcache - 页面从缓存恢复时需要重新加载以获取最新数据
|
||||
window.addEventListener('pageshow', function(event) {
|
||||
if (event.persisted) {
|
||||
// 页面从 bfcache 恢复,需要重新加载
|
||||
loadPlan();
|
||||
}
|
||||
});
|
||||
|
||||
// 标记来源为编辑页(编辑页点击"返回详情"前设置)
|
||||
function markFromEdit() {
|
||||
sessionStorage.setItem('fromEdit', 'true');
|
||||
|
||||
@@ -65,6 +65,7 @@ async function loadPlanForEdit() {
|
||||
try {
|
||||
const resp = await fetch(`/api/plans/${planId}`);
|
||||
const data = await resp.json();
|
||||
window.currentStudentId = data.student_id;
|
||||
const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content;
|
||||
|
||||
document.getElementById('editAiReport').value = content.ai_report || '';
|
||||
@@ -147,18 +148,17 @@ async function savePlanContent() {
|
||||
if (!confirm('确定要保存修改吗?')) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/plans/${planId}`, {
|
||||
const resp = await fetch(`/api/plans/${planId}/content`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ai_report: currentAiReport,
|
||||
daily_schedule: tableData
|
||||
content: JSON.stringify({ ai_report: currentAiReport, daily_schedule: tableData })
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
alert('保存成功');
|
||||
window.location.href = `/plan/${planId}`;
|
||||
// 保存后返回上一页(编辑页的上一页是方案详情,已从bfcache恢复)
|
||||
history.back();
|
||||
} else {
|
||||
alert('保存失败');
|
||||
}
|
||||
|
||||
@@ -75,18 +75,86 @@
|
||||
<script>
|
||||
// 防抖定时器
|
||||
let debounceTimer = null;
|
||||
const STORAGE_KEY = 'plans_filters';
|
||||
|
||||
function debounceLoad() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(loadPlans, 300);
|
||||
}
|
||||
|
||||
// 保存筛选状态到 sessionStorage
|
||||
function saveFilterState() {
|
||||
const state = {
|
||||
classId: document.getElementById('filterClass').value,
|
||||
templateId: document.getElementById('filterTemplate').value,
|
||||
isTypical: document.getElementById('filterTypical').value,
|
||||
studentName: document.getElementById('filterStudentName').value,
|
||||
problemId: document.getElementById('filterProblem').value,
|
||||
mineActive: document.getElementById('minePlansBtn')?.classList.contains('active')
|
||||
};
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
// 恢复筛选状态
|
||||
function restoreFilterState() {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const state = JSON.parse(saved);
|
||||
if (state.classId) document.getElementById('filterClass').value = state.classId;
|
||||
if (state.templateId) document.getElementById('filterTemplate').value = state.templateId;
|
||||
if (state.isTypical) document.getElementById('filterTypical').value = state.isTypical;
|
||||
if (state.studentName) document.getElementById('filterStudentName').value = state.studentName;
|
||||
if (state.problemId) document.getElementById('filterProblem').value = state.problemId;
|
||||
|
||||
const btn = document.getElementById('minePlansBtn');
|
||||
if (btn) {
|
||||
if (state.mineActive) {
|
||||
btn.classList.add('active', 'btn-primary');
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
} else {
|
||||
btn.classList.remove('active', 'btn-primary');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
// 确保状态同步保存
|
||||
saveFilterState();
|
||||
} catch (e) {
|
||||
console.error('恢复筛选状态失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
window.pageInit = function() {
|
||||
loadFilters();
|
||||
loadPlans();
|
||||
checkAndRefresh();
|
||||
};
|
||||
|
||||
// 检查是否需要刷新(从详情页返回)
|
||||
function checkAndRefresh() {
|
||||
const needsRefresh = sessionStorage.getItem('plans_needs_refresh') === 'true';
|
||||
if (needsRefresh) {
|
||||
sessionStorage.removeItem('plans_needs_refresh');
|
||||
loadFilters().then(() => {
|
||||
restoreFilterState();
|
||||
loadPlans(true);
|
||||
});
|
||||
} else {
|
||||
loadFilters().then(() => {
|
||||
restoreFilterState();
|
||||
loadPlans(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// pageshow 事件处理 bfcache 恢复的情况
|
||||
window.addEventListener('pageshow', function(event) {
|
||||
if (event.persisted) {
|
||||
// 页面从 bfcache 恢复,需要重新检查刷新标记
|
||||
checkAndRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// 加载筛选器选项
|
||||
async function loadFilters() {
|
||||
// 加载班级
|
||||
@@ -107,7 +175,7 @@ async function loadFilters() {
|
||||
const problems = await resp.json();
|
||||
const problemSelect = document.getElementById('filterProblem');
|
||||
problems.forEach(p => {
|
||||
problemSelect.innerHTML += `<option value="${p.id}">${p.name}</option>`;
|
||||
problemSelect.innerHTML += `<option value="${p.id}">${p.no} - ${p.name}</option>`;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('加载问题失败', e);
|
||||
@@ -128,6 +196,9 @@ async function loadFilters() {
|
||||
|
||||
// 加载方案列表
|
||||
async function loadPlans() {
|
||||
// 保存当前筛选状态
|
||||
saveFilterState();
|
||||
|
||||
const container = document.getElementById('plansContainer');
|
||||
container.innerHTML = '<div class="text-center text-muted py-5"><i class="bi bi-hourglass fs-4"></i><p class="mt-2">加载中...</p></div>';
|
||||
|
||||
@@ -199,6 +270,7 @@ async function loadPlans() {
|
||||
<td class="text-muted small">${p.created_at || ''}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewPlan(${p.id})">查看</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -224,6 +296,7 @@ function clearFilters() {
|
||||
mineBtn.classList.remove('active', 'btn-primary');
|
||||
mineBtn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
loadPlans();
|
||||
}
|
||||
|
||||
@@ -239,6 +312,7 @@ function toggleMinePlans() {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
}
|
||||
saveFilterState();
|
||||
loadPlans();
|
||||
}
|
||||
|
||||
@@ -246,5 +320,21 @@ function toggleMinePlans() {
|
||||
function viewPlan(planId) {
|
||||
window.location.href = `/plan/${planId}`;
|
||||
}
|
||||
|
||||
// 删除方案
|
||||
async function deletePlan(planId) {
|
||||
if (!confirm('确定删除该方案?删除后无法恢复。')) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/plans/${planId}`, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
loadPlans(); // 刷新列表
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert('删除失败: ' + (err.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -189,6 +189,30 @@
|
||||
let allProblems = [];
|
||||
let currentEditId = null;
|
||||
let currentDeleteId = null;
|
||||
const PROBLEM_FILTER_KEY = 'problem_filters';
|
||||
|
||||
function saveProblemFilterState() {
|
||||
const state = {
|
||||
search: document.getElementById('searchInput').value,
|
||||
filterCategory: document.getElementById('filterCategory').value,
|
||||
groupBy: document.getElementById('groupByCategory').value
|
||||
};
|
||||
sessionStorage.setItem(PROBLEM_FILTER_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function restoreProblemFilterState() {
|
||||
const saved = sessionStorage.getItem(PROBLEM_FILTER_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const state = JSON.parse(saved);
|
||||
if (state.search) document.getElementById('searchInput').value = state.search;
|
||||
if (state.filterCategory) document.getElementById('filterCategory').value = state.filterCategory;
|
||||
if (state.groupBy) document.getElementById('groupByCategory').value = state.groupBy;
|
||||
saveProblemFilterState();
|
||||
} catch (e) {
|
||||
console.error('恢复问题筛选状态失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
window.pageInit = function() {
|
||||
loadProblems();
|
||||
@@ -199,10 +223,13 @@ window.pageInit = function() {
|
||||
async function loadProblems() {
|
||||
const response = await fetch('/api/problems');
|
||||
allProblems = await response.json();
|
||||
restoreProblemFilterState();
|
||||
applyProblemFilters();
|
||||
}
|
||||
|
||||
function applyProblemFilters() {
|
||||
saveProblemFilterState();
|
||||
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
const filterCategory = document.getElementById('filterCategory').value;
|
||||
const groupBy = document.getElementById('groupByCategory').value;
|
||||
|
||||
@@ -80,6 +80,20 @@
|
||||
<p class="text-muted">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐方案区块 -->
|
||||
<div class="card mb-4 border-warning">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">📋 推荐方案</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary active" id="filterAll" onclick="setRecommendedFilter('all')">全部</button>
|
||||
<button class="btn btn-outline-secondary" id="filterMine" onclick="setRecommendedFilter('mine')">我的</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" id="recommendedPlanList">
|
||||
<p class="text-muted">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,6 +373,11 @@ const studentName = "{{ student.name }}";
|
||||
let problemModal, editProblemModal, generateModal, editStudentModal, assignGoalModal;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initPage();
|
||||
});
|
||||
|
||||
// 页面初始化
|
||||
function initPage() {
|
||||
problemModal = new bootstrap.Modal(document.getElementById('addProblemModal'));
|
||||
editProblemModal = new bootstrap.Modal(document.getElementById('editProblemModal'));
|
||||
generateModal = new bootstrap.Modal(document.getElementById('generatePlanModal'));
|
||||
@@ -372,10 +391,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('editStudentPracticeTime').value = document.getElementById('detailPracticeTime').textContent;
|
||||
document.getElementById('editStudentNotes').value = document.getElementById('detailNotes').textContent === '无备注' ? '' : document.getElementById('detailNotes').textContent;
|
||||
|
||||
// 检查是否需要刷新推荐方案
|
||||
const needsRefreshRecommended = sessionStorage.getItem('needs_refresh_recommended') === 'true';
|
||||
if (needsRefreshRecommended) {
|
||||
sessionStorage.removeItem('needs_refresh_recommended');
|
||||
// 恢复推荐方案筛选状态
|
||||
const savedFilter = sessionStorage.getItem('recommended_filter') || 'all';
|
||||
loadRecommendedPlans(savedFilter);
|
||||
} else {
|
||||
loadRecommendedPlans('all');
|
||||
}
|
||||
|
||||
loadProblems();
|
||||
loadPlans();
|
||||
loadProblemOptions();
|
||||
loadStudentGoals();
|
||||
}
|
||||
|
||||
// pageshow 事件处理 bfcache 恢复的情况
|
||||
window.addEventListener('pageshow', function(event) {
|
||||
if (event.persisted) {
|
||||
// 页面从 bfcache 恢复,需要重新检查刷新标记
|
||||
initPage();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProblemOptions() {
|
||||
@@ -590,13 +628,17 @@ function renderTimeline(timeline) {
|
||||
</div>`;
|
||||
} else {
|
||||
const p = entry.plan;
|
||||
const adoptedFrom = p.adopted_from;
|
||||
const editInfo = p.updated_at ? `<span class="text-muted small">(于${p.updated_at}编辑)</span>` : '';
|
||||
return `
|
||||
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}">
|
||||
<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>
|
||||
${editInfo}
|
||||
${p.is_typical ? '<span class="badge bg-warning text-dark ms-1">典型</span>' : ''}
|
||||
${adoptedFrom ? `<span class="badge bg-info ms-1">采纳自${escapeHtml(adoptedFrom.student_name)}的方案</span>` : ''}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary" title="查看">
|
||||
@@ -682,6 +724,7 @@ async function saveAddProblem() {
|
||||
if (resp.ok) {
|
||||
problemModal.hide();
|
||||
loadProblems();
|
||||
loadRecommendedPlans(currentRecommendedFilter);
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert('添加失败: ' + (err.error || '未知错误'));
|
||||
@@ -723,6 +766,7 @@ async function saveProblemEdit() {
|
||||
if (updateResp.ok) {
|
||||
editProblemModal.hide();
|
||||
loadProblems();
|
||||
loadRecommendedPlans(currentRecommendedFilter);
|
||||
} else {
|
||||
alert('更新失败');
|
||||
}
|
||||
@@ -739,6 +783,7 @@ async function deleteProblem(id) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
loadProblems();
|
||||
loadRecommendedPlans(currentRecommendedFilter);
|
||||
} else {
|
||||
alert('删除失败');
|
||||
}
|
||||
@@ -970,6 +1015,84 @@ async function loadStudentGoals() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ===== 推荐方案相关 =====
|
||||
let currentRecommendedFilter = 'all';
|
||||
|
||||
function setRecommendedFilter(filter) {
|
||||
currentRecommendedFilter = filter;
|
||||
sessionStorage.setItem('recommended_filter', filter);
|
||||
document.getElementById('filterAll').classList.toggle('active', filter === 'all');
|
||||
document.getElementById('filterMine').classList.toggle('active', filter === 'mine');
|
||||
loadRecommendedPlans(filter);
|
||||
}
|
||||
|
||||
async function loadRecommendedPlans(filter) {
|
||||
const container = document.getElementById('recommendedPlanList');
|
||||
container.innerHTML = '<p class="text-muted">加载中...</p>';
|
||||
|
||||
try {
|
||||
const url = `/api/students/${currentStudentId}/recommended-plans${filter === 'mine' ? '?mine=true' : ''}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('API error');
|
||||
const plans = await res.json();
|
||||
|
||||
if (plans.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">暂无匹配的推荐方案</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = plans.map(p => `
|
||||
<div class="d-flex justify-content-between align-items-start mb-2 p-2 border rounded bg-light">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>${escapeHtml(p.student_name)}的方案</strong>
|
||||
<span class="badge bg-warning text-dark">典型</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
问题匹配: ${p.matched_problems ? p.matched_problems.join(', ') : ''}
|
||||
<span class="text-info">(${p.matched_count || 0}个)</span>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
${p.created_at ? '创建: ' + formatDate(p.created_at) : ''}
|
||||
${p.template_name ? ' | 模板: ' + p.template_name : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm ms-2">
|
||||
<a href="/plan/${p.id}" class="btn btn-outline-primary">查看</a>
|
||||
${p.can_adopt
|
||||
? `<button class="btn btn-success" onclick="adoptTypicalPlan(${p.id})">采纳</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
console.error('加载推荐方案失败', e);
|
||||
container.innerHTML = '<p class="text-danger">加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function adoptTypicalPlan(planId) {
|
||||
if (!confirm('确定采纳此典型方案?系统将复制该方案到当前学员。')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/students/${currentStudentId}/plans/from-typical/${planId}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
loadPlans(); // 刷新方案列表
|
||||
loadRecommendedPlans(currentRecommendedFilter); // 刷新推荐列表
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert('采纳失败: ' + (err.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('采纳失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
|
||||
Reference in New Issue
Block a user