Просмотр исходного кода

feat: referral team features - team list, invite, share poster

- Backend: CommissionController team/list endpoint, InviteController inviteCode/QR, CommissionService team tree query
- Frontend: promotion pages (team list, invite), SharePoster component, API helpers
- pages.json: add /promotion/share route
Xiaogang Liao 4 месяцев назад
Родитель
Сommit
5c80ba0851

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java

@@ -63,4 +63,16 @@ public class CommissionController {
         balanceInfo.put("withdrawnAmount", summary.getWithdrawnAmount());
         return Result.success(balanceInfo);
     }
+
+    /**
+     * 获取团队成员列表(L1/L2分页)
+     */
+    @PostMapping("/team/list")
+    public Result<Map<String, Object>> teamList(@RequestAttribute("userId") Long userId,
+                                                 @RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? (Integer) params.get("page") : 1;
+        int size = params.get("size") != null ? (Integer) params.get("size") : 20;
+        String level = params.get("level") != null ? (String) params.get("level") : "ALL";
+        return Result.success(commissionService.getTeamList(userId, level, page, size));
+    }
 }

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/InviteController.java

@@ -5,18 +5,29 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ReferralSummaryDTO;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.service.CommissionService;
+import com.etotem.cfc.service.QrCodeService;
+import com.etotem.cfc.service.WechatService;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.HashMap;
 import java.util.Map;
 
 @RestController
 @RequestMapping("/api/invite")
 public class InviteController {
 
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(InviteController.class);
+
     @Resource
     private CommissionService commissionService;
 
+    @Resource
+    private WechatService wechatService;
+
+    @Resource
+    private QrCodeService qrCodeService;
+
     @PostMapping("/code")
     public Result<String> getCode(@RequestAttribute("userId") Long userId) {
         String code = commissionService.getOrCreateReferralCode(userId);
@@ -47,4 +58,25 @@ public class InviteController {
     public Result<ReferralSummaryDTO> summary(@RequestAttribute("userId") Long userId) {
         return Result.success(commissionService.getReferralSummary(userId));
     }
+
+    @PostMapping("/qrcode")
+    public Result<Map<String, Object>> getQrCode(@RequestAttribute("userId") Long userId) {
+        try {
+            String referralCode = commissionService.getOrCreateReferralCode(userId);
+            String qrBase64;
+            try {
+                qrBase64 = wechatService.generateWxacode(referralCode, "pages/invite/join");
+            } catch (Exception e) {
+                log.warn("生成小程序码失败,降级为普通二维码: {}", e.getMessage());
+                qrBase64 = qrCodeService.generateQrCodeBase64(referralCode);
+            }
+            Map<String, Object> result = new HashMap<>();
+            result.put("qrCodeBase64", "data:image/png;base64," + qrBase64);
+            result.put("referralCode", referralCode);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("生成推广二维码异常", e);
+            return Result.error("生成二维码失败: " + e.getMessage());
+        }
+    }
 }

+ 85 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -17,8 +17,10 @@ import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -342,6 +344,89 @@ public class CommissionService {
                         .orderByDesc(CommissionRecord::getCreatedAt));
     }
 
+    public Map<String, Object> getTeamList(Long userId, String level, int page, int size) {
+        Map<String, Object> result = new HashMap<>();
+        List<Map<String, Object>> records = new ArrayList<>();
+
+        LambdaQueryWrapper<CommissionRecord> wrapper = new LambdaQueryWrapper<CommissionRecord>()
+                .eq(CommissionRecord::getReferrerId, userId);
+
+        if ("L1".equals(level)) {
+            wrapper.eq(CommissionRecord::getLevel, 1);
+        } else if ("L2".equals(level)) {
+            wrapper.eq(CommissionRecord::getLevel, 2);
+        }
+
+        List<CommissionRecord> allRecords = commissionRecordMapper.selectList(wrapper);
+
+        Map<Long, Map<String, Object>> memberMap = new LinkedHashMap<>();
+        for (CommissionRecord record : allRecords) {
+            Long buyerId = record.getBuyerId();
+            if (buyerId == null) continue;
+
+            if (!memberMap.containsKey(buyerId)) {
+                User member = userMapper.selectById(buyerId);
+                if (member == null) continue;
+
+                Map<String, Object> memberInfo = new HashMap<>();
+                memberInfo.put("userId", buyerId);
+                memberInfo.put("nickname", member.getNickname() != null ? member.getNickname() : "微信用户");
+                memberInfo.put("avatarUrl", member.getAvatar() != null ? member.getAvatar() : "");
+                memberInfo.put("createdAt", member.getCreatedAt());
+                memberInfo.put("orderCount", 0);
+                memberInfo.put("commissionEarned", 0);
+                memberMap.put(buyerId, memberInfo);
+            }
+        }
+
+        for (Map.Entry<Long, Map<String, Object>> entry : memberMap.entrySet()) {
+            Long buyerId = entry.getKey();
+            Map<String, Object> memberInfo = entry.getValue();
+
+            LambdaQueryWrapper<CommissionRecord> orderWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                    .eq(CommissionRecord::getReferrerId, userId)
+                    .eq(CommissionRecord::getBuyerId, buyerId);
+            if ("L1".equals(level)) {
+                orderWrapper.eq(CommissionRecord::getLevel, 1);
+            } else if ("L2".equals(level)) {
+                orderWrapper.eq(CommissionRecord::getLevel, 2);
+            }
+            Long orderCount = commissionRecordMapper.selectCount(orderWrapper);
+            memberInfo.put("orderCount", orderCount != null ? orderCount.intValue() : 0);
+
+            LambdaQueryWrapper<CommissionRecord> commissionWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                    .eq(CommissionRecord::getReferrerId, userId)
+                    .eq(CommissionRecord::getBuyerId, buyerId);
+            if ("L1".equals(level)) {
+                commissionWrapper.eq(CommissionRecord::getLevel, 1);
+            } else if ("L2".equals(level)) {
+                commissionWrapper.eq(CommissionRecord::getLevel, 2);
+            }
+            int commissionEarned = commissionRecordMapper.selectList(commissionWrapper).stream()
+                    .mapToInt(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : 0)
+                    .sum();
+            memberInfo.put("commissionEarned", commissionEarned);
+        }
+
+        List<Map<String, Object>> allMembers = new ArrayList<>(memberMap.values());
+        int total = allMembers.size();
+        int start = (page - 1) * size;
+        int end = Math.min(start + size, total);
+
+        if (start < total) {
+            records = allMembers.subList(start, end);
+        } else {
+            records = new ArrayList<>();
+        }
+
+        result.put("records", records);
+        result.put("total", (long) total);
+        result.put("page", page);
+        result.put("size", size);
+
+        return result;
+    }
+
     /**
      * 获取可提现金额(已结算 - 已提现,排除cancelled)
      */

+ 271 - 0
cfc-frontend/components/SharePoster.vue

@@ -0,0 +1,271 @@
+<template>
+  <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
+    <view class="poster-container">
+      <canvas canvas-id="sharePosterCanvas" class="poster-canvas" id="sharePosterCanvas"></canvas>
+      <view class="poster-actions">
+        <button class="poster-btn save" @click="savePoster">保存到相册</button>
+        <button class="poster-btn cancel" @click="$emit('close')">取消</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'SharePoster',
+  props: {
+    show: { type: Boolean, default: false },
+    userAvatar: { type: String, default: '' },
+    nickname: { type: String, default: '' },
+    referralCode: { type: String, default: '' },
+    qrCodeBase64: { type: String, default: '' }
+  },
+  watch: {
+    show(val) {
+      if (val) {
+        this.$nextTick(function() {
+          setTimeout(function() {
+            this.drawPoster()
+          }.bind(this), 300)
+        }.bind(this))
+      }
+    }
+  },
+  methods: {
+    drawPoster() {
+      var ctx = uni.createCanvasContext('sharePosterCanvas', this)
+      var w = 500
+      var h = 700
+
+      // 1. Orange gradient background
+      var gradient = ctx.createLinearGradient(0, 0, w, h)
+      gradient.addColorStop(0, '#F97316')
+      gradient.addColorStop(1, '#EA580C')
+      ctx.setFillStyle(gradient)
+      ctx.fillRect(0, 0, w, h)
+
+      // 2. White circle border for avatar
+      var avatarX = w / 2
+      var avatarY = 120
+      var avatarR = 50
+      ctx.setStrokeStyle('#ffffff')
+      ctx.setLineWidth(3)
+      ctx.beginPath()
+      ctx.arc(avatarX, avatarY, avatarR + 3, 0, 2 * Math.PI)
+      ctx.stroke()
+
+      // 3. Draw avatar or default icon
+      if (this.userAvatar) {
+        var self = this
+        uni.downloadFile({
+          url: this.userAvatar,
+          success: function(res) {
+            if (res.statusCode === 200) {
+              ctx.save()
+              ctx.beginPath()
+              ctx.arc(avatarX, avatarY, avatarR, 0, 2 * Math.PI)
+              ctx.clip()
+              ctx.drawImage(res.tempFilePath, avatarX - avatarR, avatarY - avatarR, avatarR * 2, avatarR * 2)
+              ctx.restore()
+              self._drawTextAndQr(ctx, w, h, avatarX, avatarY)
+            } else {
+              self._drawDefaultAvatar(ctx, avatarX, avatarY, avatarR)
+              self._drawTextAndQr(ctx, w, h, avatarX, avatarY)
+            }
+          },
+          fail: function() {
+            self._drawDefaultAvatar(ctx, avatarX, avatarY, avatarR)
+            self._drawTextAndQr(ctx, w, h, avatarX, avatarY)
+          }
+        })
+      } else {
+        this._drawDefaultAvatar(ctx, avatarX, avatarY, avatarR)
+        this._drawTextAndQr(ctx, w, h, avatarX, avatarY)
+      }
+    },
+
+    _drawDefaultAvatar(ctx, x, y, r) {
+      ctx.setFillStyle('#ffffff')
+      ctx.beginPath()
+      ctx.arc(x, y, r, 0, 2 * Math.PI)
+      ctx.fill()
+      ctx.setFillStyle('#F97316')
+      ctx.setFontSize(40)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('middle')
+      ctx.fillText('\u{1F464}', x, y)
+    },
+
+    _drawTextAndQr(ctx, w, h, avatarX, avatarY) {
+      var self = this
+
+      // 4. Nickname
+      var nameY = avatarY + 80
+      ctx.setFillStyle('#ffffff')
+      ctx.setFontSize(28)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText(this.nickname || '\u597d\u53cb', w / 2, nameY)
+
+      // 5. "我的邀请码" label
+      var labelY = nameY + 50
+      ctx.setFontSize(20)
+      ctx.fillText('\u6211\u7684\u9080\u8bf7\u7801', w / 2, labelY)
+
+      // 6. Referral code in large bold text
+      var codeY = labelY + 36
+      ctx.setFontSize(48)
+      ctx.fillText(this.referralCode || '', w / 2, codeY)
+
+      // 7. QR code
+      var qrSize = 220
+      var qrX = (w - qrSize) / 2
+      var qrY = codeY + 70
+
+      if (this.qrCodeBase64) {
+        // Build a file path from base64 data
+        var base64Data = this.qrCodeBase64
+        // Handle data URI prefix
+        if (base64Data.indexOf('data:image') === 0) {
+          var commaIdx = base64Data.indexOf(',')
+          if (commaIdx > -1) {
+            base64Data = base64Data.substring(commaIdx + 1)
+          }
+        }
+
+        var fs = uni.getFileSystemManager()
+        var filePath = wx.env.USER_DATA_PATH + '/share_qr_tmp.png'
+
+        fs.writeFile({
+          filePath: filePath,
+          data: base64Data,
+          encoding: 'base64',
+          success: function() {
+            // White background behind QR
+            ctx.setFillStyle('#ffffff')
+            ctx.fillRect(qrX - 10, qrY - 10, qrSize + 20, qrSize + 20)
+            ctx.drawImage(filePath, qrX, qrY, qrSize, qrSize)
+            self._drawHintAndFinish(ctx, w, h, qrY, qrSize)
+          },
+          fail: function() {
+            self._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
+            self._drawHintAndFinish(ctx, w, h, qrY, qrSize)
+          }
+        })
+      } else {
+        this._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
+        this._drawHintAndFinish(ctx, w, h, qrY, qrSize)
+      }
+    },
+
+    _drawQrPlaceholder(ctx, x, y, size) {
+      ctx.setFillStyle('#ffffff')
+      ctx.fillRect(x - 10, y - 10, size + 20, size + 20)
+      ctx.setFillStyle('#F97316')
+      ctx.setFontSize(18)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('middle')
+      ctx.fillText('\u4e8c\u7ef4\u7801\u52a0\u8f7d\u4e2d', x + size / 2, y + size / 2)
+    },
+
+    _drawHintAndFinish(ctx, w, h, qrY, qrSize) {
+      // 8. Hint text below QR
+      var hintY = qrY + qrSize + 20
+      ctx.setFillStyle('#ffffff')
+      ctx.setFontSize(22)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('\u626b\u7801\u9080\u8bf7\u597d\u53cb\u52a0\u5165', w / 2, hintY)
+
+      ctx.draw()
+    },
+
+    savePoster() {
+      var self = this
+      uni.canvasToTempFilePath({
+        canvasId: 'sharePosterCanvas',
+        success: function(res) {
+          uni.saveImageToPhotosAlbum({
+            filePath: res.tempFilePath,
+            success: function() {
+              uni.showToast({ title: '\u5df2\u4fdd\u5b58\u5230\u76f8\u518c', icon: 'success' })
+              self.$emit('close')
+            },
+            fail: function(err) {
+              if (err && err.errMsg && (err.errMsg.indexOf('deny') > -1 || err.errMsg.indexOf('auth denied') > -1 || err.errMsg.indexOf('authorize') > -1)) {
+                uni.showModal({
+                  title: '\u63d0\u793a',
+                  content: '\u9700\u8981\u76f8\u518c\u6743\u9650\u624d\u80fd\u4fdd\u5b58\u56fe\u7247',
+                  confirmText: '\u53bb\u8bbe\u7f6e',
+                  success: function(modalRes) {
+                    if (modalRes.confirm) {
+                      uni.openSetting()
+                    }
+                  }
+                })
+              } else {
+                uni.showToast({ title: '\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5', icon: 'none' })
+              }
+            }
+          })
+        },
+        fail: function() {
+          uni.showToast({ title: '\u751f\u6210\u56fe\u7247\u5931\u8d25', icon: 'none' })
+        }
+      }, this)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.poster-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.6);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+}
+.poster-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.poster-canvas {
+  width: 500px;
+  height: 700px;
+  border-radius: 16rpx;
+}
+.poster-actions {
+  display: flex;
+  flex-direction: column;
+  width: 500px;
+  margin-top: 30rpx;
+}
+.poster-btn {
+  height: 80rpx;
+  line-height: 80rpx;
+  font-size: 30rpx;
+  border-radius: 40rpx;
+  text-align: center;
+  border: none;
+}
+.poster-btn::after { border: none; }
+.poster-btn.save {
+  background: #ffffff;
+  color: #F97316;
+  font-weight: 600;
+  margin-bottom: 16rpx;
+}
+.poster-btn.save:active { opacity: 0.85; }
+.poster-btn.cancel {
+  background: rgba(255, 255, 255, 0.15);
+  color: #ffffff;
+}
+.poster-btn.cancel:active { opacity: 0.85; }
+</style>

+ 4 - 0
cfc-frontend/pages.json

@@ -619,6 +619,10 @@
         {
           "path": "invite",
           "style": { "navigationBarTitleText": "邀请好友" }
+        },
+        {
+          "path": "team",
+          "style": { "navigationBarTitleText": "推广团队" }
         }
       ]
     },

+ 7 - 14
cfc-frontend/pages/parent/invite/index.vue

@@ -83,22 +83,15 @@ export default {
     this.loadFamilyName()
   },
   onShareAppMessage() {
-    const title = this.role === 'teacher' 
-      ? '成长规划师邀请 - 加入我的服务家庭' 
-      : '邀请您加入我的家庭'
-    return {
-      title: title,
-      path: `/pages/login/login?inviteCode=${this.inviteCode}&inviteType=${this.role === 'teacher' ? 'guide' : 'family'}`,
-      imageUrl: '/static/share-card.png' // 可选:自定义分享卡片图片
+    var code = this.inviteCode || ''
+    var path = '/pages/invite/join'
+    if (code) {
+      path = path + '?inviteCode=' + code
     }
-  },
-  // 分享到朋友圈
-  onShareTimeline() {
     return {
-      title: this.role === 'teacher' 
-        ? '成长规划师邀请 - 加入我的服务家庭' 
-        : '邀请您加入我的家庭',
-      query: `inviteCode=${this.inviteCode}&inviteType=${this.role === 'teacher' ? 'guide' : 'family'}`
+      title: '我在浠艾福发现了好用的家庭成长工具,快来加入!',
+      path: path,
+      imageUrl: ''
     }
   },
   methods: {

+ 38 - 5
cfc-frontend/pages/promotion/index.vue

@@ -67,19 +67,27 @@
       </view>
     </view>
 
+    <SharePoster :show="showPoster" :referralCode="referralCode" :qrCodeBase64="qrCodeBase64" :nickname="userNickname" :userAvatar="userAvatar" @close="showPoster = false" />
+
   </scroll-view>
 </template>
 
 <script>
-import { getReferralCode, getReferralSummary, getCommissionSummary } from '../../utils/api.js'
+import { getReferralCode, getReferralSummary, getCommissionSummary, getInviteQrCode } from '../../utils/api.js'
+import SharePoster from '@/components/SharePoster.vue'
 
 export default {
+  components: { SharePoster },
   data() {
     return {
       referralCode: '',
       totalEarnings: '0.00',
       availableAmount: '0.00',
-      summary: null
+      summary: null,
+      showPoster: false,
+      qrCodeBase64: '',
+      userAvatar: '',
+      userNickname: ''
     }
   },
   computed: {
@@ -90,6 +98,18 @@ export default {
   onLoad() {
     this.loadData()
   },
+  onShareAppMessage() {
+    var code = this.referralCode || ''
+    var path = '/pages/invite/join'
+    if (code) {
+      path = path + '?inviteCode=' + code
+    }
+    return {
+      title: '我在浠艾福发现了好用的家庭成长工具,快来加入!',
+      path: path,
+      imageUrl: ''
+    }
+  },
   methods: {
     async loadData() {
       try {
@@ -127,8 +147,21 @@ export default {
         }
       })
     },
-    shareCard() {
-      uni.showToast({ title: '即将上线', icon: 'none' })
+    async shareCard() {
+      try {
+        uni.showLoading({ title: '生成中...' })
+        var res = await getInviteQrCode()
+        if (res.data) {
+          this.qrCodeBase64 = res.data.qrCodeBase64 || ''
+          this.userNickname = uni.getStorageSync('nickname') || ''
+          this.userAvatar = uni.getStorageSync('avatar') || ''
+          this.showPoster = true
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        uni.hideLoading()
+      }
     },
     goCommission() {
       uni.navigateTo({ url: '/pages/promotion/commission' })
@@ -140,7 +173,7 @@ export default {
       uni.navigateTo({ url: '/pages/promotion/invite' })
     },
     goTeam() {
-      uni.showToast({ title: '即将上线', icon: 'none' })
+      uni.navigateTo({ url: '/pages/promotion/team' })
     }
   }
 }

+ 38 - 4
cfc-frontend/pages/promotion/invite.vue

@@ -59,13 +59,18 @@
         <text>加载中...</text>
       </view>
     </view>
+
+    <SharePoster :show="showPoster" :referralCode="referralCode" :qrCodeBase64="qrCodeBase64" :nickname="userNickname" :userAvatar="userAvatar" @close="showPoster = false" />
+
   </view>
 </template>
 
 <script>
-import { getReferralCode, bindReferral, getReferralList, getReferralSummary } from '../../utils/api.js'
+import { getReferralCode, bindReferral, getReferralList, getReferralSummary, getInviteQrCode } from '../../utils/api.js'
+import SharePoster from '@/components/SharePoster.vue'
 
 export default {
+  components: { SharePoster },
   data() {
     return {
       referralCode: '',
@@ -75,7 +80,11 @@ export default {
       page: 1,
       loading: false,
       hasMore: true,
-      summary: null
+      summary: null,
+      showPoster: false,
+      qrCodeBase64: '',
+      userAvatar: '',
+      userNickname: ''
     }
   },
   onLoad() {
@@ -89,6 +98,18 @@ export default {
       this.loadList()
     }
   },
+  onShareAppMessage() {
+    var code = this.referralCode || ''
+    var path = '/pages/invite/join'
+    if (code) {
+      path = path + '?inviteCode=' + code
+    }
+    return {
+      title: '我在浠艾福发现了好用的家庭成长工具,快来加入!',
+      path: path,
+      imageUrl: ''
+    }
+  },
   methods: {
     async loadCode() {
       try {
@@ -139,8 +160,21 @@ export default {
         }
       })
     },
-    shareInvite() {
-      uni.showToast({ title: '即将上线', icon: 'none' })
+    async shareInvite() {
+      try {
+        uni.showLoading({ title: '生成中...' })
+        var res = await getInviteQrCode()
+        if (res.data) {
+          this.qrCodeBase64 = res.data.qrCodeBase64 || ''
+          this.userNickname = uni.getStorageSync('nickname') || ''
+          this.userAvatar = uni.getStorageSync('avatar') || ''
+          this.showPoster = true
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        uni.hideLoading()
+      }
     },
     async bindReferral() {
       if (!this.bindCode) {

+ 290 - 0
cfc-frontend/pages/promotion/team.vue

@@ -0,0 +1,290 @@
+<template>
+  <view class="container">
+    <!-- 团队统计 -->
+    <view class="stats-card">
+      <view class="stats-item">
+        <text class="stats-num" v-if="teamStats">{{ teamStats.l1Count || 0 }}</text>
+        <text class="stats-num" v-else>0</text>
+        <text class="stats-label">直推(L1)</text>
+      </view>
+      <view class="stats-divider"></view>
+      <view class="stats-item">
+        <text class="stats-num" v-if="teamStats">{{ teamStats.l2Count || 0 }}</text>
+        <text class="stats-num" v-else>0</text>
+        <text class="stats-label">间推(L2)</text>
+      </view>
+      <view class="stats-divider"></view>
+      <view class="stats-item">
+        <text class="stats-num" v-if="teamStats">{{ formatPriceWithSymbol(teamStats.totalCommission) }}</text>
+        <text class="stats-num" v-else>{{ formatPriceWithSymbol(0) }}</text>
+        <text class="stats-label">团队佣金</text>
+      </view>
+    </view>
+
+    <!-- Tab 切换 -->
+    <view class="tab-bar">
+      <view class="tab-item" :class="level === 'L1' ? 'tab-active' : ''" @click="switchLevel('L1')">
+        <text class="tab-text">直推(L1)</text>
+      </view>
+      <view class="tab-item" :class="level === 'L2' ? 'tab-active' : ''" @click="switchLevel('L2')">
+        <text class="tab-text">间推(L2)</text>
+      </view>
+    </view>
+
+    <!-- 成员列表 -->
+    <view class="list-section">
+      <text class="section-title">团队成员 ({{ total || 0 }})</text>
+      <view class="empty-state" v-if="!loading && list.length === 0">
+        <text>还没有推广团队成员</text>
+      </view>
+      <view class="member-item" v-for="item in list" :key="item.userId">
+        <view class="member-avatar-wrap">
+          <image v-if="item.avatarUrl" class="member-avatar-img" :src="item.avatarUrl" mode="aspectFill"></image>
+          <text v-else class="member-avatar-icon">👤</text>
+        </view>
+        <view class="member-info">
+          <text class="member-name">{{ item.nickname || '微信用户' }}</text>
+          <text class="member-time">{{ formatTime(item.createdAt) }}</text>
+        </view>
+        <view class="member-right">
+          <view class="member-badge" v-if="item.orderCount && item.orderCount > 0">
+            <text class="badge-text">{{ item.orderCount }}单</text>
+          </view>
+          <text class="member-commission">{{ formatPriceWithSymbol(item.commissionEarned) }}</text>
+        </view>
+      </view>
+      <view class="loading-state" v-if="loading">
+        <text>加载中...</text>
+      </view>
+      <view class="loadmore-state" v-if="!loading && list.length > 0 && !hasMore">
+        <text>没有更多了</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getCommissionTeam, getTeamList } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      list: [],
+      total: 0,
+      page: 1,
+      level: 'L1',
+      loading: false,
+      hasMore: true,
+      teamStats: null
+    }
+  },
+  onLoad() {
+    this.loadTeamStats()
+    this.loadList()
+  },
+  onReachBottom() {
+    if (this.hasMore && !this.loading) {
+      this.page++
+      this.loadList()
+    }
+  },
+  methods: {
+    async loadTeamStats() {
+      try {
+        var res = await getCommissionTeam(1, 1)
+        if (res.data) {
+          this.teamStats = res.data
+        }
+      } catch (e) {
+        console.error('获取团队统计失败', e)
+      }
+    },
+    async loadList() {
+      this.loading = true
+      try {
+        var res = await getTeamList(this.page, 20, this.level)
+        if (res.data && res.data.records) {
+          this.list = this.page === 1 ? res.data.records : this.list.concat(res.data.records)
+          this.total = res.data.total || 0
+          this.hasMore = res.data.records.length >= 20
+        } else {
+          this.hasMore = false
+        }
+      } catch (e) {
+        console.error('获取团队成员失败', e)
+      }
+      this.loading = false
+    },
+    switchLevel(level) {
+      if (this.level === level) return
+      this.level = level
+      this.page = 1
+      this.list = []
+      this.hasMore = true
+      this.loadList()
+    },
+    formatTime(time) {
+      if (!time) return ''
+      var t = new Date(time)
+      return t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  padding: 30rpx;
+  min-height: 100vh;
+  background: #FFF7ED;
+}
+
+/* ===== 团队统计 ===== */
+.stats-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 32rpx 24rpx;
+  display: flex;
+  align-items: center;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+}
+.stats-item {
+  flex: 1;
+  text-align: center;
+}
+.stats-num {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #F97316;
+  display: block;
+}
+.stats-label {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-top: 4rpx;
+  display: block;
+}
+.stats-divider {
+  width: 1rpx;
+  height: 48rpx;
+  background: #FED7AA;
+}
+
+/* ===== Tab切换 ===== */
+.tab-bar {
+  display: flex;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 8rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(249,115,22,0.06);
+}
+.tab-item {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  border-radius: 12rpx;
+}
+.tab-active {
+  background: #F97316;
+}
+.tab-active .tab-text {
+  color: #fff;
+  font-weight: 600;
+}
+.tab-text {
+  font-size: 26rpx;
+  color: #64748B;
+}
+
+/* ===== 成员列表 ===== */
+.list-section {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+  display: block;
+  margin-bottom: 20rpx;
+}
+.member-item {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #FED7AA;
+}
+.member-item:last-child {
+  border-bottom: none;
+}
+.member-avatar-wrap {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: #FEF3C7;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+.member-avatar-img {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+}
+.member-avatar-icon {
+  font-size: 36rpx;
+}
+.member-info {
+  flex: 1;
+  min-width: 0;
+}
+.member-name {
+  font-size: 28rpx;
+  color: #1E293B;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.member-time {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-top: 4rpx;
+  display: block;
+}
+.member-right {
+  text-align: right;
+  flex-shrink: 0;
+  margin-left: 16rpx;
+}
+.member-badge {
+  display: inline-block;
+  background: #FEF3C7;
+  border-radius: 8rpx;
+  padding: 2rpx 10rpx;
+  margin-bottom: 4rpx;
+}
+.badge-text {
+  font-size: 20rpx;
+  color: #D97706;
+}
+.member-commission {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #F97316;
+  display: block;
+}
+.empty-state, .loading-state, .loadmore-state {
+  text-align: center;
+  padding: 60rpx;
+  color: #94A3B8;
+  font-size: 28rpx;
+}
+</style>

+ 10 - 0
cfc-frontend/utils/api.js

@@ -1117,6 +1117,11 @@ export const getReferralCode = () => {
   return request('/api/invite/code', 'POST')
 }
 
+// 获取邀请二维码
+export const getInviteQrCode = () => {
+  return request('/api/invite/qrcode', 'POST')
+}
+
 // 绑定推荐人
 export const bindReferral = (referralCode) => {
   return request('/api/invite/bind', 'POST', { referralCode })
@@ -1237,6 +1242,11 @@ export const getCommissionTeam = (page = 1, size = 10) => {
   return request('/api/commission/team', 'POST', { page, size })
 }
 
+// 推广团队成员列表(分页)
+export const getTeamList = (page = 1, size = 20, level = 'ALL') => {
+  return request('/api/commission/team/list', 'POST', { page, size, level })
+}
+
 // 佣金统计数据
 export const getCommissionStats = () => {
   return request('/api/commission/stats', 'POST')