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:
@@ -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 - 问题数据已迁移到数据库;典型方案采纳功能;审计字段完善
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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": "保存成功"})
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
<strong>学员:</strong>${data.student_name}
|
||||||
<strong>练习时间:</strong>${data.content.practice_time}
|
<strong>练习时间:</strong>${data.content.practice_time}
|
||||||
<strong>生成时间:</strong>${data.created_at}
|
<strong>生成时间:</strong>${data.created_at} ${editInfo}
|
||||||
<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');
|
||||||
|
|||||||
@@ -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('保存失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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*
|
||||||
|
|||||||
@@ -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` | 标记需要刷新方案列表页 |
|
||||||
|
|||||||
@@ -104,8 +104,17 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| id | Integer | 主键,自增 |
|
| id | Integer | 主键,自增 |
|
||||||
| student_id | Integer | 外键,关联 Student |
|
| student_id | Integer | 外键,关联 Student |
|
||||||
|
| template_id | Integer | 外键,关联 Template(AI提示词模板) |
|
||||||
|
| 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
@@ -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 | 典型方案采纳;推荐方案列表;方案编辑/详情页导航优化;审计字段完善 |
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user