feat: add goal management section to student detail page

This commit is contained in:
hmo
2026-04-23 18:30:00 +08:00
parent c605c9732a
commit 9f22717adc
2 changed files with 165 additions and 1 deletions
+7
View File
@@ -39,11 +39,18 @@ def create_app():
app.register_blueprint(main_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():
os.makedirs(os.path.join(BASE_DIR, "data"), 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()
# 简单迁移:为已存在的数据库添加新字段(必须在init_default_templates之前)
+158 -1
View File
@@ -56,6 +56,19 @@
</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-header d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> 练习方案</h6>
@@ -238,19 +251,57 @@
</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 %}
{% block extra_js %}
<script>
const currentStudentId = {{ student.id }};
const studentName = "{{ student.name }}";
let problemModal, editProblemModal, generateModal, editStudentModal;
let problemModal, editProblemModal, generateModal, editStudentModal, assignGoalModal;
document.addEventListener('DOMContentLoaded', function() {
problemModal = new bootstrap.Modal(document.getElementById('addProblemModal'));
editProblemModal = new bootstrap.Modal(document.getElementById('editProblemModal'));
generateModal = new bootstrap.Modal(document.getElementById('generatePlanModal'));
editStudentModal = new bootstrap.Modal(document.getElementById('editStudentModal'));
assignGoalModal = new bootstrap.Modal(document.getElementById('assignGoalModal'));
// 填充编辑学员表单初始值
document.getElementById('editStudentName').value = document.getElementById('detailName').textContent;
@@ -262,6 +313,7 @@ document.addEventListener('DOMContentLoaded', function() {
loadProblems();
loadPlans();
loadProblemOptions();
loadStudentGoals();
});
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) {
const toast = document.createElement('div');
toast.className = 'toast-message alert alert-' + (type === 'success' ? 'success' : 'danger');