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

feat(profile): 新建 team-tree 页面 + pages.json 注册 + API_REFERENCE 更新

- 新增 pages/profile/team-tree.vue(树形从下往上生长、层级切换 1-5 级、消费/CF 明细)
- 注册 team-tree 到 pages/profile root
- 更新 API_REFERENCE.md 新增 4.21 扩展说明及 3 个新接口
- 修复 wealth/index.vue 删除推荐码后 logout 语法错误
Sisyphus 1 месяц назад
Родитель
Сommit
c6e7e45e59

+ 6 - 0
cfc-frontend/pages.json

@@ -1611,6 +1611,12 @@
           "style": {
             "navigationBarTitleText": "我的画像"
           }
+        },
+        {
+          "path": "team-tree",
+          "style": {
+            "navigationBarTitleText": "团队层级"
+          }
         }
       ]
     },

+ 222 - 0
cfc-frontend/pages/profile/team-tree.vue

@@ -0,0 +1,222 @@
+<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>

+ 4 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -826,6 +826,10 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/commission/balance` | 可提现余额详情 |
 | `POST /api/commission/team/list` | 团队成员列表 |
 | `POST /api/commission/funnel` | 佣金转化漏斗 |
+| `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, 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}` |
 
 ### 4.22 管理端-能量配置(`/api/admin/energy-config/*`)