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