Parcourir la source

Phase15b: 课程双态 课程tab/列表进班/管理员审核/课程主页

liaoxg il y a 1 semaine
Parent
commit
248714d

+ 157 - 7
train-backend/src/main/java/com/train/controller/CourseController.java

@@ -4,18 +4,23 @@ 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.entity.TrainEnrollment;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainClassMapper;
 import com.train.mapper.TrainCourseMapper;
+import com.train.mapper.TrainEnrollmentMapper;
 import com.train.mapper.TrainUserMapper;
+import com.train.service.CertService;
 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.RequestAttribute;
+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.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -23,10 +28,16 @@ import java.util.Map;
 /**
  * 训练营课程(L0/L1/L2/L3/L4)相关接口。
  *
- * - /api/course/list     列出所有课程
+ * - /api/course/list     列出所有课程(含按时间推导的有效状态 effectiveStatus)
  * - /api/course/current  当前学员所修课程(按 user.class_id -> train_class.course_id 解析)
+ * - /api/course/enter    学员从课程列表申请进班(置 enrollment reviewing,待管理员审核)
+ * - /api/course/progress 课程内学习进度(复用离场验收 6 项)
  *
- * 注意:当前学员所修课程未写入 user,而是从 class_id 反查,因此响应里附带 class 信息。
+ * 有效状态规则(复用 status 字段):
+ * - status=draft                   → draft(管理员手动下架,学员端不可见)
+ * - status=finished                → finished(管理员手动结束)
+ * - 配置了 start_time / end_time   → 按当前时间推导 upcoming/active/finished(自动切换)
+ * - 未配置时间                     → 回落 status 原值
  */
 @Tag(name = "课程", description = "训练营课程(L0/L1/.../L4)")
 @RestController
@@ -42,11 +53,22 @@ public class CourseController {
     @Resource
     private TrainUserMapper trainUserMapper;
 
-    @Operation(summary = "列出所有课程")
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+
+    @Resource
+    private CertService certService;
+
+    @Operation(summary = "列出所有课程(含有效状态与我的进班状态)")
     @PostMapping("/list")
-    public Result<List<TrainCourse>> list() {
+    public Result<List<TrainCourse>> list(@RequestAttribute("userId") Long userId) {
         List<TrainCourse> courses = trainCourseMapper.selectList(
                 new LambdaQueryWrapper<TrainCourse>().orderByAsc(TrainCourse::getLevel));
+        for (TrainCourse c : courses) {
+            c.setEffectiveStatus(effectiveStatus(c));
+            Map<String, Object> en = myEnrollmentStatus(userId, c.getId());
+            c.setMyStatus(en == null ? "none" : en.get("status").toString());
+        }
         return Result.success(courses);
     }
 
@@ -70,14 +92,18 @@ public class CourseController {
         }
 
         if (course == null) {
-            // 兜底:未进班或课程未关联时,返回首个 active 课程(一般是 L0)
+            // 兜底:未进班或课程未关联时,返回首个非下架课程(一般是 L0)
             course = trainCourseMapper.selectOne(
                     new LambdaQueryWrapper<TrainCourse>()
-                            .eq(TrainCourse::getStatus, "active")
+                            .ne(TrainCourse::getStatus, "draft")
                             .orderByAsc(TrainCourse::getLevel)
                             .last("LIMIT 1"));
         }
 
+        if (course == null) {
+            return Result.success(data);
+        }
+        course.setEffectiveStatus(effectiveStatus(course));
         data.put("course", course);
         if (trainClass != null) {
             data.put("classId", trainClass.getId());
@@ -86,6 +112,130 @@ public class CourseController {
             data.put("classTime", trainClass.getTime());
             data.put("classPlace", trainClass.getPlace());
         }
+        // 学员在该课程下的报名/进班状态
+        data.put("enrollment", myEnrollmentStatus(userId, course.getId()));
+        return Result.success(data);
+    }
+
+    /**
+     * 学员从当前课程列表申请进班。
+     * 流程:已有 paid 报名单 → 置 reviewing(待管理员审核);无已支付报名单 → 引导先报名。
+     * 重复申请幂等:已是 reviewing/confirmed 直接返回当前状态。
+     */
+    @Operation(summary = "申请进班(从课程列表)")
+    @PostMapping("/enter")
+    public Result<Map<String, Object>> enter(@RequestBody Map<String, Object> body,
+                                             @RequestAttribute("userId") Long userId) {
+        Object courseIdObj = body.get("courseId");
+        if (courseIdObj == null) {
+            return Result.error("缺少课程ID");
+        }
+        Long courseId = Long.valueOf(courseIdObj.toString());
+        TrainCourse course = trainCourseMapper.selectById(courseId);
+        if (course == null) {
+            return Result.error("课程不存在");
+        }
+        if ("draft".equals(course.getStatus()) || "finished".equals(effectiveStatus(course))) {
+            return Result.error("该课程当前不可申请进班");
+        }
+
+        TrainEnrollment en = latestEnrollmentOf(userId, courseId);
+        Map<String, Object> data = new HashMap<>();
+        if (en == null) {
+            return Result.error("请先完成该课程报名并支付,再申请进班");
+        }
+        data.put("enrollmentId", en.getId());
+        switch (en.getStatus()) {
+            case "pending":
+                return Result.error("报名单尚未支付,请先完成支付再申请进班");
+            case "paid":
+                en.setStatus("reviewing");
+                en.setUpdatedAt(new Date());
+                trainEnrollmentMapper.updateById(en);
+                data.put("status", "reviewing");
+                data.put("message", "进班申请已提交,等待班主任审核");
+                return Result.success(data);
+            case "reviewing":
+                data.put("status", "reviewing");
+                data.put("message", "进班申请已提交,等待班主任审核");
+                return Result.success(data);
+            case "confirmed":
+                data.put("status", "confirmed");
+                data.put("message", "已进班");
+                return Result.success(data);
+            case "rejected":
+                // 被拒后可重新申请
+                en.setStatus("reviewing");
+                en.setUpdatedAt(new Date());
+                trainEnrollmentMapper.updateById(en);
+                data.put("status", "reviewing");
+                data.put("message", "已重新提交进班申请,等待班主任审核");
+                return Result.success(data);
+            default:
+                return Result.error("报名单状态异常,请联系客服");
+        }
+    }
+
+    @Operation(summary = "课程内学习进度(离场验收 6 项)")
+    @PostMapping("/progress")
+    public Result<Map<String, Object>> progress(@RequestAttribute("userId") Long userId) {
+        List<Map<String, Object>> items = certService.checkItemsOf(userId);
+        Map<String, Object> data = new HashMap<>();
+        data.put("items", items);
+        data.put("done", items.stream().filter(i -> Boolean.TRUE.equals(i.get("ok"))).count());
+        data.put("total", items.size());
         return Result.success(data);
     }
-}
+
+    /**
+     * 有效状态推导:draft/finished 为管理员手动覆盖;配置时间则按时间自动切换;否则回落原值。
+     */
+    private String effectiveStatus(TrainCourse c) {
+        if (c == null) {
+            return "draft";
+        }
+        if ("draft".equals(c.getStatus()) || "finished".equals(c.getStatus())) {
+            return c.getStatus();
+        }
+        Date now = new Date();
+        if (c.getStartTime() != null && now.before(c.getStartTime())) {
+            return "upcoming";
+        }
+        if (c.getEndTime() != null && now.after(c.getEndTime())) {
+            return "finished";
+        }
+        if (c.getStartTime() != null || c.getEndTime() != null) {
+            return "active";
+        }
+        return c.getStatus() == null ? "draft" : c.getStatus();
+    }
+
+    /** 学员在指定课程下的最新一条报名单(按该课程全部班次查找) */
+    private TrainEnrollment latestEnrollmentOf(Long uid, Long courseId) {
+        List<Long> classIds = trainClassMapper.selectList(
+                        new LambdaQueryWrapper<TrainClass>().eq(TrainClass::getCourseId, courseId))
+                .stream().map(TrainClass::getId).collect(java.util.stream.Collectors.toList());
+        if (classIds.isEmpty()) {
+            return null;
+        }
+        return trainEnrollmentMapper.selectOne(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, uid)
+                        .in(TrainEnrollment::getClassId, classIds)
+                        .orderByDesc(TrainEnrollment::getId)
+                        .last("LIMIT 1"));
+    }
+
+    /** 学员在指定课程下的报名/进班状态:{enrollmentId, status} 或 null */
+    private Map<String, Object> myEnrollmentStatus(Long uid, Long courseId) {
+        TrainEnrollment en = latestEnrollmentOf(uid, courseId);
+        if (en == null) {
+            return null;
+        }
+        Map<String, Object> m = new HashMap<>();
+        m.put("enrollmentId", en.getId());
+        m.put("classId", en.getClassId());
+        m.put("status", en.getStatus());
+        return m;
+    }
+}

+ 113 - 0
train-backend/src/main/java/com/train/controller/admin/AdminBizController.java

@@ -4,11 +4,15 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.common.Result;
 import com.train.entity.TrainClass;
 import com.train.entity.TrainEnrollment;
+import com.train.entity.TrainGroup;
+import com.train.entity.TrainGroupMember;
 import com.train.entity.TrainInvite;
 import com.train.entity.TrainOrder;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainClassMapper;
 import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainGroupMapper;
+import com.train.mapper.TrainGroupMemberMapper;
 import com.train.mapper.TrainInviteMapper;
 import com.train.mapper.TrainOrderMapper;
 import com.train.mapper.TrainUserMapper;
@@ -23,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -44,6 +49,10 @@ public class AdminBizController {
     @Resource
     private TrainInviteMapper trainInviteMapper;
     @Resource
+    private TrainGroupMapper trainGroupMapper;
+    @Resource
+    private TrainGroupMemberMapper trainGroupMemberMapper;
+    @Resource
     private PayService payService;
 
     /**
@@ -194,4 +203,108 @@ public class AdminBizController {
         }
         return Result.success(new ArrayList<>(rows.values()));
     }
+
+    /**
+     * 进班审核(Phase15b):学员从课程列表申请进班后,管理员审核。
+     * body: {enrollmentId, approved, groupId?}
+     * - approved=true  → 报名单置 confirmed,学员 user.class_id 写入班次,
+     *   且按指定/自动方式分派小组(指定 groupId 校验属于该班次;未指定自动分派人数最少小组)
+     * - approved=false → 报名单置 rejected
+     */
+    @Operation(summary = "进班审核")
+    @PostMapping("/enrollment/audit")
+    public Result<Map<String, Object>> enrollmentAudit(@RequestBody Map<String, Object> body,
+                                                       @RequestAttribute("adminId") Long adminId) {
+        Object idObj = body.get("enrollmentId");
+        if (idObj == null) {
+            return Result.error("缺少报名单ID");
+        }
+        boolean approved = Boolean.parseBoolean(
+                body.get("approved") == null ? "false" : body.get("approved").toString());
+
+        TrainEnrollment en = trainEnrollmentMapper.selectById(Long.valueOf(idObj.toString()));
+        if (en == null) {
+            return Result.error("报名单不存在");
+        }
+        if (!"reviewing".equals(en.getStatus())) {
+            return Result.error("仅「进班待审核」状态的报名单可审核");
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        if (!approved) {
+            en.setStatus("rejected");
+            en.setUpdatedAt(new Date());
+            trainEnrollmentMapper.updateById(en);
+            data.put("status", "rejected");
+            data.put("message", "已拒绝该学员进班");
+            return Result.success(data);
+        }
+
+        // 通过:报名单 → confirmed,写入 user.class_id
+        en.setStatus("confirmed");
+        en.setUpdatedAt(new Date());
+        trainEnrollmentMapper.updateById(en);
+
+        Long uid = en.getUid();
+        Long classId = en.getClassId();
+        if (uid != null && classId != null) {
+            TrainUser user = trainUserMapper.selectById(uid);
+            if (user != null) {
+                user.setClassId(classId);
+                trainUserMapper.updateById(user);
+            }
+        }
+
+        // 分派小组:指定 groupId 或自动分派人数最少小组
+        Long groupId = null;
+        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)) {
+                return Result.error("指定分组不属于该班次,请重新选择");
+            }
+            groupId = gid;
+        } else if (classId != null) {
+            groupId = leastLoadedGroupId(classId);
+        }
+        if (groupId != null && uid != null) {
+            Long exists = trainGroupMemberMapper.selectCount(
+                    new LambdaQueryWrapper<TrainGroupMember>()
+                            .eq(TrainGroupMember::getGroupId, groupId)
+                            .eq(TrainGroupMember::getUid, uid));
+            if (exists == null || exists == 0) {
+                TrainGroupMember member = new TrainGroupMember();
+                member.setGroupId(groupId);
+                member.setUid(uid);
+                member.setJoinedAt(new Date());
+                trainGroupMemberMapper.insert(member);
+            }
+        }
+
+        data.put("status", "confirmed");
+        data.put("groupId", groupId);
+        data.put("message", "已通过进班审核" + (groupId != null ? "并已分派小组" : "(未分派小组)"));
+        return Result.success(data);
+    }
+
+    /** 返回该班次当前人数最少的小组 id;无小组返回 null */
+    private Long leastLoadedGroupId(Long classId) {
+        List<TrainGroup> groups = trainGroupMapper.selectList(
+                new LambdaQueryWrapper<TrainGroup>().eq(TrainGroup::getClassId, classId));
+        if (groups.isEmpty()) {
+            return null;
+        }
+        TrainGroup best = null;
+        Long bestCnt = null;
+        for (TrainGroup g : groups) {
+            Long cnt = trainGroupMemberMapper.selectCount(
+                    new LambdaQueryWrapper<TrainGroupMember>().eq(TrainGroupMember::getGroupId, g.getId()));
+            Long c = cnt == null ? 0L : cnt;
+            if (best == null || c < bestCnt) {
+                best = g;
+                bestCnt = c;
+            }
+        }
+        return best == null ? null : best.getId();
+    }
 }

+ 15 - 1
train-backend/src/main/java/com/train/entity/TrainCourse.java

@@ -29,9 +29,23 @@ public class TrainCourse implements Serializable {
     /** 课程简介 */
     private String description;
 
-    /** 状态:draft/active/finished */
+    /** 状态:draft/active/finished(管理员手动覆盖位;时间字段存在时接口按时间推导 effectiveStatus) */
     private String status;
 
+    /** 课程开始时间(状态自动切换依据:未到=upcoming,期间=active) */
+    private Date startTime;
+
+    /** 课程结束时间(状态自动切换依据:超过=finished) */
+    private Date endTime;
+
+    /** 计算字段(不落库):按时间推导的有效状态 upcoming/active/finished;draft/finished 为管理员手动覆盖 */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String effectiveStatus;
+
+    /** 计算字段(不落库):当前登录学员在该课程下的报名状态 none/pending/paid/reviewing/confirmed/rejected */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String myStatus;
+
     private Date createdAt;
     private Date updatedAt;
 }

+ 1 - 1
train-backend/src/main/java/com/train/entity/TrainEnrollment.java

@@ -24,7 +24,7 @@ public class TrainEnrollment implements Serializable {
     private String topProblems; // 最想解决的3个问题JSON
     private String source; // scene/invite
     private String inviteCode;
-    private String status; // pending/paid/confirmed/cancelled
+    private String status; // pending(待支付)/paid(已支付)/reviewing(进班待审核)/confirmed(已进班)/rejected(进班被拒)/cancelled(已取消)
     private Date createdAt;
     private Date updatedAt;
 }

+ 6 - 2
train-backend/src/main/resources/schema.sql

@@ -9,7 +9,9 @@ CREATE TABLE IF NOT EXISTS train_course (
     level VARCHAR(20) NOT NULL COMMENT '课程等级:L0/L1/L2/L3/L4',
     name VARCHAR(100) NOT NULL COMMENT '课程名称(如 L0 = 家立方-AI管家成长营)',
     description VARCHAR(500) COMMENT '课程简介',
-    status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/active/finished',
+    status VARCHAR(20) DEFAULT 'draft' COMMENT '手动覆盖状态:draft(下架)/active(强制上架)/finished(强制结束)',
+    start_time DATETIME COMMENT '课程开始时间(状态自动切换依据)',
+    end_time DATETIME COMMENT '课程结束时间(状态自动切换依据)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_level (level),
@@ -65,6 +67,8 @@ ALTER TABLE train_class ADD COLUMN activity_id BIGINT COMMENT '关联 cfc 活动
 ALTER TABLE train_class ADD COLUMN course_id BIGINT COMMENT '关联课程 ID(train_course.id,L0/L1/L2…)';
 ALTER TABLE train_user ADD COLUMN cfc_user_id BIGINT COMMENT 'cfc 统一会员 ID(users.id,cfc 同库)';
 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 '课程结束时间(状态自动切换依据)';
 
 -- 小组表
 CREATE TABLE IF NOT EXISTS train_group (
@@ -279,7 +283,7 @@ CREATE TABLE IF NOT EXISTS train_enrollment (
     top_problems TEXT COMMENT '最想解决的3个问题JSON',
     source VARCHAR(20) DEFAULT 'scene' COMMENT '来源:scene/invite',
     invite_code VARCHAR(50) COMMENT '邀请码(基于train_user.invite_code)',
-    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paid/confirmed/cancelled',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending(待支付)/paid(已支付)/reviewing(进班待审核)/confirmed(已进班)/rejected(进班被拒)/cancelled(已取消)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_uid (uid),

+ 16 - 4
train-frontend/pages.json

@@ -12,6 +12,18 @@
         "navigationBarTitleText": "任务"
       }
     },
+    {
+      "path": "pages/course/index",
+      "style": {
+        "navigationBarTitleText": "课程"
+      }
+    },
+    {
+      "path": "pages/course/detail",
+      "style": {
+        "navigationBarTitleText": "课程主页"
+      }
+    },
     {
       "path": "pages/vote/index",
       "style": {
@@ -184,10 +196,10 @@
         "selectedIconPath": "static/tab-task-active.png"
       },
       {
-        "pagePath": "pages/vote/index",
-        "text": "路演投票",
-        "iconPath": "static/tab-vote.png",
-        "selectedIconPath": "static/tab-vote-active.png"
+        "pagePath": "pages/course/index",
+        "text": "课程",
+        "iconPath": "static/tab-course.png",
+        "selectedIconPath": "static/tab-course-active.png"
       },
       {
         "pagePath": "pages/mine/index",

+ 155 - 0
train-frontend/pages/course/detail.vue

@@ -0,0 +1,155 @@
+<template>
+  <view class="course-detail-page">
+    <!-- 课程信息 -->
+    <view class="course-head">
+      <text class="course-level">{{ course.level || 'L' }}</text>
+      <text class="course-name">{{ course.name }}</text>
+      <text class="course-status" :class="'st-' + course.effectiveStatus">{{ statusLabel(course.effectiveStatus) }}</text>
+      <text class="course-desc" v-if="course.description">{{ course.description }}</text>
+      <text class="course-class" v-if="className">我的班次:{{ className }}</text>
+    </view>
+
+    <!-- 学习进度 -->
+    <view class="section-card">
+      <view class="section-head">
+        <text class="section-title">学习进度</text>
+        <text class="progress-count">{{ progress.done }}/{{ progress.total }} 项完成</text>
+      </view>
+      <view class="progress-bar">
+        <view class="progress-fill" :style="{ width: progressPercent }"></view>
+      </view>
+      <view class="progress-list">
+        <view class="progress-item" v-for="(it, idx) in progress.items" :key="idx">
+          <text class="progress-icon" :class="{ done: it.ok }">{{ it.ok ? '✅' : '⬜' }}</text>
+          <text class="progress-label">{{ it.label }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 课程内功能入口 -->
+    <view class="section-title">课程内容</view>
+    <view class="nav-grid">
+      <view class="nav-item" @click="goTo('/pages/material/index')">
+        <text class="nav-icon">📄</text>
+        <text class="nav-text">课前资料</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/group/index')">
+        <text class="nav-icon">👥</text>
+        <text class="nav-text">我的小组</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/checkin/index')">
+        <text class="nav-icon">✅</text>
+        <text class="nav-text">装机打卡</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/upload/index')">
+        <text class="nav-icon">📤</text>
+        <text class="nav-text">成果上传</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/teaching/index')">
+        <text class="nav-icon">📝</text>
+        <text class="nav-text">教学卡</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/roadmap/index')">
+        <text class="nav-icon">🎤</text>
+        <text class="nav-text">路演材料</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/vote/index')">
+        <text class="nav-icon">🗳️</text>
+        <text class="nav-text">路演投票</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/scoreboard/index')">
+        <text class="nav-icon">🏆</text>
+        <text class="nav-text">积分榜</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/plan/index')">
+        <text class="nav-icon">📅</text>
+        <text class="nav-text">行动计划</text>
+      </view>
+      <view class="nav-item" @click="goTo('/pages/assignment/index')">
+        <text class="nav-icon">📋</text>
+        <text class="nav-text">三本账</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMyCourse, getCourseProgress } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      course: {},
+      className: '',
+      progress: { done: 0, total: 0, items: [] }
+    }
+  },
+  computed: {
+    progressPercent() {
+      var total = this.progress.total || 0
+      if (!total) return '0%'
+      var pct = Math.round((this.progress.done || 0) * 100 / total)
+      return pct + '%'
+    }
+  },
+  onLoad() {
+    this.loadAll()
+  },
+  methods: {
+    loadAll() {
+      var self = this
+      getMyCourse().then(function(resp) {
+        var d = resp.data || {}
+        var c = d.course || {}
+        self.course = c
+        self.className = d.className || ''
+      }).catch(function() {})
+      getCourseProgress().then(function(resp) {
+        var d = resp.data || {}
+        self.progress = {
+          done: d.done || 0,
+          total: d.total || 0,
+          items: d.items || []
+        }
+      }).catch(function() {})
+    },
+    statusLabel(st) {
+      var map = { upcoming: '未开始', active: '进行中', finished: '已结束', draft: '未发布' }
+      return map[st] || st || '-'
+    },
+    goTo(url) {
+      uni.navigateTo({ url: url })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.course-detail-page { min-height: 100vh; background: #F5F5F5; padding: 24rpx 32rpx; }
+.course-head { background: #FFF; border-radius: 20rpx; padding: 32rpx; margin-bottom: 24rpx; display: flex; flex-direction: column; }
+.course-level { align-self: flex-start; font-size: 22rpx; font-weight: 600; color: #F97316; background: #FFF7ED; border-radius: 8rpx; padding: 4rpx 14rpx; margin-bottom: 12rpx; }
+.course-name { font-size: 36rpx; font-weight: 700; color: #1E293B; margin-bottom: 8rpx; }
+.course-status { align-self: flex-start; font-size: 22rpx; border-radius: 6rpx; padding: 4rpx 16rpx; margin-bottom: 12rpx; }
+.course-status.st-upcoming { color: #F97316; background: #FFF7ED; }
+.course-status.st-active { color: #22C55E; background: #F0FDF4; }
+.course-status.st-finished { color: #94A3B8; background: #F1F5F9; }
+.course-desc { font-size: 26rpx; color: #64748B; line-height: 1.6; margin-bottom: 8rpx; }
+.course-class { font-size: 24rpx; color: #1E293B; background: #F1F5F9; border-radius: 8rpx; padding: 8rpx 16rpx; align-self: flex-start; }
+
+.section-card { background: #FFF; border-radius: 16rpx; padding: 28rpx; margin-bottom: 24rpx; }
+.section-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }
+.section-title { font-size: 30rpx; font-weight: 600; color: #1E293B; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+.progress-count { font-size: 24rpx; color: #64748B; }
+.progress-bar { height: 16rpx; background: #F1F5F9; border-radius: 8rpx; overflow: hidden; margin-bottom: 20rpx; }
+.progress-fill { height: 100%; background: linear-gradient(90deg, #F97316, #EA580C); border-radius: 8rpx; }
+.progress-list { display: flex; flex-direction: column; }
+.progress-item { display: flex; align-items: center; padding: 10rpx 0; }
+.progress-icon { font-size: 28rpx; margin-right: 12rpx; }
+.progress-label { font-size: 26rpx; color: #1E293B; }
+
+.nav-grid { display: flex; flex-wrap: wrap; gap: 0; background: #FFF; border-radius: 16rpx; overflow: hidden; }
+.nav-item { width: 25%; display: flex; flex-direction: column; align-items: center; padding: 28rpx 0; }
+.nav-item:active { background: #F8FAFC; }
+.nav-icon { font-size: 44rpx; margin-bottom: 8rpx; }
+.nav-text { font-size: 24rpx; color: #1E293B; text-align: center; }
+</style>

+ 180 - 0
train-frontend/pages/course/index.vue

@@ -0,0 +1,180 @@
+<template>
+  <view class="course-page">
+    <!-- 进行中的课程(上课状态) -->
+    <view v-if="activeCourse" class="active-card">
+      <view class="active-badge">
+        <text class="active-badge-text">⏰ 课程进行中</text>
+      </view>
+      <text class="active-name">{{ activeCourse.level }} {{ activeCourse.name }}</text>
+      <text class="active-desc">{{ activeCourse.description || '当前正在进行的课程' }}</text>
+      <view class="active-time">
+        <text class="active-time-text">开始 {{ fmtTime(activeCourse.startTime) }}</text>
+        <text class="active-time-text" v-if="activeCourse.endTime">结束 {{ fmtTime(activeCourse.endTime) }}</text>
+      </view>
+      <button class="active-btn" :class="{ 'btn-disabled': isReviewing(activeCourse) }"
+        :disabled="isReviewing(activeCourse)" @click="handleAction(activeCourse)">
+        {{ actionText(activeCourse) }}
+      </button>
+    </view>
+
+    <view class="section-title">全部课程</view>
+    <view class="course-list">
+      <view class="course-card" v-for="(c, idx) in courses" :key="idx">
+        <view class="course-head">
+          <text class="course-level">{{ c.level }}</text>
+          <text class="course-name">{{ c.name }}</text>
+        </view>
+        <view class="course-desc" v-if="c.description">{{ c.description }}</view>
+        <view class="course-tags">
+          <text class="course-tag" :class="'st-' + c.effectiveStatus">{{ statusLabel(c.effectiveStatus) }}</text>
+          <text class="course-tag mine" :class="'my-' + c.myStatus">{{ myLabel(c.myStatus) }}</text>
+        </view>
+        <button class="course-btn" :class="{ 'btn-disabled': isReviewing(c) }"
+          :disabled="isReviewing(c)" @click="handleAction(c)">
+          {{ actionText(c) }}
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getCourseList, enterCourse } from '@/utils/api.js'
+import { formatDateTime } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      courses: [],
+      loading: false
+    }
+  },
+  computed: {
+    isLoggedIn() {
+      return this.$store.getters.isLoggedIn
+    },
+    activeCourse() {
+      var list = this.courses || []
+      for (var i = 0; i < list.length; i++) {
+        if (list[i].effectiveStatus === 'active') {
+          return list[i]
+        }
+      }
+      return null
+    }
+  },
+  onShow() {
+    this.loadCourses()
+  },
+  methods: {
+    loadCourses() {
+      if (!this.isLoggedIn) return
+      var self = this
+      self.loading = true
+      getCourseList().then(function(resp) {
+        self.courses = resp.data || []
+      }).catch(function() {
+        uni.showToast({ title: '获取课程列表失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    statusLabel(st) {
+      var map = { upcoming: '未开始', active: '进行中', finished: '已结束', draft: '未发布' }
+      return map[st] || st || '-'
+    },
+    myLabel(st) {
+      var map = { none: '未报名', pending: '待支付', paid: '已支付', reviewing: '审核中', confirmed: '已进班', rejected: '未通过' }
+      return map[st] || st || '-'
+    },
+    isReviewing(c) {
+      return c && c.myStatus === 'reviewing'
+    },
+    actionText(c) {
+      if (!c) return ''
+      var s = c.myStatus
+      if (s === 'confirmed') return '进入课程'
+      if (s === 'reviewing') return '等待审核'
+      if (s === 'pending') return '去支付'
+      if (s === 'paid' || s === 'rejected') return '申请进班'
+      return '报名进班'
+    },
+    handleAction(c) {
+      if (!c) return
+      var s = c.myStatus
+      if (s === 'confirmed') {
+        uni.navigateTo({ url: '/pages/course/detail?courseId=' + c.id })
+      } else if (s === 'pending') {
+        uni.navigateTo({ url: '/pages/enroll/list' })
+      } else if (s === 'paid' || s === 'rejected') {
+        this.applyEnter(c)
+      } else {
+        // 未报名:先走报名流程
+        uni.navigateTo({ url: '/pages/enroll/list' })
+      }
+    },
+    applyEnter(c) {
+      var self = this
+      uni.showModal({
+        title: '申请进班',
+        content: '确认申请进入「' + c.name + '」?班主任/管理员审核通过后即可进入课程。',
+        success: function(res) {
+          if (!res.confirm) return
+          enterCourse(c.id).then(function(resp) {
+            var d = resp.data || {}
+            uni.showToast({ title: d.message || '申请已提交', icon: 'none' })
+            self.loadCourses()
+          }).catch(function(err) {
+            var msg = (err && err.message) || '申请失败'
+            uni.showToast({ title: msg, icon: 'none' })
+            // 未完成报名/支付等场景引导去报名
+            if (msg.indexOf('报名') !== -1 || msg.indexOf('支付') !== -1) {
+              setTimeout(function() {
+                uni.navigateTo({ url: '/pages/enroll/list' })
+              }, 1200)
+            }
+          })
+        }
+      })
+    },
+    fmtTime(v) {
+      if (!v) return ''
+      return formatDateTime(v)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.course-page { min-height: 100vh; background: #F5F5F5; padding: 24rpx 32rpx; }
+.section-title { font-size: 30rpx; font-weight: 600; color: #1E293B; margin-bottom: 16rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+
+.active-card { background: linear-gradient(135deg, #F97316, #EA580C); border-radius: 20rpx; padding: 32rpx; margin-bottom: 32rpx; }
+.active-badge { display: inline-block; background: rgba(255,255,255,0.2); border-radius: 100rpx; padding: 4rpx 20rpx; margin-bottom: 16rpx; }
+.active-badge-text { font-size: 22rpx; color: #FFF; font-weight: 600; }
+.active-name { display: block; font-size: 36rpx; font-weight: 700; color: #FFF; margin-bottom: 8rpx; }
+.active-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.9); margin-bottom: 16rpx; }
+.active-time { display: flex; flex-direction: column; margin-bottom: 24rpx; }
+.active-time-text { font-size: 22rpx; color: rgba(255,255,255,0.8); margin-bottom: 4rpx; }
+.active-btn { background: #FFF; color: #EA580C; font-size: 30rpx; font-weight: 600; border-radius: 48rpx; height: 80rpx; line-height: 80rpx; border: none; margin: 0; }
+.active-btn:active { opacity: 0.9; }
+
+.course-list { display: flex; flex-direction: column; }
+.course-card { background: #FFF; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; }
+.course-head { display: flex; align-items: center; margin-bottom: 8rpx; }
+.course-level { font-size: 24rpx; font-weight: 600; color: #F97316; background: #FFF7ED; border-radius: 8rpx; padding: 4rpx 14rpx; margin-right: 16rpx; }
+.course-name { font-size: 30rpx; font-weight: 600; color: #1E293B; flex: 1; }
+.course-desc { font-size: 24rpx; color: #64748B; margin-bottom: 16rpx; line-height: 1.6; }
+.course-tags { display: flex; align-items: center; margin-bottom: 20rpx; }
+.course-tag { font-size: 20rpx; color: #64748B; background: #F1F5F9; border-radius: 6rpx; padding: 4rpx 14rpx; margin-right: 12rpx; }
+.course-tag.st-upcoming { color: #F97316; background: #FFF7ED; }
+.course-tag.st-active { color: #22C55E; background: #F0FDF4; }
+.course-tag.st-finished { color: #94A3B8; background: #F1F5F9; }
+.course-tag.mine { color: #6366F1; background: #EEF2FF; }
+.course-tag.my-confirmed { color: #22C55E; background: #F0FDF4; }
+.course-tag.my-reviewing { color: #F97316; background: #FFF7ED; }
+.course-tag.my-rejected { color: #EF5350; background: #FEF2F2; }
+.course-btn { background: linear-gradient(135deg, #F97316, #EA580C); color: #FFF; font-size: 28rpx; font-weight: 600; border-radius: 44rpx; height: 76rpx; line-height: 76rpx; border: none; margin: 0; }
+.course-btn:active { opacity: 0.9; }
+.btn-disabled { background: #E2E8F0; color: #94A3B8; }
+</style>

+ 4 - 4
train-frontend/pages/index/index.vue

@@ -26,9 +26,9 @@
 
     <view class="section-title">功能导航</view>
     <view class="nav-grid">
-      <view class="nav-item" @click="goTo('/pages/class/index')">
-        <text class="nav-icon">🏫</text>
-        <text class="nav-text">进班</text>
+      <view class="nav-item" @click="switchToTab('/pages/course/index')">
+        <text class="nav-icon">📚</text>
+        <text class="nav-text">课程</text>
       </view>
       <view class="nav-item" @click="goTo('/pages/group/index')">
         <text class="nav-icon">👥</text>
@@ -54,7 +54,7 @@
         <text class="nav-icon">🎤</text>
         <text class="nav-text">路演材料</text>
       </view>
-      <view class="nav-item" @click="switchToTab('/pages/vote/index')">
+      <view class="nav-item" @click="goTo('/pages/vote/index')">
         <text class="nav-icon">🗳️</text>
         <text class="nav-text">投票</text>
       </view>

+ 1 - 1
train-frontend/pages/login/index.vue

@@ -59,7 +59,7 @@ export default {
               if (!data.alumniVerify || data.alumniVerify === 'pending') {
                 uni.redirectTo({ url: '/pages/verify/index' })
               } else if (!data.classId) {
-                uni.redirectTo({ url: '/pages/class/index' })
+                uni.switchTab({ url: '/pages/course/index' })
               } else {
                 uni.switchTab({ url: '/pages/index/index' })
               }

+ 2 - 2
train-frontend/pages/verify/index.vue

@@ -107,8 +107,8 @@ export default {
           self.$store.commit('setAlumniVerify', 'accepted')
           uni.showToast({ title: '校验通过', icon: 'success' })
           setTimeout(function() {
-            if (!self.$store.state.classId) {
-              uni.redirectTo({ url: '/pages/class/index' })
+if (!self.$store.state.classId) {
+              uni.switchTab({ url: '/pages/course/index' })
             } else {
               uni.switchTab({ url: '/pages/index/index' })
             }

BIN
train-frontend/static/tab-course-active.png


BIN
train-frontend/static/tab-course.png


+ 6 - 0
train-frontend/utils/api.js

@@ -113,6 +113,12 @@ export const getCourseList = () => {
 export const getMyCourse = () => {
   return request('/api/course/current', 'POST')
 }
+export const enterCourse = (courseId) => {
+  return request('/api/course/enter', 'POST', { courseId })
+}
+export const getCourseProgress = () => {
+  return request('/api/course/progress', 'POST')
+}
 
 // 分组
 export const joinGroup = (groupId) => {

+ 4 - 0
train-web/src/api/enroll.js

@@ -2,4 +2,8 @@ import request from '@/utils/request'
 
 export function enrollmentList(data) {
   return request.post('/api/admin/enrollment/list', data || {})
+}
+
+export function enrollmentAudit(data) {
+  return request.post('/api/admin/enrollment/audit', data || {})
 }

+ 96 - 5
train-web/src/views/Enrollments.vue

@@ -6,7 +6,9 @@
         <el-select v-model="filters.status" placeholder="状态" clearable style="width:140px" @change="fetchList">
           <el-option label="待支付" value="pending" />
           <el-option label="已支付" value="paid" />
+          <el-option label="待审核" value="reviewing" />
           <el-option label="已确认" value="confirmed" />
+          <el-option label="已拒绝" value="rejected" />
           <el-option label="已取消" value="cancelled" />
         </el-select>
         <el-button type="primary" icon="el-icon-refresh" @click="fetchList">刷新</el-button>
@@ -24,18 +26,49 @@
         </template>
       </el-table-column>
       <el-table-column prop="inviteCode" label="邀请码" width="110" />
-      <el-table-column label="状态" width="90">
+      <el-table-column label="状态" width="100">
         <template slot-scope="scope">
           <el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column prop="createdAt" label="报名时间" width="170" :formatter="formatTime" />
+      <el-table-column label="操作" width="180" fixed="right">
+        <template slot-scope="scope">
+          <template v-if="scope.row.status === 'reviewing'">
+            <el-button type="success" size="mini" @click="openAudit(scope.row)">通过</el-button>
+            <el-button type="danger" size="mini" @click="reject(scope.row)">拒绝</el-button>
+          </template>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
     </el-table>
+
+    <!-- 审核弹窗(通过:可选小组,不选则自动分派) -->
+    <el-dialog title="通过进班审核" :visible.sync="auditVisible" width="480px">
+      <el-form label-width="100px">
+        <el-form-item label="学员">
+          <span>{{ auditRow.name }}({{ auditRow.phone }})</span>
+        </el-form-item>
+        <el-form-item label="班次">
+          <span>{{ auditRow.className }}</span>
+        </el-form-item>
+        <el-form-item label="分派小组">
+          <el-select v-model="auditGroupId" placeholder="不选则自动分派到人数最少小组" clearable style="width:240px">
+            <el-option v-for="g in auditGroups" :key="g.id" :label="g.name" :value="g.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="auditVisible = false">取消</el-button>
+        <el-button type="success" :loading="auditLoading" @click="confirmAudit">确认通过</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
-import { enrollmentList } from '@/api/enroll'
+import { enrollmentList, enrollmentAudit } from '@/api/enroll'
+import { classDetail } from '@/api/class'
 
 export default {
   name: 'Enrollments',
@@ -45,7 +78,12 @@ export default {
       loading: false,
       filters: {
         status: ''
-      }
+      },
+      auditVisible: false,
+      auditLoading: false,
+      auditRow: null,
+      auditGroupId: null,
+      auditGroups: []
     }
   },
   mounted: function () {
@@ -56,11 +94,11 @@ export default {
       return val || '-'
     },
     statusType: function (status) {
-      var map = { pending: 'warning', paid: 'success', confirmed: 'success', cancelled: 'info' }
+      var map = { pending: 'warning', paid: 'success', reviewing: 'warning', confirmed: 'success', rejected: 'danger', cancelled: 'info' }
       return map[status] || 'info'
     },
     statusLabel: function (status) {
-      var map = { pending: '待支付', paid: '已支付', confirmed: '已确认', cancelled: '已取消' }
+      var map = { pending: '待支付', paid: '已支付', reviewing: '待审核', confirmed: '已确认', rejected: '已拒绝', cancelled: '已取消' }
       return map[status] || status || '-'
     },
     fetchList: function () {
@@ -77,6 +115,59 @@ export default {
       }).finally(function () {
         self.loading = false
       })
+    },
+    openAudit: function (row) {
+      var self = this
+      self.auditRow = row
+      self.auditGroupId = null
+      self.auditGroups = []
+      self.auditVisible = true
+      if (row.classId) {
+        classDetail(row.classId).then(function (res) {
+          var d = res.data || {}
+          self.auditGroups = d.groups || []
+        }).catch(function () {
+          self.auditGroups = []
+        })
+      }
+    },
+    confirmAudit: function () {
+      var self = this
+      if (!self.auditRow) return
+      self.auditLoading = true
+      var payload = {
+        enrollmentId: self.auditRow.id,
+        approved: true
+      }
+      if (self.auditGroupId) {
+        payload.groupId = self.auditGroupId
+      }
+      enrollmentAudit(payload).then(function (res) {
+        self.$message.success((res.data && res.data.message) || '审核通过')
+        self.auditVisible = false
+        self.fetchList()
+      }).catch(function (err) {
+        self.$message.error((err && err.message) || '审核失败')
+      }).finally(function () {
+        self.auditLoading = false
+      })
+    },
+    reject: function (row) {
+      var self = this
+      self.$confirm('确认拒绝该学员进班申请?', '提示', {
+        confirmButtonText: '拒绝',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(function () {
+        return enrollmentAudit({ enrollmentId: row.id, approved: false })
+      }).then(function () {
+        self.$message.success('已拒绝进班申请')
+        self.fetchList()
+      }).catch(function (err) {
+        if (err && err !== 'cancel') {
+          self.$message.error((err.message) || '操作失败')
+        }
+      })
     }
   }
 }