feat: 添加学员目标API和目标管理页面
This commit is contained in:
+10
-2
@@ -1,9 +1,17 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify, render_template
|
||||||
from app.models import db, Goal
|
from app.models import db, Goal
|
||||||
from app.routes.auth import login_required_json
|
from app.routes.auth import login_required_json, admin_required
|
||||||
|
from app.routes import main_bp
|
||||||
|
|
||||||
goals_bp = Blueprint("goals", __name__)
|
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"])
|
@goals_bp.route("/api/goals", methods=["GET"])
|
||||||
@login_required_json
|
@login_required_json
|
||||||
def get_goals():
|
def get_goals():
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user