Bladeren bron

feat(team): 新建 TreeNode 递归组件与团队层级树页面(规格 2026-09-09-team-hierarchy-design 差距补齐)

- components/TreeNode.vue: 递归树节点组件(头像/昵称/L层级/消费额/我得CF,展开折叠),
  自下而上生长,子节点经 column-reverse 向上渲染;沿用 mp-html/node 自引用模式
- pages/profile/team-tree.vue: 团队层级树页面(顶部统计卡 + 1-5级切换 + 树形渲染 + 空态),
  对接既有 /api/commission/team/tree 与 /team/consumption
- pages.json: 注册 pages/profile/team-tree 分包页面

后端接口与 utils/api.js 封装已存在,本次仅补前端缺失文件
Sisyphus Agent 2 weken geleden
bovenliggende
commit
a4896424ae
3 gewijzigde bestanden met toevoegingen van 494 en 0 verwijderingen
  1. 223 0
      cfc-frontend/components/TreeNode.vue
  2. 7 0
      cfc-frontend/pages.json
  3. 264 0
      cfc-frontend/pages/profile/team-tree.vue

+ 223 - 0
cfc-frontend/components/TreeNode.vue

@@ -0,0 +1,223 @@
+<template>
+  <view class="tree-node" :style="{ paddingLeft: paddingLeft }">
+    <!-- 节点主体:头像、昵称、层级、消费额、我得CF,点击展开/折叠 -->
+    <view class="node-main" @click="toggle">
+      <view class="avatar-wrap">
+        <image v-if="node.avatar" class="node-avatar" :src="node.avatar" mode="aspectFill" lazy-load />
+        <text v-else class="node-avatar-text">{{ avatarInitial }}</text>
+      </view>
+      <view class="node-body">
+        <view class="node-header-row">
+          <text class="node-name">{{ node.nickname || '微信用户' }}</text>
+          <text class="node-tag" :class="'node-tag-L' + nodeLevel">L{{ nodeLevel }}</text>
+        </view>
+        <view class="node-stats">
+          <text class="node-stat consumption">消费 ¥{{ consumptionText }}</text>
+          <text class="node-stat my-cf">我得CF ¥{{ cfText }}</text>
+        </view>
+      </view>
+      <view class="node-toggle" v-if="hasChildren">
+        <text class="toggle-icon">{{ toggleIcon }}</text>
+      </view>
+    </view>
+    <!-- 子节点:父容器为 column-reverse,子节点在上方渲染,形成“从下往上生长”的层级树 -->
+    <view class="node-children" v-if="expanded && hasChildren">
+      <TreeNode
+        v-for="child in node.children"
+        :key="child.userId"
+        :node="child"
+        :level="childLevel"
+        :maxLevel="maxLevel"
+      />
+    </view>
+  </view>
+</template>
+
+<script>
+import TreeNode from './TreeNode'
+
+export default {
+  name: 'TreeNode',
+  components: { TreeNode },
+  props: {
+    node: {
+      type: Object,
+      default: function() {
+        return {}
+      }
+    },
+    level: {
+      type: Number,
+      default: 1
+    },
+    maxLevel: {
+      type: Number,
+      default: 3
+    }
+  },
+  data: function() {
+    return {
+      expanded: false
+    }
+  },
+  computed: {
+    childLevel: function() {
+      return this.level + 1
+    },
+    nodeLevel: function() {
+      return this.node && this.node.level ? this.node.level : this.level
+    },
+    avatarInitial: function() {
+      var name = this.node && this.node.nickname ? String(this.node.nickname) : ''
+      return name.charAt(0) || '?'
+    },
+    hasChildren: function() {
+      var children = this.node && this.node.children
+      return Array.isArray(children) && children.length > 0
+    },
+    toggleIcon: function() {
+      return this.expanded ? '▲' : '▼'
+    },
+    consumptionText: function() {
+      var fen = this.node && this.node.memberConsumption != null ? this.node.memberConsumption : 0
+      return (fen / 100).toFixed(2)
+    },
+    cfText: function() {
+      var fen = this.node && this.node.myCfEarnedFromMember != null ? this.node.myCfEarnedFromMember : 0
+      return (fen / 100).toFixed(2)
+    },
+    paddingLeft: function() {
+      return this.level > 1 ? (this.level - 1) * 24 + 'rpx' : '0rpx'
+    }
+  },
+  methods: {
+    toggle: function() {
+      this.expanded = !this.expanded
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.tree-node {
+  margin-bottom: 16rpx;
+}
+
+.node-main {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  background: $app-surface;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
+}
+
+.avatar-wrap {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  background: $status-warning-soft;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 0;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+
+.node-avatar {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+}
+
+.node-avatar-text {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  background: $status-warning-soft;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24rpx;
+  color: $status-warning-deep;
+  flex-shrink: 0;
+}
+
+.node-body {
+  flex: 1;
+  min-width: 0;
+}
+
+.node-header-row {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+  margin-bottom: 6rpx;
+}
+
+.node-name {
+  flex: 1;
+  min-width: 0;
+  font-size: 26rpx;
+  color: $app-text;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.node-tag {
+  font-size: 20rpx;
+  padding: 2rpx 8rpx;
+  border-radius: 8rpx;
+  flex-shrink: 0;
+}
+
+.node-tag-L1 {
+  color: $brand-primary-deep;
+  background: $status-warning-soft;
+}
+
+.node-tag-L2 {
+  color: $status-success-deep;
+  background: $status-success-tint;
+}
+
+.node-tag-L3 {
+  color: $brand-primary-deep;
+  background: $brand-primary-tint-2;
+}
+
+.node-stats {
+  flex-shrink: 0;
+  text-align: right;
+  margin-right: 8rpx;
+  line-height: 1.5;
+}
+
+.node-stat {
+  font-size: 20rpx;
+  display: block;
+}
+
+.node-stat.consumption {
+  color: $status-warning-deep;
+}
+
+.node-stat.my-cf {
+  color: $status-success-strong;
+}
+
+.node-toggle {
+  font-size: 20rpx;
+  color: $app-text-subtle;
+  flex-shrink: 0;
+}
+
+.node-children {
+  margin-top: 12rpx;
+  padding-left: 30rpx;
+  border-left: 1rpx dashed $brand-primary-light-2;
+}
+</style>

+ 7 - 0
cfc-frontend/pages.json

@@ -1888,6 +1888,13 @@
           "style": {
             "navigationBarTitleText": ""
           }
+        },
+        {
+          "path": "team-tree",
+          "style": {
+            "navigationBarTitleText": "团队层级",
+            "enablePullDownRefresh": false
+          }
         }
       ]
     },

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

@@ -0,0 +1,264 @@
+<template>
+  <view class="container">
+    <!-- 顶部统计卡:团队总消费 / 人数 / 统计层级 -->
+    <view class="stats-card">
+      <view class="stat-item">
+        <text class="stat-value orange">{{ totalConsumptionYuan }}</text>
+        <text class="stat-label">团队总消费</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-value blue">{{ 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 === lvl }"
+        @click="switchLevel(lvl)"
+        v-for="lvl in levels"
+        :key="lvl">
+        {{ lvl }}级
+      </view>
+    </view>
+
+    <!-- 树形区域(从下往上生长:column-reverse,L1 在底部,分支向上延伸) -->
+    <view class="tree-wrapper">
+      <view class="tree-container" v-if="level1Roots.length > 0">
+        <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 TreeNode from '@/components/TreeNode.vue'
+import { getTeamTree, getTeamConsumption } from '@/utils/api.js'
+
+export default {
+  components: { TreeNode },
+  data: function() {
+    return {
+      nodes: [],
+      level1Roots: [],
+      consumption: { totalConsumption: 0, totalMembers: 0, maxLevel: 3 },
+      currentMaxLevel: 3,
+      levels: [1, 2, 3, 4, 5],
+      loading: false,
+      _loadFired: false
+    }
+  },
+  computed: {
+    totalConsumptionYuan: function() {
+      var fen = this.consumption && this.consumption.totalConsumption != null ? this.consumption.totalConsumption : 0
+      return (fen / 100).toFixed(2)
+    }
+  },
+  onLoad: function() {
+    this.loadData()
+  },
+  onShow: function() {
+    // 首次 onShow 跳过(onLoad 已加载);切 Tab 返回时刷新
+    if (this._loadFired) {
+      this.loadData()
+    } else {
+      this._loadFired = true
+    }
+  },
+  methods: {
+    async loadData: function() {
+      this.loading = true
+      try {
+        var treeRes = await getTeamTree(this.currentMaxLevel)
+        var consumptionRes = await getTeamConsumption(this.currentMaxLevel)
+        if (treeRes && treeRes.data && Array.isArray(treeRes.data.nodes)) {
+          this.nodes = treeRes.data.nodes
+          this.buildLevel1Roots()
+        }
+        if (consumptionRes && consumptionRes.data) {
+          var d = consumptionRes.data
+          this.consumption = {
+            totalConsumption: d.totalConsumption || 0,
+            totalMembers: d.totalMembers || 0,
+            maxLevel: d.maxLevel != null ? d.maxLevel : this.currentMaxLevel
+          }
+        }
+      } catch (e) {
+        console.error('加载团队树失败', e)
+      }
+      this.loading = false
+    },
+    switchLevel: function(lvl) {
+      if (this.currentMaxLevel === lvl) {
+        return
+      }
+      this.currentMaxLevel = lvl
+      this.loadData()
+    },
+    buildLevel1Roots: function() {
+      var nodeMap = {}
+      var l1Ids = []
+      this.nodes.forEach(function(n) {
+        nodeMap[n.userId] = Object.assign({ children: [] }, n)
+        if (n.level === 1) {
+          l1Ids.push(n.userId)
+        }
+      })
+      this.nodes.forEach(function(n) {
+        if (!nodeMap[n.userId]) {
+          return
+        }
+        if (n.parentId && nodeMap[n.parentId]) {
+          nodeMap[n.parentId].children.push(nodeMap[n.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]
+        })
+      )
+      this.level1Roots = l1Ids.map(function(id) {
+        return nodeMap[id]
+      })
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.container {
+  min-height: 100vh;
+  background: $dim-wealth-tint;
+  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;
+  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;
+  display: flex;
+  flex-direction: column-reverse;
+}
+
+.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>