Forráskód Böngészése

PhaseE3: 学员管理强化-新增详情接口与前端详情弹窗(校友校验列+报名单+卡券+证书进度)

liaoxg 1 hete
szülő
commit
5371fdae12

+ 43 - 1
train-backend/src/main/java/com/train/controller/admin/AdminClassController.java

@@ -6,6 +6,7 @@ import com.train.entity.*;
 import com.train.mapper.*;
 import com.train.cfc.entity.CfcActivity;
 import com.train.cfc.mapper.CfcActivityMapper;
+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.*;
@@ -13,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
 import java.util.*;
 
-@Tag(name = "管理端-班级与分组", description = "班级管理、分组管理")
+@Tag(name = "管理端-班级与分组", description = "班级管理、分组管理、学员管理")
 @RestController
 @RequestMapping("/api/admin")
 public class AdminClassController {
@@ -31,7 +32,11 @@ public class AdminClassController {
     @Resource
     private TrainEnrollmentMapper trainEnrollmentMapper;
     @Resource
+    private TrainCouponMapper trainCouponMapper;
+    @Resource
     private CfcActivityMapper cfcActivityMapper;
+    @Resource
+    private CertService certService;
 
     // ============ 班级 ============
     @Operation(summary = "创建班级")
@@ -179,6 +184,43 @@ public class AdminClassController {
         return Result.success(true);
     }
 
+    /**
+     * 学员详情:基本信息 + 报名单 + 卡券 + 完课证书进度(CERT 六项校验)。
+     * D3 学员管理强化:进度追踪/卡券管理/证书查看一次拉齐,前端弹窗展示。
+     */
+    @Operation(summary = "学员详情(基本信息+报名单+卡券+证书进度)")
+    @PostMapping("/user/detail")
+    public Result<Map<String, Object>> userDetail(@RequestBody Map<String, Object> body) {
+        if (body == null || body.get("uid") == null) {
+            return Result.error("缺少学员ID");
+        }
+        Long uid = Long.valueOf(body.get("uid").toString());
+        TrainUser user = trainUserMapper.selectById(uid);
+        if (user == null) {
+            return Result.error("学员不存在");
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("user", user);
+        // 报名单(倒序:最新在前)
+        data.put("enrollments", trainEnrollmentMapper.selectList(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, uid)
+                        .orderByDesc(TrainEnrollment::getId)));
+        // 卡券列表(未用在前,按发放倒序)
+        data.put("coupons", trainCouponMapper.selectList(
+                new LambdaQueryWrapper<TrainCoupon>()
+                        .eq(TrainCoupon::getUid, uid)
+                        .orderByDesc(TrainCoupon::getGrantedAt)));
+        // 完课证书进度:基于 CertService 六项校验,仅读不落审计
+        List<Map<String, Object>> certItems = certService.checkItemsOf(uid);
+        boolean eligible = certItems.stream().allMatch(i -> Boolean.TRUE.equals(i.get("ok")));
+        Map<String, Object> cert = new HashMap<>();
+        cert.put("eligible", eligible);
+        cert.put("items", certItems);
+        data.put("cert", cert);
+        return Result.success(data);
+    }
+
     // ============ 调班(助教管理能力) ============
     @Operation(summary = "调整班级(学员调班/退班)")
     @PostMapping("/user/class")

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

@@ -7,3 +7,8 @@ export function userList(data) {
 export function setUserStatus(data) {
   return request.post('/api/admin/user/status', data)
 }
+
+// 学员详情(基本信息+报名单+卡券+证书进度)
+export function userDetail(uid) {
+  return request.post('/api/admin/user/detail', { uid })
+}

+ 170 - 20
train-web/src/views/Users.vue

@@ -34,13 +34,19 @@
           {{ classNameOf(row.classId) }}
         </template>
       </el-table-column>
+      <el-table-column label="校友校验" width="100">
+        <template slot-scope="{ row }">
+          <el-tag :type="alumniType(row.alumniVerify)" size="small">{{ alumniLabel(row.alumniVerify) }}</el-tag>
+        </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="220" fixed="right">
+      <el-table-column label="操作" width="280" fixed="right">
         <template slot-scope="{ row }">
+          <el-button size="mini" type="primary" plain @click="openDetail(row)">详情</el-button>
           <el-button size="mini" @click="toggleStatus(row)">{{ row.status === 'active' ? '禁用' : '激活' }}</el-button>
           <el-button size="mini" type="warning" @click="openMoveClass(row)">调整班级</el-button>
         </template>
@@ -57,28 +63,105 @@
        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>
+<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>
+
+      <!-- 学员详情弹窗 -->
+      <el-dialog :title="'学员详情 - ' + (detailUser && detailUser.name ? detailUser.name : '')" :visible.sync="detailVisible" width="820px" top="6vh">
+        <div v-loading="detailLoading">
+          <div class="detail-section" v-if="detailUser">
+            <h4>基本信息</h4>
+            <el-descriptions :column="3" size="small" border>
+              <el-descriptions-item label="用户ID">{{ detailUser.id }}</el-descriptions-item>
+              <el-descriptions-item label="姓名">{{ detailUser.name }}</el-descriptions-item>
+              <el-descriptions-item label="手机">{{ detailUser.phone || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="角色">{{ roleLabel[detailUser.role] || detailUser.role || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="班级">{{ classNameOf(detailUser.classId) }}</el-descriptions-item>
+              <el-descriptions-item label="分组">{{ detailUser.groupName || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="校友校验">
+                <el-tag :type="alumniType(detailUser.alumniVerify)" size="small">{{ alumniLabel(detailUser.alumniVerify) }}</el-tag>
+              </el-descriptions-item>
+              <el-descriptions-item label="状态">
+                <el-tag :type="detailUser.status === 'active' ? 'success' : 'danger'" size="small">{{ detailUser.status === 'active' ? '已激活' : '已禁用' }}</el-tag>
+              </el-descriptions-item>
+              <el-descriptions-item label="邀请码">{{ detailUser.inviteCode || '-' }}</el-descriptions-item>
+            </el-descriptions>
+          </div>
+
+          <div class="detail-section">
+            <h4>报名单</h4>
+            <el-table :data="detailEnrollments" size="small" border stripe style="width:100%">
+              <el-table-column prop="id" label="ID" width="70" />
+              <el-table-column prop="courseId" label="课程ID" width="80" />
+              <el-table-column label="状态" width="110">
+                <template slot-scope="{ row }">
+                  <el-tag :type="enrollStatusType(row.status)" size="small">{{ enrollStatusLabel(row.status) }}</el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column prop="createdAt" label="报名时间" width="160" />
+              <el-table-column prop="source" label="来源" width="80">
+                <template slot-scope="{ row }">
+                  {{ row.source === 'invite' ? '邀请' : '场景' }}
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+
+          <div class="detail-section">
+            <h4>卡券</h4>
+            <el-table :data="detailCoupons" size="small" border stripe style="width:100%">
+              <el-table-column prop="id" label="ID" width="70" />
+              <el-table-column prop="type" label="类型" width="90" />
+              <el-table-column prop="code" label="卡券码" min-width="130" />
+              <el-table-column label="状态" width="90">
+                <template slot-scope="{ row }">
+                  <el-tag :type="couponStatusType(row.status)" size="small">{{ couponStatusLabel(row.status) }}</el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column prop="trigger" label="来源" width="90" />
+              <el-table-column prop="grantedAt" label="发放时间" width="160" />
+            </el-table>
+          </div>
+
+          <div class="detail-section">
+            <h4>完课证书进度(离场验收六项)</h4>
+            <div v-if="detailCert">
+              <el-alert :title="detailCert.eligible ? '已达标:可领取完课证书' : '未达标:完成以下全部项目后可领取证书'" :type="detailCert.eligible ? 'success' : 'warning'" :closable="false" show-icon style="margin-bottom:10px" />
+              <el-table :data="detailCert.items || []" size="small" border stripe style="width:100%">
+                <el-table-column prop="label" label="校验项" min-width="200" />
+                <el-table-column label="状态" width="120">
+                  <template slot-scope="{ row }">
+                    <el-tag :type="row.ok ? 'success' : 'info'" size="small">{{ row.ok ? '已完成' : '未完成' }}</el-tag>
+                  </template>
+                </el-table-column>
+              </el-table>
+            </div>
+          </div>
+        </div>
+        <span slot="footer">
+          <el-button @click="detailVisible = false">关 闭</el-button>
+        </span>
+      </el-dialog>
    </div>
  </template>
 
 <script>
-import { userList, setUserStatus } from '@/api/user'
+import { userList, setUserStatus, userDetail } from '@/api/user'
 import { classList, moveUserClass } from '@/api/class'
 
 export default {
@@ -97,7 +180,13 @@ export default {
       moveUserName: '',
       moveSubmitting: false,
       roleLabel: { student: '学员', assistant: '助教', lecturer: '讲师', admin: '管理员' },
-      roleTagMap: { student: 'info', assistant: 'warning', lecturer: '', admin: 'danger' }
+      roleTagMap: { student: 'info', assistant: 'warning', lecturer: '', admin: 'danger' },
+      detailVisible: false,
+      detailLoading: false,
+      detailUser: null,
+      detailEnrollments: [],
+      detailCoupons: [],
+      detailCert: null
     }
   },
   mounted: function () {
@@ -119,6 +208,54 @@ export default {
       })
       return c ? c.name : '-'
     },
+    alumniType: function (v) {
+      if (v === 'accepted') return 'success'
+      if (v === 'pending') return 'warning'
+      if (v === 'rejected') return 'danger'
+      return 'info'
+    },
+    alumniLabel: function (v) {
+      if (v === 'accepted') return '已认证'
+      if (v === 'pending') return '待认证'
+      if (v === 'rejected') return '未通过'
+      return '未校验'
+    },
+    enrollStatusType: function (s) {
+      var map = { confirmed: 'success', paid: '', reviewing: 'warning', pending: 'info', rejected: 'danger', cancelled: 'info' }
+      return map[s] || 'info'
+    },
+    enrollStatusLabel: function (s) {
+      var map = { pending: '待支付', paid: '已支付', reviewing: '进班待审核', confirmed: '已进班', rejected: '进班被拒', cancelled: '已取消' }
+      return map[s] || s || '-'
+    },
+    couponStatusType: function (s) {
+      var map = { unused: 'success', used: 'info', expired: 'danger' }
+      return map[s] || 'info'
+    },
+    couponStatusLabel: function (s) {
+      var map = { unused: '未使用', used: '已使用', expired: '已过期' }
+      return map[s] || s || '-'
+    },
+    openDetail: function (row) {
+      var self = this
+      self.detailVisible = true
+      self.detailLoading = true
+      self.detailUser = row
+      self.detailEnrollments = []
+      self.detailCoupons = []
+      self.detailCert = null
+      userDetail(row.id).then(function (res) {
+        var data = res.data || {}
+        self.detailUser = data.user || row
+        self.detailEnrollments = data.enrollments || []
+        self.detailCoupons = data.coupons || []
+        self.detailCert = data.cert || null
+      }).catch(function () {
+        self.$message.error('获取学员详情失败')
+      }).finally(function () {
+        self.detailLoading = false
+      })
+    },
     fetchList: function () {
       var self = this
       self.loading = true
@@ -198,4 +335,17 @@ export default {
   margin-top: 8px;
   line-height: 1.4;
 }
+
+.detail-section {
+  margin-bottom: 20px;
+}
+
+.detail-section h4 {
+  margin: 0 0 10px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  border-left: 3px solid #409eff;
+  padding-left: 8px;
+}
 </style>