Browse Source

Phase5: 裂变分享模块(海报/邀请码/激励发放/转介漏斗)

liaoxg 2 weeks ago
parent
commit
08fe3c0

+ 108 - 0
train-backend/src/main/java/com/train/controller/InviteController.java

@@ -0,0 +1,108 @@
+package com.train.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainInvite;
+import com.train.entity.TrainUser;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 裂变分享:专属海报/邀请码、我的转介绍统计。
+ * <p>邀请码复用 train_user.invite_code;未设置时按需生成(折校:可进校友校验证写入)。
+ * 海报 URL 为占位(test-mode 返回空,前端用邀请码本地渲染海报);
+ * 生产环境接入微信 getwxacodeunlimit 生成小程序码后回填。
+ */
+@Tag(name = "裂变分享", description = "专属海报/邀请码、转介绍统计")
+@RestController
+@RequestMapping("/api/invite")
+public class InviteController {
+
+    @Resource
+    private TrainUserMapper trainUserMapper;
+    @Resource
+    private TrainInviteMapper trainInviteMapper;
+
+    /**
+     * 我的专属海报 + 邀请码。
+     * @return {inviteCode, posterUrl}
+     */
+    @Operation(summary = "我的专属海报+邀请码")
+    @PostMapping("/myposter")
+    public Result<Map<String, Object>> myPoster(@RequestAttribute("userId") Long userId) {
+        TrainUser user = trainUserMapper.selectById(userId);
+        String inviteCode = user != null ? user.getInviteCode() : null;
+        if (!StringUtils.hasText(inviteCode)) {
+            // 按需生成邀请码(6位字母数字,预留幂等冲突补试)
+            inviteCode = genInviteCode(userId);
+            if (user != null) {
+                user.setInviteCode(inviteCode);
+                trainUserMapper.updateById(user);
+            }
+        }
+        Map<String, Object> row = new HashMap<>();
+        row.put("inviteCode", inviteCode);
+        row.put("posterUrl", ""); // 占位:test-mode 不生成海报图;生产接入 getwxacodeunlimit
+        return Result.success(row);
+    }
+
+    /**
+     * 我的转介绍统计。
+     * @return {invited, paid, rewards}
+     */
+    @Operation(summary = "我的转介绍统计")
+    @PostMapping("/stats")
+    public Result<Map<String, Object>> stats(@RequestAttribute("userId") Long userId) {
+        Long invited = trainInviteMapper.selectCount(
+                new LambdaQueryWrapper<TrainInvite>().eq(TrainInvite::getInviterId, userId));
+        Long paid = trainInviteMapper.selectCount(
+                new LambdaQueryWrapper<TrainInvite>()
+                        .eq(TrainInvite::getInviterId, userId)
+                        .eq(TrainInvite::getSuccessful, 1));
+        Long rewards = trainInviteMapper.selectCount(
+                new LambdaQueryWrapper<TrainInvite>()
+                        .eq(TrainInvite::getInviterId, userId)
+                        .eq(TrainInvite::getRewardStatus, "granted"));
+        Map<String, Object> row = new HashMap<>();
+        row.put("invited", invited == null ? 0L : invited);
+        row.put("paid", paid == null ? 0L : paid);
+        row.put("rewards", rewards == null ? 0L : rewards);
+        return Result.success(row);
+    }
+
+    /** 生成 6 位字母数字邀请码(低概率冲突靠唯一索引兜底,冲突时补试) */
+    private String genInviteCode(Long userId) {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        for (int i = 0; i < 5; i++) {
+            String code = randomCode(chars, 6);
+            Long exists = trainUserMapper.selectCount(
+                    new LambdaQueryWrapper<TrainUser>().eq(TrainUser::getInviteCode, code));
+            if (exists == null || exists == 0) {
+                return code;
+            }
+        }
+        // 兜底:含 userId 的编码保证唯一
+        return UUID.randomUUID().toString().substring(0, 6).toUpperCase();
+    }
+
+    private String randomCode(String chars, int len) {
+        StringBuilder sb = new StringBuilder();
+        java.util.concurrent.ThreadLocalRandom r = java.util.concurrent.ThreadLocalRandom.current();
+        for (int i = 0; i < len; i++) {
+            sb.append(chars.charAt(r.nextInt(chars.length())));
+        }
+        return sb.toString();
+    }
+}

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

@@ -4,10 +4,12 @@ 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.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 com.train.service.PayService;
@@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -39,6 +42,8 @@ public class AdminBizController {
     @Resource
     private TrainOrderMapper trainOrderMapper;
     @Resource
+    private TrainInviteMapper trainInviteMapper;
+    @Resource
     private PayService payService;
 
     /**
@@ -139,4 +144,54 @@ public class AdminBizController {
             return Result.error(e.getMessage());
         }
     }
+
+    /**
+     * 转介绍漏斗:各邀请人 {inviterId, inviterName, phone, invited, paid, rewards},
+     * paid 为邀请成功的被邀请人付费数,rewards 为已发放激励数。
+     */
+    @Operation(summary = "转介绍漏斗")
+    @PostMapping("/invite/funnel")
+    public Result<List<Map<String, Object>>> inviteFunnel(@RequestAttribute("adminId") Long adminId) {
+        List<TrainInvite> invites = trainInviteMapper.selectList(
+                new LambdaQueryWrapper<TrainInvite>().orderByDesc(TrainInvite::getId));
+        // 按邀请人聚合
+        Map<Long, Map<String, Object>> rows = new LinkedHashMap<>();
+        for (TrainInvite inv : invites) {
+            Map<String, Object> row = rows.computeIfAbsent(inv.getInviterId(), k -> {
+                Map<String, Object> m = new HashMap<>();
+                m.put("inviterId", k);
+                m.put("invited", 0);
+                m.put("paid", 0);
+                m.put("rewards", 0);
+                return m;
+            });
+            row.put("invited", (Integer) row.get("invited") + 1);
+            if (Integer.valueOf(1).equals(inv.getSuccessful())) {
+                row.put("paid", (Integer) row.get("paid") + 1);
+            }
+            if ("granted".equals(inv.getRewardStatus())) {
+                row.put("rewards", (Integer) row.get("rewards") + 1);
+            }
+        }
+        // 补邀请人姓名/手机号
+        Map<Long, TrainUser> users = new HashMap<>();
+        List<Long> ids = new ArrayList<>(rows.keySet());
+        if (!ids.isEmpty()) {
+            List<TrainUser> list = trainUserMapper.selectBatchIds(ids);
+            for (TrainUser u : list) {
+                users.put(u.getId(), u);
+            }
+        }
+        for (Map<String, Object> row : rows.values()) {
+            TrainUser u = users.get(row.get("inviterId"));
+            if (u != null) {
+                row.put("inviterName", u.getName());
+                row.put("phone", u.getPhone());
+            } else {
+                row.put("inviterName", "已注销");
+                row.put("phone", "");
+            }
+        }
+        return Result.success(new ArrayList<>(rows.values()));
+    }
 }

+ 50 - 0
train-backend/src/main/java/com/train/service/PayService.java

@@ -3,15 +3,19 @@ package com.train.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.cfc.entity.CfcActivity;
 import com.train.cfc.mapper.CfcActivityMapper;
+import com.train.controller.PlanController;
 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 lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -42,7 +46,11 @@ public class PayService {
     @Resource
     private TrainUserMapper trainUserMapper;
     @Resource
+    private TrainInviteMapper trainInviteMapper;
+    @Resource
     private CfcActivityMapper cfcActivityMapper;
+    @Resource
+    private PlanController planController;
 
     @Value("${wechat.test-mode}")
     private boolean testMode;
@@ -189,11 +197,53 @@ public class PayService {
                         log.info("支付成功自动进班: uid={}, classId={}", user.getId(), order.getClassId());
                     }
                 }
+                // 转介绍联动:被邀请人付费成功 → 更新关系 → 给邀请人发激励卡券
+                rewardInviterIfInvited(en, order);
             }
         }
         log.info("支付入账完成: orderNo={}", orderNo);
     }
 
+    /**
+     * 转介绍激励:报名单带邀请码 → 找到转介绍关系,标记 successful+orderId,
+     * 给邀请人发放激励卡券(复用 PlanController.grantCoupon,幂等:同类型+触发不重复发)。
+     */
+    private void rewardInviterIfInvited(TrainEnrollment en, TrainOrder order) {
+        if (!StringUtils.hasText(en.getInviteCode()) || order.getUid() == null) {
+            return;
+        }
+        TrainInvite invite = trainInviteMapper.selectOne(
+                new LambdaQueryWrapper<TrainInvite>()
+                        .eq(TrainInvite::getInviteCode, en.getInviteCode())
+                        .eq(TrainInvite::getInviteeId, order.getUid())
+                        .last("LIMIT 1"));
+        if (invite == null) {
+            return;
+        }
+        if (!Integer.valueOf(1).equals(invite.getSuccessful())) {
+            invite.setSuccessful(1);
+            invite.setOrderId(order.getId());
+            trainInviteMapper.updateById(invite);
+            log.info("转介绍成功: inviteId={}, inviterId={}, inviteeId={}, orderId={}",
+                    invite.getId(), invite.getInviterId(), invite.getInviteeId(), order.getId());
+        }
+        if ("granted".equals(invite.getRewardStatus())) {
+            return;
+        }
+        try {
+            com.train.entity.TrainCoupon coupon = planController.grantCoupon(
+                    invite.getInviterId(), "private", "invite", "转介绍激励-私教券");
+            if (coupon != null) {
+                invite.setRewardStatus("granted");
+                trainInviteMapper.updateById(invite);
+                log.info("转介绍激励已发放: inviterId={}, coupon={}", invite.getInviterId(), coupon.getCode());
+            }
+        } catch (Exception e) {
+            // 发券失败不影响订单入账(可后续人工补发)
+            log.warn("转介绍发券失败: inviteId={}, err={}", invite.getId(), e.getMessage());
+        }
+    }
+
     /**
      * 查询订单状态(校验归属)。
      * @return {orderNo, amount, status, payTime, classId}

+ 12 - 0
train-frontend/pages.json

@@ -114,6 +114,18 @@
       "style": {
         "navigationBarTitleText": "支付结果"
       }
+    },
+    {
+      "path": "pages/share/index",
+      "style": {
+        "navigationBarTitleText": "我的海报"
+      }
+    },
+    {
+      "path": "pages/share/stats",
+      "style": {
+        "navigationBarTitleText": "我的邀请"
+      }
     }
   ],
   "globalStyle": {

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

@@ -15,6 +15,11 @@
         <text class="menu-text">报名中心</text>
         <text class="menu-arrow">›</text>
       </view>
+      <view class="menu-item" @click="goTo('/pages/share/index')">
+        <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>

+ 176 - 0
train-frontend/pages/share/index.vue

@@ -0,0 +1,176 @@
+<template>
+  <view class="share-page">
+    <view class="poster-card">
+      <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas"></canvas>
+    </view>
+
+    <view class="invite-row">
+      <text class="invite-label">我的邀请码</text>
+      <text class="invite-code">{{ inviteCode || '加载中…' }}</text>
+      <button class="copy-btn" @click="copyCode" :disabled="!inviteCode">复制</button>
+    </view>
+
+    <button class="save-btn" @click="savePoster" :loading="saving" :disabled="saving || !inviteCode">保存海报图片</button>
+
+    <view class="stats-link" @click="goStats">
+      <text>查看我的转介绍统计</text>
+      <text class="arrow">›</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMyPoster } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      inviteCode: '',
+      posterUrl: '',
+      saving: false
+    }
+  },
+  onLoad() {
+    this.loadPoster()
+  },
+  onReady() {
+    // canvas 就绪后绘制海报
+    this.drawPoster()
+  },
+  methods: {
+    loadPoster() {
+      var self = this
+      getMyPoster().then(function(resp) {
+        var data = resp.data || {}
+        self.inviteCode = data.inviteCode || ''
+        self.posterUrl = data.posterUrl || ''
+        if (self.inviteCode) {
+          // 重新绘制带邀请码的海报
+          setTimeout(function() {
+            self.drawPoster()
+          }, 300)
+        }
+      }).catch(function() {})
+    },
+    drawPoster() {
+      var self = this
+      var ctx = uni.createCanvasContext('posterCanvas', self)
+      var width = 300
+      var height = 450
+      // 背景
+      var gradient = ctx.createLinearGradient(0, 0, 0, height)
+      gradient.addColorStop(0, '#FFF7ED')
+      gradient.addColorStop(1, '#FED7AA')
+      ctx.setFillStyle(gradient)
+      ctx.fillRect(0, 0, width, height)
+      // 顶部品牌区
+      ctx.setFillStyle('#EA580C')
+      ctx.fillRect(0, 0, width, 8)
+      ctx.setFillStyle('#1E293B')
+      ctx.setFontSize(22)
+      ctx.setTextAlign('center')
+      ctx.fillText('爱伴·AI之旅', width / 2, 60)
+      ctx.setFillStyle('#64748B')
+      ctx.setFontSize(13)
+      ctx.fillText('邀请你一起加入 AI 成长营', width / 2, 90)
+      // 中央邀请码卡片
+      ctx.setFillStyle('#FFFFFF')
+      this.roundRect(ctx, 40, 130, width - 80, 120, 12)
+      ctx.setFillStyle('#F97316')
+      ctx.setFontSize(15)
+      ctx.fillText('我的邀请码', width / 2, 165)
+      ctx.setFillStyle('#1E293B')
+      ctx.setFontSize(34)
+      ctx.fillText(self.inviteCode || '------', width / 2, 215)
+      // 底部提示
+      ctx.setFillStyle('#EA580C')
+      ctx.setFontSize(14)
+      ctx.fillText('报名时填写邀请码,一起享受专属权益', width / 2, 320)
+      ctx.setFillStyle('#94A3B8')
+      ctx.setFontSize(12)
+      ctx.fillText('爱伴·AI之旅训练营', width / 2, 400)
+      ctx.draw(false)
+    },
+    roundRect(ctx, x, y, w, h, r) {
+      ctx.beginPath()
+      ctx.moveTo(x + r, y)
+      ctx.lineTo(x + w - r, y)
+      ctx.arcTo(x + w, y, x + w, y + r, r)
+      ctx.lineTo(x + w, y + h - r)
+      ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
+      ctx.lineTo(x + r, y + h)
+      ctx.arcTo(x, y + h, x, y + h - r, r)
+      ctx.lineTo(x, y + r)
+      ctx.arcTo(x, y, x + r, y, r)
+      ctx.closePath()
+      ctx.fill()
+    },
+    copyCode() {
+      if (!this.inviteCode) return
+      uni.setClipboardData({
+        data: this.inviteCode,
+        success: function() {
+          uni.showToast({ title: '邀请码已复制', icon: 'success' })
+        }
+      })
+    },
+    savePoster() {
+      var self = this
+      if (this.saving) return
+      this.saving = true
+      uni.canvasToTempFilePath({
+        canvasId: 'posterCanvas',
+        success: function(res) {
+          uni.saveImageToPhotosAlbum({
+            filePath: res.tempFilePath,
+            success: function() {
+              uni.showToast({ title: '海报已保存到相册', icon: 'success' })
+            },
+            fail: function(err) {
+              if (err && (err.errMsg || '').indexOf('auth') > -1) {
+                uni.showModal({
+                  title: '需要相册权限',
+                  content: '请在设置中开启「保存到相册」权限',
+                  confirmText: '去设置',
+                  success: function(r) {
+                    if (r.confirm) {
+                      uni.openSetting({})
+                    }
+                  }
+                })
+              } else {
+                uni.showToast({ title: '保存失败,请稍后重试', icon: 'none' })
+              }
+            },
+            complete: function() {
+              self.saving = false
+            }
+          })
+        },
+        fail: function() {
+          self.saving = false
+          uni.showToast({ title: '海报生成失败', icon: 'none' })
+        }
+      })
+    },
+    goStats() {
+      uni.navigateTo({ url: '/pages/share/stats' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.share-page { min-height: 100vh; background: #F5F5F5; padding: 24rpx 32rpx; }
+.poster-card { background: #FFF; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; display: flex; justify-content: center; }
+.poster-canvas { width: 300px; height: 450px; border-radius: 12rpx; }
+.invite-row { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; display: flex; align-items: center; }
+.invite-label { font-size: 28rpx; color: #1E293B; font-weight: 600; flex-shrink: 0; }
+.invite-code { flex: 1; font-size: 32rpx; color: #F97316; font-weight: 700; font-family: monospace; margin-left: 24rpx; letter-spacing: 4rpx; }
+.copy-btn { flex-shrink: 0; height: 60rpx; line-height: 60rpx; padding: 0 32rpx; background: #F97316; color: #FFF; font-size: 26rpx; border-radius: 30rpx; border: none; }
+.copy-btn[disabled] { background: #FDBA74; color: #FFF; }
+.save-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: 8rpx; }
+.save-btn:active { opacity: 0.85; }
+.stats-link { display: flex; align-items: center; justify-content: center; padding: 32rpx 0; color: #64748B; font-size: 28rpx; }
+.stats-link .arrow { color: #94A3B8; font-size: 32rpx; margin-left: 8rpx; }
+</style>

+ 78 - 0
train-frontend/pages/share/stats.vue

@@ -0,0 +1,78 @@
+<template>
+  <view class="stats-page">
+    <view class="stat-card">
+      <view class="stat-row">
+        <view class="stat-item">
+          <text class="stat-num">{{ stats.invited }}</text>
+          <text class="stat-label">邀请人数</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-num">{{ stats.paid }}</text>
+          <text class="stat-label">成功付费</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-num">{{ stats.rewards }}</text>
+          <text class="stat-label">已获奖励</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="tip-card">
+      <text class="tip-title">如何获得奖励?</text>
+      <text class="tip-desc">1. 在我的海报页复制专属邀请码</text>
+      <text class="tip-desc">2. 把海报/邀请码分享给朋友</text>
+      <text class="tip-desc">3. 朋友报名时填写邀请码并完成付费</text>
+      <text class="tip-desc">4. 你将自动获得一张私教激励卡券</text>
+    </view>
+
+    <view class="poster-link" @click="goPoster">
+      <text>去生成我的专属海报</text>
+      <text class="arrow">›</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getInviteStats } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      stats: { invited: 0, paid: 0, rewards: 0 }
+    }
+  },
+  onShow() {
+    this.loadStats()
+  },
+  methods: {
+    loadStats() {
+      var self = this
+      getInviteStats().then(function(resp) {
+        var data = resp.data || {}
+        self.stats = {
+          invited: data.invited || 0,
+          paid: data.paid || 0,
+          rewards: data.rewards || 0
+        }
+      }).catch(function() {})
+    },
+    goPoster() {
+      uni.navigateBack({})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.stats-page { min-height: 100vh; background: #F5F5F5; padding: 24rpx 32rpx; }
+.stat-card { background: #FFF; border-radius: 16rpx; padding: 40rpx 24rpx; margin-bottom: 24rpx; }
+.stat-row { display: flex; justify-content: space-around; }
+.stat-item { display: flex; flex-direction: column; align-items: center; flex: 1; }
+.stat-num { font-size: 56rpx; font-weight: 700; color: #F97316; }
+.stat-label { font-size: 26rpx; color: #64748B; margin-top: 12rpx; }
+.tip-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+.tip-title { display: block; font-size: 30rpx; font-weight: 700; color: #1E293B; margin-bottom: 16rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+.tip-desc { display: block; font-size: 26rpx; color: #475569; line-height: 48rpx; }
+.poster-link { display: flex; align-items: center; justify-content: center; padding: 32rpx 0; color: #64748B; font-size: 28rpx; }
+.poster-link .arrow { color: #94A3B8; font-size: 32rpx; margin-left: 8rpx; }
+</style>

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

@@ -220,6 +220,14 @@ export const cancelPayOrder = (data) => {
   return request('/api/pay/cancel', 'POST', data)
 }
 
+// 裂变分享
+export const getMyPoster = () => {
+  return request('/api/invite/myposter', 'POST')
+}
+export const getInviteStats = () => {
+  return request('/api/invite/stats', 'POST')
+}
+
 // 文件上传
 export const uploadFile = (filePath) => {
   return new Promise((resolve, reject) => {

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

@@ -0,0 +1,5 @@
+import request from '@/utils/request'
+
+export function inviteFunnel() {
+  return request.post('/api/admin/invite/funnel', {})
+}

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

@@ -46,6 +46,12 @@ const routes = [
         component: () => import('@/views/Groups.vue'),
         meta: { title: '分组管理' }
       },
+      {
+        path: 'invites',
+        name: 'Invites',
+        component: () => import('@/views/Invites.vue'),
+        meta: { title: '转介绍漏斗' }
+      },
       {
         path: 'users',
         name: 'Users',

+ 98 - 0
train-web/src/views/Invites.vue

@@ -0,0 +1,98 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">转介绍漏斗</h2>
+      <div class="filter-bar">
+        <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="inviterId" label="ID" width="80" />
+      <el-table-column prop="inviterName" label="邀请人" width="120" />
+      <el-table-column prop="phone" label="手机号" width="140" />
+      <el-table-column label="邀请数" width="130">
+        <template slot-scope="scope">
+          <span>{{ scope.row.invited }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="成功付费" width="130">
+        <template slot-scope="scope">
+          <el-tag :type="scope.row.paid > 0 ? 'success' : 'info'" size="small">{{ scope.row.paid }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="已发放激励" width="130">
+        <template slot-scope="scope">
+          <el-tag :type="scope.row.rewards > 0 ? 'warning' : 'info'" size="small">{{ scope.row.rewards }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="转化率" min-width="120">
+        <template slot-scope="scope">
+          <span>{{ ratePercent(scope.row.paid, scope.row.invited) }}</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-empty v-if="!loading && list.length === 0" description="暂无转介绍数据" />
+  </div>
+</template>
+
+<script>
+import { inviteFunnel } from '@/api/invite'
+
+export default {
+  name: 'Invites',
+  data: function () {
+    return {
+      list: [],
+      loading: false
+    }
+  },
+  mounted: function () {
+    this.fetchList()
+  },
+  methods: {
+    ratePercent: function (paid, invited) {
+      var total = Number(invited)
+      if (!total) return '-'
+      var p = Math.round(Number(paid) / total * 100)
+      return p + '%'
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      inviteFunnel().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

@@ -27,6 +27,10 @@
             <i class="el-icon-wallet"></i>
             <span slot="title">订单管理</span>
           </el-menu-item>
+          <el-menu-item index="/invites">
+            <i class="el-icon-share"></i>
+            <span slot="title">转介绍漏斗</span>
+          </el-menu-item>
           <el-menu-item index="/groups">
             <i class="el-icon-s-group"></i>
             <span slot="title">分组管理</span>