diff --git a/app/routes/classes.py b/app/routes/classes.py index f3956c2..d0ea809 100644 --- a/app/routes/classes.py +++ b/app/routes/classes.py @@ -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//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 diff --git a/app/templates/classes.html b/app/templates/classes.html index 0392937..cf25ff5 100644 --- a/app/templates/classes.html +++ b/app/templates/classes.html @@ -36,6 +36,19 @@ + + + + + {% endblock %} {% block extra_js %} @@ -165,6 +234,7 @@ function loadClasses() { ${c.student_count} ${c.created_at} + ${isAdmin ? ` ` : ''} @@ -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 => ``).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; +} {% endblock %} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 86548e7..1438871 100644 --- a/docs/API.md +++ b/docs/API.md @@ -266,6 +266,45 @@ POST /api/classes//assign --- +### 批量分配目标给班级学员 + +``` +POST /api/classes//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 +} +``` + +--- + ## 学员管理 ### 获取学员列表 diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index e9e9fa1..fa8118d 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -168,6 +168,7 @@ def create_app(): | `/api/classes/` | DELETE | 删除班级 | 管理员 | | `/api/classes//students` | GET | 班级学员 | 登录用户 | | `/api/classes//assign` | POST | 分配学员 | 登录用户 | +| `/api/classes//goals` | POST | 批量分配目标 | 登录用户 | ### routes/users.py(新增)