2026-09-09-team-hierarchy.md 37 KB

团队层级树与推荐关系可视化 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 实现「我的」页面推荐人卡片、团队层级入口、独立树形页面(从下往上生长),去掉富页面推荐码,新增 3 个后端接口并扩展 1 个现有接口。

架构:

  • 后端:在 CommissionService 新增 getTeamConsumptiongetMyReferrergetTeamTree,扩展 getTeamListCommissionController 暴露 4 个 POST 接口
  • 前端:删除 wealth/index.vue 推荐码横条;profile/profile.vue 新增推荐人卡片 + 入口按钮;新建 profile/team-tree.vue 树形页面(flex column-reverse + 递归组件);utils/api.js 新增 3 个方法;pages.json 注册新页面

技术栈: Spring Boot 2.7 + MyBatis-Plus / uni-app Vue 2 (Options API)


任务 1:后端 - 新增 Mapper 依赖注入

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java:39-60

  • [ ] 步骤 1:编写测试用例(手工验证)

    # 编译验证
    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 2:添加注入字段

CommissionService 类中已有的注入字段后追加:

    @Resource
    private ProductOrderMapper productOrderMapper;

    @Resource
    private ReferralTreeMapper referralTreeMapper;

    @Resource
    private CfReferralService cfReferralService;
  • [ ] 步骤 3:编译验证

    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
    git commit -m "feat: 注入 ProductOrderMapper、ReferralTreeMapper、CfReferralService 到 CommissionService"
    

任务 2:后端 - 实现 getTeamConsumption(团队消费统计)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java(在 getTeamStats 方法后,约第 599 行后追加)

  • [ ] 步骤 1:编写 curl 测试

    curl -X POST http://localhost:9082/api/commission/team/consumption \
    -H "Authorization: Bearer <token>" \
    -H "Content-Type: application/json" \
    -d '{"maxLevel": 3}'
    # 预期:{"code":200,"data":{"totalConsumption":...,"levelBreakdown":[...],"totalMembers":...,"maxLevel":3}}
    
  • [ ] 步骤 2:实现方法

    /**
     * 团队总消费额分级汇总(按 referral_tree level 聚合)
     * maxLevel 默认 3,可后台配置
     */
    public Map<String, Object> getTeamConsumption(Long userId, Integer maxLevel) {
        if (maxLevel == null || maxLevel <= 0) maxLevel = 3;
        if (maxLevel > 10) maxLevel = 10; // 安全上限
    
        Map<String, Object> result = new LinkedHashMap<>();
        List<Map<String, Object>> levelBreakdown = new ArrayList<>();
    
        // 1. 查 referral_tree: parentId = userId 且 level <= maxLevel
        List<ReferralTree> treeNodes = referralTreeMapper.selectList(
                new LambdaQueryWrapper<ReferralTree>()
                        .eq(ReferralTree::getParentId, userId)
                        .le(ReferralTree::getLevel, maxLevel)
                        .orderByAsc(ReferralTree::getLevel)
        );
    
        if (treeNodes.isEmpty()) {
            result.put("totalConsumption", 0);
            result.put("levelBreakdown", levelBreakdown);
            result.put("totalMembers", 0);
            result.put("maxLevel", maxLevel);
            return result;
        }
    
        // 2. 按 level 分组收集 childId
        Map<Integer, List<Long>> levelToChildIds = new LinkedHashMap<>();
        for (ReferralTree node : treeNodes) {
            levelToChildIds.computeIfAbsent(node.getLevel(), k -> new ArrayList<>()).add(node.getChildId());
        }
    
        // 3. 查 product_orders: buyerId IN childIds 且 fulfillStatus = 'completed'
        int totalConsumption = 0;
        for (Map.Entry<Integer, List<Long>> entry : levelToChildIds.entrySet()) {
            Integer level = entry.getKey();
            List<Long> childIds = entry.getValue();
            if (childIds.isEmpty()) continue;
    
            LambdaQueryWrapper<ProductOrder> orderWrapper = new LambdaQueryWrapper<ProductOrder>()
                    .in(ProductOrder::getBuyerId, childIds)
                    .eq(ProductOrder::getFulfillStatus, "completed");
            Long sum = productOrderMapper.selectList(orderWrapper).stream()
                    .mapToLong(o -> o.getTotalAmount() != null ? o.getTotalAmount() : 0)
                    .sum();
    
            int memberCount = childIds.size();
            totalConsumption += sum;
    
            Map<String, Object> levelInfo = new LinkedHashMap<>();
            levelInfo.put("level", level);
            levelInfo.put("memberCount", memberCount);
            levelInfo.put("consumption", (int) sum);
            levelBreakdown.add(levelInfo);
        }
    
        result.put("totalConsumption", totalConsumption);
        result.put("levelBreakdown", levelBreakdown);
        result.put("totalMembers", treeNodes.size());
        result.put("maxLevel", maxLevel);
        return result;
    }
    
  • [ ] 步骤 3:编译验证

    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
    git commit -m "feat: 实现 getTeamConsumption 团队消费统计(按 referral_tree level 聚合,fulfillStatus=completed)"
    

任务 3:后端 - 实现 getMyReferrer(我的推荐人)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java(在 getTeamConsumption 后新增)

  • [ ] 步骤 1:实现方法

    /**
     * 获取我的推荐人信息(直推人)
     */
    public Map<String, Object> getMyReferrer(Long userId) {
        Map<String, Object> result = new LinkedHashMap<>();
        User user = userMapper.selectById(userId);
        if (user == null || user.getReferrerId() == null) {
            result.put("hasReferrer", false);
            return result;
        }
        User referrer = userMapper.selectById(user.getReferrerId());
        if (referrer == null) {
            result.put("hasReferrer", false);
            return result;
        }
        result.put("hasReferrer", true);
        result.put("referrerId", referrer.getId());
        result.put("nickname", referrer.getNickname());
        result.put("avatar", referrer.getAvatar());
        result.put("referralCode", referrer.getReferralCode());
        result.put("bindTime", user.getCreatedAt()); // 以用户创建时间作为绑定时间近似
        return result;
    }
    
  • [ ] 步骤 2:编译验证

    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
    git commit -m "feat: 实现 getMyReferrer 获取我的直推人信息"
    

任务 4:后端 - 实现 getTeamTree(树形扁平数据)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java(在 getMyReferrer 后新增)

  • [ ] 步骤 1:实现方法

    /**
     * 获取团队树形扁平数据(不含根节点,仅 level>=1 成员)
     * 每个节点包含 memberConsumption、myCfEarnedFromMember
     */
    public Map<String, Object> getTeamTree(Long userId, Integer maxLevel) {
        if (maxLevel == null || maxLevel <= 0) maxLevel = 3;
        if (maxLevel > 10) maxLevel = 10;
    
        Map<String, Object> result = new LinkedHashMap<>();
        List<Map<String, Object>> nodes = new ArrayList<>();
    
        // 1. 查 referral_tree
        List<ReferralTree> treeNodes = referralTreeMapper.selectList(
                new LambdaQueryWrapper<ReferralTree>()
                        .eq(ReferralTree::getParentId, userId)
                        .le(ReferralTree::getLevel, maxLevel)
                        .orderByAsc(ReferralTree::getLevel)
        );
    
        if (treeNodes.isEmpty()) {
            result.put("nodes", nodes);
            result.put("totalMembers", 0);
            result.put("maxLevel", maxLevel);
            return result;
        }
    
        // 2. 批量查用户基本信息
        List<Long> childIds = treeNodes.stream().map(ReferralTree::getChildId).distinct().collect(Collectors.toList());
        Map<Long, User> userMap = userMapper.selectBatchIds(childIds).stream()
                .collect(Collectors.toMap(User::getId, u -> u));
    
        // 3. 按成员聚合 memberConsumption(fulfillStatus=completed 订单总额)
        Map<Long, Integer> memberConsumptionMap = new HashMap<>();
        if (!childIds.isEmpty()) {
            List<ProductOrder> orders = productOrderMapper.selectList(
                    new LambdaQueryWrapper<ProductOrder>()
                            .in(ProductOrder::getBuyerId, childIds)
                            .eq(ProductOrder::getFulfillStatus, "completed")
            );
            for (ProductOrder o : orders) {
                if (o.getBuyerId() != null && o.getTotalAmount() != null) {
                    memberConsumptionMap.merge(o.getBuyerId(), o.getTotalAmount(), Integer::sum);
                }
            }
        }
    
        // 4. 按成员聚合 myCfEarnedFromMember(commission_records 已结算+待结算,排除 cancelled)
        Map<Long, Integer> myCfEarnedMap = new HashMap<>();
        if (!childIds.isEmpty()) {
            List<CommissionRecord> records = commissionRecordMapper.selectList(
                    new LambdaQueryWrapper<CommissionRecord>()
                            .eq(CommissionRecord::getReferrerId, userId)
                            .in(CommissionRecord::getBuyerId, childIds)
                            .ne(CommissionRecord::getStatus, "cancelled")
            );
            for (CommissionRecord r : records) {
                if (r.getBuyerId() != null && r.getCommissionAmount() != null) {
                    myCfEarnedMap.merge(r.getBuyerId(), r.getCommissionAmount(), Integer::sum);
                }
            }
        }
    
        // 5. 组装 nodes(不含根节点,仅 level>=1 的成员)
        for (ReferralTree node : treeNodes) {
            User u = userMap.get(node.getChildId());
            if (u == null) continue;
    
            Map<String, Object> n = new LinkedHashMap<>();
            n.put("userId", node.getChildId());
            n.put("nickname", u.getNickname());
            n.put("avatar", u.getAvatar());
            n.put("level", node.getLevel());
            n.put("parentId", node.getParentId());
            n.put("referrerId", u.getReferrerId());
            n.put("memberConsumption", memberConsumptionMap.getOrDefault(node.getChildId(), 0));
            n.put("myCfEarnedFromMember", myCfEarnedMap.getOrDefault(node.getChildId(), 0));
            nodes.add(n);
        }
    
        result.put("nodes", nodes);
        result.put("totalMembers", nodes.size());
        result.put("maxLevel", maxLevel);
        return result;
    }
    
  • [ ] 步骤 2:编译验证

    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
    git commit -m "feat: 实现 getTeamTree 树形扁平数据(含 memberConsumption、myCfEarnedFromMember)"
    

任务 5:后端 - 扩展 getTeamList(新增消费/CF 字段)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.javagetTeamList 方法,约第 460-543 行)

  • [ ] 步骤 1:在 getTeamList 中,循环查询 memberConsumption 和 myCfEarnedFromMember 之前,改为预查所有成员的汇总

在现有 getTeamList 方法中,找到 for (Map.Entry<Long, Map<String, Object>> entry : memberMap.entrySet()) 循环之前,插入预查逻辑(替换现有循环内的逐成员查询):

        // 预查所有成员的 memberConsumption 和 myCfEarnedFromMember
        Set<Long> allBuyerIds = memberMap.keySet();
        Map<Long, Integer> memberConsumptionMap = new HashMap<>();
        Map<Long, Integer> myCfEarnedMap = new HashMap<>();

        if (!allBuyerIds.isEmpty()) {
            // memberConsumption:该成员已完成履约订单总额
            LambdaQueryWrapper<ProductOrder> consumptionWrapper = new LambdaQueryWrapper<ProductOrder>()
                    .in(ProductOrder::getBuyerId, allBuyerIds)
                    .eq(ProductOrder::getFulfillStatus, "completed");
            for (ProductOrder o : productOrderMapper.selectList(consumptionWrapper)) {
                if (o.getBuyerId() != null && o.getTotalAmount() != null) {
                    memberConsumptionMap.merge(o.getBuyerId(), o.getTotalAmount(), Integer::sum);
                }
            }
            // myCfEarnedFromMember:我从该成员消费获得的 CF 总额
            LambdaQueryWrapper<CommissionRecord> cfWrapper = new LambdaQueryWrapper<CommissionRecord>()
                    .eq(CommissionRecord::getReferrerId, userId)
                    .in(CommissionRecord::getBuyerId, allBuyerIds)
                    .ne(CommissionRecord::getStatus, "cancelled");
            if ("L1".equals(level)) cfWrapper.eq(CommissionRecord::getLevel, 1);
            else if ("L2".equals(level)) cfWrapper.eq(CommissionRecord::getLevel, 2);
            SortUtil.applySort(cfWrapper);
            for (CommissionRecord r : commissionRecordMapper.selectList(cfWrapper)) {
                if (r.getBuyerId() != null && r.getCommissionAmount() != null) {
                    myCfEarnedMap.merge(r.getBuyerId(), r.getCommissionAmount(), Integer::sum);
                }
            }
        }
  • 步骤 2:在现有循环内部,用预查结果替换原来的逐成员查询,加入新字段

找到现有循环内:

            int commissionEarned = commissionRecordMapper.selectList(commissionWrapper).stream()
                    .mapToInt(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : 0)
                    .sum();
            memberInfo.put("commissionEarned", commissionEarned);

替换为:

            memberInfo.put("memberConsumption", memberConsumptionMap.getOrDefault(buyerId, 0));
            memberInfo.put("myCfEarnedFromMember", myCfEarnedMap.getOrDefault(buyerId, 0));
            // 保留原有 commissionEarned(用于展示佣金)
            int commissionEarned = commissionRecordMapper.selectList(commissionWrapper).stream()
                    .mapToInt(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : 0)
                    .sum();
            memberInfo.put("commissionEarned", commissionEarned);
  • [ ] 步骤 3:编译验证

    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 4:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
    git commit -m "feat: 扩展 getTeamList 新增 memberConsumption、myCfEarnedFromMember 字段"
    

任务 6:后端 - 新增 Controller 端点

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java(在类末尾前追加 3 个方法)

  • [ ] 步骤 1:追加 3 个新端点

    /**
     * 团队消费额分级汇总
     */
    @PostMapping("/team/consumption")
    public Result<Map<String, Object>> teamConsumption(@RequestAttribute("userId") Long userId,
                                                        @RequestBody Map<String, Object> params) {
        Integer maxLevel = params.get("maxLevel") != null
                ? ((Number) params.get("maxLevel")).intValue()
                : 3;
        return Result.success(commissionService.getTeamConsumption(userId, maxLevel));
    }
    
    /**
     * 获取我的推荐人
     */
    @PostMapping("/my-referrer")
    public Result<Map<String, Object>> myReferrer(@RequestAttribute("userId") Long userId) {
        return Result.success(commissionService.getMyReferrer(userId));
    }
    
    /**
     * 团队树形扁平数据
     */
    @PostMapping("/team/tree")
    public Result<Map<String, Object>> teamTree(@RequestAttribute("userId") Long userId,
                                                 @RequestBody Map<String, Object> params) {
        Integer maxLevel = params.get("maxLevel") != null
                ? ((Number) params.get("maxLevel")).intValue()
                : 3;
        return Result.success(commissionService.getTeamTree(userId, maxLevel));
    }
    
  • [ ] 步骤 2:编译验证

    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java
    git commit -m "feat: 新增 /api/commission/team/consumption、/my-referrer、/team/tree 接口"
    

任务 7:后端 - 更新 API_REFERENCE.md

文件:

  • 修改:docs/superpowers/api/API_REFERENCE.md(在 4.21 佣金管理章节后追加)

  • [ ] 步骤 1:追加新接口文档

### 4.21 佣金管理(/api/commission/*) 之后新增:

### 4.22 团队消费与推荐关系(`/api/commission/*` 扩展)

| 路径 | 说明 |
|------|------|
| `POST /api/commission/team/list` | 扩展:返回中每个成员新增 `memberConsumption`(该成员自身完成履约订单总额,分)、`myCfEarnedFromMember`(我从该成员消费获得的 CF 总额,分) |
| `POST /api/commission/team/consumption` | 团队总消费额分级汇总,请求 `{maxLevel}`(默认 3),返回 `{totalConsumption: 分, levelBreakdown[{level, memberCount, consumption}], totalMembers, maxLevel}` |
| `POST /api/commission/my-referrer` | 获取我的直推人信息,返回 `{hasReferrer: bool, referrerId, nickname, avatar, referralCode, bindTime}`,无推荐人时 hasReferrer=false |
| `POST /api/commission/team/tree` | 团队树形扁平数据(不含根节点),请求 `{maxLevel}`(默认 3),返回 `{nodes[{userId, nickname, avatar, level, parentId, referrerId, memberConsumption, myCfEarnedFromMember}], totalMembers, maxLevel}` |
  • [ ] 步骤 2:Commit

    git add docs/superpowers/api/API_REFERENCE.md
    git commit -m "docs: 更新 API_REFERENCE.md 新增团队消费与推荐关系接口"
    

任务 8:前端 - 删除富页面推荐码横条

文件:

  • 修改:cfc-frontend/pages/wealth/index.vue:161-165

  • [ ] 步骤 1:删除模板中的 referral-code-bar 区块

    -       <!-- 邀请码复制条 -->
    -       <view class="referral-code-bar" v-if="wealthData.referralCode" @click="copyReferralCode">
    -         <text class="referral-code-label">我的邀请码</text>
    -         <text class="referral-code-value">{{ wealthData.referralCode }}</text>
    -         <text class="referral-code-copy">复制</text>
    -       </view>
    
  • [ ] 步骤 2:删除 CSS 中 .referral-code-bar 相关样式

    - /* 邀请码复制条 */
    - .referral-code-bar {
    -   display: flex;
    -   align-items: center;
    -   background: #fff;
    -   margin-top: 16rpx;
    -   border-radius: 16rpx;
    -   padding: 24rpx 30rpx;
    -   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
    - }
    - .referral-code-label { font-size: 26rpx; color: #666; margin-right: 16rpx; }
    - .referral-code-value { flex: 1; font-size: 30rpx; font-weight: bold; color: #F59E0B; letter-spacing: 2rpx; }
    - .referral-code-copy { font-size: 24rpx; color: #fff; background: linear-gradient(135deg, #F59E0B, #FBBF24); padding: 8rpx 24rpx; border-radius: 20rpx; }
    
  • [ ] 步骤 3:删除 methods 中的 copyReferralCode 方法(如不再使用)

检查 copyReferralCode 是否在其他地方使用,若未使用则删除。

  • [ ] 步骤 4:Commit

    git add cfc-frontend/pages/wealth/index.vue
    git commit -m "feat(wealth): 删除富页面推荐码横条"
    

任务 9:前端 - 我的页面新增推荐人卡片 + 团队入口

文件:

  • 修改:cfc-frontend/pages/profile-main/profile.vue

  • [ ] 步骤 1:import 部分追加

    // 在现有 import 中追加
    import { getMyReferrer } from '../../utils/api.js'
    
  • [ ] 步骤 2:data() 追加字段

    data() {
    return {
    // ...existing fields...
    myReferrer: null
    }
    }
    
  • [ ] 步骤 3:methods 新增加载与工具方法

    methods: {
    // ...existing methods...
    async loadMyReferrer() {
    var self = this
    try {
      var res = await getMyReferrer()
      if (res && res.code === 200 && res.data && res.data.hasReferrer) {
        self.myReferrer = res.data
      }
    } catch (e) {
      console.error('获取推荐人失败', e)
    }
    },
    goTeamTree() {
    uni.navigateTo({ url: '/pages/profile/team-tree' })
    },
    copyReferrerCode() {
    if (!this.myReferrer || !this.myReferrer.referralCode) return
    uni.setClipboardData({
      data: this.myReferrer.referralCode,
      success: function() {
        uni.showToast({ title: '邀请码已复制', icon: 'success' })
      }
    })
    }
    }
    
  • [ ] 步骤 4:onShow 中调用 loadMyReferrer

    onShow() {
    if (!this.isLoggedIn) return
    // ...existing...
    this.loadMyReferrer()
    }
    
  • [ ] 步骤 5:模板中在「个人成就统计」section 前插入以下 HTML

    <!-- 我的推荐人卡片 -->
    <view class="referrer-card" v-if="myReferrer && myReferrer.hasReferrer">
    <view class="referrer-header">
    <text class="referrer-title">我的推荐人</text>
    </view>
    <view class="referrer-content">
    <view class="referrer-avatar-wrap">
      <image v-if="myReferrer.avatar" class="referrer-avatar" :src="myReferrer.avatar" mode="aspectFill"></image>
      <text v-else class="referrer-avatar-text">{{ myReferrer.nickname ? myReferrer.nickname.charAt(0) : '?' }}</text>
    </view>
    <view class="referrer-info">
      <text class="referrer-name">{{ myReferrer.nickname }}</text>
      <text class="referrer-code">邀请码:{{ myReferrer.referralCode }}</text>
      <text class="referrer-time" v-if="myReferrer.bindTime">{{ formatReferralTime(myReferrer.bindTime) }}</text>
    </view>
    <view class="referrer-copy-btn" @click="copyReferrerCode">
      <text>复制</text>
    </view>
    </view>
    </view>
    
    <!-- 团队层级入口 -->
    <view class="team-tree-entry" @click="goTeamTree">
    <view class="entry-icon">🌳</view>
    <view class="entry-text">
    <text class="entry-title">团队层级</text>
    <text class="entry-desc">查看团队结构与消费明细</text>
    </view>
    <text class="entry-arrow">›</text>
    </view>
    
  • [ ] 步骤 6:添加时间格式化方法

    methods: {
    // ...existing...
    formatReferralTime(ts) {
    if (!ts) return ''
    var d = ts
    if (typeof d === 'string' && d.indexOf('T') > -1) {
      d = d.substring(0, 16).replace('T', ' ')
    }
    return '绑定于 ' + d
    }
    }
    
  • [ ] 步骤 7:在 style 末尾追加样式

    /* 我的推荐人卡片 */
    .referrer-card {
    margin: 0 30rpx 20rpx;
    background: #fff;
    border-radius: 20rpx;
    padding: 24rpx;
    box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
    }
    .referrer-header { margin-bottom: 16rpx; }
    .referrer-title { font-size: 28rpx; font-weight: 600; color: #1E293B; }
    .referrer-content { display: flex; align-items: center; gap: 20rpx; }
    .referrer-avatar-wrap {
    width: 72rpx; height: 72rpx; border-radius: 50%; background: #FEF3C7;
    display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0;
    }
    .referrer-avatar { width: 72rpx; height: 72rpx; border-radius: 50%; }
    .referrer-avatar-text { font-size: 28rpx; color: #F97316; }
    .referrer-info { flex: 1; min-width: 0; }
    .referrer-name { font-size: 28rpx; font-weight: 500; color: #1E293B; display: block; }
    .referrer-code { font-size: 24rpx; color: #F97316; margin-top: 4rpx; display: block; }
    .referrer-time { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
    .referrer-copy-btn {
    font-size: 26rpx; color: #F97316; padding: 8rpx 16rpx; border: 1rpx solid #FED7AA;
    border-radius: 20rpx; flex-shrink: 0;
    }
    .team-tree-entry {
    margin: 0 30rpx 20rpx;
    background: linear-gradient(135deg, #FFF7ED, rgba(249,115,22,0.06));
    border-radius: 20rpx; padding: 24rpx;
    display: flex; align-items: center; gap: 20rpx;
    box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
    border-left: 6rpx solid #F97316;
    }
    .entry-icon { font-size: 40rpx; }
    .entry-text { flex: 1; min-width: 0; }
    .entry-title { font-size: 28rpx; font-weight: 600; color: #1E293B; display: block; }
    .entry-desc { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
    .entry-arrow { font-size: 28rpx; color: #F97316; }
    
  • [ ] 步骤 8:Commit

    git add cfc-frontend/pages/profile-main/profile.vue
    git commit -m "feat(profile): 新增我的推荐人卡片与团队层级入口"
    

任务 10:前端 - 新增 api.js 方法

文件:

  • 修改:cfc-frontend/utils/api.js(在现有 commission 相关 export 后追加)

  • [ ] 步骤 1:追加 3 个 export 函数

    export const getTeamConsumption = (maxLevel = 3) => request('/api/commission/team/consumption', 'POST', { maxLevel })
    
    export const getMyReferrer = () => request('/api/commission/my-referrer', 'POST')
    
    export const getTeamTree = (maxLevel = 3) => request('/api/commission/team/tree', 'POST', { maxLevel })
    
  • [ ] 步骤 2:Commit

    git add cfc-frontend/utils/api.js
    git commit -m "feat(api): 新增 getTeamConsumption、getMyReferrer、getTeamTree"
    

任务 11:前端 - 新建 pages/profile/team-tree.vue

文件:

  • 创建:cfc-frontend/pages/profile/team-tree.vue

  • [ ] 步骤 1:创建完整页面文件

    <template>
    <view class="container">
    <!-- 顶部统计卡 -->
    <view class="stats-card">
      <view class="stat-item">
        <text class="stat-value orange">{{ fenToYuan(consumption.totalConsumption) }}</text>
        <text class="stat-label">团队总消费</text>
      </view>
      <view class="stat-divider"></view>
      <view class="stat-item">
        <text class="stat-value blue">{{ consumption.totalMembers }}</text>
        <text class="stat-label">团队人数</text>
      </view>
      <view class="stat-divider"></view>
      <view class="stat-item">
        <text class="stat-value green">{{ currentMaxLevel }}</text>
        <text class="stat-label">统计层级</text>
      </view>
    </view>
    
    <!-- 层级切换 tabs -->
    <view class="level-tabs">
      <view class="level-tab" :class="{ active: currentMaxLevel === 1 }" @click="switchLevel(1)">1级</view>
      <view class="level-tab" :class="{ active: currentMaxLevel === 2 }" @click="switchLevel(2)">2级</view>
      <view class="level-tab" :class="{ active: currentMaxLevel === 3 }" @click="switchLevel(3)">3级</view>
      <view class="level-tab" :class="{ active: currentMaxLevel === 4 }" @click="switchLevel(4)">4级</view>
      <view class="level-tab" :class="{ active: currentMaxLevel === 5 }" @click="switchLevel(5)">5级</view>
    </view>
    
    <!-- 树形区域(从下往上生长:column-reverse,L1 在底部) -->
    <view class="tree-wrapper">
      <view class="tree-container">
        <TreeNode
          v-for="root in level1Roots"
          :key="root.userId"
          :node="root"
          :level="1"
          :maxLevel="currentMaxLevel"
        />
      </view>
      <view class="empty-state" v-if="!loading && level1Roots.length === 0">
        <text>暂无团队成员</text>
      </view>
    </view>
    
    <view class="loading-overlay" v-if="loading">
      <text>加载中...</text>
    </view>
    </view>
    </template>
    
    <script>
    import { getTeamTree, getTeamConsumption } from '@/utils/api.js'
    
    export default {
    components: { TreeNode: {
    name: 'TreeNode',
    props: { node: Object, level: Number, maxLevel: Number },
    data() { return { expanded: false } },
    methods: {
      toggle() { this.expanded = !this.expanded }
    },
    template: `
      <view class="tree-node" :style="{ paddingLeft: (level > 1 ? (level - 1) * 24 : 0) + 'rpx' }">
        <view class="node-main" @click="toggle">
          <image v-if="node.avatar" class="node-avatar" :src="node.avatar" mode="aspectFill" />
          <text v-else class="node-avatar-text">{{ node.nickname ? node.nickname.charAt(0) : '?' }}</text>
          <text class="node-name">{{ node.nickname }}</text>
          <text class="node-tag" v-if="level > 1">L{{ level }}</text>
          <view class="node-stats">
            <text class="node-stat consumption">消费 ¥{{ (node.memberConsumption / 100).toFixed(2) }}</text>
            <text class="node-stat my-cf">得CF ¥{{ (node.myCfEarnedFromMember / 100).toFixed(2) }}</text>
          </view>
          <text class="node-arrow">{{ expanded ? '▲' : '▼' }}</text>
        </view>
        <view v-if="expanded && node.children && node.children.length > 0" class="children">
          <TreeNode
            v-for="child in node.children"
            :key="child.userId"
            :node="child"
            :level="level + 1"
            :maxLevel="maxLevel"
          />
        </view>
      </view>
    `
    }},
    data() {
    return {
      nodes: [],
      level1Roots: [],
      consumption: { totalConsumption: 0, totalMembers: 0, maxLevel: 3 },
      currentMaxLevel: 3,
      loading: false
    }
    },
    onLoad() {
    this.loadData()
    },
    onShow() {
    if (!this._loadFired) {
      this._loadFired = true
    } else {
      this.loadData()
    }
    },
    methods: {
    async loadData() {
      this.loading = true
      try {
        var self = this
        var treeRes = await getTeamTree(self.currentMaxLevel)
        var consumptionRes = await getTeamConsumption(self.currentMaxLevel)
        if (treeRes && treeRes.data && treeRes.data.nodes) {
          self.nodes = treeRes.data.nodes
          self.buildLevel1Roots()
        }
        if (consumptionRes && consumptionRes.data) {
          self.consumption = consumptionRes.data
          self.consumption.maxLevel = self.consumption.maxLevel || self.currentMaxLevel
        }
      } catch (e) {
        console.error('加载团队树失败', e)
      }
      this.loading = false
    },
    switchLevel(level) {
      if (this.currentMaxLevel === level) return
      this.currentMaxLevel = level
      this.loadData()
    },
    buildLevel1Roots() {
      var self = this
      // 将扁平 nodes 构建成以 L1 为根的子树
      var nodeMap = {}
      var l1Ids = []
      self.nodes.forEach(function(n) {
        nodeMap[n.userId] = { userId: n.userId, children: [] }
        if (n.level === 1) l1Ids.push(n.userId)
      })
      self.nodes.forEach(function(n) {
        if (!nodeMap[n.userId]) return
        nodeMap[n.userId] = Object.assign({}, nodeMap[n.userId], n)
        if (n.parentId && nodeMap[n.parentId]) {
          nodeMap[n.parentId].children.push(nodeMap[n.userId])
        }
      })
      // 按 userId 排序
      var sortNodes = function(arr) {
        arr.sort(function(a, b) { return (a.userId || 0) - (b.userId || 0) })
        arr.forEach(function(c) { sortNodes(c.children) })
      }
      sortNodes(l1Ids.map(function(id) { return nodeMap[id] }))
      self.level1Roots = l1Ids.map(function(id) { return nodeMap[id] })
    },
    fenToYuan(fen) {
      if (fen === null || fen === undefined) return '0.00'
      var num = Number(fen)
      if (isNaN(num)) return '0.00'
      return (num / 100).toFixed(2)
    }
    }
    }
    </script>
    
    <style scoped>
    .container { min-height: 100vh; background: #FFF7ED; padding-bottom: 40rpx; }
    .stats-card {
    background: #fff; border-radius: 20rpx; padding: 30rpx; margin: 20rpx 30rpx 16rpx;
    display: flex; align-items: center; box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
    }
    .stat-item { flex: 1; text-align: center; }
    .stat-value { font-size: 36rpx; font-weight: bold; display: block; }
    .stat-value.orange { color: #F97316; }
    .stat-value.blue { color: #0EA5E9; }
    .stat-value.green { color: #10B981; }
    .stat-label { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
    .stat-divider { width: 1rpx; height: 48rpx; background: #FED7AA; }
    .level-tabs {
    display: flex; padding: 0 30rpx; margin-bottom: 16rpx; overflow-x: auto; gap: 12rpx;
    white-space: nowrap;
    }
    .level-tab {
    flex-shrink: 0; padding: 12rpx 24rpx; border-radius: 20rpx;
    font-size: 24rpx; color: #64748B; background: #F1F5F9;
    }
    .level-tab.active { background: #F97316; color: #fff; font-weight: 600; }
    .tree-wrapper { padding: 0 30rpx; }
    .tree-container { min-height: 300rpx; }
    .tree-node { margin-bottom: 16rpx; }
    .node-main {
    display: flex; align-items: center; gap: 16rpx;
    background: #fff; border-radius: 16rpx; padding: 20rpx;
    box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
    }
    .node-avatar { width: 60rpx; height: 60rpx; border-radius: 50%; background: #FEF3C7; flex-shrink: 0; }
    .node-avatar-text {
    width: 60rpx; height: 60rpx; border-radius: 50%; background: #FEF3C7;
    display: flex; align-items: center; justify-content: center;
    font-size: 24rpx; color: #F97316; flex-shrink: 0;
    }
    .node-name { flex: 1; min-width: 0; font-size: 26rpx; color: #1E293B; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .node-tag { font-size: 20rpx; background: #FFF7ED; color: #F97316; padding: 2rpx 8rpx; border-radius: 8rpx; flex-shrink: 0; }
    .node-stats { flex-shrink: 0; text-align: right; margin-right: 8rpx; }
    .node-stat { font-size: 20rpx; display: block; line-height: 1.5; }
    .node-stat.consumption { color: #F97316; }
    .node-stat.my-cf { color: #0EA5E9; }
    .node-arrow { font-size: 20rpx; color: #94A3B8; flex-shrink: 0; }
    .children { margin-top: 12rpx; padding-left: 32rpx; border-left: 1rpx dashed #FED7AA; }
    .empty-state { text-align: center; padding: 80rpx; color: #94A3B8; font-size: 28rpx; }
    .loading-overlay {
    position: fixed; top: 0; left: 0; right: 0; bottom: 0;
    background: rgba(255,255,255,0.85); display: flex; align-items: center; justify-content: center;
    z-index: 999; font-size: 28rpx; color: #94A3B8;
    }
    </style>
    
  • [ ] 步骤 2:语法检查

    cd cfc-frontend && node --check pages/profile/team-tree.vue 2>&1 || true
    
  • [ ] 步骤 3:Commit

    git add cfc-frontend/pages/profile/team-tree.vue
    git commit -m "feat(profile): 新建 team-tree 页面(树形从下往上生长、层级切换、消费/CF明细)"
    

任务 12:前端 - 注册 pages.json 新页面

文件:

  • 修改:cfc-frontend/pages.json(在 pages/profile root 下的 pages 数组追加)

  • [ ] 步骤 1:找到 pages/profile root 并追加

pages.json 中找到 "root": "pages/profile" 的 section,在数组末尾追加:

{
  "path": "pages/profile/team-tree",
  "style": {
    "navigationBarTitleText": "团队层级",
    "enablePullDownRefresh": false
  }
}
  • [ ] 步骤 2:语法验证

    cd cfc-frontend && node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8'))" && echo "JSON valid"
    
  • [ ] 步骤 3:Commit

    git add cfc-frontend/pages.json
    git commit -m "feat(pages.json): 注册 team-tree 页面"
    

任务 13:联调验证 + API 审计

文件:

  • 无新增文件

  • [ ] 步骤 1:编译后端

    cd cfc-backend && mvn clean compile
    
  • [ ] 步骤 2:启动后端服务(HBuilderX 或命令行)并手动验证接口

    # 后端编译通过后启动,在微信开发者工具中打开小程序测试
    # 关键接口验证点:
    # 1. GET /api/commission/team/consumption?maxLevel=3 → 200
    # 2. POST /api/commission/my-referrer → 200
    # 3. POST /api/commission/team/tree?maxLevel=3 → 200
    # 4. POST /api/commission/team/list?level=L1 → 200, records[].memberConsumption exists
    
  • [ ] 步骤 3:HBuilderX 打包小程序

在 HBuilderX 中打开 cfc-frontend/,运行到微信开发者工具,依次验证:

  • 富页面:无推荐码横条,有「邀请人数」
  • 我的页面:有推荐人卡片(显示昵称/头像/邀请码/复制按钮)、有「团队层级」入口
  • 点击「团队层级」→ 进入 team-tree,显示统计卡 + 层级 tabs + 树形
  • 切换层级 tabs → 树形和统计更新
  • 点击节点 → 展开/折叠
  • 无推荐人用户 → 推荐人卡片不显示
  • 空团队 → 显示「暂无团队成员」

  • [ ] 步骤 4:API 重复调用审计

    cd cfc-frontend && node scripts/audit-duplicate-api-calls.js
    # 预期:0 error
    
  • [ ] 步骤 5:Commit

    git add -A
    git commit -m "chore: 联调验证通过 + API审计0 error"
    

自检清单(实施后逐条确认)

  • 富页面无推荐码横条,保留「邀请人数」数字
  • 我的页面有推荐人卡片(有推荐人时显示;无推荐人时不显示)
  • 我的页面有「团队层级」入口,点击跳转 /pages/profile/team-tree
  • team-tree 页面:
    • 顶部统计卡:总消费额(分转元)、人数、层级数
    • 1-5 级切换 tabs,切换后统计和树形同步更新
    • 树形从下往上生长(L1 在底部,L2/L3 向上延伸)
    • 每节点显示:头像、昵称、L标签、消费额、我得CF、展开图标
    • 点击节点展开/折叠,子节点在上方渲染,虚线连接正确
    • 空团队显示「暂无团队成员」
  • 复制推荐码按钮正常工作
  • 后端 4 个接口响应正确,字段名与设计文档一致
  • mvn clean compile 通过
  • audit-duplicate-api-calls.js 0 error
  • API_REFERENCE.md 已同步更新

文档结束