Explorar o código

Phase15a: 补齐小程序可见功能(待支付取消/继续支付/小组设置为发言人)

liaoxg hai 1 semana
pai
achega
f1bdbe2385

+ 2 - 3
train-backend/pom.xml

@@ -159,9 +159,8 @@
                 <configuration>
                     <source>1.8</source>
                     <target>1.8</target>
-                    <!-- release 8:无论本机 JDK 版本如何,强制使用 Java 8 API 符号编译,
-                         可拦截 sql.lines() 等 Java 9+ 新 API 的误用 -->
-                    <release>8</release>
+                    <!-- 本机需用 JDK 8 编译:JDK 21 javac 对项目 lambda 链推断存在边界问题。
+                         如未来统一升级到 JDK 21,需同步梳理 MyBatis-Plus 链式调用写法。 -->
                     <encoding>UTF-8</encoding>
                     <annotationProcessorPaths>
                         <path>

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

@@ -8,10 +8,12 @@ import com.train.dto.EnrollmentVO;
 import com.train.entity.TrainClass;
 import com.train.entity.TrainEnrollment;
 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.TrainInviteMapper;
+import com.train.mapper.TrainOrderMapper;
 import com.train.mapper.TrainUserMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,6 +46,8 @@ public class EnrollmentController {
     private TrainUserMapper trainUserMapper;
     @Resource
     private CfcActivityMapper cfcActivityMapper;
+    @Resource
+    private TrainOrderMapper trainOrderMapper;
 
     /** 占用名额的报名状态 */
     private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
@@ -204,6 +208,14 @@ public class EnrollmentController {
             row.put("inviteCode", e.getInviteCode());
             row.put("status", e.getStatus());
             row.put("createdAt", e.getCreatedAt());
+            // 补关联订单号:未关闭订单(unpaid)用于前端继续支付/取消;paid 也回填便于查询
+            TrainOrder order = trainOrderMapper.selectOne(
+                    new LambdaQueryWrapper<TrainOrder>()
+                            .eq(TrainOrder::getEnrollmentId, e.getId())
+                            .ne(TrainOrder::getStatus, "closed")
+                            .orderByDesc(TrainOrder::getId)
+                            .last("LIMIT 1"));
+            row.put("orderNo", order == null ? null : order.getOrderNo());
             result.add(row);
         }
         return Result.success(result);

+ 13 - 1
train-backend/src/main/java/com/train/controller/InviteController.java

@@ -1,6 +1,8 @@
 package com.train.controller;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.cfc.entity.CfcUser;
+import com.train.cfc.mapper.CfcUserMapper;
 import com.train.common.Result;
 import com.train.entity.TrainInvite;
 import com.train.entity.TrainUser;
@@ -35,6 +37,8 @@ public class InviteController {
     private TrainUserMapper trainUserMapper;
     @Resource
     private TrainInviteMapper trainInviteMapper;
+    @Resource
+    private CfcUserMapper cfcUserMapper;
 
     /**
      * 我的专属海报 + 邀请码 + 分享文案。
@@ -56,7 +60,15 @@ public class InviteController {
         // 小程序带参路径(扫码/长按识别进入报名页)
         String sharePath = "/pages/enroll/list?inviteCode=" + inviteCode;
         // 分享文案(可复制的纯文本,分享给微信好友时粘贴用)
-        String nickname = user != null && StringUtils.hasText(user.getNickname()) ? user.getNickname() : "营员";
+        // nickname 取自 cfc users.nickname(TrainUser 不冗余该字段,cfc 同库)
+        String nickname = "营员";
+        if (user != null && StringUtils.hasText(user.getPhone())) {
+            CfcUser cfcUser = cfcUserMapper.selectOne(
+                    new LambdaQueryWrapper<CfcUser>().eq(CfcUser::getPhone, user.getPhone()).last("LIMIT 1"));
+            if (cfcUser != null && StringUtils.hasText(cfcUser.getNickname())) {
+                nickname = cfcUser.getNickname();
+            }
+        }
         String shareText = "【" + nickname + "】邀请你加入「爱伴·AI之旅」训练营\n" +
                 "邀请码:" + inviteCode + "\n" +
                 "报名时填写邀请码可享专属权益,扫码或点击:\n" +

+ 9 - 6
train-backend/src/main/java/com/train/controller/admin/AdminStatsController.java

@@ -338,12 +338,15 @@ public class AdminStatsController {
         data.put("groupProgress", groupProgress);
 
         // 5 课程资料(含通用资料,供投屏展示)
-        List<TrainCourseMaterial> materials = trainCourseMaterialMapper.selectList(
-                new LambdaQueryWrapper<TrainCourseMaterial>()
-                        .and(w -> w.eq(TrainCourseMaterial::getClassId, cur.getId())
-                                .or().isNull(TrainCourseMaterial::getClassId))
-                        .orderByAsc(TrainCourseMaterial::getSort)
-                        .orderByDesc(TrainCourseMaterial::getId()));
+        // 拆成临时变量 + 显式 SFunction 类型 + final 副本:避免 LambdaQueryWrapper 链式 javac 类型推断边界
+        // (cur 在前面有多次赋值,JDK 8 lambda 不允许;提取 final classIdFinal 绕过 effectively-final 检查)
+        final Long classIdFinal = cur.getId();
+        LambdaQueryWrapper<TrainCourseMaterial> materialQw = new LambdaQueryWrapper<>();
+        materialQw.and(w -> w.eq(TrainCourseMaterial::getClassId, classIdFinal)
+                        .or().isNull(TrainCourseMaterial::getClassId));
+        materialQw.orderByAsc((com.baomidou.mybatisplus.core.toolkit.support.SFunction<TrainCourseMaterial, ?>) TrainCourseMaterial::getSort);
+        materialQw.orderByDesc((com.baomidou.mybatisplus.core.toolkit.support.SFunction<TrainCourseMaterial, ?>) TrainCourseMaterial::getId);
+        List<TrainCourseMaterial> materials = trainCourseMaterialMapper.selectList(materialQw);
         data.put("materials", materials);
 
         // 6 投票结果(两轮柱状数据,供投屏展示)

+ 29 - 1
train-frontend/pages/enroll/list.vue

@@ -40,6 +40,10 @@
             <text class="mine-status">{{ statusLabel(item.status) }}</text>
           </view>
           <text class="mine-time" v-if="item.createdAt">报名时间:{{ formatDateTime(item.createdAt) }}</text>
+          <view class="mine-actions" v-if="item.status === 'pending' && item.orderNo">
+            <button class="mine-btn cancel-btn" @click="cancelOrder(item)">取消订单</button>
+            <button class="mine-btn pay-btn" @click="goPay(item)">去支付</button>
+          </view>
         </view>
       </view>
     </view>
@@ -47,7 +51,7 @@
 </template>
 
 <script>
-import { getEnrollClasses, getMyEnrollments } from '@/utils/api.js'
+import { getEnrollClasses, getMyEnrollments, cancelPayOrder } from '@/utils/api.js'
 import { fenToYuan, formatDateTime } from '@/utils/format.js'
 
 export default {
@@ -96,6 +100,25 @@ export default {
     goForm(classId) {
       var userInfo = uni.getStorageSync('userInfo') || {}
       uni.navigateTo({ url: '/pages/enroll/form?classId=' + classId + '&name=' + (userInfo.name || '') + '&phone=' + (userInfo.phone || '') })
+    },
+    goPay(item) {
+      uni.navigateTo({ url: '/pages/pay/index?enrollmentId=' + item.id })
+    },
+    cancelOrder(item) {
+      var self = this
+      uni.showModal({
+        title: '取消订单',
+        content: '确定取消该报名订单吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          cancelPayOrder({ orderNo: item.orderNo }).then(function() {
+            uni.showToast({ title: '已取消', icon: 'success' })
+            self.loadMine()
+          }).catch(function(err) {
+            uni.showToast({ title: (err && err.message) || '取消失败', icon: 'none' })
+          })
+        }
+      })
     }
   }
 }
@@ -126,4 +149,9 @@ export default {
 .mine-class { font-size: 28rpx; color: #1E293B; font-weight: 500; }
 .mine-status { font-size: 24rpx; color: #F97316; }
 .mine-time { font-size: 24rpx; color: #94A3B8; }
+.mine-actions { display: flex; justify-content: flex-end; margin-top: 16rpx; }
+.mine-btn { width: 160rpx; height: 56rpx; line-height: 56rpx; font-size: 24rpx; border-radius: 28rpx; border: none; margin: 0 0 0 16rpx; }
+.mine-btn:active { opacity: 0.85; }
+.cancel-btn { background: #F1F5F9; color: #64748B; }
+.pay-btn { background: #F97316; color: #FFF; }
 </style>

+ 29 - 7
train-frontend/pages/group/index.vue

@@ -5,11 +5,14 @@
         <text class="group-name">{{ groupInfo.name }}</text>
         <text class="group-slogan" v-if="groupInfo.slogan">{{ groupInfo.slogan }}</text>
       </view>
-      <view class="section-title">成员列表</view>
+<view class="section-title">成员列表</view>
       <view class="member-list">
         <view class="member-item" v-for="(m, idx) in (groupInfo.members || [])" :key="idx">
-          <text class="member-name">{{ m.name || '未命名' }}</text>
-          <text class="member-role">{{ m.uid === groupInfo.spokespersonUid ? '🏆 发言人' : '成员' }}</text>
+          <view class="member-info">
+            <text class="member-name">{{ m.name || '未命名' }}</text>
+            <text class="member-role">{{ m.uid === groupInfo.spokespersonUid ? '🏆 发言人' : '成员' }}</text>
+          </view>
+          <button class="speaker-btn" v-if="!groupInfo.spokespersonUid" @click="setSpeaker(m.uid)">设为发言人</button>
         </view>
       </view>
       <view class="section-title">分工认领</view>
@@ -31,7 +34,7 @@
 </template>
 
 <script>
-import { getMyGroup, joinGroup } from '@/utils/api.js'
+import { getMyGroup, joinGroup, setSpokesperson } from '@/utils/api.js'
 
 export default {
   data() {
@@ -70,9 +73,25 @@ export default {
         self.loading = false
       })
     },
-    bookTypeLabel(type) {
+bookTypeLabel(type) {
       var map = { wealth: '财富账', health: '健康账', growth: '成长账', support: '通用支援' }
       return map[type] || type
+    },
+    setSpeaker(uid) {
+      var self = this
+      uni.showModal({
+        title: '设为发言人',
+        content: '登记后不可更换,确认将该成员设为发言人?',
+        success: function(res) {
+          if (!res.confirm) return
+          setSpokesperson(uid).then(function() {
+            uni.showToast({ title: '已登记', icon: 'success' })
+            self.loadGroup()
+          }).catch(function(err) {
+            uni.showToast({ title: (err && err.message) || '设置失败', icon: 'none' })
+          })
+        }
+      })
     }
   }
 }
@@ -86,9 +105,12 @@ export default {
 .group-slogan { display: block; font-size: 26rpx; color: #F97316; }
 .section-title { font-size: 30rpx; font-weight: 600; color: #1E293B; margin: 24rpx 0 16rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
 .member-list { margin-bottom: 24rpx; }
-.member-item { display: flex; justify-content: space-between; padding: 16rpx 0; border-bottom: 1rpx solid #F1F5F9; }
+.member-item { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0; border-bottom: 1rpx solid #F1F5F9; }
+.member-info { display: flex; flex-direction: column; flex: 1; }
 .member-name { font-size: 28rpx; color: #1E293B; }
-.member-role { font-size: 24rpx; color: #64748B; }
+.member-role { font-size: 24rpx; color: #64748B; margin-top: 4rpx; }
+.speaker-btn { width: 160rpx; height: 56rpx; line-height: 56rpx; font-size: 24rpx; color: #F97316; background: #FFF0E6; border-radius: 28rpx; border: none; margin: 0 0 0 16rpx; }
+.speaker-btn:active { opacity: 0.85; }
 .empty-text { font-size: 26rpx; color: #94A3B8; padding: 16rpx 0; }
 .assign-list { margin-bottom: 24rpx; }
 .assign-item { display: flex; justify-content: space-between; padding: 16rpx 0; border-bottom: 1rpx solid #F1F5F9; }