diff --git a/README.md b/README.md index 7bd3559..c5f267d 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,18 @@ ## 功能特点 - ✅ 学员管理(增删改查) -- ✅ 问题记录(15种常见问题 + 严重程度 + 练习时间 + 级别) +- ✅ 问题记录(15种常见问题 + 严重程度 + 练习时间 + 级别,数据存储在数据库) - ✅ **AI生成个性化练习方案报告**(支持 MiniMax、火山方舟、DeepSeek) - ✅ **模板管理**(AI提示词模板、报告导出模板,支持排序) +- ✅ **典型方案采纳**(推荐方案列表,可一键采纳) - ✅ 三种输出方式: - 网页展示 - **PDF下载(支持中文)** - 微信卡片分享 - ✅ **可视化编辑**(AI报告用 Markdown编辑器,每日练习用表格编辑器) - ✅ **API配置界面**(多提供商支持,切换时自动填充) +- ✅ **方案审计字段**(created_by/updated_by/updated_at) +- ✅ **目标管理**(目标设定、阶段评估、最终评估) ## 技术栈 @@ -159,6 +162,8 @@ piano-plan/ --- -> **版本**:v1.3.0 +> **版本**:v1.4.0 > **创建时间**:2026-04-17 -> **最后更新**:2026-04-25 +> **最后更新**:2026-04-27 +> +> **重要更新**:v1.4.0 - 问题数据已迁移到数据库;典型方案采纳功能;审计字段完善 diff --git a/app/__init__.py b/app/__init__.py index 3d724dd..e18ed38 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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] diff --git a/app/models.py b/app/models.py index c09f3c9..8695ae7 100644 --- a/app/models.py +++ b/app/models.py @@ -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, } diff --git a/app/routes/plans.py b/app/routes/plans.py index d4303f7..4afae35 100644 --- a/app/routes/plans.py +++ b/app/routes/plans.py @@ -13,7 +13,7 @@ from flask import ( session, ) from app.routes import main_bp -from app.models import db, Student, PracticePlan, StudentProblem, 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//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//plans/from-typical/", 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": "保存成功"}) diff --git a/app/templates/classes.html b/app/templates/classes.html index 9b93898..2cc3c55 100644 --- a/app/templates/classes.html +++ b/app/templates/classes.html @@ -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 => ` + + ${c.id} + ${c.name} + ${c.level || '启蒙'} + ${c.description || '-'} + ${c.active ? '进行中' : '已结束'} + ${c.student_count} + ${c.created_at} + + + ${isAdmin ? ` + ` : ''} + + + `).join(''); + }); +} loadClasses(); }; diff --git a/app/templates/goals.html b/app/templates/goals.html index 4cbdbc8..de9ff81 100644 --- a/app/templates/goals.html +++ b/app/templates/goals.html @@ -174,8 +174,34 @@ {{ super() }} {% endblock %} \ No newline at end of file diff --git a/app/templates/problems.html b/app/templates/problems.html index c61e83f..425c764 100644 --- a/app/templates/problems.html +++ b/app/templates/problems.html @@ -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; diff --git a/app/templates/student.html b/app/templates/student.html index e5e1076..69d9328 100644 --- a/app/templates/student.html +++ b/app/templates/student.html @@ -80,6 +80,20 @@

加载中...

+ + +
+
+
📋 推荐方案
+
+ + +
+
+
+

加载中...

+
+
@@ -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) { `; } else { const p = entry.plan; + const adoptedFrom = p.adopted_from; + const editInfo = p.updated_at ? `(于${p.updated_at}编辑)` : ''; return `
${formatDate(entry.date)} + ${editInfo} ${p.is_typical ? '典型' : ''} + ${adoptedFrom ? `采纳自${escapeHtml(adoptedFrom.student_name)}的方案` : ''}
@@ -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 = '

加载中...

'; + + 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 = '

暂无匹配的推荐方案

'; + return; + } + + container.innerHTML = plans.map(p => ` +
+ `).join(''); + } catch (e) { + console.error('加载推荐方案失败', e); + container.innerHTML = '

加载失败

'; + } +} + +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); diff --git a/deploy.sh b/deploy.sh index ba43caf..d7ee6ce 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,14 +1,5 @@ #!/bin/bash -# 钢琴练习方案系统 - 自动化部署脚本 -# 使用方法: ./deploy.sh - -set -e # 任何命令失败就停止 - -if [ -z "$1" ]; then - echo "用法: $0 " - echo "示例: $0 /path/to/piano-plan.tar" - exit 1 -fi +set -e IMAGE_TAR="$1" CONTAINER_NAME="piano-plan" @@ -20,34 +11,30 @@ echo "==========================================" echo "钢琴练习方案系统 - 部署脚本" echo "==========================================" -# 1. 检查镜像文件是否存在 +# 1. 检查镜像文件 if [ ! -f "$IMAGE_TAR" ]; then echo "错误: 镜像文件不存在: $IMAGE_TAR" exit 1 fi -# 2. 检查容器是否存在 -if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then - echo "[2/7] 停止并删除旧容器..." - docker stop $CONTAINER_NAME > /dev/null 2>&1 || true - docker rm $CONTAINER_NAME > /dev/null 2>&1 || true -else - echo "[2/7] 容器不存在,跳过停止/删除" -fi +# 2. 停止并删除旧容器 +echo "[2/7] 停止并删除旧容器..." +docker stop $CONTAINER_NAME > /dev/null 2>&1 || true +docker rm $CONTAINER_NAME > /dev/null 2>&1 || true # 3. 加载新镜像 echo "[3/7] 加载镜像..." docker load -i "$IMAGE_TAR" > /dev/null echo "镜像加载完成" -# 4. 创建备份 +# 4. 备份数据库(从旧容器) echo "[4/7] 备份数据库..." mkdir -p "$BACKUP_DIR" 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 echo "备份完成: ${BACKUP_DIR}/piano_plans.db.bak.${TIMESTAMP}" -# 5. 启动新容器(使用正确的挂载配置!) +# 5. 启动新容器 echo "[5/7] 启动新容器..." docker run -d \ --name $CONTAINER_NAME \ @@ -60,7 +47,7 @@ docker run -d \ -v /opt/piano-plan/config:/app/config \ piano-plan:latest -# 6. 等待容器启动 +# 6. 等待启动 echo "[6/7] 等待容器启动..." sleep 3 @@ -75,11 +62,9 @@ else exit 1 fi -# 验证问题文件 PROBLEM_COUNT=$(docker exec $CONTAINER_NAME ls /app/个性化方案/*.md 2>/dev/null | wc -l) 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") echo "✓ 数据库表数量: $DB_TABLES" diff --git a/docs/API.md b/docs/API.md index fee5dc4..405ca5a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,7 +2,7 @@ ## 基础信息 -- **Base URL**: `http://127.0.0.1:5000` +- **Base URL**: `http://127.0.0.1:5001` - **Content-Type**: `application/json` --- @@ -547,8 +547,11 @@ GET /api/plans/ "id": 1, "student_id": 1, "student_name": "张三", + "template_name": "默认模板", "is_typical": false, "created_at": "2026-04-17 10:30", + "updated_at": "2026-04-27 15:00", + "updated_by_name": "管理员", "content": { "student_name": "张三", "practice_time": "30分钟", @@ -561,6 +564,8 @@ GET /api/plans/ } ``` +> **注意**: `updated_at` 和 `updated_by_name` 仅在方案被编辑过后才会有值。 + --- ### 获取学员方案列表 @@ -571,6 +576,82 @@ GET /api/students//plans --- +### 获取推荐方案列表 + +``` +GET /api/students//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//adopt +``` + +**请求体**: +```json +{ + "student_id": 1 +} +``` + +**响应**: +```json +{ + "message": "方案已采纳", + "plan_id": 6 +} +``` + +--- + +### 更新方案内容 + +``` +PUT /api/plans//content +``` + +**功能**: 编辑方案后保存内容 + +**请求体**: +```json +{ + "content": "{\"ai_report\": \"...\", \"daily_schedule\": [...]}" +} +``` + +**响应**: +```json +{ + "message": "保存成功" +} +``` + +--- + ### 设为典型方案 ``` @@ -592,8 +673,6 @@ POST /api/plans//typical ``` DELETE /api/plans/ ``` -} -``` ### 导出PDF diff --git a/docs/DEPLOYMENT_SOP.md b/docs/DEPLOYMENT_SOP.md index 37e3a62..f4eb65e 100644 --- a/docs/DEPLOYMENT_SOP.md +++ b/docs/DEPLOYMENT_SOP.md @@ -1,11 +1,37 @@ # 钢琴练习方案系统 - 部署 SOP -> 版本:v1.3.6 -> 日期:2026-04-26 +> 版本:v1.4.0 +> 日期: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 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. 构建镜像 -docker build -t piano-plan:v1.3.0 . +docker build -t piano-plan:v1.4.0 . # 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 [ ] 服务响应: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 配置正确 [ ] 功能验证:能生成练习方案 +[ ] 方案列表支持删除 +[ ] 学员列表"暂无方案/问题"样式正常 ``` --- @@ -371,18 +404,19 @@ A: 检查是否执行了 migrate_goals_v3.py 迁移脚本,该脚本创建 stud | 版本 | 日期 | 变更 | |------|------|------| -| v1.3.6 | 2026-04-24 | 方案详情导航优化(学员名→学员详情、返回按钮修复);典型方案开关移至方案详情;方案列表显示问题级别+严重程度;plan.content新增level字段;学员生成方案增加模板选择器;生成时禁用按钮;完成后显示提示词/报告字数;学员目标删除支持级联删除评估;目标模板删除增加依赖检查;API文档更新 | -| v1.3.5 | 2026-04-24 | 班级班主任字段;用户姓名name字段;班级/学员/方案增加"我的"筛选;用户管理:姓名字段+可编辑;方案管理:模板列表权限修复;时间线"我的"按钮样式优化 | -| v1.3.4 | 2026-04-24 | 方案编辑按钮;问题增量添加;teachers API公开;用户管理权限修复 | +| v1.4.0 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化(bfcache处理);审计字段完善(created_by/updated_by/updated_at);方案列表支持删除;学员列表"暂无方案/问题"样式统一 | +| v1.3.6 | 2026-04-24 | 方案详情导航优化;典型方案开关移至方案详情;方案列表显示问题级别+严重程度 | +| v1.3.5 | 2026-04-24 | 班级班主任字段;用户姓名name字段;班级/学员/方案增加"我的"筛选 | +| v1.3.4 | 2026-04-24 | 方案编辑按钮;问题增量添加;teachers API公开 | | 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 | 2026-04-24 | 目标管理模块:Goal/GoalRelation/StudentGoal;问题分类重构;学习历程时间线 | +| v1.3 | 2026-04-24 | 目标管理模块:Goal/GoalRelation/StudentGoal;问题分类重构 | | v1.2 | 2026-04-23 | 问题迁移到数据库;移除个性化方案挂载 | | v1.1 | 2026-04-20 | 模板管理;API配置界面 | | v1.0 | 2026-04-17 | 初始版本 | --- -> **最后更新**:2026-04-24 -> **更新原因**:v1.3.6 发布;方案详情导航优化;典型方案开关移至详情页;列表显示级别+严重程度;生成方案增加模板选择器;提示词字数确认 +> **最后更新**:2026-04-27 +> **更新原因**:v1.4.0 发布;问题文件已废弃(迁移到数据库);添加代理配置说明;审计字段;方案列表删除功能 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index abd97c5..e00815b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -200,7 +200,8 @@ deploy: v1.2.0 生产环境部署 | V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 | | V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 | | V1.3 | 2026-04-25 | 目标管理模块上线;班级级别属性;{student_goals}参数;提示词预览 | +| V1.4 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化;审计字段完善 | --- -*最后更新:2026-04-25* +*最后更新:2026-04-27* diff --git a/docs/FRONTEND_ARCH.md b/docs/FRONTEND_ARCH.md index 0c536ff..b14d3fb 100644 --- a/docs/FRONTEND_ARCH.md +++ b/docs/FRONTEND_ARCH.md @@ -255,3 +255,28 @@ app/templates/ |------|------|----------| | 2026-04-21 | v1.0 | 初始文档,定义 base.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` | 标记需要刷新方案列表页 | diff --git a/docs/MODELS.md b/docs/MODELS.md index c253ce6..3e27f2b 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -104,8 +104,17 @@ |------|------|------| | id | Integer | 主键,自增 | | student_id | Integer | 外键,关联 Student | +| template_id | Integer | 外键,关联 Template(AI提示词模板) | +| is_typical | Boolean | 是否为典型方案 | | content | Text | 方案内容(JSON格式) | +| created_by | Integer | 外键,关联 User(创建人) | | created_at | DateTime | 创建时间 | +| updated_by | Integer | 外键,关联 User(更新人,仅编辑时设置) | +| updated_at | DateTime | 更新时间(仅编辑时设置) | + +**审计字段说明**: +- `created_by`:创建时设置 +- `updated_by`、`updated_at`:仅在编辑更新时设置,初次创建时为空 **content 字段结构**: ```json diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 0539675..c2943d2 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -36,7 +36,9 @@ │ ├── index.html # 学员管理页面(继承base) │ ├── home.html # 默认首页(显示统计信息) │ ├── student.html # 学员详情页(URL导航) +│ ├── plan_detail.html # 方案详情页(URL导航) │ ├── plan_edit.html # 方案编辑页(URL导航) +│ ├── plans.html # 方案管理列表页 │ ├── settings.html # 问题配置页面(继承base) │ ├── login.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/plans/` | GET | 获取方案详情 | +| `/api/plans//content` | PUT | 更新方案内容 | | `/api/plans//pdf` | GET | 导出PDF | | `/api/plans//md` | GET | 导出Markdown | | `/plans//wechat` | GET | 微信卡片 | | `/api/plans/` | DELETE | 删除方案 | | `/api/plans//typical` | POST | 设为典型方案 | +| `/api/plans//adopt` | POST | 采纳典型方案 | +| `/api/students//plans` | GET | 获取学员方案列表 | +| `/api/students//recommended-plans` | GET | 获取推荐方案列表 | ### routes/settings.py @@ -285,4 +291,5 @@ generate_pdf(plan_id, student_name, content, output_dir) | V1.1 | 2026-04-17 | 添加用户登录认证系统 | | V1.2 | 2026-04-18 | 添加用户管理、角色权限、班级管理 | | V1.2.0 | 2026-04-23 | 问题迁移到数据库;URL导航改造;侧边栏统一 | -| V1.3 | 2026-04-25 | 目标管理模块上线;班级级别属性;{student_goals}参数;提示词预览 | \ No newline at end of file +| V1.3 | 2026-04-25 | 目标管理模块上线;班级级别属性;{student_goals}参数;提示词预览 | +| V1.4 | 2026-04-27 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化;审计字段完善 | \ No newline at end of file diff --git a/run.bat b/run.bat index d62a94b..e6b10a3 100644 --- a/run.bat +++ b/run.bat @@ -1,18 +1,20 @@ @echo off +chcp 65001 >nul 2>&1 cd /d "%~dp0" echo ============================================ -echo Working Directory: %CD% -echo Access: http://127.0.0.1:5001 +echo Working Directory: %CD% +echo Access: http://127.0.0.1:5001 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... -python -m venv venv +"%PYTHON_EXE%" -m venv venv 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 -venv\Scripts\python.exe run.py -pause \ No newline at end of file +start "" venv\Scripts\python.exe run.py \ No newline at end of file