feat: 班级管理增加分配目标功能,支持批量分配目标给班级所有学员
This commit is contained in:
+76
-1
@@ -1,8 +1,9 @@
|
||||
# 班级管理路由
|
||||
|
||||
from flask import request, jsonify, render_template, session
|
||||
from datetime import datetime, timedelta
|
||||
from app.routes import main_bp
|
||||
from app.models import db, Class, Student
|
||||
from app.models import db, Class, Student, StudentGoal, Goal
|
||||
from app.routes.auth import login_required_json, admin_required
|
||||
import logging
|
||||
|
||||
@@ -149,3 +150,77 @@ def api_classes_assign(class_id):
|
||||
db.session.rollback()
|
||||
logger.error(f"分配学员失败: {str(e)}")
|
||||
return jsonify({"error": "操作失败,请稍后重试", "code": "SERVER_ERROR"}), 500
|
||||
|
||||
|
||||
@main_bp.route("/api/classes/<int:class_id>/goals", methods=["POST"])
|
||||
@login_required_json
|
||||
def api_classes_assign_goals(class_id):
|
||||
"""批量分配目标给班级所有学员"""
|
||||
cls = Class.query.get_or_404(class_id)
|
||||
data = request.get_json()
|
||||
|
||||
goal_id = data.get("goal_id")
|
||||
assessment_days = data.get("assessment_days")
|
||||
assessment_date = data.get("assessment_date")
|
||||
start_date = data.get("start_date")
|
||||
start_now = data.get("start_now", True)
|
||||
|
||||
if not goal_id:
|
||||
return jsonify({"error": "请选择目标", "code": "VALIDATION_ERROR"}), 400
|
||||
if not assessment_days and not assessment_date:
|
||||
return jsonify({"error": "请选择评估方式", "code": "VALIDATION_ERROR"}), 400
|
||||
|
||||
# 检查目标是否存在
|
||||
goal = Goal.query.get(goal_id)
|
||||
if not goal:
|
||||
return jsonify({"error": "目标不存在", "code": "NOT_FOUND"}), 404
|
||||
|
||||
# 获取班级所有学员
|
||||
students = Student.query.filter_by(class_id=class_id).all()
|
||||
if not students:
|
||||
return jsonify({"error": "班级没有学员", "code": "NO_STUDENTS"}), 400
|
||||
|
||||
# 处理评估日期
|
||||
final_assessment_date = None
|
||||
if assessment_date:
|
||||
final_assessment_date = datetime.fromisoformat(assessment_date)
|
||||
elif assessment_days:
|
||||
final_assessment_date = datetime.now() + timedelta(days=int(assessment_days))
|
||||
|
||||
# 处理开始日期
|
||||
final_start_date = None
|
||||
if start_date:
|
||||
final_start_date = datetime.fromisoformat(start_date)
|
||||
elif start_now:
|
||||
final_start_date = datetime.now()
|
||||
|
||||
# 批量创建 StudentGoal
|
||||
assigned_count = 0
|
||||
skipped = []
|
||||
for student in students:
|
||||
# 检查是否已分配
|
||||
existing = StudentGoal.query.filter_by(student_id=student.id, goal_id=goal_id).first()
|
||||
if existing:
|
||||
skipped.append(student.name)
|
||||
continue
|
||||
|
||||
record = StudentGoal(
|
||||
student_id=student.id,
|
||||
goal_id=goal_id,
|
||||
start_date=final_start_date,
|
||||
assessment_date=final_assessment_date
|
||||
)
|
||||
db.session.add(record)
|
||||
assigned_count += 1
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
result = {"message": f"成功分配 {assigned_count} 个学员", "assigned": assigned_count}
|
||||
if skipped:
|
||||
result["skipped"] = skipped
|
||||
result["skipped_count"] = len(skipped)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"分配目标失败: {str(e)}")
|
||||
return jsonify({"error": "操作失败,请稍后重试", "code": "SERVER_ERROR"}), 500
|
||||
|
||||
@@ -36,6 +36,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-footer-with-top {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background: #f8f9fa;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 新增/编辑班级弹窗 -->
|
||||
<div class="modal fade" id="classModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@@ -114,6 +127,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分配目标弹窗 -->
|
||||
<div class="modal fade" id="classAssignGoalModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">分配目标 - <span id="classAssignGoalClassName"></span></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="class-assign-goal-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">评估日期</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-auto">
|
||||
<select class="form-select" id="class-assign-assessment-days">
|
||||
<option value="">指定日期</option>
|
||||
<option value="15">15天后</option>
|
||||
<option value="30">30天后</option>
|
||||
<option value="60">60天后</option>
|
||||
<option value="90">90天后</option>
|
||||
<option value="180">180天后</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="date" class="form-control" id="class-assign-assessment-date">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<a class="text-decoration-none" data-bs-toggle="collapse" href="#classAssignMoreSettings" role="button">
|
||||
更多设置 ▼
|
||||
</a>
|
||||
<div class="collapse" id="classAssignMoreSettings">
|
||||
<div class="card card-body mt-2">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">开始日期</label>
|
||||
<input type="date" class="form-control" id="class-assign-start-date">
|
||||
<small class="text-muted">默认立即开始</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer-with-top">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="confirm-class-assign-goal">分配</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -165,6 +234,7 @@ function loadClasses() {
|
||||
<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.description || ''}', ${c.active})">编辑</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteClass(${c.id})">删除</button>` : ''}
|
||||
</td>
|
||||
@@ -281,5 +351,91 @@ document.getElementById('addClassBtn').onclick = () => {
|
||||
document.getElementById('classModalTitle').textContent = '新增班级';
|
||||
new bootstrap.Modal(document.getElementById('classModal')).show();
|
||||
};
|
||||
|
||||
// ========== 分配目标功能 ==========
|
||||
|
||||
let classAssignGoalModal;
|
||||
|
||||
document.getElementById('classAssignGoalModal').addEventListener('show.bs.modal', function () {
|
||||
loadClassGoalOptions();
|
||||
});
|
||||
|
||||
function openAssignGoalModal(classId, className) {
|
||||
currentClassId = classId;
|
||||
document.getElementById('classAssignGoalClassName').textContent = className;
|
||||
// 重置表单
|
||||
document.getElementById('class-assign-assessment-days').value = '';
|
||||
document.getElementById('class-assign-assessment-date').value = '';
|
||||
document.getElementById('class-assign-start-date').value = '';
|
||||
classAssignGoalModal = new bootstrap.Modal(document.getElementById('classAssignGoalModal'));
|
||||
classAssignGoalModal.show();
|
||||
}
|
||||
|
||||
async function loadClassGoalOptions() {
|
||||
const res = await fetch('/api/goals');
|
||||
const goals = await res.json();
|
||||
const select = document.getElementById('class-assign-goal-select');
|
||||
select.innerHTML = goals.map(g => `<option value="${g.id}">${escapeHtml(g.name)} [${g.level || '入门'} - ${g.category || '综合'}]</option>`).join('');
|
||||
|
||||
// 设置默认开始日期为今天
|
||||
document.getElementById('class-assign-start-date').value = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 评估日期联动
|
||||
document.getElementById('class-assign-assessment-days').addEventListener('change', function() {
|
||||
const days = parseInt(this.value);
|
||||
if (days) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
document.getElementById('class-assign-assessment-date').value = d.toISOString().split('T')[0];
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('class-assign-assessment-date').addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
document.getElementById('class-assign-assessment-days').value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 确认分配目标
|
||||
document.getElementById('confirm-class-assign-goal').addEventListener('click', async () => {
|
||||
const goalId = document.getElementById('class-assign-goal-select').value;
|
||||
const assessmentDays = document.getElementById('class-assign-assessment-days').value;
|
||||
const assessmentDate = document.getElementById('class-assign-assessment-date').value;
|
||||
const startDate = document.getElementById('class-assign-start-date').value;
|
||||
|
||||
if (!goalId) { alert('请选择目标'); return; }
|
||||
if (!assessmentDays && !assessmentDate) { alert('请选择评估方式'); return; }
|
||||
|
||||
// 弹出确认框
|
||||
if (!confirm('将给班级所有学员分配此目标,确定吗?')) return;
|
||||
|
||||
const res = await fetch(`/api/classes/${currentClassId}/goals`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
goal_id: parseInt(goalId),
|
||||
assessment_days: assessmentDays || null,
|
||||
assessment_date: assessmentDate || null,
|
||||
start_date: startDate || null,
|
||||
start_now: !startDate
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
classAssignGoalModal.hide();
|
||||
alert(data.message + (data.skipped_count ? `(${data.skipped_count}个学员已分配此目标,跳过)` : ''));
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || '分配失败');
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
+39
@@ -266,6 +266,45 @@ POST /api/classes/<id>/assign
|
||||
|
||||
---
|
||||
|
||||
### 批量分配目标给班级学员
|
||||
|
||||
```
|
||||
POST /api/classes/<id>/goals
|
||||
```
|
||||
|
||||
**权限**: 登录用户
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"goal_id": 1,
|
||||
"assessment_days": "30",
|
||||
"assessment_date": null,
|
||||
"start_date": null,
|
||||
"start_now": true
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| goal_id | int | 是 | 目标ID |
|
||||
| assessment_days | string | 否 | 评估天数(15/30/60/90/180) |
|
||||
| assessment_date | string | 否 | 评估日期(YYYY-MM-DD),与 assessment_days 二选一 |
|
||||
| start_date | string | 否 | 开始日期(YYYY-MM-DD) |
|
||||
| start_now | bool | 否 | 是否立即开始(默认true) |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "成功分配 5 个学员",
|
||||
"assigned": 5,
|
||||
"skipped": ["李四", "王五"],
|
||||
"skipped_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 学员管理
|
||||
|
||||
### 获取学员列表
|
||||
|
||||
@@ -168,6 +168,7 @@ def create_app():
|
||||
| `/api/classes/<id>` | DELETE | 删除班级 | 管理员 |
|
||||
| `/api/classes/<id>/students` | GET | 班级学员 | 登录用户 |
|
||||
| `/api/classes/<id>/assign` | POST | 分配学员 | 登录用户 |
|
||||
| `/api/classes/<id>/goals` | POST | 批量分配目标 | 登录用户 |
|
||||
|
||||
### routes/users.py(新增)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user