Merge branch 'feature/goal-management'
This commit is contained in:
@@ -34,14 +34,23 @@ def create_app():
|
|||||||
# 注册蓝图
|
# 注册蓝图
|
||||||
from app.routes import main_bp
|
from app.routes import main_bp
|
||||||
from app.routes.templates import templates_bp
|
from app.routes.templates import templates_bp
|
||||||
|
from app.routes.goals import goals_bp
|
||||||
|
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(templates_bp)
|
app.register_blueprint(templates_bp)
|
||||||
|
app.register_blueprint(goals_bp)
|
||||||
|
from app.routes import student_goals
|
||||||
|
app.register_blueprint(student_goals.student_goals_bp)
|
||||||
|
|
||||||
# 创建数据库和目录
|
# 创建数据库和目录
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
os.makedirs(os.path.join(BASE_DIR, "data"), exist_ok=True)
|
os.makedirs(os.path.join(BASE_DIR, "data"), exist_ok=True)
|
||||||
os.makedirs(app.config["PDF_OUTPUT_DIR"], exist_ok=True)
|
os.makedirs(app.config["PDF_OUTPUT_DIR"], exist_ok=True)
|
||||||
|
|
||||||
|
# 确保所有模型都被导入
|
||||||
|
from app.models import Student, Problem, StudentProblem, Template, PracticePlan
|
||||||
|
from app.models import Goal, GoalRelation, StudentGoal # 新增目标相关模型
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# 简单迁移:为已存在的数据库添加新字段(必须在init_default_templates之前)
|
# 简单迁移:为已存在的数据库添加新字段(必须在init_default_templates之前)
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ from flask import Blueprint
|
|||||||
main_bp = Blueprint("main", __name__)
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
# 导入各路由模块
|
# 导入各路由模块
|
||||||
from app.routes import students, plans, problems, settings, auth, classes, users, backup
|
from app.routes import students, plans, problems, settings, auth, classes, users, backup, goals
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, render_template
|
||||||
|
from app.models import db, Goal
|
||||||
|
from app.routes.auth import login_required_json, admin_required
|
||||||
|
from app.routes import main_bp
|
||||||
|
|
||||||
|
goals_bp = Blueprint("goals", __name__)
|
||||||
|
|
||||||
|
# 目标管理页面路由
|
||||||
|
@main_bp.route("/goals")
|
||||||
|
@admin_required
|
||||||
|
def goals_page():
|
||||||
|
"""目标管理页面"""
|
||||||
|
return render_template("goals.html", active_nav="goals")
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals", methods=["GET"])
|
||||||
|
@login_required_json
|
||||||
|
def get_goals():
|
||||||
|
goals = Goal.query.order_by(Goal.created_at.desc()).all()
|
||||||
|
return jsonify([g.to_dict() for g in goals])
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals", methods=["POST"])
|
||||||
|
@login_required_json
|
||||||
|
def create_goal():
|
||||||
|
data = request.get_json()
|
||||||
|
goal = Goal(name=data["name"], content=data.get("content", ""))
|
||||||
|
db.session.add(goal)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(goal.to_dict()), 201
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals/<int:goal_id>", methods=["GET"])
|
||||||
|
@login_required_json
|
||||||
|
def get_goal(goal_id):
|
||||||
|
goal = Goal.query.get_or_404(goal_id)
|
||||||
|
return jsonify(goal.to_dict())
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals/<int:goal_id>", methods=["PUT"])
|
||||||
|
@login_required_json
|
||||||
|
def update_goal(goal_id):
|
||||||
|
goal = Goal.query.get_or_404(goal_id)
|
||||||
|
data = request.get_json()
|
||||||
|
if "name" in data:
|
||||||
|
goal.name = data["name"]
|
||||||
|
if "content" in data:
|
||||||
|
goal.content = data["content"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(goal.to_dict())
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals/<int:goal_id>", methods=["DELETE"])
|
||||||
|
@login_required_json
|
||||||
|
def delete_goal(goal_id):
|
||||||
|
goal = Goal.query.get_or_404(goal_id)
|
||||||
|
db.session.delete(goal)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "删除成功"})
|
||||||
|
|
||||||
|
# ===== 以下是 Task 3 的循环检测和关联 API =====
|
||||||
|
|
||||||
|
def _has_cycle(parent_id, child_id):
|
||||||
|
"""检测添加 child_id 作为 parent_id 的子目标是否会形成循环"""
|
||||||
|
visited = set()
|
||||||
|
stack = [child_id]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
if current == parent_id:
|
||||||
|
return True
|
||||||
|
if current in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current)
|
||||||
|
from app.models import GoalRelation
|
||||||
|
for rel in GoalRelation.query.filter_by(parent_goal_id=current).all():
|
||||||
|
stack.append(rel.child_goal_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals/<int:goal_id>/children", methods=["GET"])
|
||||||
|
@login_required_json
|
||||||
|
def get_goal_children(goal_id):
|
||||||
|
from app.models import GoalRelation
|
||||||
|
relations = GoalRelation.query.filter_by(parent_goal_id=goal_id).all()
|
||||||
|
child_ids = [r.child_goal_id for r in relations]
|
||||||
|
children = Goal.query.filter(Goal.id.in_(child_ids)).all() if child_ids else []
|
||||||
|
return jsonify([c.to_dict() for c in children])
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals/<int:goal_id>/parents", methods=["GET"])
|
||||||
|
@login_required_json
|
||||||
|
def get_goal_parents(goal_id):
|
||||||
|
from app.models import GoalRelation
|
||||||
|
relations = GoalRelation.query.filter_by(child_goal_id=goal_id).all()
|
||||||
|
parent_ids = [r.parent_goal_id for r in relations]
|
||||||
|
parents = Goal.query.filter(Goal.id.in_(parent_ids)).all() if parent_ids else []
|
||||||
|
return jsonify([p.to_dict() for p in parents])
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals/<int:goal_id>/children", methods=["POST"])
|
||||||
|
@login_required_json
|
||||||
|
def add_goal_child(goal_id):
|
||||||
|
from app.models import GoalRelation
|
||||||
|
data = request.get_json()
|
||||||
|
child_id = data["child_goal_id"]
|
||||||
|
|
||||||
|
if goal_id == child_id:
|
||||||
|
return jsonify({"error": "不能将目标关联到自身"}), 400
|
||||||
|
|
||||||
|
if _has_cycle(goal_id, child_id):
|
||||||
|
return jsonify({"error": "添加此关联会形成循环引用"}), 400
|
||||||
|
|
||||||
|
existing = GoalRelation.query.filter_by(parent_goal_id=goal_id, child_goal_id=child_id).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({"error": "关联已存在"}), 400
|
||||||
|
|
||||||
|
relation = GoalRelation(parent_goal_id=goal_id, child_goal_id=child_id)
|
||||||
|
db.session.add(relation)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "添加成功"})
|
||||||
|
|
||||||
|
@goals_bp.route("/api/goals/<int:goal_id>/children/<int:child_id>", methods=["DELETE"])
|
||||||
|
@login_required_json
|
||||||
|
def remove_goal_child(goal_id, child_id):
|
||||||
|
from app.models import GoalRelation
|
||||||
|
relation = GoalRelation.query.filter_by(parent_goal_id=goal_id, child_goal_id=child_id).first()
|
||||||
|
if relation:
|
||||||
|
db.session.delete(relation)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "移除成功"})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.models import db, StudentGoal, Goal
|
||||||
|
from app.routes.auth import login_required_json
|
||||||
|
|
||||||
|
student_goals_bp = Blueprint("student_goals", __name__)
|
||||||
|
|
||||||
|
@student_goals_bp.route("/api/students/<int:student_id>/goals", methods=["GET"])
|
||||||
|
@login_required_json
|
||||||
|
def get_student_goals(student_id):
|
||||||
|
records = StudentGoal.query.filter_by(student_id=student_id).all()
|
||||||
|
return jsonify([r.to_dict() for r in records])
|
||||||
|
|
||||||
|
@student_goals_bp.route("/api/students/<int:student_id>/goals", methods=["POST"])
|
||||||
|
@login_required_json
|
||||||
|
def assign_goal(student_id):
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# 检查目标是否存在
|
||||||
|
goal = Goal.query.get(data["goal_id"])
|
||||||
|
if not goal:
|
||||||
|
return jsonify({"error": "目标不存在"}), 404
|
||||||
|
|
||||||
|
# 检查是否已分配
|
||||||
|
existing = StudentGoal.query.filter_by(student_id=student_id, goal_id=data["goal_id"]).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({"error": "目标已分配"}), 400
|
||||||
|
|
||||||
|
record = StudentGoal(
|
||||||
|
student_id=student_id,
|
||||||
|
goal_id=data["goal_id"],
|
||||||
|
status=data.get("status", "未开始"),
|
||||||
|
mastery_level=data.get("mastery_level", 1),
|
||||||
|
deadline=data.get("deadline")
|
||||||
|
)
|
||||||
|
db.session.add(record)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(record.to_dict()), 201
|
||||||
|
|
||||||
|
@student_goals_bp.route("/api/students/<int:student_id>/goals/<int:goal_id>", methods=["PUT"])
|
||||||
|
@login_required_json
|
||||||
|
def update_student_goal(student_id, goal_id):
|
||||||
|
record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404()
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if "status" in data:
|
||||||
|
record.status = data["status"]
|
||||||
|
if data["status"] == "已完成" and not record.completed_at:
|
||||||
|
from datetime import datetime
|
||||||
|
record.completed_at = datetime.now()
|
||||||
|
if "mastery_level" in data:
|
||||||
|
record.mastery_level = data["mastery_level"]
|
||||||
|
if "deadline" in data:
|
||||||
|
record.deadline = data["deadline"]
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(record.to_dict())
|
||||||
|
|
||||||
|
@student_goals_bp.route("/api/students/<int:student_id>/goals/<int:goal_id>", methods=["DELETE"])
|
||||||
|
@login_required_json
|
||||||
|
def remove_student_goal(student_id, goal_id):
|
||||||
|
record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404()
|
||||||
|
db.session.delete(record)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "移除成功"})
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<h2 class="mb-4">🎯 目标管理</h2>
|
||||||
|
|
||||||
|
<!-- 目标列表 -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">目标库</h5>
|
||||||
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#goalModal">
|
||||||
|
+ 新建目标
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="goals-grid" class="row g-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 目标编辑 Modal -->
|
||||||
|
<div class="modal fade" id="goalModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="goalModalTitle">新建目标</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="goal-id">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">目标名称</label>
|
||||||
|
<input type="text" class="form-control" id="goal-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">目标内容 (Markdown)</label>
|
||||||
|
<textarea class="form-control" id="goal-content" rows="8"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-goal">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关系管理 Modal -->
|
||||||
|
<div class="modal fade" id="relationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">管理目标关系</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="relation-goal-id">
|
||||||
|
<h6>上级目标(父目标)</h6>
|
||||||
|
<div id="parent-goals" class="mb-3"></div>
|
||||||
|
<hr>
|
||||||
|
<h6>下级目标(子目标)</h6>
|
||||||
|
<div id="child-goals" class="mb-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
const API_BASE = '/api/goals';
|
||||||
|
|
||||||
|
// 加载目标列表
|
||||||
|
async function loadGoals() {
|
||||||
|
const res = await fetch(API_BASE);
|
||||||
|
const goals = await res.json();
|
||||||
|
const grid = document.getElementById('goals-grid');
|
||||||
|
grid.innerHTML = goals.map(g => `
|
||||||
|
<div class="col-md-4 col-lg-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">${escapeHtml(g.name)}</h6>
|
||||||
|
<p class="card-text small text-muted">${escapeHtml(g.content || '').substring(0, 100)}...</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="editGoal(${g.id})">编辑</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="manageRelations(${g.id})">关联</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteGoal(${g.id})">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存目标
|
||||||
|
async function saveGoal() {
|
||||||
|
const id = document.getElementById('goal-id').value;
|
||||||
|
const name = document.getElementById('goal-name').value;
|
||||||
|
const content = document.getElementById('goal-content').value;
|
||||||
|
|
||||||
|
if (!name) { alert('请输入目标名称'); return; }
|
||||||
|
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
const url = id ? `${API_BASE}/${id}` : API_BASE;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name, content})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('goalModal')).hide();
|
||||||
|
loadGoals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editGoal(id) {
|
||||||
|
fetch(`${API_BASE}/${id}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(g => {
|
||||||
|
document.getElementById('goal-id').value = g.id;
|
||||||
|
document.getElementById('goal-name').value = g.name;
|
||||||
|
document.getElementById('goal-content').value = g.content || '';
|
||||||
|
document.getElementById('goalModalTitle').textContent = '编辑目标';
|
||||||
|
new bootstrap.Modal(document.getElementById('goalModal')).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGoal(id) {
|
||||||
|
if (!confirm('确定删除此目标?')) return;
|
||||||
|
await fetch(`${API_BASE}/${id}`, {method: 'DELETE'});
|
||||||
|
loadGoals();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manageRelations(id) {
|
||||||
|
document.getElementById('relation-goal-id').value = id;
|
||||||
|
|
||||||
|
const [parents, children] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/${id}/parents`).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE}/${id}/children`).then(r => r.json())
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.getElementById('parent-goals').innerHTML = parents.map(g =>
|
||||||
|
`<span class="badge bg-info me-1 mb-1">${escapeHtml(g.name)} <button type="button" class="btn-close btn-close-white ms-1" onclick="removeRelation(${id}, ${g.id}, 'parent')"></button></span>`
|
||||||
|
).join('') || '<span class="text-muted">无</span>';
|
||||||
|
|
||||||
|
document.getElementById('child-goals').innerHTML = children.map(g =>
|
||||||
|
`<span class="badge bg-primary me-1 mb-1">${escapeHtml(g.name)} <button type="button" class="btn-close btn-close-white ms-1" onclick="removeRelation(${id}, ${g.id}, 'child')"></button></span>`
|
||||||
|
).join('') || '<span class="text-muted">无</span>';
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('relationModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRelation(parentId, childId, type) {
|
||||||
|
const url = type === 'parent'
|
||||||
|
? `${API_BASE}/${childId}/children/${parentId}`
|
||||||
|
: `${API_BASE}/${parentId}/children/${childId}`;
|
||||||
|
await fetch(url, {method: 'DELETE'});
|
||||||
|
manageRelations(parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('save-goal').addEventListener('click', saveGoal);
|
||||||
|
document.getElementById('goalModal').addEventListener('hidden.bs.modal', () => {
|
||||||
|
document.getElementById('goal-id').value = '';
|
||||||
|
document.getElementById('goalModalTitle').textContent = '新建目标';
|
||||||
|
});
|
||||||
|
|
||||||
|
loadGoals();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+158
-1
@@ -56,6 +56,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 学员目标区块 -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">🎯 练习目标</h5>
|
||||||
|
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#assignGoalModal">
|
||||||
|
+ 分配目标
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="student-goals-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> 练习方案</h6>
|
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> 练习方案</h6>
|
||||||
@@ -238,19 +251,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 分配目标 Modal -->
|
||||||
|
<div class="modal fade" id="assignGoalModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">分配目标</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">选择目标</label>
|
||||||
|
<select class="form-select" id="assign-goal-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">状态</label>
|
||||||
|
<select class="form-select" id="assign-status">
|
||||||
|
<option value="未开始">未开始</option>
|
||||||
|
<option value="进行中">进行中</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">掌握程度 (1-5)</label>
|
||||||
|
<input type="number" class="form-control" id="assign-mastery" value="1" min="1" max="5">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">截止日期</label>
|
||||||
|
<input type="date" class="form-control" id="assign-deadline">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="confirm-assign-goal">分配</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
const currentStudentId = {{ student.id }};
|
const currentStudentId = {{ student.id }};
|
||||||
const studentName = "{{ student.name }}";
|
const studentName = "{{ student.name }}";
|
||||||
let problemModal, editProblemModal, generateModal, editStudentModal;
|
let problemModal, editProblemModal, generateModal, editStudentModal, assignGoalModal;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
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'));
|
||||||
editStudentModal = new bootstrap.Modal(document.getElementById('editStudentModal'));
|
editStudentModal = new bootstrap.Modal(document.getElementById('editStudentModal'));
|
||||||
|
assignGoalModal = new bootstrap.Modal(document.getElementById('assignGoalModal'));
|
||||||
|
|
||||||
// 填充编辑学员表单初始值
|
// 填充编辑学员表单初始值
|
||||||
document.getElementById('editStudentName').value = document.getElementById('detailName').textContent;
|
document.getElementById('editStudentName').value = document.getElementById('detailName').textContent;
|
||||||
@@ -262,6 +313,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
loadProblems();
|
loadProblems();
|
||||||
loadPlans();
|
loadPlans();
|
||||||
loadProblemOptions();
|
loadProblemOptions();
|
||||||
|
loadStudentGoals();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadProblemOptions() {
|
async function loadProblemOptions() {
|
||||||
@@ -571,6 +623,111 @@ async function deletePlan(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 目标相关 =====
|
||||||
|
async function loadStudentGoals() {
|
||||||
|
const res = await fetch(`/api/students/${currentStudentId}/goals`);
|
||||||
|
const goals = await res.json();
|
||||||
|
|
||||||
|
if (goals.length === 0) {
|
||||||
|
document.getElementById('student-goals-list').innerHTML =
|
||||||
|
'<p class="text-muted">暂无分配的目标</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('student-goals-list').innerHTML = goals.map(g => `
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2 p-2 border rounded">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(g.goal_name)}</strong>
|
||||||
|
<div class="small text-muted">
|
||||||
|
掌握程度:${'⭐'.repeat(g.mastery_level)}${'☆'.repeat(5-g.mastery_level)}
|
||||||
|
| 状态:${g.status}
|
||||||
|
${g.deadline ? '| 截止:'+g.deadline : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="editStudentGoal(${g.goal_id})">编辑</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="removeStudentGoal(${g.goal_id})">移除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editStudentGoal(goalId) {
|
||||||
|
const goal = await fetch(`/api/students/${currentStudentId}/goals`).then(r => r.json())
|
||||||
|
.then(goals => goals.find(g => g.goal_id === goalId));
|
||||||
|
|
||||||
|
const status = prompt('状态 (未开始/进行中/已完成)', goal.status);
|
||||||
|
const mastery = prompt('掌握程度 (1-5)', goal.mastery_level);
|
||||||
|
|
||||||
|
if (status && mastery) {
|
||||||
|
await fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({status, mastery_level: parseInt(mastery)})
|
||||||
|
});
|
||||||
|
loadStudentGoals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStudentGoal(goalId) {
|
||||||
|
if (!confirm('确定移除此目标?')) return;
|
||||||
|
await fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {method: 'DELETE'});
|
||||||
|
loadStudentGoals();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载可选目标列表到 Modal
|
||||||
|
async function loadGoalOptions() {
|
||||||
|
const res = await fetch('/api/goals');
|
||||||
|
const goals = await res.json();
|
||||||
|
const select = document.getElementById('assign-goal-select');
|
||||||
|
|
||||||
|
// 获取已分配的目标
|
||||||
|
const assignedRes = await fetch(`/api/students/${currentStudentId}/goals`);
|
||||||
|
const assigned = await assignedRes.json();
|
||||||
|
const assignedIds = assigned.map(g => g.goal_id);
|
||||||
|
|
||||||
|
select.innerHTML = goals
|
||||||
|
.filter(g => !assignedIds.includes(g.id))
|
||||||
|
.map(g => `<option value="${g.id}">${escapeHtml(g.name)}</option>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirm-assign-goal').addEventListener('click', async () => {
|
||||||
|
const goalId = document.getElementById('assign-goal-select').value;
|
||||||
|
const status = document.getElementById('assign-status').value;
|
||||||
|
const mastery = document.getElementById('assign-mastery').value;
|
||||||
|
const deadline = document.getElementById('assign-deadline').value;
|
||||||
|
|
||||||
|
if (!goalId) { alert('请选择目标'); return; }
|
||||||
|
|
||||||
|
const res = await fetch(`/api/students/${currentStudentId}/goals`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
goal_id: parseInt(goalId),
|
||||||
|
status,
|
||||||
|
mastery_level: parseInt(mastery),
|
||||||
|
deadline: deadline || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
assignGoalModal.hide();
|
||||||
|
loadStudentGoals();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || '分配失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('assignGoalModal').addEventListener('show.bs.modal', loadGoalOptions);
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(message, type) {
|
function showToast(message, type) {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = 'toast-message alert alert-' + (type === 'success' ? 'success' : 'danger');
|
toast.className = 'toast-message alert alert-' + (type === 'success' ? 'success' : 'danger');
|
||||||
|
|||||||
+83
@@ -523,6 +523,89 @@ POST /api/config/test
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 目标管理 API
|
||||||
|
|
||||||
|
### GET /api/goals
|
||||||
|
获取所有目标
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "掌握基本音阶",
|
||||||
|
"content": "...",
|
||||||
|
"created_at": "2026-04-23T10:00:00",
|
||||||
|
"updated_at": "2026-04-23T10:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/goals
|
||||||
|
创建新目标
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "目标名称",
|
||||||
|
"content": "目标内容(Markdown)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/goals/{id}
|
||||||
|
获取单个目标
|
||||||
|
|
||||||
|
### PUT /api/goals/{id}
|
||||||
|
更新目标
|
||||||
|
|
||||||
|
### DELETE /api/goals/{id}
|
||||||
|
删除目标
|
||||||
|
|
||||||
|
### GET /api/goals/{id}/children
|
||||||
|
获取目标的子目标
|
||||||
|
|
||||||
|
### GET /api/goals/{id}/parents
|
||||||
|
获取目标的父目标
|
||||||
|
|
||||||
|
### POST /api/goals/{id}/children
|
||||||
|
添加子目标关联(含循环检测)
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"child_goal_id": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /api/goals/{id}/children/{child_id}
|
||||||
|
移除子目标关联
|
||||||
|
|
||||||
|
## 学员目标 API
|
||||||
|
|
||||||
|
### GET /api/students/{id}/goals
|
||||||
|
获取学员的所有目标
|
||||||
|
|
||||||
|
### POST /api/students/{id}/goals
|
||||||
|
为学员分配目标
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"goal_id": 1,
|
||||||
|
"status": "未开始",
|
||||||
|
"mastery_level": 1,
|
||||||
|
"deadline": "2026-05-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT /api/students/{id}/goals/{goal_id}
|
||||||
|
更新学员目标状态/掌握程度
|
||||||
|
|
||||||
|
### DELETE /api/students/{id}/goals/{goal_id}
|
||||||
|
移除学员的目标
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 权限说明
|
## 权限说明
|
||||||
|
|
||||||
| 接口 | 管理员 | 普通用户 |
|
| 接口 | 管理员 | 普通用户 |
|
||||||
|
|||||||
+45
-1
@@ -294,4 +294,48 @@ SELECT * FROM users;
|
|||||||
| 60分钟 | 中高级学员 |
|
| 60分钟 | 中高级学员 |
|
||||||
| 90分钟 | 高级学员 |
|
| 90分钟 | 高级学员 |
|
||||||
| 120分钟 | 专业学员 |
|
| 120分钟 | 专业学员 |
|
||||||
| 150分钟以上 | 竞技水平 |
|
| 150分钟以上 | 竞技水平 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目标管理模块
|
||||||
|
|
||||||
|
### Goal (目标表)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | Integer | 主键 |
|
||||||
|
| name | String(100) | 目标名称 |
|
||||||
|
| content | Text | 目标内容(Markdown) |
|
||||||
|
| created_at | DateTime | 创建时间 |
|
||||||
|
| updated_at | DateTime | 更新时间 |
|
||||||
|
|
||||||
|
### GoalRelation (目标关联表)
|
||||||
|
|
||||||
|
自关联多对多关系,用于表示目标之间的父子关系(DAG)。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| parent_goal_id | Integer | 父目标ID,外键 |
|
||||||
|
| child_goal_id | Integer | 子目标ID,外键 |
|
||||||
|
|
||||||
|
**关系类型**:自引用多对多(一个目标可以有多个子目标,也可以有多个父目标)
|
||||||
|
|
||||||
|
**约束**:通过应用层循环检测防止形成循环
|
||||||
|
|
||||||
|
### StudentGoal (学员目标记录表)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | Integer | 主键 |
|
||||||
|
| student_id | Integer | 学员ID,外键 |
|
||||||
|
| goal_id | Integer | 目标ID,外键 |
|
||||||
|
| status | String(20) | 状态:未开始/进行中/已完成 |
|
||||||
|
| mastery_level | Integer | 掌握程度 1-5 |
|
||||||
|
| deadline | DateTime | 截止日期 |
|
||||||
|
| completed_at | DateTime | 完成时间 |
|
||||||
|
| created_at | DateTime | 创建时间 |
|
||||||
|
|
||||||
|
**关系**:
|
||||||
|
- 一个学员可以分配多个目标
|
||||||
|
- 一个目标可以分配给多个学员
|
||||||
+4
-1
@@ -21,7 +21,9 @@
|
|||||||
│ │ ├── problems.py # 问题记录API
|
│ │ ├── problems.py # 问题记录API
|
||||||
│ │ ├── plans.py # 方案生成API
|
│ │ ├── plans.py # 方案生成API
|
||||||
│ │ ├── settings.py # 系统设置API
|
│ │ ├── settings.py # 系统设置API
|
||||||
│ │ └── classes.py # 班级管理API(新增)
|
│ │ ├── classes.py # 班级管理API
|
||||||
|
│ │ ├── goals.py # 目标管理 API
|
||||||
|
│ │ └── student_goals.py # 学员目标 API
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/ # 业务逻辑
|
│ ├── services/ # 业务逻辑
|
||||||
│ │ ├── plan_generator.py # 方案生成器
|
│ │ ├── plan_generator.py # 方案生成器
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
│ ├── setup.html # 初始设置页面(独立)
|
│ ├── setup.html # 初始设置页面(独立)
|
||||||
│ ├── users.html # 用户管理页面(继承base)
|
│ ├── users.html # 用户管理页面(继承base)
|
||||||
│ ├── classes.html # 班级管理页面(继承base)
|
│ ├── classes.html # 班级管理页面(继承base)
|
||||||
|
│ ├── goals.html # 目标管理页面
|
||||||
│ ├── templates.html # 模板管理页面(继承base)
|
│ ├── templates.html # 模板管理页面(继承base)
|
||||||
│ ├── api_settings.html # API设置页面(继承base)
|
│ ├── api_settings.html # API设置页面(继承base)
|
||||||
│ └── wechat_card.html # 微信卡片模板(独立)
|
│ └── wechat_card.html # 微信卡片模板(独立)
|
||||||
|
|||||||
Reference in New Issue
Block a user