Browse Source

Phase3: 报名模块(后端接口+小程序报名页+后台报名管理)

liaoxg 2 weeks ago
parent
commit
1e073a0f0d

+ 211 - 0
train-backend/src/main/java/com/train/controller/EnrollmentController.java

@@ -0,0 +1,211 @@
+package com.train.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.cfc.entity.CfcActivity;
+import com.train.cfc.mapper.CfcActivityMapper;
+import com.train.dto.EnrollmentVO;
+import com.train.entity.TrainClass;
+import com.train.entity.TrainEnrollment;
+import com.train.entity.TrainInvite;
+import com.train.entity.TrainUser;
+import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainInviteMapper;
+import com.train.mapper.TrainUserMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.util.StringUtils;
+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.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "报名", description = "课程报名、我的报名")
+@RestController
+@RequestMapping("/api/enroll")
+public class EnrollmentController {
+
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+    @Resource
+    private TrainInviteMapper trainInviteMapper;
+    @Resource
+    private TrainUserMapper trainUserMapper;
+    @Resource
+    private CfcActivityMapper cfcActivityMapper;
+
+    /** 占用名额的报名状态 */
+    private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
+
+    /**
+     * 可报名班次列表(含剩余名额/价格)
+     */
+    @Operation(summary = "可报名班次列表")
+    @PostMapping("/classes")
+    public Result<List<Map<String, Object>>> listClasses(@RequestBody(required = false) Map<String, Object> body) {
+        List<TrainClass> classes = trainClassMapper.selectList(
+                new LambdaQueryWrapper<TrainClass>()
+                        .eq(TrainClass::getStatus, "active")
+                        .orderByAsc(TrainClass::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainClass c : classes) {
+            Long occupied = trainEnrollmentMapper.selectCount(
+                    new LambdaQueryWrapper<TrainEnrollment>()
+                            .eq(TrainEnrollment::getClassId, c.getId())
+                            .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", c.getId());
+            row.put("name", c.getName());
+            row.put("time", c.getTime());
+            row.put("place", c.getPlace());
+            row.put("capacity", c.getCapacity());
+            row.put("num", occupied == null ? 0L : occupied);
+            row.put("status", c.getStatus());
+            // 价格来自 cfc activities(分为单位,cfc 同库)
+            int price = 0;
+            int memberPrice = 0;
+            if (c.getActivityId() != null) {
+                CfcActivity activity = cfcActivityMapper.selectById(c.getActivityId());
+                if (activity != null) {
+                    price = activity.getPrice() == null ? 0 : activity.getPrice();
+                    memberPrice = activity.getMemberPrice() == null ? price : activity.getMemberPrice();
+                }
+            }
+            row.put("price", price);
+            row.put("memberPrice", memberPrice);
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 创建报名单(带邀请码则记录转介绍关系)
+     */
+    @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("请选择班次");
+        }
+        Long classId = Long.valueOf(classIdObj.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("班次不存在或不可报名");
+        }
+        // 容量校验(并发下允许少量超卖,最终以支付为准)
+        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"));
+        if (exists != null && exists > 0) {
+            return Result.error("您已报名该班次,请勿重复提交");
+        }
+
+        String inviteCode = body.get("inviteCode") == null ? "" : body.get("inviteCode").toString().trim();
+        TrainEnrollment e = new TrainEnrollment();
+        e.setUid(userId);
+        e.setClassId(classId);
+        e.setName(name);
+        e.setPhone(phone);
+        e.setBringLaptop(body.get("bringLaptop") != null ? Integer.valueOf(body.get("bringLaptop").toString()) : 0);
+        e.setDataReady(body.get("dataReady") == null ? null : body.get("dataReady").toString());
+        e.setTopProblems(body.get("topProblems") == null ? null : body.get("topProblems").toString());
+        e.setSource(StringUtils.hasText(inviteCode) ? "invite" : "scene");
+        e.setInviteCode(StringUtils.hasText(inviteCode) ? inviteCode : null);
+        e.setStatus("pending");
+        trainEnrollmentMapper.insert(e);
+
+        // 转介绍关系(幂等:uk_code_invitee 唯一键兜底)
+        if (StringUtils.hasText(inviteCode)) {
+            TrainUser inviter = trainUserMapper.selectOne(
+                    new LambdaQueryWrapper<TrainUser>()
+                            .eq(TrainUser::getInviteCode, inviteCode)
+                            .last("LIMIT 1"));
+            if (inviter != null && !inviter.getId().equals(userId)) {
+                Long invCount = trainInviteMapper.selectCount(
+                        new LambdaQueryWrapper<TrainInvite>()
+                                .eq(TrainInvite::getInviteCode, inviteCode)
+                                .eq(TrainInvite::getInviteeId, userId));
+                if (invCount == null || invCount == 0) {
+                    TrainInvite inv = new TrainInvite();
+                    inv.setInviterId(inviter.getId());
+                    inv.setInviteeId(userId);
+                    inv.setInviteCode(inviteCode);
+                    inv.setSuccessful(0);
+                    inv.setRewardStatus("pending");
+                    try {
+                        trainInviteMapper.insert(inv);
+                    } catch (Exception ex) {
+                        // 唯一键冲突(并发重复注册)可忽略
+                    }
+                }
+            }
+        }
+
+        EnrollmentVO vo = new EnrollmentVO();
+        vo.setId(e.getId());
+        vo.setClassId(classId);
+        vo.setClassName(trainClass.getName());
+        vo.setName(name);
+        vo.setPhone(phone);
+        vo.setSource(e.getSource());
+        vo.setInviteCode(e.getInviteCode());
+        vo.setStatus(e.getStatus());
+        return Result.success(vo);
+    }
+
+    /**
+     * 我的报名单列表
+     */
+    @Operation(summary = "我的报名单列表")
+    @PostMapping("/mine")
+    public Result<List<Map<String, Object>>> mine(@RequestAttribute("userId") Long userId) {
+        List<TrainEnrollment> list = trainEnrollmentMapper.selectList(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, userId)
+                        .orderByDesc(TrainEnrollment::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainEnrollment e : list) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", e.getId());
+            row.put("classId", e.getClassId());
+            TrainClass c = e.getClassId() == null ? null : trainClassMapper.selectById(e.getClassId());
+            row.put("className", c != null ? c.getName() : null);
+            row.put("name", e.getName());
+            row.put("phone", e.getPhone());
+            row.put("source", e.getSource());
+            row.put("inviteCode", e.getInviteCode());
+            row.put("status", e.getStatus());
+            row.put("createdAt", e.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+}

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

@@ -0,0 +1,74 @@
+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.TrainEnrollment;
+import com.train.entity.TrainUser;
+import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainUserMapper;
+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.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-报名/订单/转介", description = "报名单、订单、转介绍管理")
+@RestController
+@RequestMapping("/api/admin")
+public class AdminBizController {
+
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainUserMapper trainUserMapper;
+
+    /**
+     * 报名单列表(支持按班次/状态筛选)
+     */
+    @Operation(summary = "报名单列表")
+    @PostMapping("/enrollment/list")
+    public Result<List<Map<String, Object>>> enrollmentList(@RequestBody(required = false) Map<String, Object> body) {
+        LambdaQueryWrapper<TrainEnrollment> qw = new LambdaQueryWrapper<>();
+        if (body != null && body.get("classId") != null) {
+            qw.eq(TrainEnrollment::getClassId, Long.valueOf(body.get("classId").toString()));
+        }
+        if (body != null && body.get("status") != null
+                && !body.get("status").toString().trim().isEmpty()) {
+            qw.eq(TrainEnrollment::getStatus, body.get("status").toString());
+        }
+        qw.orderByDesc(TrainEnrollment::getId);
+        List<TrainEnrollment> list = trainEnrollmentMapper.selectList(qw);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainEnrollment e : list) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", e.getId());
+            row.put("classId", e.getClassId());
+            TrainClass c = e.getClassId() == null ? null : trainClassMapper.selectById(e.getClassId());
+            row.put("className", c != null ? c.getName() : null);
+            row.put("uid", e.getUid());
+            TrainUser u = e.getUid() == null ? null : trainUserMapper.selectById(e.getUid());
+            row.put("userName", u != null ? u.getName() : null);
+            row.put("name", e.getName());
+            row.put("phone", e.getPhone());
+            row.put("bringLaptop", e.getBringLaptop());
+            row.put("dataReady", e.getDataReady());
+            row.put("source", e.getSource());
+            row.put("inviteCode", e.getInviteCode());
+            row.put("status", e.getStatus());
+            row.put("createdAt", e.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+}

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

@@ -0,0 +1,23 @@
+package com.train.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 报名单视图对象(enroll/create 返回)
+ */
+@Data
+public class EnrollmentVO implements Serializable {
+
+    private Long id;
+    private Long classId;
+    private String className;
+    private String name;
+    private String phone;
+    private String source; // scene/invite
+    private String inviteCode;
+    private String status; // pending/paid/confirmed/cancelled
+    private Date createdAt;
+}

+ 12 - 0
train-frontend/pages.json

@@ -90,6 +90,18 @@
       "style": {
         "navigationBarTitleText": "兴趣调研"
       }
+    },
+    {
+      "path": "pages/enroll/list",
+      "style": {
+        "navigationBarTitleText": "课程报名"
+      }
+    },
+    {
+      "path": "pages/enroll/form",
+      "style": {
+        "navigationBarTitleText": "报名信息"
+      }
     }
   ],
   "globalStyle": {

+ 150 - 0
train-frontend/pages/enroll/form.vue

@@ -0,0 +1,150 @@
+<template>
+  <view class="form-page">
+    <view class="section-card">
+      <text class="section-title">报名信息</text>
+      <text class="card-desc">请填写您的联系方式与准备情况</text>
+
+      <view class="form-item">
+        <text class="form-label">姓名</text>
+        <input class="form-input" v-model="form.name" placeholder="请输入真实姓名" />
+      </view>
+      <view class="form-item">
+        <text class="form-label">手机号</text>
+        <input class="form-input" v-model="form.phone" type="number" maxlength="11" placeholder="请输入手机号" />
+      </view>
+      <view class="form-item switch-item">
+        <text class="form-label">是否携带笔记本</text>
+        <switch :checked="form.bringLaptop === 1" color="#F97316" @change="onLaptopChange" />
+      </view>
+      <view class="form-item">
+        <text class="form-label">已备数据</text>
+        <view class="check-group">
+          <view class="check-item" @click="toggleData('wealth')">
+            <text class="check-square" :class="{ checked: dataReadyList.indexOf('wealth') > -1 }">✓</text>
+            <text class="check-text">券商持仓</text>
+          </view>
+          <view class="check-item" @click="toggleData('health')">
+            <text class="check-square" :class="{ checked: dataReadyList.indexOf('health') > -1 }">✓</text>
+            <text class="check-text">体检/血压报告</text>
+          </view>
+          <view class="check-item" @click="toggleData('growth')">
+            <text class="check-square" :class="{ checked: dataReadyList.indexOf('growth') > -1 }">✓</text>
+            <text class="check-text">孩子成绩/日程</text>
+          </view>
+        </view>
+      </view>
+      <view class="form-item">
+        <text class="form-label">最想解决的问题</text>
+        <textarea class="form-textarea" v-model="form.topProblems" placeholder="如:如何用AI管理家庭资产、孩子学习计划…" />
+      </view>
+      <view class="form-item" v-if="form.inviteCode">
+        <text class="form-label">邀请码</text>
+        <input class="form-input" v-model="form.inviteCode" disabled placeholder="微信好友邀请码" />
+      </view>
+
+      <button class="submit-btn" @click="handleSubmit" :loading="loading" :disabled="loading">提交报名</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { createEnrollment } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      form: {
+        classId: '',
+        name: '',
+        phone: '',
+        bringLaptop: 0,
+        dataReady: '',
+        topProblems: '',
+        inviteCode: ''
+      },
+      dataReadyList: [],
+      loading: false
+    }
+  },
+  onLoad(options) {
+    this.form.classId = options && options.classId ? options.classId : ''
+    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' })
+      setTimeout(function() {
+        uni.navigateBack({ delta: 1 })
+      }, 1500)
+    }
+  },
+  methods: {
+    onLaptopChange(e) {
+      this.form.bringLaptop = e.detail.value ? 1 : 0
+    },
+    toggleData(key) {
+      var idx = this.dataReadyList.indexOf(key)
+      if (idx > -1) {
+        this.dataReadyList.splice(idx, 1)
+      } else {
+        this.dataReadyList.push(key)
+      }
+      this.form.dataReady = this.dataReadyList.join(',')
+    },
+    handleSubmit() {
+      var name = (this.form.name || '').trim()
+      var phone = (this.form.phone || '').trim()
+      if (!name) {
+        uni.showToast({ title: '请填写姓名', icon: 'none' })
+        return
+      }
+      if (!/^1\d{10}$/.test(phone)) {
+        uni.showToast({ title: '请填写正确的手机号', icon: 'none' })
+        return
+      }
+      if (this.loading) return
+      this.loading = true
+      var self = this
+      var payload = {
+        classId: self.form.classId,
+        name: name,
+        phone: phone,
+        bringLaptop: self.form.bringLaptop,
+        dataReady: self.form.dataReady,
+        topProblems: (self.form.topProblems || '').trim(),
+        inviteCode: self.form.inviteCode
+      }
+      createEnrollment(payload).then(function(resp) {
+        uni.showToast({ title: '报名成功', icon: 'success' })
+        setTimeout(function() {
+          uni.redirectTo({ url: '/pages/enroll/list' })
+        }, 1200)
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '报名失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.form-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; }
+.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: 32rpx; }
+.form-item { margin-bottom: 24rpx; }
+.form-label { display: block; font-size: 26rpx; color: #475569; font-weight: 500; margin-bottom: 12rpx; }
+.form-input { height: 72rpx; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 0 16rpx; font-size: 28rpx; color: #1E293B; background: #F8FAFC; }
+.form-textarea { width: 100%; height: 160rpx; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 16rpx; font-size: 28rpx; color: #1E293B; background: #F8FAFC; box-sizing: border-box; }
+.check-group { display: flex; flex-wrap: wrap; }
+.check-item { display: flex; align-items: center; width: 50%; margin-bottom: 16rpx; }
+.check-square { width: 36rpx; height: 36rpx; border: 2rpx solid #CBD5E1; border-radius: 6rpx; display: flex; align-items: center; justify-content: center; font-size: 24rpx; color: #FFF; margin-right: 12rpx; flex-shrink: 0; }
+.check-square.checked { background: #F97316; border-color: #F97316; }
+.check-text { font-size: 26rpx; color: #1E293B; }
+.switch-item { display: flex; align-items: center; justify-content: space-between; }
+.switch-item .form-label { margin-bottom: 0; }
+.submit-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #F97316; color: #FFF; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; margin-top: 24rpx; }
+.submit-btn:active { opacity: 0.85; }
+</style>

+ 129 - 0
train-frontend/pages/enroll/list.vue

@@ -0,0 +1,129 @@
+<template>
+  <view class="enroll-page">
+    <view class="section-card">
+      <text class="section-title">可报名班次</text>
+      <text class="card-desc">选择您想参加的班次,填写报名信息</text>
+      <view v-if="loading" class="empty-tip">加载中…</view>
+      <view v-else-if="classList.length === 0" class="empty-tip">暂无可报名班次</view>
+      <view v-else>
+        <view class="class-card" v-for="item in classList" :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.id)">立即报名</button>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="section-card">
+      <text class="section-title">我的报名</text>
+      <view v-if="mineLoading" class="empty-tip">加载中…</view>
+      <view v-else-if="mineList.length === 0" class="empty-tip">暂无报名记录</view>
+      <view v-else>
+        <view class="mine-item" v-for="item in mineList" :key="item.id">
+          <view class="mine-head">
+            <text class="mine-class">{{ item.className || ('班次' + item.classId) }}</text>
+            <text class="mine-status">{{ statusLabel(item.status) }}</text>
+          </view>
+          <text class="mine-time" v-if="item.createdAt">报名时间:{{ formatDateTime(item.createdAt) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getEnrollClasses, getMyEnrollments } from '@/utils/api.js'
+import { fenToYuan, formatDateTime } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      classList: [],
+      mineList: [],
+      loading: false,
+      mineLoading: false
+    }
+  },
+  onShow() {
+    this.loadClasses()
+    this.loadMine()
+  },
+  methods: {
+    fenToYuan: fenToYuan,
+    formatDateTime: formatDateTime,
+    loadClasses() {
+      var self = this
+      self.loading = true
+      getEnrollClasses().then(function(resp) {
+        self.classList = resp.data || []
+      }).catch(function() {
+        uni.showToast({ title: '加载班次失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    loadMine() {
+      var self = this
+      self.mineLoading = true
+      getMyEnrollments().then(function(resp) {
+        self.mineList = resp.data || []
+      }).catch(function() {}).finally(function() {
+        self.mineLoading = false
+      })
+    },
+    isFull(item) {
+      return item.capacity != null && item.num != null && item.num >= item.capacity
+    },
+    statusLabel(status) {
+      var map = { pending: '待支付', paid: '已支付', confirmed: '已确认', cancelled: '已取消' }
+      return map[status] || status || '未知'
+    },
+    goForm(classId) {
+      var userInfo = uni.getStorageSync('userInfo') || {}
+      uni.navigateTo({ url: '/pages/enroll/form?classId=' + classId + '&name=' + (userInfo.name || '') + '&phone=' + (userInfo.phone || '') })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.enroll-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+.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; }
+.class-card { border: 2rpx solid #E2E8F0; border-radius: 12rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.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; }
+.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; }
+.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; }
+.mine-class { font-size: 28rpx; color: #1E293B; font-weight: 500; }
+.mine-status { font-size: 24rpx; color: #F97316; }
+.mine-time { font-size: 24rpx; color: #94A3B8; }
+</style>

+ 5 - 0
train-frontend/pages/mine/index.vue

@@ -10,6 +10,11 @@
     </view>
 
     <view class="menu-card">
+      <view class="menu-item" @click="goTo('/pages/enroll/list')">
+        <text class="menu-icon">🎫</text>
+        <text class="menu-text">报名中心</text>
+        <text class="menu-arrow">›</text>
+      </view>
       <view class="menu-item" @click="goTo('/pages/group/index')">
         <text class="menu-icon">👥</text>
         <text class="menu-text">我的小组</text>

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

@@ -198,6 +198,17 @@ export const getMyCoupons = () => {
   return request('/api/plan/coupons', 'POST')
 }
 
+// 报名
+export const getEnrollClasses = () => {
+  return request('/api/enroll/classes', 'POST')
+}
+export const createEnrollment = (data) => {
+  return request('/api/enroll/create', 'POST', data)
+}
+export const getMyEnrollments = () => {
+  return request('/api/enroll/mine', 'POST')
+}
+
 // 文件上传
 export const uploadFile = (filePath) => {
   return new Promise((resolve, reject) => {

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

@@ -0,0 +1,5 @@
+import request from '@/utils/request'
+
+export function enrollmentList(data) {
+  return request.post('/api/admin/enrollment/list', data || {})
+}

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

@@ -28,6 +28,12 @@ const routes = [
         component: () => import('@/views/Classes.vue'),
         meta: { title: '班级管理' }
       },
+      {
+        path: 'enrollments',
+        name: 'Enrollments',
+        component: () => import('@/views/Enrollments.vue'),
+        meta: { title: '报名管理' }
+      },
       {
         path: 'groups',
         name: 'Groups',

+ 107 - 0
train-web/src/views/Enrollments.vue

@@ -0,0 +1,107 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">报名单管理</h2>
+      <div class="filter-bar">
+        <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="confirmed" />
+          <el-option label="已取消" value="cancelled" />
+        </el-select>
+        <el-button type="primary" icon="el-icon-refresh" @click="fetchList">刷新</el-button>
+      </div>
+    </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="className" label="班次" min-width="140" />
+      <el-table-column prop="name" label="姓名" width="100" />
+      <el-table-column prop="phone" label="手机号" width="130" />
+      <el-table-column label="来源" width="90">
+        <template slot-scope="scope">
+          <span>{{ scope.row.source === 'invite' ? '转介绍' : '自主' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="inviteCode" label="邀请码" width="110" />
+      <el-table-column label="状态" width="90">
+        <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>
+  </div>
+</template>
+
+<script>
+import { enrollmentList } from '@/api/enroll'
+
+export default {
+  name: 'Enrollments',
+  data: function () {
+    return {
+      list: [],
+      loading: false,
+      filters: {
+        status: ''
+      }
+    }
+  },
+  mounted: function () {
+    this.fetchList()
+  },
+  methods: {
+    formatTime: function (row, col, val) {
+      return val || '-'
+    },
+    statusType: function (status) {
+      var map = { pending: 'warning', paid: 'success', confirmed: 'success', cancelled: 'info' }
+      return map[status] || 'info'
+    },
+    statusLabel: function (status) {
+      var map = { pending: '待支付', paid: '已支付', confirmed: '已确认', cancelled: '已取消' }
+      return map[status] || status || '-'
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      var payload = {}
+      if (self.filters.status) {
+        payload.status = self.filters.status
+      }
+      enrollmentList(payload).then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取报名单列表失败')
+      }).finally(function () {
+        self.loading = 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;
+}
+
+.filter-bar {
+  display: flex;
+  align-items: center;
+}
+
+.filter-bar .el-select {
+  margin-right: 12px;
+}
+</style>

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

@@ -19,6 +19,10 @@
             <i class="el-icon-s-grid"></i>
             <span slot="title">班级管理</span>
           </el-menu-item>
+          <el-menu-item index="/enrollments">
+            <i class="el-icon-tickets"></i>
+            <span slot="title">报名管理</span>
+          </el-menu-item>
           <el-menu-item index="/groups">
             <i class="el-icon-s-group"></i>
             <span slot="title">分组管理</span>