فهرست منبع

feat: 报名只报课程班次审核分配;新增课程管理/班级维度分组/按签到重分/助教调班

liaoxg 1 هفته پیش
والد
کامیت
9f06216

+ 34 - 28
train-backend/src/main/java/com/train/controller/EnrollmentController.java

@@ -102,50 +102,54 @@ public class EnrollmentController {
     }
 
     /**
-     * 创建报名单(带邀请码则记录转介绍关系)
+     * 创建报名单(报名=报课程:courseId 必填,classId 可选,班次由助教审核时分配;
+     * 带邀请码则记录转介绍关系)
      */
     @Operation(summary = "创建报名单")
     @PostMapping("/create")
     public Result<EnrollmentVO> create(@RequestBody Map<String, Object> body,
                                        @RequestAttribute("userId") Long userId) {
-        Object classIdObj = body.get("classId");
-        if (classIdObj == null) {
-            return Result.error("请选择班次");
+        if (body.get("courseId") == null || body.get("courseId").toString().trim().isEmpty()) {
+            return Result.error("请选择要报名的课程");
         }
-        Long classId = Long.valueOf(classIdObj.toString());
+        Long courseId = Long.valueOf(body.get("courseId").toString());
         String name = body.get("name") == null ? "" : body.get("name").toString().trim();
         String phone = body.get("phone") == null ? "" : body.get("phone").toString().trim();
         if (!StringUtils.hasText(name) || !StringUtils.hasText(phone)) {
             return Result.error("请填写姓名和手机号");
         }
-        TrainClass trainClass = trainClassMapper.selectById(classId);
-        if (trainClass == null || !"active".equals(trainClass.getStatus())) {
-            return Result.error("班次不存在或不可报名");
+        TrainCourse course = trainCourseMapper.selectById(courseId);
+        if (course == null || "draft".equals(course.getStatus())) {
+            return Result.error("课程不存在或不可报名");
         }
-        // 报名=报课程:显式传入 courseId 优先,否则由班次所属课程推导(两者不一致时以班次为准)
-        Long courseId = null;
-        if (body.get("courseId") != null && !body.get("courseId").toString().trim().isEmpty()) {
-            courseId = Long.valueOf(body.get("courseId").toString());
-        }
-        if (courseId == null) {
-            courseId = trainClass.getCourseId();
-        }
-        // 容量校验(并发下允许少量超卖,最终以支付为准)
-        Long occupied = trainEnrollmentMapper.selectCount(
-                new LambdaQueryWrapper<TrainEnrollment>()
-                        .eq(TrainEnrollment::getClassId, classId)
-                        .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
-        if (trainClass.getCapacity() != null && occupied != null && occupied >= trainClass.getCapacity()) {
-            return Result.error("该班次名额已满");
+        // 报名=报课程:班次可选(兼容存量选择班次报名的入口);若传 classId 校验归属该课程
+        Long classId = null;
+        if (body.get("classId") != null && !body.get("classId").toString().trim().isEmpty()) {
+            classId = Long.valueOf(body.get("classId").toString());
+            TrainClass trainClass = trainClassMapper.selectById(classId);
+            if (trainClass == null || !"active".equals(trainClass.getStatus())) {
+                return Result.error("班次不存在或不可报名");
+            }
+            if (trainClass.getCourseId() != null && !trainClass.getCourseId().equals(courseId)) {
+                return Result.error("所选班次不属于该课程");
+            }
+            // 容量校验(并发下允许少量超卖,最终以支付为准)
+            Long occupied = trainEnrollmentMapper.selectCount(
+                    new LambdaQueryWrapper<TrainEnrollment>()
+                            .eq(TrainEnrollment::getClassId, classId)
+                            .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
+            if (trainClass.getCapacity() != null && occupied != null && occupied >= trainClass.getCapacity()) {
+                return Result.error("该班次名额已满");
+            }
         }
-        // 防重复报名(同班次已有 pending 报名单)
+        // 防重复报名(同课程已有未支付/在途报名单)
         Long exists = trainEnrollmentMapper.selectCount(
                 new LambdaQueryWrapper<TrainEnrollment>()
                         .eq(TrainEnrollment::getUid, userId)
-                        .eq(TrainEnrollment::getClassId, classId)
-                        .eq(TrainEnrollment::getStatus, "pending"));
+                        .eq(TrainEnrollment::getCourseId, courseId)
+                        .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
         if (exists != null && exists > 0) {
-            return Result.error("您已报名该班次,请勿重复提交");
+            return Result.error("您已报名该课程,请勿重复提交");
         }
 
         String inviteCode = body.get("inviteCode") == null ? "" : body.get("inviteCode").toString().trim();
@@ -192,8 +196,10 @@ public class EnrollmentController {
 
         EnrollmentVO vo = new EnrollmentVO();
         vo.setId(e.getId());
+        vo.setCourseId(courseId);
+        vo.setCourseName(course.getLevel() + ' ' + course.getName());
         vo.setClassId(classId);
-        vo.setClassName(trainClass.getName());
+        vo.setClassName(classId == null ? null : trainClassMapper.selectById(classId).getName());
         vo.setName(name);
         vo.setPhone(phone);
         vo.setSource(e.getSource());

+ 41 - 9
train-backend/src/main/java/com/train/controller/admin/AdminBizController.java

@@ -223,13 +223,15 @@ public class AdminBizController {
     }
 
     /**
-     * 进班审核(Phase15b):学员从课程列表申请进班后,管理员审核。
-     * body: {enrollmentId, approved, groupId?}
-     * - approved=true  → 报名单置 confirmed,学员 user.class_id 写入班次,
+     * 进班审核(Phase15b):学员从课程列表申请进班后,管理员/助教审核。
+     * body: {enrollmentId, approved, classId?, groupId?}
+     * - 报名=报课程:班次由审核时分配。approved=true 时必填班次
+     *   (body.classId 优先,兜底报名单已带 classId),并校验班次归属该课程;
+     *   然后报名单置 confirmed、学员 user.class_id 写入班次,
      *   且按指定/自动方式分派小组(指定 groupId 校验属于该班次;未指定自动分派人数最少小组)
      * - approved=false → 报名单置 rejected
      */
-    @Operation(summary = "进班审核")
+    @Operation(summary = "进班审核(审核时分配班次+小组)")
     @PostMapping("/enrollment/audit")
     public Result<Map<String, Object>> enrollmentAudit(@RequestBody Map<String, Object> body,
                                                        @RequestAttribute("adminId") Long adminId) {
@@ -258,17 +260,45 @@ public class AdminBizController {
             return Result.success(data);
         }
 
-        // 通过:报名单 → confirmed,写入 user.class_id
+        // 通过:先确定班次——审核时分配(body.classId 优先,兜底报名单已带 classId)
+        Long classId = null;
+        if (body.get("classId") != null && !body.get("classId").toString().trim().isEmpty()) {
+            classId = Long.valueOf(body.get("classId").toString());
+        } else {
+            classId = en.getClassId();
+        }
+        if (classId == null) {
+            return Result.error("请为该学员选择班次(报名=报课程,班次由审核时分配)");
+        }
+        TrainClass trainClass = trainClassMapper.selectById(classId);
+        if (trainClass == null || !"active".equals(trainClass.getStatus())) {
+            return Result.error("所选班次不存在或不可用");
+        }
+        // 校验班次归属该课程(报名单冗余 course_id 优先,兜底班次所属课程)
+        Long courseId = en.getCourseId();
+        if (courseId == null) {
+            courseId = trainClass.getCourseId();
+        }
+        if (courseId != null && trainClass.getCourseId() != null
+                && !trainClass.getCourseId().equals(courseId)) {
+            return Result.error("所选班次不属于该课程,请重新选择");
+        }
+
+        // 通过:报名单写入班次并置 confirmed,写入 user.class_id
+        en.setClassId(classId);
+        if (courseId != null) {
+            en.setCourseId(courseId);
+        }
         en.setStatus("confirmed");
         en.setUpdatedAt(new Date());
         trainEnrollmentMapper.updateById(en);
 
         Long uid = en.getUid();
-        Long classId = en.getClassId();
-        if (uid != null && classId != null) {
+        if (uid != null) {
             TrainUser user = trainUserMapper.selectById(uid);
             if (user != null) {
                 user.setClassId(classId);
+                user.setUpdatedAt(new Date());
                 trainUserMapper.updateById(user);
             }
         }
@@ -278,11 +308,11 @@ public class AdminBizController {
         if (body.get("groupId") != null && !body.get("groupId").toString().trim().isEmpty()) {
             Long gid = Long.valueOf(body.get("groupId").toString());
             TrainGroup g = gid == null ? null : trainGroupMapper.selectById(gid);
-            if (g == null || !g.getClassId().equals(classId)) {
+            if (g == null || !classId.equals(g.getClassId())) {
                 return Result.error("指定分组不属于该班次,请重新选择");
             }
             groupId = gid;
-        } else if (classId != null) {
+        } else {
             groupId = leastLoadedGroupId(classId);
         }
         if (groupId != null && uid != null) {
@@ -300,6 +330,8 @@ public class AdminBizController {
         }
 
         data.put("status", "confirmed");
+        data.put("classId", classId);
+        data.put("className", trainClass.getName());
         data.put("groupId", groupId);
         data.put("message", "已通过进班审核" + (groupId != null ? "并已分派小组" : "(未分派小组)"));
         return Result.success(data);

+ 204 - 0
train-backend/src/main/java/com/train/controller/admin/AdminClassController.java

@@ -27,6 +27,10 @@ public class AdminClassController {
     @Resource
     private TrainGroupMemberMapper trainGroupMemberMapper;
     @Resource
+    private TrainCheckinMapper trainCheckinMapper;
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+    @Resource
     private CfcActivityMapper cfcActivityMapper;
 
     // ============ 班级 ============
@@ -174,4 +178,204 @@ public class AdminClassController {
         trainUserMapper.updateById(user);
         return Result.success(true);
     }
+
+    // ============ 调班(助教管理能力) ============
+    @Operation(summary = "调整班级(学员调班/退班)")
+    @PostMapping("/user/class")
+    public Result<Boolean> moveUserClass(@RequestBody Map<String, Object> body) {
+        Object uidObj = body.get("uid");
+        if (uidObj == null) {
+            return Result.error("缺少学员ID");
+        }
+        Long uid = Long.valueOf(uidObj.toString());
+        // classId 为空字符串/null 表示退班(清空班级)
+        Long newClassId = null;
+        if (body.get("classId") != null && !body.get("classId").toString().trim().isEmpty()) {
+            newClassId = Long.valueOf(body.get("classId").toString());
+        }
+        TrainUser user = trainUserMapper.selectById(uid);
+        if (user == null) {
+            return Result.error("学员不存在");
+        }
+        if (newClassId != null) {
+            TrainClass clazz = trainClassMapper.selectById(newClassId);
+            if (clazz == null || !"active".equals(clazz.getStatus())) {
+                return Result.error("目标班次不存在或不可用");
+            }
+        }
+        user.setClassId(newClassId);
+        user.setUpdatedAt(new Date());
+        trainUserMapper.updateById(user);
+        // 同步最新一条 confirmed 报名单(如有)的 classId,保持数据一致
+        TrainEnrollment latestEn = trainEnrollmentMapper.selectOne(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, uid)
+                        .eq(TrainEnrollment::getStatus, "confirmed")
+                        .orderByDesc(TrainEnrollment::getId)
+                        .last("LIMIT 1"));
+        if (latestEn != null) {
+            latestEn.setClassId(newClassId);
+            latestEn.setUpdatedAt(new Date());
+            trainEnrollmentMapper.updateById(latestEn);
+        }
+        return Result.success(true);
+    }
+
+    // ============ 分组成员管理 ============
+    @Operation(summary = "将学员移入/调入分组(自动移出当前所在组)")
+    @PostMapping("/group/member/add")
+    public Result<Boolean> addGroupMember(@RequestBody Map<String, Object> body) {
+        Object groupIdObj = body.get("groupId");
+        Object uidObj = body.get("uid");
+        if (groupIdObj == null || uidObj == null) {
+            return Result.error("groupId 和 uid 不能为空");
+        }
+        Long groupId = Long.valueOf(groupIdObj.toString());
+        Long uid = Long.valueOf(uidObj.toString());
+        TrainGroup group = trainGroupMapper.selectById(groupId);
+        if (group == null) {
+            return Result.error("分组不存在");
+        }
+        // 移出当前所在组(move 语义:同一班级只能属于一个组)
+        TrainGroupMember currentMember = trainGroupMemberMapper.selectOne(
+                new LambdaQueryWrapper<TrainGroupMember>()
+                        .eq(TrainGroupMember::getUid, uid));
+        if (currentMember != null) {
+            trainGroupMemberMapper.deleteById(currentMember.getId());
+        }
+        // 加入目标组
+        TrainGroupMember m = new TrainGroupMember();
+        m.setGroupId(groupId);
+        m.setUid(uid);
+        m.setJoinedAt(new Date());
+        trainGroupMemberMapper.insert(m);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "将学员移出分组")
+    @PostMapping("/group/member/remove")
+    public Result<Boolean> removeGroupMember(@RequestBody Map<String, Object> body) {
+        Object uidObj = body.get("uid");
+        if (uidObj == null) {
+            return Result.error("缺少学员ID");
+        }
+        Long uid = Long.valueOf(uidObj.toString());
+        // 若指定 groupId 则仅移出该组;否则移出所有组
+        Long groupId = null;
+        if (body.get("groupId") != null && !body.get("groupId").toString().trim().isEmpty()) {
+            groupId = Long.valueOf(body.get("groupId").toString());
+        }
+        LambdaQueryWrapper<TrainGroupMember> qw = new LambdaQueryWrapper<TrainGroupMember>()
+                .eq(TrainGroupMember::getUid, uid);
+        if (groupId != null) {
+            qw.eq(TrainGroupMember::getGroupId, groupId);
+        }
+        trainGroupMemberMapper.delete(qw);
+        return Result.success(true);
+    }
+
+    // ============ 按签到重分 ============
+    @Operation(summary = "按签到记录重分组(已签到学员平均分配,未签到移出分组)")
+    @PostMapping("/class/redistribute")
+    public Result<Map<String, Object>> redistributeByCheckin(@RequestBody Map<String, Object> body) {
+        Object classIdObj = body.get("classId");
+        if (classIdObj == null) {
+            return Result.error("缺少班级ID");
+        }
+        Long classId = Long.valueOf(classIdObj.toString());
+        // 可选:每组人数(未传或<=0 自动计算)
+        int perGroup = 5;
+        if (body.get("perGroup") != null) {
+            try {
+                perGroup = Integer.parseInt(body.get("perGroup").toString());
+                if (perGroup <= 0) {
+                    perGroup = 5;
+                }
+            } catch (NumberFormatException e) {
+                perGroup = 5;
+            }
+        }
+        TrainClass clazz = trainClassMapper.selectById(classId);
+        if (clazz == null) {
+            return Result.error("班级不存在");
+        }
+        // 1) 查该班所有学员(已进班)
+        List<TrainUser> classUsers = trainUserMapper.selectList(
+                new LambdaQueryWrapper<TrainUser>()
+                        .eq(TrainUser::getClassId, classId)
+                        .ne(TrainUser::getStatus, "blocked"));
+        // 2) 查已签到的学员(status=done)
+        Set<Long> checkedInUids = new HashSet<>();
+        for (TrainUser u : classUsers) {
+            Long cnt = trainCheckinMapper.selectCount(
+                    new LambdaQueryWrapper<TrainCheckin>()
+                            .eq(TrainCheckin::getUid, u.getId())
+                            .eq(TrainCheckin::getStatus, "done"));
+            if (cnt != null && cnt > 0) {
+                checkedInUids.add(u.getId());
+            }
+        }
+        // 3) 准备目标分组:已有分组优先复用,否则按 perGroup 自动创建
+        List<TrainGroup> groups = trainGroupMapper.selectList(
+                new LambdaQueryWrapper<TrainGroup>().eq(TrainGroup::getClassId, classId)
+                        .orderByAsc(TrainGroup::getId));
+        int checkedInCount = checkedInUids.size();
+        if (checkedInCount == 0) {
+            return Result.error("无可签到记录,无法进行重分组");
+        }
+        int needGroups = (int) Math.ceil((double) checkedInCount / perGroup);
+        if (needGroups < 1) {
+            needGroups = 1;
+        }
+        if (groups.size() < needGroups) {
+            for (int i = groups.size(); i < needGroups; i++) {
+                TrainGroup g = new TrainGroup();
+                g.setClassId(classId);
+                g.setName("组" + (i + 1));
+                g.setCreatedAt(new Date());
+                g.setUpdatedAt(new Date());
+                trainGroupMapper.insert(g);
+                groups.add(g);
+            }
+        }
+        // 4) 清空该班所有分组成员(原组保留,成员重排)
+        List<TrainGroup> allClassGroups = trainGroupMapper.selectList(
+                new LambdaQueryWrapper<TrainGroup>().eq(TrainGroup::getClassId, classId));
+        for (TrainGroup g : allClassGroups) {
+            trainGroupMemberMapper.delete(
+                    new LambdaQueryWrapper<TrainGroupMember>()
+                            .eq(TrainGroupMember::getGroupId, g.getId()));
+        }
+        // 5) 平均分配已签到学员(round-robin)
+        List<Long> uids = new ArrayList<>(checkedInUids);
+        int i = 0;
+        for (Long uid : uids) {
+            TrainGroup target = groups.get(i % groups.size());
+            TrainGroupMember m = new TrainGroupMember();
+            m.setGroupId(target.getId());
+            m.setUid(uid);
+            m.setJoinedAt(new Date());
+            trainGroupMemberMapper.insert(m);
+            i++;
+        }
+        // 6) 返回重分组结果
+        Map<String, Object> result = new HashMap<>();
+        result.put("classId", classId);
+        result.put("checkedIn", checkedInCount);
+        result.put("totalUsers", classUsers.size());
+        result.put("groupCount", groups.size());
+        List<Map<String, Object>> groupDetails = new ArrayList<>();
+        for (TrainGroup g : groups) {
+            Long cnt = trainGroupMemberMapper.selectCount(
+                    new LambdaQueryWrapper<TrainGroupMember>()
+                            .eq(TrainGroupMember::getGroupId, g.getId()));
+            Map<String, Object> gd = new HashMap<>();
+            gd.put("id", g.getId());
+            gd.put("name", g.getName());
+            gd.put("memberCount", cnt == null ? 0L : cnt);
+            groupDetails.add(gd);
+        }
+        result.put("groups", groupDetails);
+        return Result.success(result);
+    }
 }

+ 120 - 0
train-backend/src/main/java/com/train/controller/admin/AdminCourseController.java

@@ -0,0 +1,120 @@
+package com.train.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainClass;
+import com.train.entity.TrainCourse;
+import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainCourseMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端-课程管理(L0/L1/L2/L3/L4 课程主数据 + 价格)。
+ * <p>报名=报课程:课程级 price/member_price(分)作为报名支付价格来源;
+ * 班次仅关联课程(course_id),作为助教审核时分配的上课期次。
+ */
+@Tag(name = "管理端-课程管理", description = "课程列表、新建、编辑(含价格/时间/状态)")
+@RestController
+@RequestMapping("/api/admin/course")
+public class AdminCourseController {
+
+    @Resource
+    private TrainCourseMapper trainCourseMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+
+    @Operation(summary = "课程列表(含各班次数量/可分配班次)")
+    @PostMapping("/list")
+    public Result<List<TrainCourse>> list() {
+        return Result.success(trainCourseMapper.selectList(
+                new LambdaQueryWrapper<TrainCourse>().orderByAsc(TrainCourse::getLevel)));
+    }
+
+    @Operation(summary = "新建课程")
+    @PostMapping("/create")
+    public Result<TrainCourse> create(@RequestBody TrainCourse c) {
+        if (c.getLevel() == null || c.getLevel().trim().isEmpty()) {
+            return Result.error("课程等级不能为空(如 L0/L1/L2/L3/L4)");
+        }
+        if (c.getName() == null || c.getName().trim().isEmpty()) {
+            return Result.error("课程名称不能为空");
+        }
+        // level 唯一约束兜底:已存在同名等级直接报错
+        Long exists = trainCourseMapper.selectCount(
+                new LambdaQueryWrapper<TrainCourse>().eq(TrainCourse::getLevel, c.getLevel().trim()));
+        if (exists != null && exists > 0) {
+            return Result.error("该课程等级已存在:" + c.getLevel().trim());
+        }
+        c.setLevel(c.getLevel().trim());
+        if (c.getStatus() == null || c.getStatus().trim().isEmpty()) {
+            c.setStatus("draft");
+        }
+        if (c.getPrice() == null) {
+            c.setPrice(0);
+        }
+        if (c.getMemberPrice() == null) {
+            c.setMemberPrice(0);
+        }
+        trainCourseMapper.insert(c);
+        return Result.success(c);
+    }
+
+    @Operation(summary = "编辑课程(名称/简介/价格/时间/状态)")
+    @PostMapping("/update")
+    public Result<Boolean> update(@RequestBody TrainCourse c) {
+        if (c.getId() == null) {
+            return Result.error("缺少课程ID");
+        }
+        TrainCourse exist = trainCourseMapper.selectById(c.getId());
+        if (exist == null) {
+            return Result.error("课程不存在");
+        }
+        // 仅更新允许编辑的字段,其余保持原值
+        if (c.getName() != null && !c.getName().trim().isEmpty()) {
+            exist.setName(c.getName().trim());
+        }
+        if (c.getDescription() != null) {
+            exist.setDescription(c.getDescription());
+        }
+        if (c.getStatus() != null && !c.getStatus().trim().isEmpty()) {
+            exist.setStatus(c.getStatus().trim());
+        }
+        if (c.getStartTime() != null) {
+            exist.setStartTime(c.getStartTime());
+        }
+        if (c.getEndTime() != null) {
+            exist.setEndTime(c.getEndTime());
+        }
+        if (c.getPrice() != null) {
+            exist.setPrice(c.getPrice());
+        }
+        if (c.getMemberPrice() != null) {
+            exist.setMemberPrice(c.getMemberPrice());
+        }
+        trainCourseMapper.updateById(exist);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "课程下可分配班次(审核分班下拉用)")
+    @PostMapping("/classes")
+    public Result<List<TrainClass>> classesOf(@RequestBody Map<String, Object> body) {
+        Object courseIdObj = body == null ? null : body.get("courseId");
+        if (courseIdObj == null) {
+            return Result.error("缺少课程ID");
+        }
+        Long courseId = Long.valueOf(courseIdObj.toString());
+        return Result.success(trainClassMapper.selectList(
+                new LambdaQueryWrapper<TrainClass>()
+                        .eq(TrainClass::getCourseId, courseId)
+                        .orderByAsc(TrainClass::getId)));
+    }
+}

+ 2 - 0
train-backend/src/main/java/com/train/dto/EnrollmentVO.java

@@ -12,6 +12,8 @@ import java.util.Date;
 public class EnrollmentVO implements Serializable {
 
     private Long id;
+    private Long courseId;
+    private String courseName;
     private Long classId;
     private String className;
     private String name;

+ 6 - 0
train-backend/src/main/java/com/train/entity/TrainCourse.java

@@ -38,6 +38,12 @@ public class TrainCourse implements Serializable {
     /** 课程结束时间(状态自动切换依据:超过=finished) */
     private Date endTime;
 
+    /** 课程报名价(分,报名=报课程,价格挂在课程上;单位分,展示时除以100) */
+    private Integer price;
+
+    /** 课程会员价(分) */
+    private Integer memberPrice;
+
     /** 计算字段(不落库):按时间推导的有效状态 upcoming/active/finished;draft/finished 为管理员手动覆盖 */
     @com.baomidou.mybatisplus.annotation.TableField(exist = false)
     private String effectiveStatus;

+ 14 - 1
train-backend/src/main/java/com/train/service/PayService.java

@@ -5,6 +5,7 @@ import com.train.cfc.entity.CfcActivity;
 import com.train.cfc.mapper.CfcActivityMapper;
 import com.train.controller.PlanController;
 import com.train.entity.TrainClass;
+import com.train.entity.TrainCourse;
 import com.train.entity.TrainEnrollment;
 import com.train.entity.TrainGroupOrder;
 import com.train.entity.TrainGroupOrderMember;
@@ -12,6 +13,7 @@ import com.train.entity.TrainInvite;
 import com.train.entity.TrainOrder;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainCourseMapper;
 import com.train.mapper.TrainEnrollmentMapper;
 import com.train.mapper.TrainGroupOrderMapper;
 import com.train.mapper.TrainGroupOrderMemberMapper;
@@ -48,6 +50,8 @@ public class PayService {
     @Resource
     private TrainClassMapper trainClassMapper;
     @Resource
+    private TrainCourseMapper trainCourseMapper;
+    @Resource
     private TrainUserMapper trainUserMapper;
     @Resource
     private TrainInviteMapper trainInviteMapper;
@@ -101,7 +105,9 @@ public class PayService {
         }
 
         // 金额:优先会员价(memberPrice),否则原价(price);单位为分
-        TrainClass trainClass = trainClassMapper.selectById(en.getClassId());
+        // 报名=报课程:新报名单无班次(classId=null)时价格取自课程级 price/member_price;
+        // 存量带班次的报名单保持原链路(班次 -> cfc activity 取价)不变
+        TrainClass trainClass = en.getClassId() == null ? null : trainClassMapper.selectById(en.getClassId());
         CfcActivity activity = null;
         if (trainClass != null && trainClass.getActivityId() != null) {
             activity = cfcActivityMapper.selectById(trainClass.getActivityId());
@@ -109,6 +115,13 @@ public class PayService {
         int price = activity != null && activity.getPrice() != null ? activity.getPrice() : 0;
         int memberPrice = activity != null && activity.getMemberPrice() != null
                 ? activity.getMemberPrice() : price;
+        if (activity == null && en.getCourseId() != null) {
+            TrainCourse course = trainCourseMapper.selectById(en.getCourseId());
+            if (course != null) {
+                price = course.getPrice() == null ? 0 : course.getPrice();
+                memberPrice = course.getMemberPrice() == null ? price : course.getMemberPrice();
+            }
+        }
         int amount = memberPrice > 0 ? memberPrice : price;
 
         TrainOrder order = new TrainOrder();

+ 4 - 0
train-backend/src/main/resources/schema.sql

@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS train_course (
     status VARCHAR(20) DEFAULT 'draft' COMMENT '手动覆盖状态:draft(下架)/active(强制上架)/finished(强制结束)',
     start_time DATETIME COMMENT '课程开始时间(状态自动切换依据)',
     end_time DATETIME COMMENT '课程结束时间(状态自动切换依据)',
+    price INT DEFAULT 0 COMMENT '课程报名价(分,报名=报课程,价格挂在课程上)',
+    member_price INT DEFAULT 0 COMMENT '课程会员价(分)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_level (level),
@@ -69,6 +71,8 @@ ALTER TABLE train_user ADD COLUMN cfc_user_id BIGINT COMMENT 'cfc 统一会员 I
 ALTER TABLE train_class ADD COLUMN start_at DATETIME COMMENT '课程开始时间(投屏计时基准,NULL=未开始)';
 ALTER TABLE train_course ADD COLUMN start_time DATETIME COMMENT '课程开始时间(状态自动切换依据)';
 ALTER TABLE train_course ADD COLUMN end_time DATETIME COMMENT '课程结束时间(状态自动切换依据)';
+ALTER TABLE train_course ADD COLUMN price INT DEFAULT 0 COMMENT '课程报名价(分,报名=报课程,价格挂在课程上)';
+ALTER TABLE train_course ADD COLUMN member_price INT DEFAULT 0 COMMENT '课程会员价(分)';
 ALTER TABLE train_enrollment ADD COLUMN course_id BIGINT COMMENT '报名所属课程 ID(train_course.id,报名=报课程;班次仅确定上课期次)';
 
 -- 小组表

+ 5 - 14
train-frontend/pages/enroll/form.vue

@@ -2,17 +2,13 @@
   <view class="form-page">
     <view class="section-card">
       <text class="section-title">报名信息</text>
-      <text class="card-desc">填写您的联系方式与准备情况</text>
+      <text class="card-desc">填写联系方式与准备情况,缴费后由助教分配班次</text>
 
-      <view class="summary-box" v-if="courseName || className">
-        <view class="summary-row" v-if="courseName">
+      <view class="summary-box" v-if="courseName">
+        <view class="summary-row">
           <text class="summary-label">报名课程</text>
           <text class="summary-value">{{ courseName }}</text>
         </view>
-        <view class="summary-row" v-if="className">
-          <text class="summary-label">上课期次</text>
-          <text class="summary-value">{{ className }}</text>
-        </view>
       </view>
 
       <view class="form-item">
@@ -65,7 +61,6 @@ export default {
   data() {
     return {
       form: {
-        classId: '',
         courseId: '',
         name: '',
         phone: '',
@@ -75,21 +70,18 @@ export default {
         inviteCode: ''
       },
       courseName: '',
-      className: '',
       dataReadyList: [],
       loading: false
     }
   },
   onLoad(options) {
-    this.form.classId = options && options.classId ? options.classId : ''
     this.form.courseId = options && options.courseId ? options.courseId : ''
     this.courseName = options && options.courseName ? decodeURIComponent(options.courseName) : ''
-    this.className = options && options.className ? decodeURIComponent(options.className) : ''
     this.form.inviteCode = options && options.inviteCode ? decodeURIComponent(options.inviteCode) : ''
     this.form.name = options && options.name ? decodeURIComponent(options.name) : ''
     this.form.phone = options && options.phone ? decodeURIComponent(options.phone) : ''
-    if (!this.form.classId) {
-      uni.showToast({ title: '请先选择期次(班次)', icon: 'none' })
+    if (!this.form.courseId) {
+      uni.showToast({ title: '参数错误', icon: 'none' })
       setTimeout(function() {
         uni.navigateBack({ delta: 1 })
       }, 1500)
@@ -123,7 +115,6 @@ export default {
       this.loading = true
       var self = this
       var payload = {
-        classId: self.form.classId,
         courseId: self.form.courseId,
         name: name,
         phone: phone,

+ 91 - 70
train-frontend/pages/enroll/list.vue

@@ -2,33 +2,35 @@
   <view class="enroll-page">
     <view class="section-card">
       <text class="section-title">选择课程</text>
-      <text class="card-desc">课程决定学习内容,报名后按所选期次(班次)进班上课</text>
+      <text class="card-desc">填写联系方式与准备情况,缴费后由助教分配班次</text>
       <view v-if="loading" class="empty-tip">加载中…</view>
-      <view v-else-if="courseGroups.length === 0" class="empty-tip">暂无可报名课程</view>
+      <view v-else-if="courses.length === 0" class="empty-tip">暂无可报名课程</view>
       <view v-else>
-        <view class="course-group" v-for="(g, gi) in courseGroups" :key="gi">
-          <view class="group-head">
-            <text class="group-course">{{ g.courseName }}</text>
+        <view class="course-card" v-for="item in courses" :key="item.id">
+          <view class="course-head">
+            <text class="course-level">{{ item.level }}</text>
+            <text class="course-name">{{ item.name }}</text>
           </view>
-          <view class="class-card" v-for="item in g.classes" :key="item.id">
-            <view class="class-head">
-              <text class="class-name">{{ item.name }}</text>
-              <text class="class-price" v-if="item.memberPrice > 0 && item.memberPrice < item.price">
-                会员价 ¥{{ fenToYuan(item.memberPrice) }}
-              </text>
-              <text class="class-price" v-else-if="item.price > 0">¥{{ fenToYuan(item.price) }}</text>
-              <text class="class-price-free" v-else>免费</text>
-            </view>
-            <text class="class-time">{{ item.time || '时间待定' }}</text>
-            <text class="class-place">{{ item.place || '地点待定' }}</text>
-            <view class="class-foot">
-              <text class="class-capacity">
-                已报名 {{ item.num }} / {{ item.capacity }}
-                <text v-if="isFull(item)" class="full-tag">已满</text>
-                <text v-else class="remain-tag">余 {{ item.capacity - item.num }}</text>
-              </text>
-              <button class="enroll-btn" :disabled="isFull(item)" @click="goForm(item)">立即报名</button>
-            </view>
+          <view class="course-price-box">
+            <text class="course-price" v-if="item.price > 0">¥{{ fenToYuan(item.price) }}</text>
+            <text class="course-price-free" v-else>免费</text>
+            <text class="member-price" v-if="item.memberPrice > 0 && item.memberPrice < item.price">
+              会员价 ¥{{ fenToYuan(item.memberPrice) }}
+            </text>
+          </view>
+          <view class="course-status-box">
+            <text class="status-text" :class="item.statusCls">
+              {{ item.statusLabel }}
+            </text>
+          </view>
+          <view class="course-foot">
+            <button 
+              class="enroll-btn" 
+              :disabled="!canEnroll(item)" 
+              @click="handleCourseClick(item)"
+            >
+              {{ getButtonText(item) }}
+            </button>
           </view>
         </view>
       </view>
@@ -59,50 +61,42 @@
 </template>
 
 <script>
-import { getEnrollClasses, getMyEnrollments, cancelPayOrder } from '@/utils/api.js'
+import { getCourseList, getMyEnrollments, cancelPayOrder } from '@/utils/api.js'
 import { fenToYuan, formatDateTime } from '@/utils/format.js'
 
 export default {
   data() {
     return {
-      classList: [],
+      courses: [],
       mineList: [],
       loading: false,
       mineLoading: false
     }
   },
   onShow() {
-    this.loadClasses()
+    this.loadCourses()
     this.loadMine()
   },
-  computed: {
-    // 报名对象=课程:可报名班次按课程分组(一组=一门课程下的各期班次)
-    courseGroups() {
-      var groups = []
-      var index = {}
-      var list = this.classList || []
-      for (var i = 0; i < list.length; i++) {
-        var item = list[i]
-        var key = item.courseId || ('course-' + item.id)
-        if (!index[key]) {
-          index[key] = { courseId: item.courseId, courseName: item.courseName || '课程', classes: [] }
-          groups.push(index[key])
-        }
-        index[key].classes.push(item)
-      }
-      return groups
-    }
-  },
   methods: {
     fenToYuan: fenToYuan,
     formatDateTime: formatDateTime,
-    loadClasses() {
+    loadCourses() {
       var self = this
+      var statusLabelMap = { draft: '未开放', active: '报名中', finished: '已结束' }
       self.loading = true
-      getEnrollClasses().then(function(resp) {
-        self.classList = resp.data || []
+      getCourseList().then(function(resp) {
+        var raw = resp.data || []
+        self.courses = raw.map(function(item) {
+          var st = item.effectiveStatus
+          var cls = 'status-active'
+          if (st === 'draft') cls = 'status-draft'
+          if (st === 'finished') cls = 'status-finished'
+          item.statusCls = cls
+          item.statusLabel = statusLabelMap[st] || '未知'
+          return item
+        })
       }).catch(function() {
-        uni.showToast({ title: '加载班次失败', icon: 'none' })
+        uni.showToast({ title: '加载课程失败', icon: 'none' })
       }).finally(function() {
         self.loading = false
       })
@@ -116,19 +110,45 @@ export default {
         self.mineLoading = false
       })
     },
-    isFull(item) {
-      return item.capacity != null && item.num != null && item.num >= item.capacity
+    getButtonText(item) {
+      var status = item.myStatus
+      if (status === 'pending') return '去支付'
+      if (status === 'paid') return '待审核'
+      if (status === 'reviewing') return '审核中'
+      if (status === 'confirmed') return '已进班'
+      if (status === 'rejected') return '重新报名'
+      return '立即报名'
+    },
+    canEnroll(item) {
+      if (item.effectiveStatus === 'draft' || item.effectiveStatus === 'finished') return false
+      if (item.myStatus === 'paid' || item.myStatus === 'reviewing' || item.myStatus === 'confirmed') return false
+      return true
+    },
+    handleCourseClick(item) {
+      if (item.myStatus === 'pending') {
+        var mine = this.mineList.filter(function(m) { return m.courseId === item.id })[0]
+        if (mine) {
+          this.goPay(mine)
+        } else {
+          uni.showToast({ title: '未找到订单', icon: 'none' })
+        }
+      } else {
+        this.goForm(item)
+      }
     },
     statusLabel(status) {
       var map = { pending: '待支付', paid: '已支付', reviewing: '进班审核中', confirmed: '已进班', rejected: '进班被拒', cancelled: '已取消' }
       return map[status] || status || '未知'
     },
-    goForm(item) {
+    goForm(course) {
       var userInfo = uni.getStorageSync('userInfo') || {}
-      // 报名对象=课程:透传 courseId(报名单挂课程)+ 课程名,班次为所选期次
-      var courseName = ''
-      if (item.courseName) courseName = item.courseName
-      uni.navigateTo({ url: '/pages/enroll/form?classId=' + item.id + '&courseId=' + (item.courseId || '') + '&courseName=' + encodeURIComponent(courseName) + '&className=' + encodeURIComponent(item.name || '') + '&name=' + (userInfo.name || '') + '&phone=' + (userInfo.phone || '') })
+      var courseName = course.level + ' ' + course.name
+      uni.navigateTo({ 
+        url: '/pages/enroll/form?courseId=' + course.id + 
+             '&courseName=' + encodeURIComponent(courseName) + 
+             '&name=' + encodeURIComponent(userInfo.name || '') + 
+             '&phone=' + encodeURIComponent(userInfo.phone || '') 
+      })
     },
     goPay(item) {
       uni.navigateTo({ url: '/pages/pay/index?enrollmentId=' + item.id })
@@ -159,22 +179,23 @@ export default {
 .section-title { display: block; font-size: 32rpx; font-weight: 700; color: #1E293B; margin-bottom: 8rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
 .card-desc { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 24rpx; }
 .empty-tip { font-size: 26rpx; color: #94A3B8; padding: 32rpx 0; text-align: center; }
-.course-group { margin-bottom: 8rpx; }
-.group-head { display: flex; align-items: center; margin-bottom: 16rpx; }
-.group-course { font-size: 28rpx; font-weight: 600; color: #1E293B; }
-.class-card { border: 2rpx solid #E2E8F0; border-radius: 12rpx; padding: 24rpx; margin-bottom: 20rpx; margin-left: 16rpx; }
-.class-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
-.class-name { font-size: 30rpx; font-weight: 600; color: #1E293B; flex: 1; }
-.class-price { font-size: 28rpx; color: #F97316; font-weight: 600; }
-.class-price-free { font-size: 28rpx; color: #22C55E; font-weight: 600; }
-.class-time { display: block; font-size: 26rpx; color: #475569; margin-bottom: 6rpx; }
-.class-place { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 16rpx; }
-.class-foot { display: flex; align-items: center; justify-content: space-between; }
-.class-capacity { font-size: 24rpx; color: #64748B; }
-.full-tag { color: #EF4444; }
-.remain-tag { color: #22C55E; }
+.course-card { border: 2rpx solid #E2E8F0; border-radius: 12rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.course-head { display: flex; align-items: center; margin-bottom: 12rpx; }
+.course-level { background: #F97316; color: #FFF; padding: 2rpx 8rpx; border-radius: 4rpx; margin-right: 12rpx; font-weight: 600; font-size: 24rpx; }
+.course-name { font-size: 30rpx; font-weight: 600; color: #1E293B; flex: 1; }
+.course-price-box { display: flex; align-items: center; gap: 16rpx; margin-bottom: 12rpx; }
+.course-price { font-size: 28rpx; color: #F97316; font-weight: 600; }
+.course-price-free { font-size: 28rpx; color: #22C55E; font-weight: 600; }
+.member-price { font-size: 24rpx; color: #64748B; }
+.course-status-box { margin-bottom: 16rpx; }
+.status-text { font-size: 24rpx; }
+.status-active { color: #22C55E; }
+.status-draft { color: #94A3B8; }
+.status-finished { color: #EF4444; }
+.course-foot { display: flex; align-items: center; justify-content: flex-end; }
 .enroll-btn { width: 200rpx; height: 64rpx; line-height: 64rpx; font-size: 26rpx; background: #F97316; color: #FFF; border-radius: 32rpx; border: none; margin: 0; }
 .enroll-btn:active { opacity: 0.85; }
+.enroll-btn:disabled { background: #CBD5E1; }
 .mine-item { padding: 20rpx 0; border-bottom: 1rpx solid #F1F5F9; }
 .mine-item:last-child { border-bottom: none; }
 .mine-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }

+ 10 - 0
train-web/src/api/class.js

@@ -11,3 +11,13 @@ export function classList() {
 export function classDetail(classId) {
   return request.post('/api/admin/class/detail', { classId })
 }
+
+// 按签到重分组(已签到学员平均分配,未签到移出分组)
+export function redistributeByCheckin(data) {
+  return request.post('/api/admin/class/redistribute', data)
+}
+
+// 调整班级(学员调班/退班)
+export function moveUserClass(data) {
+  return request.post('/api/admin/user/class', data)
+}

+ 19 - 0
train-web/src/api/course.js

@@ -0,0 +1,19 @@
+import request from '@/utils/request'
+
+// 课程管理(L0/L1/L2/L3/L4)
+
+export function courseList() {
+  return request.post('/api/admin/course/list', {})
+}
+
+export function createCourse(data) {
+  return request.post('/api/admin/course/create', data)
+}
+
+export function updateCourse(data) {
+  return request.post('/api/admin/course/update', data)
+}
+
+export function courseClasses(courseId) {
+  return request.post('/api/admin/course/classes', { courseId })
+}

+ 10 - 0
train-web/src/api/group.js

@@ -7,3 +7,13 @@ export function createGroup(data) {
 export function setSpokesperson(data) {
   return request.post('/api/admin/group/spokesperson', data)
 }
+
+// 将学员移入/调入分组(自动移出当前所在组)
+export function addGroupMember(data) {
+  return request.post('/api/admin/group/member/add', data)
+}
+
+// 将学员移出分组(指定 groupId 则仅移出该组)
+export function removeGroupMember(data) {
+  return request.post('/api/admin/group/member/remove', data)
+}

+ 6 - 5
train-web/src/config/menu-roles.js

@@ -1,17 +1,18 @@
 // 管理后台菜单可见性:role → 可访问菜单路径(Layout 过滤与路由守卫共用,防两处漂移)
-// admin 全量 / lecturer 审核评分 / assistant 限负责组(审核与评分)
+// admin 全量 / lecturer 审核评分 / assistant 限班级·分组·学员管理及审核评分
 const MENU_ROLES = {
   '/dashboard': ['admin', 'lecturer', 'assistant'],
   '/screen': ['admin', 'lecturer', 'assistant'],
-  '/classes': ['admin'],
-  '/enrollments': ['admin'],
+  '/courses': ['admin'],
+  '/classes': ['admin', 'assistant'],
+  '/enrollments': ['admin', 'assistant'],
   '/orders': ['admin'],
   '/group-orders': ['admin'],
   '/cases': ['admin'],
   '/invites': ['admin'],
   '/materials': ['admin'],
-  '/groups': ['admin'],
-  '/users': ['admin'],
+  '/groups': ['admin', 'assistant'],
+  '/users': ['admin', 'assistant'],
   '/review/submissions': ['admin', 'lecturer', 'assistant'],
   '/review/cards': ['admin', 'lecturer', 'assistant'],
   '/review/roadmaps': ['admin', 'lecturer', 'assistant'],

+ 6 - 0
train-web/src/router/index.js

@@ -29,6 +29,12 @@ const routes = [
         component: () => import('@/views/Dashboard.vue'),
         meta: { title: '概览' }
       },
+      {
+        path: 'courses',
+        name: 'Courses',
+        component: () => import('@/views/Courses.vue'),
+        meta: { title: '课程管理' }
+      },
       {
         path: 'classes',
         name: 'Classes',

+ 25 - 6
train-web/src/views/Classes.vue

@@ -8,7 +8,9 @@
     <el-table :data="list" v-loading="loading" border stripe style="width:100%">
       <el-table-column prop="id" label="ID" width="80" />
       <el-table-column prop="name" label="班级名称" min-width="160" />
-      <el-table-column prop="code" label="邀请码" width="120" />
+      <el-table-column label="所属课程" width="180">
+        <template slot-scope="{ row }">{{ courseNameOf(row.courseId) }}</template>
+      </el-table-column>
       <el-table-column prop="time" label="时间" width="160" />
       <el-table-column prop="place" label="地点" width="160" />
       <el-table-column prop="capacity" label="容量" width="80" />
@@ -20,8 +22,10 @@
         <el-form-item label="名称" prop="name">
           <el-input v-model="form.name" placeholder="如:AI 1期" />
         </el-form-item>
-        <el-form-item label="邀请码" prop="code">
-          <el-input v-model="form.code" placeholder="4位以上" />
+        <el-form-item label="所属课程" prop="courseId">
+          <el-select v-model="form.courseId" placeholder="请选择课程">
+            <el-option v-for="c in courses" :key="c.id" :label="c.level + ' ' + c.name" :value="c.id" />
+          </el-select>
         </el-form-item>
         <el-form-item label="时间">
           <el-input v-model="form.time" placeholder="如:周六10:00-17:00" />
@@ -43,35 +47,50 @@
 
 <script>
 import { classList, createClass } from '@/api/class'
+import { courseList } from '@/api/course'
 
 export default {
   name: 'Classes',
   data: function () {
     return {
       list: [],
+      courses: [],
       loading: false,
       showDialog: false,
       submitting: false,
       form: {
         name: '',
-        code: '',
+        courseId: '',
         time: '',
         place: '',
         capacity: 30
       },
       rules: {
         name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
-        code: [{ required: true, message: '请输入邀请码', trigger: 'blur' }]
+        courseId: [{ required: true, message: '请选择所属课程', trigger: 'change' }]
       }
     }
   },
   mounted: function () {
+    var self = this
     this.fetchList()
+    courseList().then(function (res) {
+      self.courses = res.data || []
+    }).catch(function () {
+      self.$message.error('获取课程列表失败')
+    })
   },
   methods: {
     formatTime: function (row, col, val) {
       return val || '-'
     },
+    courseNameOf: function (courseId) {
+      var self = this
+      var c = self.courses.find(function (item) {
+        return item.id === courseId
+      })
+      return c ? c.level + ' ' + c.name : '-'
+    },
     fetchList: function () {
       var self = this
       self.loading = true
@@ -84,7 +103,7 @@ export default {
       })
     },
     resetForm: function () {
-      this.form = { name: '', code: '', time: '', place: '', capacity: 30 }
+      this.form = { name: '', courseId: '', time: '', place: '', capacity: 30 }
     },
     handleSubmit: function () {
       var self = this

+ 227 - 0
train-web/src/views/Courses.vue

@@ -0,0 +1,227 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">课程管理</h2>
+      <el-button type="primary" icon="el-icon-plus" @click="openCreate">新建课程</el-button>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe style="width:100%">
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="level" label="等级" width="80" />
+      <el-table-column prop="name" label="课程名称" min-width="180" />
+      <el-table-column prop="description" label="简介" min-width="220" show-overflow-tooltip />
+      <el-table-column label="报名价" width="100">
+        <template slot-scope="{ row }">
+          <span v-if="row.price > 0">¥{{ fenToYuan(row.price) }}</span>
+          <span v-else>免费</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="会员价" width="100">
+        <template slot-scope="{ row }">
+          <span v-if="row.memberPrice > 0">¥{{ fenToYuan(row.memberPrice) }}</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="时间" width="200">
+        <template slot-scope="{ row }">
+          <span>{{ fmt(row.startTime) }} ~ {{ fmt(row.endTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="90">
+        <template slot-scope="{ row }">
+          <el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
+      <el-table-column label="操作" width="90" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 新建/编辑课程弹窗 -->
+    <el-dialog :title="form.id ? '编辑课程' : '新建课程'" :visible.sync="showDialog" width="560px" @close="resetForm">
+      <el-form :model="form" :rules="rules" ref="form" label-width="90px">
+        <el-form-item label="等级" prop="level">
+          <el-select v-model="form.level" placeholder="选择课程等级" style="width:100%">
+            <el-option v-for="lv in ['L0','L1','L2','L3','L4']" :key="lv" :label="lv" :value="lv" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="课程名称" prop="name">
+          <el-input v-model="form.name" placeholder="如:家立方-AI管家成长营" />
+        </el-form-item>
+        <el-form-item label="简介">
+          <el-input v-model="form.description" type="textarea" :rows="2" placeholder="课程简介(选填)" />
+        </el-form-item>
+        <el-form-item label="报名价">
+          <el-input-number v-model="form.price" :min="0" :max="100000000" :step="100" />
+          <span class="tips">单位:分(100 = ¥1.00,仅显示用)</span>
+        </el-form-item>
+        <el-form-item label="会员价">
+          <el-input-number v-model="form.memberPrice" :min="0" :max="100000000" :step="100" />
+          <span class="tips">单位:分,为空则等同报名价</span>
+        </el-form-item>
+        <el-form-item label="开始时间">
+          <el-date-picker v-model="form.startTime" type="datetime" placeholder="选填,用于状态自动切换" style="width:100%" value-format="yyyy-MM-dd HH:mm:ss" />
+        </el-form-item>
+        <el-form-item label="结束时间">
+          <el-date-picker v-model="form.endTime" type="datetime" placeholder="选填,用于状态自动切换" style="width:100%" value-format="yyyy-MM-dd HH:mm:ss" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="form.status" style="width:100%">
+            <el-option label="草稿(学员端不可见)" value="draft" />
+            <el-option label="启用(按时间自动切换)" value="active" />
+            <el-option label="已结束" value="finished" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="showDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { courseList, createCourse, updateCourse } from '@/api/course'
+
+function fenToYuan(fen) {
+  return ((fen || 0) / 100).toFixed(2)
+}
+
+export default {
+  name: 'Courses',
+  data: function () {
+    return {
+      list: [],
+      loading: false,
+      showDialog: false,
+      submitting: false,
+      form: {
+        id: null,
+        level: 'L0',
+        name: '',
+        description: '',
+        price: 0,
+        memberPrice: 0,
+        startTime: null,
+        endTime: null,
+        status: 'draft'
+      },
+      rules: {
+        level: [{ required: true, message: '请选择课程等级', trigger: 'change' }],
+        name: [{ required: true, message: '请输入课程名称', trigger: 'blur' }]
+      }
+    }
+  },
+  mounted: function () {
+    this.fetchList()
+  },
+  methods: {
+    fenToYuan: fenToYuan,
+    fmt: function (v) {
+      return v ? String(v).slice(0, 16) : '-'
+    },
+    formatTime: function (row, col, val) {
+      return val || '-'
+    },
+    statusType: function (s) {
+      var map = { draft: 'info', active: 'success', finished: '' }
+      return map[s] || 'info'
+    },
+    statusLabel: function (s) {
+      var map = { draft: '草稿', active: '启用', finished: '已结束' }
+      return map[s] || s || '-'
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      courseList().then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取课程列表失败')
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    openCreate: function () {
+      this.resetForm()
+      this.showDialog = true
+    },
+    openEdit: function (row) {
+      this.form = {
+        id: row.id,
+        level: row.level,
+        name: row.name,
+        description: row.description,
+        price: row.price || 0,
+        memberPrice: row.memberPrice || 0,
+        startTime: row.startTime || null,
+        endTime: row.endTime || null,
+        status: row.status || 'draft'
+      }
+      this.showDialog = true
+    },
+    resetForm: function () {
+      this.form = { id: null, level: 'L0', name: '', description: '', price: 0, memberPrice: 0, startTime: null, endTime: null, status: 'draft' }
+    },
+    handleSubmit: function () {
+      var self = this
+      if (self.submitting) return
+      self.$refs.form.validate(function (valid) {
+        if (!valid) return
+        self.submitting = true
+        var payload = {
+          level: self.form.level,
+          name: self.form.name,
+          description: self.form.description,
+          price: self.form.price,
+          memberPrice: self.form.memberPrice,
+          startTime: self.form.startTime,
+          endTime: self.form.endTime,
+          status: self.form.status
+        }
+        var p
+        if (self.form.id) {
+          payload.id = self.form.id
+          p = updateCourse(payload)
+        } else {
+          p = createCourse(payload)
+        }
+        p.then(function () {
+          self.$message.success('保存成功')
+          self.showDialog = false
+          self.fetchList()
+        }).catch(function (err) {
+          self.$message.error((err && err.message) || '保存失败')
+        }).finally(function () {
+          self.submitting = false
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.page-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.tips {
+  margin-left: 8px;
+  font-size: 12px;
+  color: #94A3B8;
+}
+</style>

+ 41 - 1
train-web/src/views/Enrollments.vue

@@ -54,7 +54,9 @@
           <span>{{ auditRow.courseName || '-' }}</span>
         </el-form-item>
         <el-form-item label="班次">
-          <span>{{ auditRow.className }}</span>
+          <el-select v-model="auditClassId" placeholder="请选择班次(必选)" style="width:240px" @change="auditClassChange">
+            <el-option v-for="c in auditClasses" :key="c.id" :label="c.name + (c.time ? ' (' + c.time + ')' : '')" :value="c.id" />
+          </el-select>
         </el-form-item>
         <el-form-item label="分派小组">
           <el-select v-model="auditGroupId" placeholder="不选则自动分派到人数最少小组" clearable style="width:240px">
@@ -73,6 +75,7 @@
 <script>
 import { enrollmentList, enrollmentAudit } from '@/api/enroll'
 import { classDetail } from '@/api/class'
+import { courseClasses } from '@/api/course'
 
 export default {
   name: 'Enrollments',
@@ -86,6 +89,8 @@ export default {
       auditVisible: false,
       auditLoading: false,
       auditRow: null,
+      auditClassId: null,
+      auditClasses: [],
       auditGroupId: null,
       auditGroups: []
     }
@@ -120,13 +125,38 @@ export default {
         self.loading = false
       })
     },
+    auditClassChange: function (classId) {
+      var self = this
+      self.auditGroupId = null
+      if (!classId) {
+        self.auditGroups = []
+        return
+      }
+      classDetail(classId).then(function (res) {
+        var d = res.data || {}
+        self.auditGroups = d.groups || []
+      }).catch(function () {
+        self.auditGroups = []
+      })
+    },
     openAudit: function (row) {
       var self = this
       self.auditRow = row
       self.auditGroupId = null
       self.auditGroups = []
+      self.auditClassId = null
+      self.auditClasses = []
       self.auditVisible = true
+      
+      // 加载该课程所有班次
+      courseClasses(row.courseId).then(function (res) {
+        self.auditClasses = res.data || []
+      }).catch(function () {
+        self.auditClasses = []
+      })
+
       if (row.classId) {
+        self.auditClassId = row.classId
         classDetail(row.classId).then(function (res) {
           var d = res.data || {}
           self.auditGroups = d.groups || []
@@ -138,11 +168,21 @@ export default {
     confirmAudit: function () {
       var self = this
       if (!self.auditRow) return
+      
+      // 校验:若当前 auditRow 无 classId 且 auditClassId 为空 -> 提示并返回
+      if (!self.auditRow.classId && !self.auditClassId) {
+        self.$message.warning('请选择班次')
+        return
+      }
+
       self.auditLoading = true
       var payload = {
         enrollmentId: self.auditRow.id,
         approved: true
       }
+      if (self.auditClassId) {
+        payload.classId = self.auditClassId
+      }
       if (self.auditGroupId) {
         payload.groupId = self.auditGroupId
       }

+ 259 - 46
train-web/src/views/Groups.vue

@@ -1,50 +1,87 @@
 <template>
   <div class="page-container">
     <div class="page-header">
-      <h2 class="page-title">分组管理</h2>
-      <el-button type="primary" icon="el-icon-plus" @click="showDialog = true">新建分组</el-button>
+      <div class="header-left">
+        <h2 class="page-title">分组管理</h2>
+        <el-select v-model="curClassId" placeholder="请选择班级" @change="loadGroups" style="width:200px; margin-left: 16px">
+          <el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
+        </el-select>
+      </div>
+      <div class="header-right">
+        <el-button type="primary" icon="el-icon-plus" @click="showCreateDialog = true">新建分组</el-button>
+        <el-button type="success" icon="el-icon-refresh" @click="showRedistributeDialog = true">按签到重分</el-button>
+      </div>
     </div>
 
-    <el-table :data="list" v-loading="loading" border stripe style="width:100%">
+    <el-table :data="groupRows" v-loading="loading" border stripe style="width:100%">
+      <el-table-column type="expand">
+        <template slot-scope="props">
+          <div style="padding: 10px 20px">
+            <el-table :data="props.row.members" border size="mini">
+              <el-table-column prop="name" label="姓名" />
+              <el-table-column prop="phone" label="手机" />
+              <el-table-column label="操作" width="100">
+                <template slot-scope="{ row }">
+                  <el-button size="mini" type="danger" @click="handleRemoveMember(props.row, row)">移出</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+            <div v-if="!props.row.members || props.row.members.length === 0" style="text-align: center; color: #999; padding: 10px">
+              该组暂无成员
+            </div>
+          </div>
+        </template>
+      </el-table-column>
       <el-table-column prop="id" label="ID" width="80" />
-      <el-table-column prop="groupId" label="组ID" width="90" />
-      <el-table-column prop="groupName" label="组名" min-width="120" />
+      <el-table-column prop="name" label="组名" min-width="120" />
       <el-table-column prop="slogan" label="口号" min-width="160" />
-      <el-table-column prop="groupTotal" label="组总分" width="90" />
-      <el-table-column prop="memberTotal" label="成员分" width="90" />
-      <el-table-column prop="memberCount" label="人数" width="70" />
-      <el-table-column label="操作" width="120" fixed="right">
+      <el-table-column label="人数" width="80">
+        <template slot-scope="{ row }">
+          {{ row.memberCount }} 人
+        </template>
+      </el-table-column>
+      <el-table-column label="发言人" width="120">
         <template slot-scope="{ row }">
-          <el-button size="mini" type="primary" @click="openSetSpokesperson(row)">设置发言人</el-button>
+          {{ getSpokespersonName(row.spokespersonUid) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="300" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="mini" @click="openSetSpokesperson(row)">设发言人</el-button>
+          <el-button size="mini" type="primary" @click="openAddMember(row)">调入</el-button>
         </template>
       </el-table-column>
     </el-table>
 
-    <el-dialog title="新建分组" :visible.sync="showDialog" width="450px" @close="resetForm">
-      <el-form :model="form" :rules="rules" ref="form" label-width="80px">
+    <!-- 新建分组 -->
+    <el-dialog title="新建分组" :visible.sync="showCreateDialog" width="450px" @close="resetCreateForm">
+      <el-form :model="createForm" :rules="createRules" ref="createForm" label-width="80px">
         <el-form-item label="班级ID" prop="classId">
-          <el-input-number v-model="form.classId" :min="1" />
+          <el-input :value="curClassId" disabled />
         </el-form-item>
         <el-form-item label="组名" prop="name">
-          <el-input v-model="form.name" placeholder="如:飞鸟队" />
+          <el-input v-model="createForm.name" placeholder="如:飞鸟队" />
         </el-form-item>
         <el-form-item label="口号">
-          <el-input v-model="form.slogan" placeholder="选填" />
+          <el-input v-model="createForm.slogan" placeholder="选填" />
         </el-form-item>
       </el-form>
       <div slot="footer">
-        <el-button @click="showDialog = false">取消</el-button>
-        <el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
+        <el-button @click="showCreateDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleCreateGroup" :loading="submitting">确定</el-button>
       </div>
     </el-dialog>
 
+    <!-- 设置发言人 -->
     <el-dialog title="设置发言人" :visible.sync="showSpokespersonDialog" width="400px">
       <el-form label-width="70px">
-        <el-form-item label="组ID">
-          <el-input :value="spokespersonForm.groupId" disabled />
+        <el-form-item label="组">
+          <el-input :value="spokespersonForm.groupName" disabled />
         </el-form-item>
-        <el-form-item label="用户ID">
-          <el-input-number v-model="spokespersonForm.uid" :min="1" />
+        <el-form-item label="成员">
+          <el-select v-model="spokespersonForm.uid" placeholder="请选择成员" style="width:100%">
+            <el-option v-for="m in currentGroupMembers" :key="m.id" :label="m.name" :value="m.id" />
+          </el-select>
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -52,59 +89,157 @@
         <el-button type="primary" @click="handleSetSpokesperson" :loading="spokespersonSubmitting">确定</el-button>
       </div>
     </el-dialog>
+
+    <!-- 调入成员 -->
+    <el-dialog title="调入成员" :visible.sync="showAddMemberDialog" width="400px">
+      <el-form label-width="70px">
+        <el-form-item label="目标组">
+          <el-input :value="addMemberForm.groupName" disabled />
+        </el-form-item>
+        <el-form-item label="选择学员">
+          <el-select v-model="addMemberForm.uid" placeholder="请选择未分组学员" style="width:100%">
+            <el-option v-for="u in unassignedUsers" :key="u.id" :label="u.name + ' (' + u.phone + ')'" :value="u.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="showAddMemberDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleAddMember" :loading="addMemberSubmitting">确定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 按签到重分 -->
+    <el-dialog title="按签到重分" :visible.sync="showRedistributeDialog" width="400px">
+      <div style="margin-bottom: 20px">
+        当前班级:<strong>{{ currentClassName }}</strong>
+      </div>
+      <el-form label-width="100px">
+        <el-form-item label="每组人数">
+          <el-input-number v-model="redistributeForm.perGroup" :min="1" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="showRedistributeDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleRedistribute" :loading="redistributeSubmitting">开始重分</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
-import { createGroup, setSpokesperson } from '@/api/group'
-import { statsScoreboard } from '@/api/stats'
+import { createGroup, setSpokesperson, addGroupMember, removeGroupMember } from '@/api/group'
+import { classList, classDetail, redistributeByCheckin } from '@/api/class'
+import { userList } from '@/api/user'
 
 export default {
   name: 'Groups',
   data: function () {
     return {
-      list: [],
+      classes: [],
+      curClassId: '',
+      currentClassName: '',
+      groupRows: [],
+      allUsers: [],
       loading: false,
-      showDialog: false,
+      
+      // Create Group
+      showCreateDialog: false,
       submitting: false,
-      form: { classId: 1, name: '', slogan: '' },
-      rules: {
-        classId: [{ required: true, message: '请输入班级ID', trigger: 'blur' }],
+      createForm: { classId: '', name: '', slogan: '' },
+      createRules: {
         name: [{ required: true, message: '请输入组名', trigger: 'blur' }]
       },
+      
+      // Spokesperson
       showSpokespersonDialog: false,
-      spokespersonForm: { groupId: 0, uid: 0 },
-      spokespersonSubmitting: false
+      spokespersonSubmitting: false,
+      spokespersonForm: { groupId: 0, groupName: '', uid: '' },
+      currentGroupMembers: [],
+      
+      // Add Member
+      showAddMemberDialog: false,
+      addMemberSubmitting: false,
+      addMemberForm: { groupId: 0, groupName: '', uid: '' },
+      
+      // Redistribute
+      showRedistributeDialog: false,
+      redistributeSubmitting: false,
+      redistributeForm: { perGroup: 5 }
     }
   },
   mounted: function () {
-    this.fetchList()
+    this.initClasses()
+  },
+  computed: {
+    unassignedUsers: function () {
+      return this.allUsers.filter(u => !u.groupId)
+    }
   },
   methods: {
-    fetchList: function () {
+    initClasses: function () {
       var self = this
-      self.loading = true
-      statsScoreboard().then(function (res) {
-        self.list = res.data || []
+      classList().then(function (res) {
+        self.classes = res.data || []
+        if (self.classes.length > 0) {
+          self.curClassId = self.classes[0].id
+          self.currentClassName = self.classes[0].name
+          self.loadGroups()
+        }
       }).catch(function () {
-        self.$message.error('获取分组列表失败')
+        self.$message.error('获取班级列表失败')
+      })
+    },
+    loadGroups: function () {
+      var self = this
+      if (!self.curClassId) return
+      self.loading = true
+      
+      // 1. Load Class Detail (Groups)
+      classDetail(self.curClassId).then(function (res) {
+        var data = res.data || {}
+        self.currentClassName = data.clazz ? data.clazz.name : ''
+        var groups = data.groups || []
+        self.groupRows = groups.map(function (g) {
+          return { ...g, memberCount: 0, members: [] }
+        })
+        
+        // 2. Load Users to populate members
+        return userList({ classId: self.curClassId })
+      }).then(function (res) {
+        var users = res.data || []
+        self.allUsers = users
+        
+        // Group users into groupRows
+        self.groupRows.forEach(function (row) {
+          row.members = users.filter(function (u) { return u.groupId === row.id })
+          row.memberCount = row.members.length
+        })
+      }).catch(function (err) {
+        console.error(err)
+        self.$message.error('加载分组数据失败')
       }).finally(function () {
         self.loading = false
       })
     },
-    resetForm: function () {
-      this.form = { classId: 1, name: '', slogan: '' }
+    getSpokespersonName: function (uid) {
+      if (!uid) return '未设置'
+      var user = this.allUsers.find(u => u.id === uid)
+      return user ? user.name : '未知用户'
+    },
+    resetCreateForm: function () {
+      this.createForm = { classId: this.curClassId, name: '', slogan: '' }
     },
-    handleSubmit: function () {
+    handleCreateGroup: function () {
       var self = this
       if (self.submitting) return
-      self.$refs.form.validate(function (valid) {
+      self.$refs.createForm.validate(function (valid) {
         if (!valid) return
         self.submitting = true
-        createGroup(self.form).then(function () {
+        var payload = { ...self.createForm, classId: self.curClassId }
+        createGroup(payload).then(function () {
           self.$message.success('创建成功')
-          self.showDialog = false
-          self.fetchList()
+          self.showCreateDialog = false
+          self.loadGroups()
         }).catch(function () {
           self.$message.error('创建失败')
         }).finally(function () {
@@ -113,22 +248,95 @@ export default {
       })
     },
     openSetSpokesperson: function (row) {
-      this.spokespersonForm = { groupId: row.groupId, uid: 0 }
+      this.spokespersonForm = { 
+        groupId: row.id, 
+        groupName: row.name, 
+        uid: row.spokespersonUid || '' 
+      }
+      this.currentGroupMembers = row.members || []
       this.showSpokespersonDialog = true
     },
     handleSetSpokesperson: function () {
       var self = this
       if (self.spokespersonSubmitting) return
+      if (!self.spokespersonForm.uid) return self.$message.warning('请选择成员')
+      
       self.spokespersonSubmitting = true
-      setSpokesperson(self.spokespersonForm).then(function () {
+      setSpokesperson({ 
+        groupId: self.spokespersonForm.groupId, 
+        uid: self.spokespersonForm.uid 
+      }).then(function () {
         self.$message.success('设置成功')
         self.showSpokespersonDialog = false
-        self.fetchList()
+        self.loadGroups()
       }).catch(function () {
         self.$message.error('设置失败')
       }).finally(function () {
         self.spokespersonSubmitting = false
       })
+    },
+    openAddMember: function (row) {
+      this.addMemberForm = { 
+        groupId: row.id, 
+        groupName: row.name, 
+        uid: '' 
+      }
+      this.showAddMemberDialog = true
+    },
+    handleAddMember: function () {
+      var self = this
+      if (self.addMemberSubmitting) return
+      if (!self.addMemberForm.uid) return self.$message.warning('请选择学员')
+      
+      self.addMemberSubmitting = true
+      addGroupMember({ 
+        groupId: self.addMemberForm.groupId, 
+        uid: self.addMemberForm.uid 
+      }).then(function () {
+        self.$message.success('调入成功')
+        self.showAddMemberDialog = false
+        self.loadGroups()
+      }).catch(function () {
+        self.$message.error('调入失败')
+      }).finally(function () {
+        self.addMemberSubmitting = false
+      })
+    },
+    handleRemoveMember: function (group, user) {
+      var self = this
+      this.$confirm('确定将 ' + user.name + ' 移出分组吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(function () {
+        removeGroupMember({ uid: user.id, groupId: group.id }).then(function () {
+          self.$message.success('移出成功')
+          self.loadGroups()
+        }).catch(function () {
+          self.$message.error('移出失败')
+        })
+      }).catch(function () {
+        // cancel
+      })
+    },
+    handleRedistribute: function () {
+      var self = this
+      if (self.redistributeSubmitting) return
+      self.redistributeSubmitting = true
+      redistributeByCheckin({ 
+        classId: self.curClassId, 
+        perGroup: self.redistributeForm.perGroup 
+      }).then(function (res) {
+        var d = res.data || {}
+        // 后端返回 {classId, checkedIn, totalUsers, groupCount, groups}
+        self.$message.success('重分完成:已签到 ' + d.checkedIn + ' 人,分配到 ' + d.groupCount + ' 组')
+        self.showRedistributeDialog = false
+        self.loadGroups()
+      }).catch(function () {
+        self.$message.error('重分失败')
+      }).finally(function () {
+        self.redistributeSubmitting = false
+      })
     }
   }
 }
@@ -142,6 +350,11 @@ export default {
   margin-bottom: 16px;
 }
 
+.header-left {
+  display: flex;
+  align-items: center;
+}
+
 .page-title {
   margin: 0;
   font-size: 18px;

+ 4 - 0
train-web/src/views/Layout.vue

@@ -19,6 +19,10 @@
             <i class="el-icon-video-camera"></i>
             <span slot="title">现场投屏</span>
           </el-menu-item>
+          <el-menu-item index="/courses" v-if="canSee('/courses')">
+            <i class="el-icon-notebook-2"></i>
+            <span slot="title">课程管理</span>
+          </el-menu-item>
           <el-menu-item index="/classes" v-if="canSee('/classes')">
             <i class="el-icon-s-grid"></i>
             <span slot="title">班级管理</span>

+ 75 - 13
train-web/src/views/Users.vue

@@ -29,33 +29,57 @@
         </template>
       </el-table-column>
       <el-table-column prop="groupName" label="分组" width="120" />
+      <el-table-column label="班级" width="120">
+        <template slot-scope="{ row }">
+          {{ classNameOf(row.classId) }}
+        </template>
+      </el-table-column>
       <el-table-column prop="status" label="状态" width="90">
         <template slot-scope="{ row }">
           <el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">{{ row.status === 'active' ? '已激活' : '已禁用' }}</el-tag>
         </template>
       </el-table-column>
-      <el-table-column label="操作" width="140" fixed="right">
+      <el-table-column label="操作" width="220" fixed="right">
         <template slot-scope="{ row }">
           <el-button size="mini" @click="toggleStatus(row)">{{ row.status === 'active' ? '禁用' : '激活' }}</el-button>
+          <el-button size="mini" type="warning" @click="openMoveClass(row)">调整班级</el-button>
         </template>
       </el-table-column>
     </el-table>
 
-    <el-pagination
-      v-if="total > pageSize"
-      layout="prev, pager, next, total"
-      :total="total"
-      :page-size="pageSize"
-      :current-page.sync="page"
-      @current-change="fetchList"
-      style="margin-top:16px;text-align:right"
-    />
-  </div>
-</template>
+     <el-pagination
+       v-if="total > pageSize"
+       layout="prev, pager, next, total"
+       :total="total"
+       :page-size="pageSize"
+       :current-page.sync="page"
+       @current-change="fetchList"
+       style="margin-top:16px;text-align:right"
+     />
+
+     <el-dialog title="调整班级" :visible.sync="moveVisible" width="420px" @closed="moveForm = { uid: null, classId: null }">
+       <el-form label-width="80px">
+         <el-form-item label="学员">
+           <span>{{ moveUserName }}</span>
+         </el-form-item>
+         <el-form-item label="目标班级">
+           <el-select v-model="moveForm.classId" placeholder="不选则移出班级(退班)" clearable style="width:100%">
+             <el-option v-for="c in classes" :key="c.id" :label="c.name" :value="c.id" />
+           </el-select>
+         </el-form-item>
+         <div class="tips">留空提交即学员退班,将同时清空其报名单班次</div>
+       </el-form>
+       <span slot="footer" class="dialog-footer">
+         <el-button @click="moveVisible = false">取 消</el-button>
+         <el-button type="primary" :loading="moveSubmitting" @click="handleMoveClass">确 定</el-button>
+       </span>
+     </el-dialog>
+   </div>
+ </template>
 
 <script>
 import { userList, setUserStatus } from '@/api/user'
-import { classList } from '@/api/class'
+import { classList, moveUserClass } from '@/api/class'
 
 export default {
   name: 'Users',
@@ -68,6 +92,10 @@ export default {
       pageSize: 20,
       filters: { classId: '', role: '', status: '' },
       classes: [],
+      moveVisible: false,
+      moveForm: { uid: null, classId: null },
+      moveUserName: '',
+      moveSubmitting: false,
       roleLabel: { student: '学员', assistant: '助教', lecturer: '讲师', admin: '管理员' },
       roleTagMap: { student: 'info', assistant: 'warning', lecturer: '', admin: 'danger' }
     }
@@ -83,6 +111,14 @@ export default {
         self.classes = res.data || []
       }).catch(function () {})
     },
+    classNameOf: function (classId) {
+      if (!classId) return '-'
+      var self = this
+      var c = self.classes.find(function (item) {
+        return item.id === classId
+      })
+      return c ? c.name : '-'
+    },
     fetchList: function () {
       var self = this
       self.loading = true
@@ -111,6 +147,25 @@ export default {
           self.$message.error(label + '失败')
         })
       }).catch(function () {})
+    },
+    openMoveClass: function (row) {
+      var self = this
+      self.moveForm = { uid: row.id, classId: row.classId || null }
+      self.moveUserName = row.name
+      self.moveVisible = true
+    },
+    handleMoveClass: function () {
+      var self = this
+      self.moveSubmitting = true
+      moveUserClass({ uid: self.moveForm.uid, classId: self.moveForm.classId }).then(function () {
+        self.$message.success('操作成功')
+        self.moveVisible = false
+        self.fetchList()
+      }).catch(function () {
+        self.$message.error('操作失败')
+      }).finally(function () {
+        self.moveSubmitting = false
+      })
     }
   }
 }
@@ -136,4 +191,11 @@ export default {
   display: flex;
   align-items: center;
 }
+
+.tips {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 8px;
+  line-height: 1.4;
+}
 </style>