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:
hmo
2026-04-27 02:01:22 +08:00
parent 6abdd49c04
commit e50a9207b4
20 changed files with 873 additions and 88 deletions
+8 -3
View File
@@ -23,15 +23,18 @@
## 功能特点 ## 功能特点
- ✅ 学员管理(增删改查) - ✅ 学员管理(增删改查)
- ✅ 问题记录(15种常见问题 + 严重程度 + 练习时间 + 级别) - ✅ 问题记录(15种常见问题 + 严重程度 + 练习时间 + 级别,数据存储在数据库
-**AI生成个性化练习方案报告**(支持 MiniMax、火山方舟、DeepSeek -**AI生成个性化练习方案报告**(支持 MiniMax、火山方舟、DeepSeek
-**模板管理**(AI提示词模板、报告导出模板,支持排序) -**模板管理**(AI提示词模板、报告导出模板,支持排序)
-**典型方案采纳**(推荐方案列表,可一键采纳)
- ✅ 三种输出方式: - ✅ 三种输出方式:
- 网页展示 - 网页展示
- **PDF下载(支持中文)** - **PDF下载(支持中文)**
- 微信卡片分享 - 微信卡片分享
-**可视化编辑**AI报告用 Markdown编辑器,每日练习用表格编辑器) -**可视化编辑**AI报告用 Markdown编辑器,每日练习用表格编辑器)
-**API配置界面**(多提供商支持,切换时自动填充) -**API配置界面**(多提供商支持,切换时自动填充)
-**方案审计字段**created_by/updated_by/updated_at
-**目标管理**(目标设定、阶段评估、最终评估)
## 技术栈 ## 技术栈
@@ -159,6 +162,8 @@ piano-plan/
--- ---
> **版本**v1.3.0 > **版本**v1.4.0
> **创建时间**2026-04-17 > **创建时间**2026-04-17
> **最后更新**2026-04-25 > **最后更新**2026-04-27
>
> **重要更新**:v1.4.0 - 问题数据已迁移到数据库;典型方案采纳功能;审计字段完善
+11
View File
@@ -125,6 +125,17 @@ def create_app():
db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN is_typical INTEGER DEFAULT 0")) db.session.execute(text("ALTER TABLE practice_plans ADD COLUMN is_typical INTEGER DEFAULT 0"))
db.session.commit() 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字段 # 检查goals表是否有level字段
result7 = db.session.execute(text("PRAGMA table_info(goals)")) result7 = db.session.execute(text("PRAGMA table_info(goals)"))
goal_columns = [row[1] for row in result7] goal_columns = [row[1] for row in result7]
+14
View File
@@ -340,10 +340,15 @@ class PracticePlan(db.Model):
template_id = db.Column(db.Integer, db.ForeignKey("templates.id"), nullable=True) # 使用的AI提示词模板 template_id = db.Column(db.Integer, db.ForeignKey("templates.id"), nullable=True) # 使用的AI提示词模板
is_typical = db.Column(db.Boolean, default=False, nullable=False) # 是否为典型方案 is_typical = db.Column(db.Boolean, default=False, nullable=False) # 是否为典型方案
content = db.Column(db.Text, nullable=False) # JSON格式存储方案内容 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) 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]) 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): def to_dict(self):
import json as json_module import json as json_module
@@ -370,6 +375,9 @@ class PracticePlan(db.Model):
if self.student and self.student.class_obj: if self.student and self.student.class_obj:
class_name = self.student.class_obj.name class_name = self.student.class_obj.name
# 采纳来源信息
adopted_from = content_obj.get("adopted_from")
return { return {
"id": self.id, "id": self.id,
"student_id": self.student_id, "student_id": self.student_id,
@@ -380,10 +388,16 @@ class PracticePlan(db.Model):
"is_typical": self.is_typical, "is_typical": self.is_typical,
"problem_names": problem_names, "problem_names": problem_names,
"problem_details": problem_details, "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") "created_at": self.created_at.strftime("%Y-%m-%d %H:%M")
if self.created_at if self.created_at
else None, 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, "content": self.content,
"adopted_from": adopted_from,
} }
+198 -15
View File
@@ -13,7 +13,7 @@ from flask import (
session, session,
) )
from app.routes import main_bp 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.plan_generator import generate_practice_plan, generate_ai_report
from app.services.pdf_generator import generate_pdf from app.services.pdf_generator import generate_pdf
from app.routes.auth import login_required_json, admin_required from app.routes.auth import login_required_json, admin_required
@@ -48,13 +48,30 @@ def get_all_plans():
""" """
import json as json_module import json as json_module
from app.models import Class from app.models import Class
from sqlalchemy.orm import joinedload
from sqlalchemy import exists
query = PracticePlan.query query = PracticePlan.query
# 按班级筛选 # 检查是否需要 join Student
needs_student_join = False
needs_class_join = False
class_id = request.args.get('class_id', type=int) 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: 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) template_id = request.args.get('template_id', type=int)
@@ -67,19 +84,14 @@ def get_all_plans():
query = query.filter(PracticePlan.is_typical == True) query = query.filter(PracticePlan.is_typical == True)
# 按学员姓名模糊筛选 # 按学员姓名模糊筛选
student_name = request.args.get('student_name')
if 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_id 关联到学员的问题)
problem_ids = request.args.get('problem_ids')
if problem_ids: if problem_ids:
problem_id_list = [int(pid.strip()) for pid in problem_ids.split(',') if pid.strip()] problem_id_list = [int(pid.strip()) for pid in problem_ids.split(',') if pid.strip()]
if problem_id_list: if problem_id_list:
# 筛选:方案对应的学员有指定问题之一的 query = query.filter(
# 使用子查询避免笛卡尔积导致的重复
from sqlalchemy import exists
query = query.join(Student).filter(
exists().where( exists().where(
(StudentProblem.student_id == Student.id) & (StudentProblem.student_id == Student.id) &
(StudentProblem.problem_id.in_(problem_id_list)) (StudentProblem.problem_id.in_(problem_id_list))
@@ -87,11 +99,10 @@ def get_all_plans():
) )
# 我的学员筛选(所在班级的老师是当前用户) # 我的学员筛选(所在班级的老师是当前用户)
mine = request.args.get('mine') if needs_class_join:
if mine and mine.lower() == 'true':
user_id = session.get('user_id') user_id = session.get('user_id')
if 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() plans = query.order_by(PracticePlan.created_at.desc()).all()
return jsonify([p.to_dict() for p in plans]) 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}) 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") @main_bp.route("/plans")
@login_required_json @login_required_json
def plans_page(): def plans_page():
@@ -364,6 +524,7 @@ def generate_plan():
student_id=student_id, student_id=student_id,
template_id=template_id, template_id=template_id,
content=json.dumps(plan_content, ensure_ascii=False), content=json.dumps(plan_content, ensure_ascii=False),
created_by=session.get('user_id')
) )
db.session.add(plan) db.session.add(plan)
db.session.commit() db.session.commit()
@@ -433,6 +594,8 @@ def get_plan(plan_id):
"student_id": plan.student_id, "student_id": plan.student_id,
"student_name": plan.student.name if plan.student else "", "student_name": plan.student.name if plan.student else "",
"created_at": plan.created_at.strftime("%Y-%m-%d %H:%M"), "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, "is_typical": plan.is_typical,
"content": content, "content": content,
} }
@@ -613,12 +776,32 @@ def delete_plan(plan_id):
@login_required_json @login_required_json
def update_plan_content(plan_id): def update_plan_content(plan_id):
"""更新方案内容(用于编辑)""" """更新方案内容(用于编辑)"""
from datetime import datetime
plan = PracticePlan.query.get_or_404(plan_id) plan = PracticePlan.query.get_or_404(plan_id)
data = request.get_json() data = request.get_json()
# 更新content字段 # 合并content字段 - 保留原有字段,只更新ai_report和daily_schedule
if "content" in data: 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() db.session.commit()
return jsonify({"message": "保存成功"}) return jsonify({"message": "保存成功"})
+84
View File
@@ -182,6 +182,90 @@ window.pageInit = function(data) {
const addClassBtn = document.getElementById('addClassBtn'); const addClassBtn = document.getElementById('addClassBtn');
if (addClassBtn) addClassBtn.style.display = 'inline-block'; 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(); loadClasses();
}; };
+29
View File
@@ -174,8 +174,34 @@
{{ super() }} {{ super() }}
<script> <script>
const API_BASE = '/api/goals'; const API_BASE = '/api/goals';
const GOAL_FILTER_KEY = 'goal_filters';
let allGoals = []; // 缓存所有目标数据 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() { async function loadGoals() {
const res = await fetch(API_BASE); const res = await fetch(API_BASE);
@@ -188,11 +214,14 @@ async function loadGoals() {
return {...g, children: children}; return {...g, children: children};
})); }));
restoreGoalFilterState();
applyFilters(); applyFilters();
} }
// 应用筛选和分组 // 应用筛选和分组
function applyFilters() { function applyFilters() {
saveGoalFilterState();
const filterLevel = document.getElementById('filter-level').value; const filterLevel = document.getElementById('filter-level').value;
const filterCategory = document.getElementById('filter-category').value; const filterCategory = document.getElementById('filter-category').value;
const groupBy = document.getElementById('group-by').value; const groupBy = document.getElementById('group-by').value;
+51 -5
View File
@@ -389,12 +389,49 @@ const problemList = {{ problem_list | tojson }};
const severityLevels = {{ severity_levels | tojson }}; const severityLevels = {{ severity_levels | tojson }};
const practiceTimeOptions = {{ practice_time_options | 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 统一登录检查后调用) // 页面初始化(base.html 统一登录检查后调用)
window.pageInit = function(data) { window.pageInit = function(data) {
loadAiTemplates(); loadAiTemplates();
loadReportTemplates(); loadReportTemplates();
loadClassFilter(); loadClassFilter().then(() => {
loadStudents(); restoreStudentFilterState();
loadStudents();
});
initProblemCheckboxes(); initProblemCheckboxes();
// 检查 URL 参数,自动打开学员详情 // 检查 URL 参数,自动打开学员详情
@@ -481,6 +518,8 @@ function importStudents(input) {
// 加载学员列表 // 加载学员列表
async function loadStudents() { async function loadStudents() {
saveStudentFilterState();
const classId = document.getElementById('classFilter').value; const classId = document.getElementById('classFilter').value;
const name = document.getElementById('nameFilter').value; const name = document.getElementById('nameFilter').value;
const mineFilter = document.getElementById('mineStudentFilterBtn').classList.contains('active'); const mineFilter = document.getElementById('mineStudentFilterBtn').classList.contains('active');
@@ -552,10 +591,17 @@ function renderStudentList(students) {
} else { } else {
problemText = s.problem_names.join('、'); problemText = s.problem_names.join('、');
} }
} else { } else if (s.problem_count > 0) {
problemText = `${s.problem_count} 个问题`; 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 += ` html += `
<div class="col-md-4 col-sm-6 mb-3"> <div class="col-md-4 col-sm-6 mb-3">
<div class="card"> <div class="card">
@@ -564,8 +610,8 @@ function renderStudentList(students) {
<h5 class="card-title">${s.name}</h5> <h5 class="card-title">${s.name}</h5>
<p class="card-text text-muted small">${s.wechat_nickname || ''} ${s.phone ? '| ' + s.phone : ''}</p> <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-info">${s.practice_time}</span>
<span class="badge bg-secondary">${problemText}</span> <span class="badge ${s.problem_count > 0 ? 'bg-secondary' : 'bg-light text-muted'}">${problemText}</span>
<span class="badge bg-primary">${s.plan_count} 个方案</span> <span class="badge ${planBadgeClass}">${planBadgeText}</span>
${s.goal_count > 0 ? `<span class="badge bg-success">${s.goal_count}个目标(${s.completed_goal_count}已达成)</span>` : ''} ${s.goal_count > 0 ? `<span class="badge bg-success">${s.goal_count}个目标(${s.completed_goal_count}已达成)</span>` : ''}
</div> </div>
</a> </a>
+39 -8
View File
@@ -28,16 +28,40 @@ var currentPlanId = null;
async function loadPlan() { async function loadPlan() {
currentPlanId = window.location.pathname.split('/').pop(); 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 { try {
const resp = await fetch(`/api/plans/${currentPlanId}`); const resp = await fetch(`/api/plans/${currentPlanId}`);
const data = await resp.json(); const data = await resp.json();
window.currentStudentId = data.student_id; 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 = ` let html = `
<div class="mb-3"> <div class="mb-3">
<strong>学员:</strong>${data.student_name} &nbsp;&nbsp; <strong>学员:</strong>${data.student_name} &nbsp;&nbsp;
<strong>练习时间:</strong>${data.content.practice_time} &nbsp;&nbsp; <strong>练习时间:</strong>${data.content.practice_time} &nbsp;&nbsp;
<strong>生成时间:</strong>${data.created_at} &nbsp;&nbsp; <strong>生成时间:</strong>${data.created_at} ${editInfo} &nbsp;&nbsp;
<strong>模板:</strong>${data.template_name || '无'} <strong>模板:</strong>${data.template_name || '无'}
<div class="mt-2"> <div class="mt-2">
<a href="/student/${data.student_id}" class="btn btn-sm btn-outline-primary"> <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) { async function toggleTypical(planId, isTypical) {
try { try {
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'}); await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
// 标记需要刷新方案列表
sessionStorage.setItem('plans_needs_refresh', 'true');
} catch (e) { } catch (e) {
alert('设置失败: ' + e.message); alert('设置失败: ' + e.message);
} }
} }
// 返回按钮处理:如果是编辑页返回的,跳过编辑页 // 返回按钮处理
function goBack() { function goBack() {
if (sessionStorage.getItem('fromEdit') === 'true') { // 标记需要刷新推荐方案列表
sessionStorage.removeItem('fromEdit'); sessionStorage.setItem('needs_refresh_recommended', 'true');
history.go(-2); // 跳过编辑页 history.back();
} else {
history.back();
}
} }
// 处理 bfcache - 页面从缓存恢复时需要重新加载以获取最新数据
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
// 页面从 bfcache 恢复,需要重新加载
loadPlan();
}
});
// 标记来源为编辑页(编辑页点击"返回详情"前设置) // 标记来源为编辑页(编辑页点击"返回详情"前设置)
function markFromEdit() { function markFromEdit() {
sessionStorage.setItem('fromEdit', 'true'); sessionStorage.setItem('fromEdit', 'true');
+5 -5
View File
@@ -65,6 +65,7 @@ async function loadPlanForEdit() {
try { try {
const resp = await fetch(`/api/plans/${planId}`); const resp = await fetch(`/api/plans/${planId}`);
const data = await resp.json(); const data = await resp.json();
window.currentStudentId = data.student_id;
const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content; const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content;
document.getElementById('editAiReport').value = content.ai_report || ''; document.getElementById('editAiReport').value = content.ai_report || '';
@@ -147,18 +148,17 @@ async function savePlanContent() {
if (!confirm('确定要保存修改吗?')) return; if (!confirm('确定要保存修改吗?')) return;
try { try {
const resp = await fetch(`/api/plans/${planId}`, { const resp = await fetch(`/api/plans/${planId}/content`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
ai_report: currentAiReport, content: JSON.stringify({ ai_report: currentAiReport, daily_schedule: tableData })
daily_schedule: tableData
}) })
}); });
if (resp.ok) { if (resp.ok) {
alert('保存成功'); // 保存后返回上一页(编辑页的上一页是方案详情,已从bfcache恢复)
window.location.href = `/plan/${planId}`; history.back();
} else { } else {
alert('保存失败'); alert('保存失败');
} }
+93 -3
View File
@@ -75,18 +75,86 @@
<script> <script>
// 防抖定时器 // 防抖定时器
let debounceTimer = null; let debounceTimer = null;
const STORAGE_KEY = 'plans_filters';
function debounceLoad() { function debounceLoad() {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadPlans, 300); 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() { window.pageInit = function() {
loadFilters(); checkAndRefresh();
loadPlans();
}; };
// 检查是否需要刷新(从详情页返回)
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() { async function loadFilters() {
// 加载班级 // 加载班级
@@ -107,7 +175,7 @@ async function loadFilters() {
const problems = await resp.json(); const problems = await resp.json();
const problemSelect = document.getElementById('filterProblem'); const problemSelect = document.getElementById('filterProblem');
problems.forEach(p => { 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) { } catch (e) {
console.error('加载问题失败', e); console.error('加载问题失败', e);
@@ -128,6 +196,9 @@ async function loadFilters() {
// 加载方案列表 // 加载方案列表
async function loadPlans() { async function loadPlans() {
// 保存当前筛选状态
saveFilterState();
const container = document.getElementById('plansContainer'); 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>'; 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 class="text-muted small">${p.created_at || ''}</td>
<td> <td>
<button class="btn btn-sm btn-outline-primary" onclick="viewPlan(${p.id})">查看</button> <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> </td>
</tr> </tr>
`; `;
@@ -224,6 +296,7 @@ function clearFilters() {
mineBtn.classList.remove('active', 'btn-primary'); mineBtn.classList.remove('active', 'btn-primary');
mineBtn.classList.add('btn-outline-secondary'); mineBtn.classList.add('btn-outline-secondary');
} }
sessionStorage.removeItem(STORAGE_KEY);
loadPlans(); loadPlans();
} }
@@ -239,6 +312,7 @@ function toggleMinePlans() {
btn.classList.remove('btn-primary'); btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-secondary'); btn.classList.add('btn-outline-secondary');
} }
saveFilterState();
loadPlans(); loadPlans();
} }
@@ -246,5 +320,21 @@ function toggleMinePlans() {
function viewPlan(planId) { function viewPlan(planId) {
window.location.href = `/plan/${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> </script>
{% endblock %} {% endblock %}
+27
View File
@@ -189,6 +189,30 @@
let allProblems = []; let allProblems = [];
let currentEditId = null; let currentEditId = null;
let currentDeleteId = 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() { window.pageInit = function() {
loadProblems(); loadProblems();
@@ -199,10 +223,13 @@ window.pageInit = function() {
async function loadProblems() { async function loadProblems() {
const response = await fetch('/api/problems'); const response = await fetch('/api/problems');
allProblems = await response.json(); allProblems = await response.json();
restoreProblemFilterState();
applyProblemFilters(); applyProblemFilters();
} }
function applyProblemFilters() { function applyProblemFilters() {
saveProblemFilterState();
const search = document.getElementById('searchInput').value.toLowerCase(); const search = document.getElementById('searchInput').value.toLowerCase();
const filterCategory = document.getElementById('filterCategory').value; const filterCategory = document.getElementById('filterCategory').value;
const groupBy = document.getElementById('groupByCategory').value; const groupBy = document.getElementById('groupByCategory').value;
+123
View File
@@ -80,6 +80,20 @@
<p class="text-muted">加载中...</p> <p class="text-muted">加载中...</p>
</div> </div>
</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>
</div> </div>
@@ -359,6 +373,11 @@ const studentName = "{{ student.name }}";
let problemModal, editProblemModal, generateModal, editStudentModal, assignGoalModal; let problemModal, editProblemModal, generateModal, editStudentModal, assignGoalModal;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initPage();
});
// 页面初始化
function initPage() {
problemModal = new bootstrap.Modal(document.getElementById('addProblemModal')); problemModal = new bootstrap.Modal(document.getElementById('addProblemModal'));
editProblemModal = new bootstrap.Modal(document.getElementById('editProblemModal')); editProblemModal = new bootstrap.Modal(document.getElementById('editProblemModal'));
generateModal = new bootstrap.Modal(document.getElementById('generatePlanModal')); 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('editStudentPracticeTime').value = document.getElementById('detailPracticeTime').textContent;
document.getElementById('editStudentNotes').value = document.getElementById('detailNotes').textContent === '无备注' ? '' : document.getElementById('detailNotes').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(); loadProblems();
loadPlans(); loadPlans();
loadProblemOptions(); loadProblemOptions();
loadStudentGoals(); loadStudentGoals();
}
// pageshow 事件处理 bfcache 恢复的情况
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
// 页面从 bfcache 恢复,需要重新检查刷新标记
initPage();
}
}); });
async function loadProblemOptions() { async function loadProblemOptions() {
@@ -590,13 +628,17 @@ function renderTimeline(timeline) {
</div>`; </div>`;
} else { } else {
const p = entry.plan; const p = entry.plan;
const adoptedFrom = p.adopted_from;
const editInfo = p.updated_at ? `<span class="text-muted small">(于${p.updated_at}编辑)</span>` : '';
return ` return `
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}"> <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="flex-grow-1">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<a href="/plan/${p.id}" class="fw-bold text-decoration-none">${formatDate(entry.date)}</a> <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>' : ''} ${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>
<div class="btn-group"> <div class="btn-group">
<a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary" title="查看"> <a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary" title="查看">
@@ -682,6 +724,7 @@ async function saveAddProblem() {
if (resp.ok) { if (resp.ok) {
problemModal.hide(); problemModal.hide();
loadProblems(); loadProblems();
loadRecommendedPlans(currentRecommendedFilter);
} else { } else {
const err = await resp.json(); const err = await resp.json();
alert('添加失败: ' + (err.error || '未知错误')); alert('添加失败: ' + (err.error || '未知错误'));
@@ -723,6 +766,7 @@ async function saveProblemEdit() {
if (updateResp.ok) { if (updateResp.ok) {
editProblemModal.hide(); editProblemModal.hide();
loadProblems(); loadProblems();
loadRecommendedPlans(currentRecommendedFilter);
} else { } else {
alert('更新失败'); alert('更新失败');
} }
@@ -739,6 +783,7 @@ async function deleteProblem(id) {
}); });
if (resp.ok) { if (resp.ok) {
loadProblems(); loadProblems();
loadRecommendedPlans(currentRecommendedFilter);
} else { } else {
alert('删除失败'); alert('删除失败');
} }
@@ -970,6 +1015,84 @@ async function loadStudentGoals() {
`).join(''); `).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) { function formatDate(dateStr) {
if (!dateStr) return ''; if (!dateStr) return '';
const d = new Date(dateStr); const d = new Date(dateStr);
+9 -24
View File
@@ -1,14 +1,5 @@
#!/bin/bash #!/bin/bash
# 钢琴练习方案系统 - 自动化部署脚本 set -e
# 使用方法: ./deploy.sh <image-tar-path>
set -e # 任何命令失败就停止
if [ -z "$1" ]; then
echo "用法: $0 <image-tar-path>"
echo "示例: $0 /path/to/piano-plan.tar"
exit 1
fi
IMAGE_TAR="$1" IMAGE_TAR="$1"
CONTAINER_NAME="piano-plan" CONTAINER_NAME="piano-plan"
@@ -20,34 +11,30 @@ echo "=========================================="
echo "钢琴练习方案系统 - 部署脚本" echo "钢琴练习方案系统 - 部署脚本"
echo "==========================================" echo "=========================================="
# 1. 检查镜像文件是否存在 # 1. 检查镜像文件
if [ ! -f "$IMAGE_TAR" ]; then if [ ! -f "$IMAGE_TAR" ]; then
echo "错误: 镜像文件不存在: $IMAGE_TAR" echo "错误: 镜像文件不存在: $IMAGE_TAR"
exit 1 exit 1
fi fi
# 2. 检查容器是否存在 # 2. 停止并删除旧容器
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then echo "[2/7] 停止并删除旧容器..."
echo "[2/7] 停止并删除旧容器..." docker stop $CONTAINER_NAME > /dev/null 2>&1 || true
docker stop $CONTAINER_NAME > /dev/null 2>&1 || true docker rm $CONTAINER_NAME > /dev/null 2>&1 || true
docker rm $CONTAINER_NAME > /dev/null 2>&1 || true
else
echo "[2/7] 容器不存在,跳过停止/删除"
fi
# 3. 加载新镜像 # 3. 加载新镜像
echo "[3/7] 加载镜像..." echo "[3/7] 加载镜像..."
docker load -i "$IMAGE_TAR" > /dev/null docker load -i "$IMAGE_TAR" > /dev/null
echo "镜像加载完成" echo "镜像加载完成"
# 4. 创建备份 # 4. 备份数据库(从旧容器)
echo "[4/7] 备份数据库..." echo "[4/7] 备份数据库..."
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
docker cp ${CONTAINER_NAME}:/app/data/piano_plans.db ${BACKUP_DIR}/piano_plans.db.bak.${TIMESTAMP} 2>/dev/null || true docker cp ${CONTAINER_NAME}:/app/data/piano_plans.db ${BACKUP_DIR}/piano_plans.db.bak.${TIMESTAMP} 2>/dev/null || true
echo "备份完成: ${BACKUP_DIR}/piano_plans.db.bak.${TIMESTAMP}" echo "备份完成: ${BACKUP_DIR}/piano_plans.db.bak.${TIMESTAMP}"
# 5. 启动新容器(使用正确的挂载配置!) # 5. 启动新容器
echo "[5/7] 启动新容器..." echo "[5/7] 启动新容器..."
docker run -d \ docker run -d \
--name $CONTAINER_NAME \ --name $CONTAINER_NAME \
@@ -60,7 +47,7 @@ docker run -d \
-v /opt/piano-plan/config:/app/config \ -v /opt/piano-plan/config:/app/config \
piano-plan:latest piano-plan:latest
# 6. 等待容器启动 # 6. 等待启动
echo "[6/7] 等待容器启动..." echo "[6/7] 等待容器启动..."
sleep 3 sleep 3
@@ -75,11 +62,9 @@ else
exit 1 exit 1
fi fi
# 验证问题文件
PROBLEM_COUNT=$(docker exec $CONTAINER_NAME ls /app/个性化方案/*.md 2>/dev/null | wc -l) PROBLEM_COUNT=$(docker exec $CONTAINER_NAME ls /app/个性化方案/*.md 2>/dev/null | wc -l)
echo "✓ 问题文件数量: $PROBLEM_COUNT" echo "✓ 问题文件数量: $PROBLEM_COUNT"
# 验证数据库
DB_TABLES=$(docker exec $CONTAINER_NAME python -c "import sqlite3; conn=sqlite3.connect('/app/data/piano_plans.db'); print(len(conn.execute('SELECT name FROM sqlite_master WHERE type=\"table\"').fetchall()))" 2>/dev/null || echo "0") DB_TABLES=$(docker exec $CONTAINER_NAME python -c "import sqlite3; conn=sqlite3.connect('/app/data/piano_plans.db'); print(len(conn.execute('SELECT name FROM sqlite_master WHERE type=\"table\"').fetchall()))" 2>/dev/null || echo "0")
echo "✓ 数据库表数量: $DB_TABLES" echo "✓ 数据库表数量: $DB_TABLES"
+82 -3
View File
@@ -2,7 +2,7 @@
## 基础信息 ## 基础信息
- **Base URL**: `http://127.0.0.1:5000` - **Base URL**: `http://127.0.0.1:5001`
- **Content-Type**: `application/json` - **Content-Type**: `application/json`
--- ---
@@ -547,8 +547,11 @@ GET /api/plans/<plan_id>
"id": 1, "id": 1,
"student_id": 1, "student_id": 1,
"student_name": "张三", "student_name": "张三",
"template_name": "默认模板",
"is_typical": false, "is_typical": false,
"created_at": "2026-04-17 10:30", "created_at": "2026-04-17 10:30",
"updated_at": "2026-04-27 15:00",
"updated_by_name": "管理员",
"content": { "content": {
"student_name": "张三", "student_name": "张三",
"practice_time": "30分钟", "practice_time": "30分钟",
@@ -561,6 +564,8 @@ GET /api/plans/<plan_id>
} }
``` ```
> **注意**: `updated_at` 和 `updated_by_name` 仅在方案被编辑过后才会有值。
--- ---
### 获取学员方案列表 ### 获取学员方案列表
@@ -571,6 +576,82 @@ GET /api/students/<student_id>/plans
--- ---
### 获取推荐方案列表
```
GET /api/students/<student_id>/recommended-plans
```
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| filter | string | 筛选条件:`all`(全部)或 `mine`(我的) |
**响应示例**:
```json
[
{
"id": 5,
"student_name": "李四",
"template_name": "默认模板",
"is_typical": true,
"created_at": "2026-04-20 10:00",
"problem_names": ["手小", "识谱慢"],
"can_adopt": true,
"adopted": false
}
]
```
---
### 采纳典型方案
```
POST /api/plans/<plan_id>/adopt
```
**请求体**:
```json
{
"student_id": 1
}
```
**响应**:
```json
{
"message": "方案已采纳",
"plan_id": 6
}
```
---
### 更新方案内容
```
PUT /api/plans/<plan_id>/content
```
**功能**: 编辑方案后保存内容
**请求体**:
```json
{
"content": "{\"ai_report\": \"...\", \"daily_schedule\": [...]}"
}
```
**响应**:
```json
{
"message": "保存成功"
}
```
---
### 设为典型方案 ### 设为典型方案
``` ```
@@ -592,8 +673,6 @@ POST /api/plans/<plan_id>/typical
``` ```
DELETE /api/plans/<plan_id> DELETE /api/plans/<plan_id>
``` ```
}
```
### 导出PDF ### 导出PDF
+46 -12
View File
@@ -1,11 +1,37 @@
# 钢琴练习方案系统 - 部署 SOP # 钢琴练习方案系统 - 部署 SOP
> 版本:v1.3.6 > 版本:v1.4.0
> 日期:2026-04-26 > 日期:2026-04-27
> 核心原则:**不删除,只备份后新增/替换** > 核心原则:**不删除,只备份后新增/替换**
--- ---
## 重要更新(v1.4.0
### ⚠️ 问题文件已迁移到数据库
**历史**`/app/个性化方案/*.md`15个问题文件)
**现状**:所有问题数据已迁移到 `problems` 表,不再需要挂载问题文件目录。
**影响**
- 部署时不再检查问题文件数量
- 不再需要 `/opt/piano-plan/个性化方案` 挂载
- 验证清单中"问题文件数量"检查已废弃
### ⚠️ Docker 构建需要代理
**本地代理端口**`15000`
构建命令:
```powershell
$env:HTTP_PROXY="http://127.0.0.1:15000"
$env:HTTPS_PROXY="http://127.0.0.1:15000"
docker build -t piano-plan:latest .
```
---
## 一、部署原则(铁律) ## 一、部署原则(铁律)
| 操作 | 允许? | 说明 | | 操作 | 允许? | 说明 |
@@ -121,11 +147,15 @@ with open('releases/v1.3.0/toRelease/schema.sql', 'w', encoding='utf-8') as f:
```powershell ```powershell
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统" cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
# 0. 配置代理(必须!)
$env:HTTP_PROXY="http://127.0.0.1:15000"
$env:HTTPS_PROXY="http://127.0.0.1:15000"
# 1. 构建镜像 # 1. 构建镜像
docker build -t piano-plan:v1.3.0 . docker build -t piano-plan:v1.4.0 .
# 2. 保存镜像 # 2. 保存镜像
docker save piano-plan:v1.3.0 -o releases/v1.3.0/toRelease/program/piano-plan.tar docker save piano-plan:v1.4.0 -o releases/v1.4.0/toRelease/program/piano-plan.tar
``` ```
--- ---
@@ -358,11 +388,14 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
``` ```
[ ] 容器状态:running [ ] 容器状态:running
[ ] 服务响应:HTTP 200/302 [ ] 服务响应:HTTP 200/302
[ ] 数据库表完整:users, students, classes, student_problems, practice_plans, problems, goals, goal_relations, student_goals, student_goal_evaluations [ ] 数据库表完整:users, students, classes, student_problems, practice_plans, templates, problems, goals, goal_relations, student_goals, student_goal_evaluations
[ ] practice_plans 表有新字段:created_by, updated_by, updated_at, template_id, is_typical
[ ] 目标管理功能正常:创建目标、分配目标、评估目标 [ ] 目标管理功能正常:创建目标、分配目标、评估目标
[ ] 时间线正常显示阶段评估和最终评估 [ ] 时间线正常显示阶段评估和最终评估
[ ] API 配置正确 [ ] API 配置正确
[ ] 功能验证:能生成练习方案 [ ] 功能验证:能生成练习方案
[ ] 方案列表支持删除
[ ] 学员列表"暂无方案/问题"样式正常
``` ```
--- ---
@@ -371,18 +404,19 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud
| 版本 | 日期 | 变更 | | 版本 | 日期 | 变更 |
|------|------|------| |------|------|------|
| v1.3.6 | 2026-04-24 | 方案详情导航优化(学员名→学员详情、返回按钮修复);典型方案开关移至方案详情;方案列表显示问题级别+严重程度;plan.content新增level字段;学员生成方案增加模板选择器;生成时禁用按钮;完成后显示提示词/报告字数;学员目标删除支持级联删除评估;目标模板删除增加依赖检查;API文档更新 | | v1.4.0 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情导航优化(bfcache处理);审计字段完善(created_by/updated_by/updated_at);方案列表支持删除;学员列表"暂无方案/问题"样式统一 |
| v1.3.5 | 2026-04-24 | 班级班主任字段;用户姓名name字段;班级/学员/方案增加"我的"筛选;用户管理:姓名字段+可编辑;方案管理:模板列表权限修复;时间线"我的"按钮样式优化 | | v1.3.6 | 2026-04-24 | 方案详情导航优化;典型方案开关移至方案详情;方案列表显示问题级别+严重程度 |
| v1.3.4 | 2026-04-24 | 方案编辑按钮;问题增量添加;teachers API公开;用户管理权限修复 | | v1.3.5 | 2026-04-24 | 班级班主任字段;用户姓名name字段;班级/学员/方案增加"我的"筛选 |
| v1.3.4 | 2026-04-24 | 方案编辑按钮;问题增量添加;teachers API公开 |
| v1.3.3 | 2026-04-24 | 评估日期编辑;最终评估关联 StudentGoal 同步 | | v1.3.3 | 2026-04-24 | 评估日期编辑;最终评估关联 StudentGoal 同步 |
| v1.3.2 | 2026-04-24 | StudentGoal 新增 status 字段;新增 StudentGoalEvaluation 表;阶段评估+最终评估功能;时间线增强(尚余天数/提前或延迟达成) | | v1.3.2 | 2026-04-24 | StudentGoal 新增 status 字段;新增 StudentGoalEvaluation 表 |
| v1.3.1 | 2026-04-24 | DRY 规范;Fragment 复用方案;班级批量分配目标 | | v1.3.1 | 2026-04-24 | DRY 规范;Fragment 复用方案;班级批量分配目标 |
| v1.3 | 2026-04-24 | 目标管理模块:Goal/GoalRelation/StudentGoal;问题分类重构;学习历程时间线 | | v1.3 | 2026-04-24 | 目标管理模块:Goal/GoalRelation/StudentGoal;问题分类重构 |
| v1.2 | 2026-04-23 | 问题迁移到数据库;移除个性化方案挂载 | | v1.2 | 2026-04-23 | 问题迁移到数据库;移除个性化方案挂载 |
| v1.1 | 2026-04-20 | 模板管理;API配置界面 | | v1.1 | 2026-04-20 | 模板管理;API配置界面 |
| v1.0 | 2026-04-17 | 初始版本 | | v1.0 | 2026-04-17 | 初始版本 |
--- ---
> **最后更新**2026-04-24 > **最后更新**2026-04-27
> **更新原因**v1.3.6 发布;方案详情导航优化;典型方案开关移至详情页;列表显示级别+严重程度;生成方案增加模板选择器;提示词字数确认 > **更新原因**v1.4.0 发布;问题文件已废弃(迁移到数据库);添加代理配置说明;审计字段;方案列表删除功能
+2 -1
View File
@@ -200,7 +200,8 @@ deploy: v1.2.0 生产环境部署
| V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 | | V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 |
| V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 | | V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 |
| V1.3 | 2026-04-25 | 目标管理模块上线;班级级别属性;{student_goals}参数;提示词预览 | | V1.3 | 2026-04-25 | 目标管理模块上线;班级级别属性;{student_goals}参数;提示词预览 |
| V1.4 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化;审计字段完善 |
--- ---
*最后更新:2026-04-25* *最后更新:2026-04-27*
+25
View File
@@ -255,3 +255,28 @@ app/templates/
|------|------|----------| |------|------|----------|
| 2026-04-21 | v1.0 | 初始文档,定义 base.html 模板继承模式 | | 2026-04-21 | v1.0 | 初始文档,定义 base.html 模板继承模式 |
| 2026-04-23 | v1.1 | 添加 URL 导航模式说明;新增 home.html, student.html, plan_edit.html | | 2026-04-23 | v1.1 | 添加 URL 导航模式说明;新增 home.html, student.html, plan_edit.html |
| 2026-04-27 | v1.2 | 方案编辑/详情页导航优化:bfcache 处理、pageshow 事件、sessionStorage 标记 |
## 9. 方案编辑页面导航
### 9.1 编辑流程
```
学员详情/方案列表 → 方案详情 → 编辑 → 保存 → 返回方案详情/学员详情/方案列表
```
### 9.2 导航实现
| 操作 | 实现方式 |
|------|----------|
| 保存后返回 | `history.back()` 返回上一页(编辑页),浏览器从 bfcache 恢复方案详情页 |
| 方案详情加载 | `pageshow` 事件检测 bfcache 恢复,自动调用 `loadPlan()` 刷新数据 |
| 返回按钮 | `history.back()` 返回上一页 |
### 9.3 sessionStorage 标记
| 标记 | 用途 |
|------|------|
| `plan_detail_referrer` | 记录方案详情页的来源(student/plans),编辑保存后用于决定跳转目标 |
| `needs_refresh_recommended` | 标记需要刷新推荐方案列表 |
| `plans_needs_refresh` | 标记需要刷新方案列表页 |
+9
View File
@@ -104,8 +104,17 @@
|------|------|------| |------|------|------|
| id | Integer | 主键,自增 | | id | Integer | 主键,自增 |
| student_id | Integer | 外键,关联 Student | | student_id | Integer | 外键,关联 Student |
| template_id | Integer | 外键,关联 TemplateAI提示词模板) |
| is_typical | Boolean | 是否为典型方案 |
| content | Text | 方案内容(JSON格式) | | content | Text | 方案内容(JSON格式) |
| created_by | Integer | 外键,关联 User(创建人) |
| created_at | DateTime | 创建时间 | | created_at | DateTime | 创建时间 |
| updated_by | Integer | 外键,关联 User(更新人,仅编辑时设置) |
| updated_at | DateTime | 更新时间(仅编辑时设置) |
**审计字段说明**
- `created_by`:创建时设置
- `updated_by``updated_at`:仅在编辑更新时设置,初次创建时为空
**content 字段结构**: **content 字段结构**:
```json ```json
+9 -2
View File
@@ -36,7 +36,9 @@
│ ├── index.html # 学员管理页面(继承base) │ ├── index.html # 学员管理页面(继承base)
│ ├── home.html # 默认首页(显示统计信息) │ ├── home.html # 默认首页(显示统计信息)
│ ├── student.html # 学员详情页(URL导航) │ ├── student.html # 学员详情页(URL导航)
│ ├── plan_detail.html # 方案详情页(URL导航)
│ ├── plan_edit.html # 方案编辑页(URL导航) │ ├── plan_edit.html # 方案编辑页(URL导航)
│ ├── plans.html # 方案管理列表页
│ ├── settings.html # 问题配置页面(继承base) │ ├── settings.html # 问题配置页面(继承base)
│ ├── login.html # 登录页面(独立) │ ├── login.html # 登录页面(独立)
│ ├── setup.html # 初始设置页面(独立) │ ├── setup.html # 初始设置页面(独立)
@@ -140,14 +142,18 @@ def create_app():
| 路由 | 方法 | 说明 | | 路由 | 方法 | 说明 |
|------|------|------| |------|------|------|
| `/api/generate-plan` | POST | 生成练习方案 | | `/api/generate-plan` | POST | 生成练习方案SSE |
| `/api/generate-plan/preview` | POST | 预览提示词 | | `/api/generate-plan/preview` | POST | 预览提示词 |
| `/api/plans/<id>` | GET | 获取方案详情 | | `/api/plans/<id>` | GET | 获取方案详情 |
| `/api/plans/<id>/content` | PUT | 更新方案内容 |
| `/api/plans/<id>/pdf` | GET | 导出PDF | | `/api/plans/<id>/pdf` | GET | 导出PDF |
| `/api/plans/<id>/md` | GET | 导出Markdown | | `/api/plans/<id>/md` | GET | 导出Markdown |
| `/plans/<id>/wechat` | GET | 微信卡片 | | `/plans/<id>/wechat` | GET | 微信卡片 |
| `/api/plans/<id>` | DELETE | 删除方案 | | `/api/plans/<id>` | DELETE | 删除方案 |
| `/api/plans/<id>/typical` | POST | 设为典型方案 | | `/api/plans/<id>/typical` | POST | 设为典型方案 |
| `/api/plans/<id>/adopt` | POST | 采纳典型方案 |
| `/api/students/<id>/plans` | GET | 获取学员方案列表 |
| `/api/students/<id>/recommended-plans` | GET | 获取推荐方案列表 |
### routes/settings.py ### routes/settings.py
@@ -285,4 +291,5 @@ generate_pdf(plan_id, student_name, content, output_dir)
| V1.1 | 2026-04-17 | 添加用户登录认证系统 | | V1.1 | 2026-04-17 | 添加用户登录认证系统 |
| V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 | | V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 |
| V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 | | V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 |
| V1.3 | 2026-04-25 | 目标管理模块上线;班级级别属性;{student_goals}参数;提示词预览 | | V1.3 | 2026-04-25 | 目标管理模块上线;班级级别属性;{student_goals}参数;提示词预览 |
| V1.4 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化;审计字段完善 |
+9 -7
View File
@@ -1,18 +1,20 @@
@echo off @echo off
chcp 65001 >nul 2>&1
cd /d "%~dp0" cd /d "%~dp0"
echo ============================================ echo ============================================
echo Working Directory: %CD% echo Working Directory: %CD%
echo Access: http://127.0.0.1:5001 echo Access: http://127.0.0.1:5001
echo ============================================ echo ============================================
echo. echo.
if exist "venv" goto usevenv set PYTHON_EXE=C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe
if exist "venv" goto :usevenv
echo Creating virtual environment... echo Creating virtual environment...
python -m venv venv "%PYTHON_EXE%" -m venv venv
echo Installing dependencies... echo Installing dependencies...
call venv\Scripts\pip.exe install Flask Flask-SQLAlchemy reportlab Jinja2 requests call venv\Scripts\pip.exe install -r requirements.txt
:usevenv :usevenv
venv\Scripts\python.exe run.py start "" venv\Scripts\python.exe run.py
pause