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

feat(promotion): 团队页新增我的推荐人、消费额分级统计与层级树,清理富维度页推荐码残留

zxyj-web 2 недель назад
Родитель
Сommit
35d3b64b85

+ 87 - 0
cfc-frontend/__tests__/utils/team-tree.test.js

@@ -0,0 +1,87 @@
+/**
+ * Tests for cfc-frontend utils/team-tree.js
+ *
+ * Tests the pure function that converts flat /api/commission/team/tree nodes
+ * (as returned by the backend) into preorder rows usable by the tree view.
+ * These are pure functions - no uni mock needed.
+ */
+
+describe('team-tree.js', () => {
+  let teamTree
+
+  beforeAll(() => {
+    teamTree = require('@/utils/team-tree')
+  })
+
+  describe('buildTeamTreeRows', () => {
+    const nodes = [
+      { userId: 101, nickname: '直推A', level: 1, parentId: 100, memberConsumption: 32000, myCfEarnedFromMember: 9600 },
+      { userId: 102, nickname: '间推B', level: 2, parentId: 101, memberConsumption: 8000, myCfEarnedFromMember: 2400 },
+      { userId: 103, nickname: '直推C', level: 1, parentId: 100, memberConsumption: 15000, myCfEarnedFromMember: 4500 }
+    ]
+
+    test('builds preorder rows with depth from flat nodes', () => {
+      const rows = teamTree.buildTeamTreeRows(nodes)
+      expect(rows.map(r => r.node.userId)).toEqual([101, 102, 103])
+      expect(rows.map(r => r.depth)).toEqual([0, 1, 0])
+    })
+
+    test('marks hasChildren and collapsed flags', () => {
+      const rows = teamTree.buildTeamTreeRows(nodes)
+      expect(rows[0].hasChildren).toBe(true)
+      expect(rows[0].collapsed).toBe(false)
+      expect(rows[1].hasChildren).toBe(false)
+      expect(rows[2].hasChildren).toBe(false)
+    })
+
+    test('collapsed node hides its descendants only', () => {
+      const rows = teamTree.buildTeamTreeRows(nodes, [101])
+      expect(rows.map(r => r.node.userId)).toEqual([101, 103])
+      expect(rows[0].collapsed).toBe(true)
+    })
+
+    test('treats nodes with unknown parentId as roots', () => {
+      const rows = teamTree.buildTeamTreeRows(nodes)
+      expect(rows.filter(r => r.depth === 0).length).toBe(2)
+    })
+
+    test('prefers referrerId over parentId for immediate parent (materialized tree)', () => {
+      // /api/commission/team/tree 物化表查询结果中 parentId 恒为根节点,referrerId 才是直接上级
+      const materialized = [
+        { userId: 101, referrerId: 100, parentId: 100, level: 1 },
+        { userId: 102, referrerId: 101, parentId: 100, level: 2 }
+      ]
+      const rows = teamTree.buildTeamTreeRows(materialized)
+      expect(rows.map(r => r.node.userId)).toEqual([101, 102])
+      expect(rows.map(r => r.depth)).toEqual([0, 1])
+      expect(rows[0].hasChildren).toBe(true)
+      expect(rows[1].hasChildren).toBe(false)
+    })
+
+    test('rootUserId shifts root depth so "self" row can sit at depth 0', () => {
+      // team.vue 自造根行(depth=0),传入 rootUserId 后 L1 成员 depth 从 1 开始
+      const materialized = [
+        { userId: 101, referrerId: 100, parentId: 100, level: 1 },
+        { userId: 102, referrerId: 101, parentId: 100, level: 2 }
+      ]
+      const rows = teamTree.buildTeamTreeRows(materialized, [], 100)
+      expect(rows.map(r => r.node.userId)).toEqual([101, 102])
+      expect(rows.map(r => r.depth)).toEqual([1, 2])
+    })
+
+    test('returns empty array for empty nodes', () => {
+      expect(teamTree.buildTeamTreeRows([])).toEqual([])
+      expect(teamTree.buildTeamTreeRows(null)).toEqual([])
+      expect(teamTree.buildTeamTreeRows(undefined)).toEqual([])
+    })
+
+    test('guards against self-referencing cycles', () => {
+      const cyclic = [
+        { userId: 201, nickname: 'A', level: 1, parentId: 201 }
+      ]
+      const rows = teamTree.buildTeamTreeRows(cyclic)
+      expect(rows.length).toBe(1)
+      expect(rows[0].depth).toBe(0)
+    })
+  })
+})

+ 489 - 3
cfc-frontend/pages/promotion/team.vue

@@ -42,6 +42,86 @@
       </view>
     </view>
 
+    <!-- 我的推荐人 -->
+    <view class="referrer-card" v-if="referrerLoaded">
+      <view class="referrer-header">
+        <text class="referrer-title">我的推荐人</text>
+      </view>
+      <view class="referrer-body" v-if="myReferrer && myReferrer.hasReferrer">
+        <view class="referrer-avatar-wrap">
+          <image v-if="myReferrer.avatar" class="referrer-avatar-img" :src="myReferrer.avatar" mode="aspectFill"></image>
+          <text v-else class="referrer-avatar-icon">👤</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">绑定时间 {{ formatBindTime(myReferrer.bindTime) }}</text>
+        </view>
+      </view>
+      <view class="referrer-body" v-else>
+        <view class="referrer-empty">
+          <text class="referrer-empty-text">暂无推荐人,成为第一批推广伙伴吧</text>
+          <view class="referrer-invite-btn" @click="goInvite">
+            <text class="referrer-invite-text">去邀请 ›</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 团队消费额分级统计 -->
+    <view class="consumption-card" v-if="teamConsumption">
+      <view class="consumption-header">
+        <text class="consumption-title">团队消费额分级统计</text>
+        <text class="consumption-level-hint">统计 {{ teamConsumption.maxLevel || 3 }} 级</text>
+      </view>
+      <view class="consumption-total">
+        <text class="consumption-total-num">{{ formatPriceWithSymbol(teamConsumption.totalConsumption) }}</text>
+        <text class="consumption-total-label">团队总消费额</text>
+      </view>
+      <view class="consumption-rows" v-if="teamConsumption.levelBreakdown && teamConsumption.levelBreakdown.length">
+        <view class="consumption-row" v-for="(item, idx) in teamConsumption.levelBreakdown" :key="idx">
+          <text class="consumption-level-tag">L{{ item.level }}</text>
+          <text class="consumption-members">{{ item.memberCount }} 人</text>
+          <text class="consumption-amount">{{ formatPriceWithSymbol(item.consumption) }}</text>
+        </view>
+      </view>
+      <view class="consumption-total-members" v-if="teamConsumption.totalMembers">
+        <text class="consumption-total-members-text">共 {{ teamConsumption.totalMembers }} 位成员</text>
+      </view>
+    </view>
+
+    <!-- 团队层级树(自下而上生长,扁平化渲染近似呈现) -->
+    <view class="tree-card" v-if="treeLoaded">
+      <view class="tree-header">
+        <text class="tree-title">团队层级树</text>
+        <text class="tree-hint">根节点在底部 · 分支向上生长(点击节点折叠/展开)</text>
+      </view>
+      <view class="tree-container">
+        <view class="tree-row" :class="row.isSelf ? 'tree-row-self' : ''" v-for="row in treeRows" :key="row.key" :style="'padding-left:' + (row.depth * 28 + 16) + 'rpx'" @click="toggleTreeNode(row)">
+          <view class="tree-avatar-wrap">
+            <image v-if="row.node.avatar" class="tree-avatar-img" :src="row.node.avatar" mode="aspectFill"></image>
+            <text v-else class="tree-avatar-icon">👤</text>
+          </view>
+          <view class="tree-info">
+            <view class="tree-name-row">
+              <text class="tree-name">{{ row.node.nickname || '微信用户' }}</text>
+              <text class="tree-level-tag" v-if="!row.isSelf">L{{ row.node.level }}</text>
+            </view>
+            <view class="tree-meta">
+              <text class="tree-meta-item">消费 {{ formatPriceWithSymbol(row.node.memberConsumption) }}</text>
+              <text class="tree-meta-item">我得CF {{ formatPriceWithSymbol(row.node.myCfEarnedFromMember) }}</text>
+            </view>
+          </view>
+          <view class="tree-toggle" v-if="row.hasChildren">
+            <text class="tree-toggle-text">{{ row.collapsed ? '▸' : '▾' }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="tree-empty" v-if="treeRootHasChildren === false">
+        <text class="tree-empty-text">还没有推广团队成员,去邀请第一位伙伴吧</text>
+      </view>
+    </view>
+
     <!-- Tab 切换 -->
     <view class="tab-bar">
       <view class="tab-item" :class="level === 'L1' ? 'tab-active' : ''" @click="switchLevel('L1')">
@@ -133,8 +213,18 @@
 
 <script>
 import { BrandColors } from '../../utils/brand-colors.js'
-import { getCfRate, getTeamList, getTeamGrowthCurve, getTeamAllMembersConsumptionCurve, getTeamL1ConsumptionCurve } from '../../utils/api.js'
-import { parseDate } from '../../utils/format.js'
+import {
+  getCfRate,
+  getTeamList,
+  getTeamGrowthCurve,
+  getTeamAllMembersConsumptionCurve,
+  getTeamL1ConsumptionCurve,
+  getTeamConsumption,
+  getMyReferrer,
+  getTeamTree
+} from '../../utils/api.js'
+import { buildTeamTreeRows } from '../../utils/team-tree.js'
+import { parseDate, formatDateTime } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -147,12 +237,26 @@ export default {
       level: 'L1',
       loading: false,
       hasMore: true,
-      cfRate: null
+      cfRate: null,
+      // 我的推荐人
+      myReferrer: null,
+      referrerLoaded: false,
+      // 团队消费额分级统计
+      teamConsumption: null,
+      // 团队层级树
+      treeNodes: [],
+      treeRows: [],
+      treeCollapsedIds: [],
+      treeLoaded: false,
+      treeRootHasChildren: false
     }
   },
   onLoad() {
     this.loadTeamStats()
     this.loadList()
+    this.loadReferrer()
+    this.loadConsumption()
+    this.loadTree()
     // 小程序首次进入时 onLoad 与 onShow 都会触发,此标记让随后的首次 onShow
     // 跳过 loadTeamStats/loadList,避免同一接口一次冷启动被请求两遍
     this._onLoadFired = true
@@ -167,6 +271,9 @@ export default {
       // 切 Tab 返回 / 从子页面返回:刷新
       this.loadTeamStats()
       this.loadList()
+      this.loadReferrer()
+      this.loadConsumption()
+      this.loadTree()
     }
   },
   onReachBottom() {
@@ -217,6 +324,107 @@ export default {
       return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
     },
 
+    // ===== 我的推荐人 =====
+    async loadReferrer() {
+      try {
+        var memberId = uni.getStorageSync('currentMemberId') || 0
+        var res = await getMyReferrer(memberId)
+        if (res && res.code === 200 && res.data) {
+          this.myReferrer = res.data
+        } else {
+          this.myReferrer = { hasReferrer: false }
+        }
+      } catch (e) {
+        console.error('获取我的推荐人失败', e)
+        this.myReferrer = { hasReferrer: false }
+      } finally {
+        this.referrerLoaded = true
+      }
+    },
+    // 绑定时间:yyyy-MM-dd HH:mm:ss
+    formatBindTime(time) {
+      if (!time) return ''
+      return formatDateTime(time)
+    },
+    // 无推荐人时的邀请入口
+    goInvite() {
+      uni.navigateTo({ url: '/pages/promotion/invite' })
+    },
+
+    // ===== 团队消费额分级统计 =====
+    async loadConsumption() {
+      try {
+        var res = await getTeamConsumption(3)
+        if (res && res.code === 200 && res.data) {
+          this.teamConsumption = res.data
+        }
+      } catch (e) {
+        console.error('获取团队消费额分级统计失败', e)
+      }
+    },
+
+    // ===== 团队层级树 =====
+    // /api/commission/team/tree 的 nodes 不含根节点「我」,此处自造一行根行
+    async loadTree() {
+      try {
+        var res = await getTeamTree(3)
+        var data = res && res.code === 200 && res.data ? res.data : null
+        var nodes = data && data.nodes ? data.nodes : []
+        this.treeNodes = nodes
+        this.treeRootHasChildren = nodes.length > 0
+        this.rebuildTreeRows()
+      } catch (e) {
+        console.error('获取团队层级树失败', e)
+        this.treeRootHasChildren = false
+      } finally {
+        this.treeLoaded = true
+      }
+    },
+    // 依据折叠集合把扁平 nodes 拍平成渲染行
+    rebuildTreeRows() {
+      var nodes = this.treeNodes || []
+      var rootUserId = uni.getStorageSync('userId') || null
+      var rows = buildTeamTreeRows(nodes, this.treeCollapsedIds, rootUserId)
+
+      var selfCollapsed = this.treeCollapsedIds.indexOf('self') !== -1
+      var selfRow = {
+        node: {
+          userId: 'self',
+          nickname: '我',
+          avatar: '',
+          level: 0,
+          memberConsumption: 0,
+          myCfEarnedFromMember: 0
+        },
+        depth: 0,
+        hasChildren: nodes.length > 0,
+        collapsed: selfCollapsed,
+        isSelf: true,
+        key: 'self'
+      }
+      var out = [selfRow]
+
+      if (!selfCollapsed) {
+        for (var i = 0; i < rows.length; i++) {
+          rows[i].key = 'node-' + rows[i].node.userId
+          out.push(rows[i])
+        }
+      }
+      this.treeRows = out
+    },
+    // 折叠 / 展开节点
+    toggleTreeNode(row) {
+      if (!row || !row.hasChildren) return
+      var id = row.node.userId
+      var idx = this.treeCollapsedIds.indexOf(id)
+      if (idx === -1) {
+        this.treeCollapsedIds.push(id)
+      } else {
+        this.treeCollapsedIds.splice(idx, 1)
+      }
+      this.rebuildTreeRows()
+    },
+
     // 绘制团队成长曲线
     drawGrowthCurve(data) {
       var labels = data.labels || []
@@ -578,6 +786,284 @@ export default {
   margin: 0 20rpx;
 }
 
+/* ===== 我的推荐人 ===== */
+.referrer-card {
+  background: $app-surface;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+}
+.referrer-title {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $uni-text-color;
+  display: block;
+}
+.referrer-body {
+  display: flex;
+  align-items: center;
+  margin-top: 24rpx;
+}
+.referrer-avatar-wrap {
+  width: 96rpx;
+  height: 96rpx;
+  border-radius: 50%;
+  background: $status-warning-soft;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 24rpx;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+.referrer-avatar-img {
+  width: 96rpx;
+  height: 96rpx;
+  border-radius: 50%;
+}
+.referrer-avatar-icon {
+  font-size: 48rpx;
+}
+.referrer-info {
+  flex: 1;
+  min-width: 0;
+}
+.referrer-name {
+  font-size: calc(30rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $uni-text-color;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.referrer-code {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  margin-top: 6rpx;
+  display: block;
+}
+.referrer-time {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  margin-top: 4rpx;
+  display: block;
+}
+.referrer-empty {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  padding: 8rpx 0;
+}
+.referrer-empty-text {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  flex: 1;
+  min-width: 0;
+}
+.referrer-invite-btn {
+  background: $brand-primary;
+  border-radius: 32rpx;
+  padding: 14rpx 32rpx;
+  flex-shrink: 0;
+  margin-left: 20rpx;
+}
+.referrer-invite-text {
+  font-size: 26rpx;
+  color: $app-surface;
+  font-weight: 600;
+}
+
+/* ===== 团队消费额分级统计 ===== */
+.consumption-card {
+  background: $app-surface;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+}
+.consumption-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.consumption-title {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $uni-text-color;
+}
+.consumption-level-hint {
+  font-size: 22rpx;
+  color: $app-text-subtle;
+}
+.consumption-total {
+  text-align: center;
+  padding: 28rpx 0 20rpx;
+}
+.consumption-total-num {
+  font-size: calc(44rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $brand-primary;
+  display: block;
+}
+.consumption-total-label {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  margin-top: 6rpx;
+  display: block;
+}
+.consumption-rows {
+  border-top: 1rpx solid $brand-primary-light-2;
+  padding-top: 12rpx;
+}
+.consumption-row {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.consumption-level-tag {
+  font-size: 22rpx;
+  color: $status-warning-deep;
+  background: $status-warning-soft;
+  border-radius: 8rpx;
+  padding: 4rpx 0;
+  width: 68rpx;
+  text-align: center;
+  flex-shrink: 0;
+}
+.consumption-members {
+  flex: 1;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  margin: 0 20rpx;
+}
+.consumption-amount {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $uni-text-color;
+}
+.consumption-total-members {
+  text-align: center;
+  padding-top: 16rpx;
+}
+.consumption-total-members-text {
+  font-size: 22rpx;
+  color: $app-text-subtle;
+}
+
+/* ===== 团队层级树 =====
+   规格要求「自下而上生长」(根节点在底部、分支向上生长)。
+   canvas 逐节点绘图成本高,此处以「扁平行 + 缩进」近似呈现:
+   容器 flex-direction: column-reverse,DOM 顺序按前序遍历(父先于子),
+   反转后视觉上「父在下、子在上」,缩进保留父子归属,节点可折叠子树。 */
+.tree-card {
+  background: $app-surface;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+}
+.tree-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+.tree-title {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $uni-text-color;
+}
+.tree-hint {
+  font-size: 20rpx;
+  color: $app-text-subtle;
+}
+.tree-container {
+  display: flex;
+  flex-direction: column-reverse;
+}
+.tree-row {
+  display: flex;
+  align-items: center;
+  background: $uni-primary-pale;
+  border-radius: 16rpx;
+  padding: 18rpx 20rpx;
+  margin-bottom: 12rpx;
+}
+.tree-row-self {
+  border: 2rpx solid $brand-primary-light-2;
+}
+.tree-avatar-wrap {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  background: $status-warning-soft;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 18rpx;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+.tree-avatar-img {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+}
+.tree-avatar-icon {
+  font-size: 32rpx;
+}
+.tree-info {
+  flex: 1;
+  min-width: 0;
+}
+.tree-name-row {
+  display: flex;
+  align-items: center;
+}
+.tree-name {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $uni-text-color;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.tree-level-tag {
+  font-size: 20rpx;
+  color: $status-warning-deep;
+  background: $status-warning-soft;
+  border-radius: 6rpx;
+  padding: 2rpx 10rpx;
+  margin-left: 12rpx;
+  flex-shrink: 0;
+}
+.tree-meta {
+  display: flex;
+  margin-top: 6rpx;
+}
+.tree-meta-item {
+  font-size: 20rpx;
+  color: $app-text-subtle;
+  margin-right: 20rpx;
+}
+.tree-toggle {
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+.tree-toggle-text {
+  font-size: 28rpx;
+  color: $brand-primary;
+}
+.tree-empty {
+  text-align: center;
+  padding: 32rpx 0 8rpx;
+}
+.tree-empty-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
 /* ===== Tab切换 ===== */
 .tab-bar {
   display: flex;

+ 1 - 10
cfc-frontend/pages/wealth/index.vue

@@ -281,7 +281,6 @@ import MemberSelector from '@/components/MemberSelector.vue'
 import {
   getChildren,
   getWealthDetail,
-  getReferralCode,
   getReferralSummary,
   getCommissionSummary,
   getFamilyMemberList,
@@ -289,7 +288,6 @@ import {
   getFamilyEnergySandbox,
   getEnergyOverview,
   productList,
-  extractReferralCode,
   getChildBadgeStats,
   getDailyTaskOverview, reportAppEnter,
   kickFamilyMemberById,
@@ -325,8 +323,7 @@ export default {
       wealthData: {
         totalEarnings: 0,
         availableAmount: 0,
-        referralCount: 0,
-        referralCode: ''
+        referralCount: 0
       },
       // 财富成长
       deltaPercent: 0,
@@ -715,12 +712,6 @@ export default {
     },
     async loadWealthData() {
       if (this.role !== 'parent') return
-      try {
-        var codeRes = await getReferralCode()
-          this.wealthData.referralCode = extractReferralCode(codeRes)
-      } catch (e) {
-        console.error('获取邀请码失败', e)
-      }
       try {
         var summaryRes = await getReferralSummary()
         if (summaryRes && summaryRes.code === 200 && summaryRes.data) {

+ 110 - 0
cfc-frontend/utils/team-tree.js

@@ -0,0 +1,110 @@
+/**
+ * 团队层级树工具(纯函数)
+ *
+ * 后端 /api/commission/team/tree 返回扁平 nodes(不含根节点"我"),字段含:
+ * userId/nickname/avatar/level/parentId/referrerId/memberConsumption/myCfEarnedFromMember。
+ *
+ * 注意:后端按 referral_tree 物化表查询(parent_id = 当前用户),
+ * 因此响应里的 parentId 恒为根节点,真正的「直接上级」是 referrerId。
+ * 本模块优先用 referrerId 建立父子关系,缺失时回退 parentId。
+ *
+ * 本模块把扁平数据按父子关系组装成树并拍平成「前序遍历」行,
+ * 供 team.vue 的树形视图(column-reverse 自下而上生长)渲染。
+ */
+
+/**
+ * 取节点直接上级:优先 referrerId,回退 parentId。
+ * @param {Object} node 树节点
+ * @returns {*} 上级 id,无上级时返回 null
+ */
+function directParentId(node) {
+  if (!node) return null
+  if (node.referrerId !== null && node.referrerId !== undefined) return node.referrerId
+  if (node.parentId !== null && node.parentId !== undefined) return node.parentId
+  return null
+}
+
+/**
+ * 将扁平 nodes 构建为前序遍历行。
+ *
+ * @param {Array} nodes 后端返回的扁平节点数组(可为空/undefined)
+ * @param {Array} collapsedIds 已折叠的 userId 列表(其子节点不再输出)
+ * @param {*} rootUserId 根用户("我")id;传入时 L1 节点 depth 从 1 开始
+ * @returns {Array} rows = [{ node, depth, hasChildren, collapsed }]
+ */
+export function buildTeamTreeRows(nodes, collapsedIds, rootUserId) {
+  const list = Array.isArray(nodes) ? nodes : []
+  if (list.length === 0) return []
+
+  const collapseSet = {}
+  const collapsed = Array.isArray(collapsedIds) ? collapsedIds : []
+  for (let i = 0; i < collapsed.length; i++) {
+    collapseSet[String(collapsed[i])] = true
+  }
+
+  // 1. 建立 userId 集合与「直接上级 → children」映射
+  const ids = {}
+  for (let i = 0; i < list.length; i++) {
+    const n = list[i]
+    if (n && n.userId !== null && n.userId !== undefined) ids[String(n.userId)] = true
+  }
+
+  const childrenMap = {}
+  for (let i = 0; i < list.length; i++) {
+    const n = list[i]
+    if (!n || n.userId === null || n.userId === undefined) continue
+    const key = String(n.userId)
+    if (!childrenMap[key]) childrenMap[key] = []
+    const pid = directParentId(n)
+    if (pid !== null && String(pid) !== key) {
+      if (!childrenMap[String(pid)]) childrenMap[String(pid)] = []
+      childrenMap[String(pid)].push(n)
+    }
+  }
+
+  // 2. 根节点:直接上级为空、上级是"我",或上级不在节点集合中
+  const hasRoot = rootUserId !== null && rootUserId !== undefined
+  const rootKey = hasRoot ? String(rootUserId) : null
+  const roots = []
+  for (let i = 0; i < list.length; i++) {
+    const n = list[i]
+    if (!n || n.userId === null || n.userId === undefined) continue
+    const pid = directParentId(n)
+    const isRoot = pid === null ||
+      String(pid) === String(n.userId) ||
+      (hasRoot && String(pid) === rootKey) ||
+      !ids[String(pid)]
+    if (isRoot) roots.push(n)
+  }
+
+  // 3. 前序遍历;折叠节点跳过子节点;visited 防循环
+  const rows = []
+  const visited = {}
+
+  function walk(node, depth) {
+    const key = String(node.userId)
+    if (visited[key]) return
+    visited[key] = true
+
+    const children = childrenMap[key] || []
+    const isCollapsed = !!collapseSet[key]
+    rows.push({
+      node,
+      depth,
+      hasChildren: children.length > 0,
+      collapsed: isCollapsed
+    })
+
+    if (isCollapsed) return
+    for (let i = 0; i < children.length; i++) {
+      walk(children[i], depth + 1)
+    }
+  }
+
+  const rootDepth = hasRoot ? 1 : 0
+  for (let i = 0; i < roots.length; i++) {
+    walk(roots[i], rootDepth)
+  }
+
+  return rows
+}