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

feat: 家庭推广体系重构 P3+P4

P3: 小程序推广中心重构
- cfc-frontend/utils/api.js: 新增6个家庭收益API函数
- cfc-frontend/pages/promotion/index.vue: 重设计(家庭数据卡片+成员贡献榜+管理员面板+孩子只读+支付预留)

P4: 管理后台家庭收益管理
- cfc-backend: 新增 AdminEarningsController (3个管理端点)
- cfc-web/src/api/admin.js: 新增家庭收益管理API
- cfc-web/src/router/index.js: 新增两个路由
- cfc-web/src/views/admin/FamilyEarnings.vue: 家庭收益列表+详情弹窗
- cfc-web/src/views/admin/FamilyEarningsWithdraw.vue: 家庭收益流水查看
Xiaogang Liao 2 месяцев назад
Родитель
Сommit
c5e23cc538

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminEarningsController.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyEarningsSummaryDTO;
+import com.etotem.cfc.entity.FamilyEarnings;
+import com.etotem.cfc.entity.FamilyEarningsRecord;
+import com.etotem.cfc.mapper.FamilyEarningsMapper;
+import com.etotem.cfc.mapper.FamilyEarningsRecordMapper;
+import com.etotem.cfc.service.FamilyEarningsService;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/earnings")
+public class AdminEarningsController {
+
+    @Resource
+    private FamilyEarningsService familyEarningsService;
+
+    @Resource
+    private FamilyEarningsMapper familyEarningsMapper;
+
+    @Resource
+    private FamilyEarningsRecordMapper familyEarningsRecordMapper;
+
+    @PostMapping("/families")
+    public Result<List<FamilyEarnings>> listFamilies(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        List<FamilyEarnings> list = familyEarningsMapper.selectList(null);
+        return Result.success(list);
+    }
+
+    @PostMapping("/family/{familyId}/summary")
+    public Result<FamilyEarningsSummaryDTO> familySummary(@PathVariable Long familyId) {
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+        return Result.success(familyEarningsService.getFamilySummary(familyId));
+    }
+
+    @PostMapping("/family/{familyId}/records")
+    public Result<List<FamilyEarningsRecord>> familyRecords(@PathVariable Long familyId, @RequestBody Map<String, Object> params) {
+        String type = (String) params.get("type");
+        return Result.success(familyEarningsService.getRecords(familyId, type));
+    }
+}

+ 564 - 15
cfc-frontend/pages/promotion/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <scroll-view class="container" scroll-y>
+  <scroll-view class="container" scroll-y scroll-with-animation>
     <!-- 邀请码区域 -->
     <view class="invite-card">
       <view class="code-section">
@@ -13,6 +13,103 @@
       <button class="share-btn" @click="shareCard">分享邀请卡</button>
     </view>
 
+    <!-- 家庭公共收益总览 -->
+    <view class="family-earnings-card" v-if="familySummary">
+      <view class="fe-header">
+        <view class="fe-title-row">
+          <text class="fe-title">🏠 家庭公共收益</text>
+          <text class="fe-badge" v-if="isAdmin">管理员</text>
+        </view>
+        <text class="fe-subtitle">家庭成员共同努力的成果</text>
+      </view>
+      <view class="fe-grid">
+        <view class="fe-item">
+          <text class="fe-value orange">{{ fenToYuan(familySummary.totalEarned) }}</text>
+          <text class="fe-label">累计推荐收入</text>
+        </view>
+        <view class="fe-item">
+          <text class="fe-value blue">{{ fenToYuan(familySummary.availableAmount) }}</text>
+          <text class="fe-label">可支配余额</text>
+        </view>
+        <view class="fe-item">
+          <text class="fe-value green">{{ fenToYuan(familySummary.withdrawnAmount) }}</text>
+          <text class="fe-label">已提现</text>
+        </view>
+        <view class="fe-item">
+          <text class="fe-value purple">{{ fenToYuan(familySummary.distributedAmount) }}</text>
+          <text class="fe-label">已分配</text>
+        </view>
+      </view>
+
+      <!-- 管理员操作按钮(仅管理员可见) -->
+      <view class="fe-actions" v-if="isAdmin">
+        <button class="fe-btn fe-btn-withdraw" @click="showWithdrawSheet = true">提现</button>
+        <button class="fe-btn fe-btn-distribute" @click="showDistributeSheet = true">分配收益</button>
+      </view>
+    </view>
+
+    <!-- 成员贡献榜 -->
+    <view class="members-card" v-if="members && members.length > 0">
+      <view class="members-header">
+        <text class="members-title">👥 成员贡献榜</text>
+        <text class="members-toggle" @click="membersExpanded = !membersExpanded">
+          {{ membersExpanded ? '收起' : '展开全部(' + members.length + ')' }}
+        </text>
+      </view>
+      <view class="member-list">
+        <view class="member-item" v-for="(m, idx) in displayMembers" :key="m.userId">
+          <view class="member-rank">
+            <text class="rank-num" v-if="idx < 3 && !showAllRanks">🏅</text>
+            <text class="rank-num rank-plain" v-else>{{ idx + 1 }}</text>
+          </view>
+          <image v-if="m.avatar" class="member-avatar" :src="m.avatar" mode="aspectFill"></image>
+          <text v-else class="member-avatar-placeholder">{{ m.nickname ? m.nickname.charAt(0) : '?' }}</text>
+          <view class="member-info">
+            <text class="member-name">{{ m.nickname || '用户' }}</text>
+            <text class="member-role-tag" v-if="m.isAdmin">管理员</text>
+            <text class="member-balance-tag" v-if="m.canUseFamilyBalance">池可用</text>
+            <text class="member-balance-tag disabled-tag" v-else>池关闭</text>
+          </view>
+          <view class="member-amounts">
+            <text class="member-amount allocated">+{{ fenToYuan(m.allocatedAmount) }}</text>
+            <text class="member-amount consumed">-{{ fenToYuan(m.consumedAmount) }}</text>
+            <text class="member-amount available">={{ fenToYuan(m.availableAmount) }}</text>
+          </view>
+          <!-- 管理员可切换成员池权限 -->
+          <switch v-if="isAdmin && !m.isAdmin" class="member-switch" :checked="m.canUseFamilyBalance" @change="toggleMemberAccess(m)" color="#F97316"></switch>
+        </view>
+      </view>
+    </view>
+
+    <!-- 提现弹窗 -->
+    <view class="sheet-mask" v-if="showWithdrawSheet" @click="showWithdrawSheet = false">
+      <view class="sheet-content" @click.stop>
+        <text class="sheet-title">家庭收益提现</text>
+        <text class="sheet-hint">可提现余额: {{ fenToYuan(familySummary ? familySummary.availableAmount : 0) }}</text>
+        <input class="sheet-input" type="digit" v-model="withdrawAmount" placeholder="输入提现金额(分)" />
+        <view class="sheet-actions">
+          <button class="sheet-cancel" @click="showWithdrawSheet = false">取消</button>
+          <button class="sheet-confirm" @click="doWithdraw" :disabled="!withdrawAmount || withdrawAmount <= 0">确认提现</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 分配弹窗 -->
+    <view class="sheet-mask" v-if="showDistributeSheet" @click="showDistributeSheet = false">
+      <view class="sheet-content" @click.stop>
+        <text class="sheet-title">分配收益</text>
+        <text class="sheet-hint">选择成员并输入分配金额(分)</text>
+        <picker class="sheet-picker" mode="selector" :range="memberOptions" :value="distributeMemberIdx" @change="onDistributeMemberChange">
+          <text>{{ distributeMemberIdx >= 0 ? memberOptions[distributeMemberIdx] : '选择成员' }}</text>
+        </picker>
+        <input class="sheet-input" type="digit" v-model="distributeAmount" placeholder="输入分配金额(分)" />
+        <view class="sheet-actions">
+          <button class="sheet-cancel" @click="showDistributeSheet = false">取消</button>
+          <button class="sheet-confirm" @click="doDistribute" :disabled="distributeMemberIdx < 0 || !distributeAmount || distributeAmount <= 0">确认分配</button>
+        </view>
+      </view>
+    </view>
+
     <!-- 统计卡片 -->
     <view class="stats-cards">
       <view class="stat-card today-card">
@@ -33,15 +130,14 @@
     <view class="funnel-section" v-if="funnelSteps && funnelSteps.length > 0">
       <text class="section-title">转化漏斗</text>
       <view class="funnel">
-        <view class="funnel-step" v-for="(step, index) in funnelSteps" :key="index"
-              :style="{width: step.width + '%'}">
+        <view class="funnel-step" v-for="(step, index) in funnelSteps" :key="index" :style="{width: step.width + '%'}">
           <text class="funnel-label">{{ step.label }}</text>
           <text class="funnel-count">{{ step.count }}</text>
         </view>
       </view>
     </view>
 
-    <!-- 收益览 -->
+    <!-- 收益览 -->
     <view class="earnings-card">
       <view class="earnings-header">
         <text class="earnings-title">收益总览</text>
@@ -75,7 +171,7 @@
         </view>
         <text class="menu-text">佣金明细</text>
       </view>
-      <view class="menu-item" @click="goWithdraw">
+      <view class="menu-item" @click="goWithdraw" v-if="!isChild">
         <view class="menu-icon-wrap icon-withdraw">
           <text class="menu-icon-text">💰</text>
         </view>
@@ -99,15 +195,23 @@
         </view>
         <text class="menu-text">推广排行榜</text>
       </view>
+      <view class="menu-item" @click="goRecords" v-if="isAdmin">
+        <view class="menu-icon-wrap icon-records">
+          <text class="menu-icon-text">📊</text>
+        </view>
+        <text class="menu-text">家庭流水</text>
+      </view>
     </view>
 
-    <SharePoster :show="showPoster" :referralCode="referralCode" :qrCodeBase64="qrCodeBase64" :nickname="userNickname" :userAvatar="userAvatar" @close="showPoster = false" />
+    <!-- 底部安全区 -->
+    <view style="height:40rpx"></view>
 
+    <SharePoster :show="showPoster" :referralCode="referralCode" :qrCodeBase64="qrCodeBase64" :nickname="userNickname" :userAvatar="userAvatar" @close="showPoster = false" />
   </scroll-view>
 </template>
 
 <script>
-import { getReferralCode, getReferralSummary, getCommissionSummary, getInviteQrCode } from '../../utils/api.js'
+import { getReferralCode, getReferralSummary, getCommissionSummary, getInviteQrCode, getFamilyEarningsSummary, getFamilyEarningsMembers, getFamilyEarningsRecords, distributeToMember, toggleMemberBalance, withdrawFamilyEarnings } from '../../utils/api.js'
 import SharePoster from '@/components/SharePoster.vue'
 
 export default {
@@ -124,12 +228,41 @@ export default {
       userNickname: '',
       stats: {},
       funnelData: {},
-      funnelSteps: []
+      funnelSteps: [],
+      // 家庭收益
+      familySummary: null,
+      members: [],
+      membersExpanded: false,
+      showAllRanks: false,
+      // 弹窗
+      showWithdrawSheet: false,
+      showDistributeSheet: false,
+      withdrawAmount: '',
+      distributeAmount: '',
+      distributeMemberIdx: -1,
+      memberOptions: []
     }
   },
   computed: {
     isLoggedIn() {
       return !!uni.getStorageSync('token')
+    },
+    role() {
+      return uni.getStorageSync('role') || ''
+    },
+    isAdmin() {
+      // 管理员 = parent 或 teacher 角色
+      var r = this.role
+      return r === 'parent' || r === 'teacher'
+    },
+    isChild() {
+      return this.role === 'child'
+    },
+    displayMembers() {
+      if (this.membersExpanded || this.showAllRanks) {
+        return this.members
+      }
+      return this.members.slice(0, 5)
     }
   },
   onLoad() {
@@ -138,6 +271,7 @@ export default {
   onShow() {
     this.loadStats()
     this.loadFunnelData()
+    this.loadFamilyData()
   },
   onShareAppMessage() {
     var code = this.referralCode || ''
@@ -154,7 +288,7 @@ export default {
   methods: {
     async loadData() {
       try {
-        const codeRes = await getReferralCode()
+        var codeRes = await getReferralCode()
         if (codeRes.data) {
           this.referralCode = codeRes.data.referralCode || codeRes.data.code || ''
         }
@@ -162,7 +296,7 @@ export default {
         console.error('获取邀请码失败', e)
       }
       try {
-        const summaryRes = await getReferralSummary()
+        var summaryRes = await getReferralSummary()
         if (summaryRes.data) {
           this.totalEarnings = summaryRes.data.totalEarnings || '0.00'
         }
@@ -170,7 +304,7 @@ export default {
         console.error('获取邀请统计失败', e)
       }
       try {
-        const comRes = await getCommissionSummary()
+        var comRes = await getCommissionSummary()
         if (comRes.data) {
           this.summary = comRes.data
           this.availableAmount = comRes.data.availableAmount || '0.00'
@@ -179,7 +313,38 @@ export default {
         console.error('获取佣金总览失败', e)
       }
     },
-    loadStats: function() {
+    async loadFamilyData() {
+      if (this.isChild) {
+        // 孩子只读:仅加载概要,不加载管理员功能
+        try {
+          var sRes = await getFamilyEarningsSummary()
+          if (sRes.data) {
+            this.familySummary = sRes.data
+            this.members = sRes.data.members || []
+          }
+        } catch (e) {
+          console.error('获取家庭收益概览失败', e)
+        }
+        return
+      }
+      try {
+        var sRes = await getFamilyEarningsSummary()
+        if (sRes.data) {
+          this.familySummary = sRes.data
+          this.members = sRes.data.members || []
+          // 构建picker选项
+          var opts = []
+          var arr = this.members
+          for (var i = 0; i < arr.length; i++) {
+            opts.push((arr[i].nickname || '用户' + arr[i].userId) + ' (余额:' + this.fenToYuan(arr[i].availableAmount) + ')')
+          }
+          this.memberOptions = opts
+        }
+      } catch (e) {
+        console.error('获取家庭收益概览失败', e)
+      }
+    },
+    loadStats() {
       var self = this
       var token = uni.getStorageSync('token')
       var baseUrl = ''
@@ -200,7 +365,7 @@ export default {
         }
       })
     },
-    loadFunnelData: function() {
+    loadFunnelData() {
       var self = this
       var token = uni.getStorageSync('token')
       var baseUrl = ''
@@ -222,7 +387,7 @@ export default {
         }
       })
     },
-    buildFunnelSteps: function() {
+    buildFunnelSteps() {
       var data = this.funnelData
       var max = Math.max(data.invites || 1, data.registered || 0, data.purchased || 0, data.repeatPurchased || 0, data.totalOrders || 0)
       if (max < 1) max = 1
@@ -234,6 +399,95 @@ export default {
         { label: '订单', count: data.totalOrders || 0, width: Math.round(((data.totalOrders || 0) / max) * 100) }
       ]
     },
+    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)
+    },
+    formatPriceWithSymbol(val) {
+      if (!val && val !== 0) return '¥0.00'
+      var num = Number(val)
+      if (isNaN(num)) return '¥0.00'
+      // val 已经是元单位(后端返回的 commission summary 是元)
+      return '¥' + Number(num).toFixed(2)
+    },
+    // 管理员操作
+    toggleMemberAccess(m) {
+      var self = this
+      var enabled = !m.canUseFamilyBalance
+      uni.showLoading({ title: '操作中...' })
+      toggleMemberBalance({ userId: m.userId, enabled: enabled }).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200) {
+          m.canUseFamilyBalance = enabled
+          uni.showToast({ title: '操作成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '操作失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: '操作失败', icon: 'none' })
+      })
+    },
+    onDistributeMemberChange(e) {
+      this.distributeMemberIdx = e.detail.value
+    },
+    doDistribute() {
+      var idx = this.distributeMemberIdx
+      if (idx < 0 || !this.members[idx]) {
+        uni.showToast({ title: '请选择成员', icon: 'none' })
+        return
+      }
+      var amount = Number(this.distributeAmount)
+      if (!amount || amount <= 0) {
+        uni.showToast({ title: '请输入有效金额', icon: 'none' })
+        return
+      }
+      var self = this
+      uni.showLoading({ title: '分配中...' })
+      distributeToMember({ userId: this.members[idx].userId, amount: amount }).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200) {
+          uni.showToast({ title: '分配成功', icon: 'success' })
+          self.showDistributeSheet = false
+          self.distributeAmount = ''
+          self.distributeMemberIdx = -1
+          self.loadFamilyData()
+        } else {
+          uni.showToast({ title: res.message || '分配失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: '分配失败', icon: 'none' })
+      })
+    },
+    doWithdraw() {
+      var amount = Number(this.withdrawAmount)
+      if (!amount || amount <= 0) {
+        uni.showToast({ title: '请输入有效金额', icon: 'none' })
+        return
+      }
+      var self = this
+      uni.showLoading({ title: '提现中...' })
+      withdrawFamilyEarnings({ amount: amount }).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200) {
+          uni.showToast({ title: '提现申请已提交', icon: 'success' })
+          self.showWithdrawSheet = false
+          self.withdrawAmount = ''
+          self.loadFamilyData()
+        } else {
+          uni.showToast({ title: res.message || '提现失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: '提现失败', icon: 'none' })
+      })
+    },
+    goRecords() {
+      uni.navigateTo({ url: '/pages/promotion/records' })
+    },
     copyCode() {
       if (!this.referralCode) return
       uni.setClipboardData({
@@ -331,6 +585,300 @@ export default {
 .share-btn::after { border: none; }
 .share-btn:active { opacity: 0.8; }
 
+/* ===== 家庭公共收益卡 ===== */
+.family-earnings-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+  border-left: 6rpx solid #F97316;
+}
+.fe-header {
+  margin-bottom: 24rpx;
+}
+.fe-title-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.fe-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.fe-badge {
+  font-size: 20rpx;
+  color: #F97316;
+  background: #FFF7ED;
+  padding: 4rpx 14rpx;
+  border-radius: 20rpx;
+}
+.fe-subtitle {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-top: 6rpx;
+  display: block;
+}
+.fe-grid {
+  display: flex;
+  flex-wrap: wrap;
+  margin-bottom: 20rpx;
+}
+.fe-item {
+  width: 50%;
+  text-align: center;
+  padding: 16rpx 0;
+}
+.fe-value {
+  font-size: 36rpx;
+  font-weight: bold;
+  display: block;
+}
+.fe-value.orange { color: #F97316; }
+.fe-value.blue { color: #0EA5E9; }
+.fe-value.green { color: #10B981; }
+.fe-value.purple { color: #8B5CF6; }
+.fe-label {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-top: 6rpx;
+  display: block;
+}
+.fe-actions {
+  display: flex;
+  gap: 20rpx;
+  padding-top: 16rpx;
+  border-top: 1rpx solid #FED7AA;
+}
+.fe-btn {
+  flex: 1;
+  height: 64rpx;
+  line-height: 64rpx;
+  font-size: 26rpx;
+  border-radius: 32rpx;
+  text-align: center;
+}
+.fe-btn::after { border: none; }
+.fe-btn-withdraw {
+  background: #F97316;
+  color: #fff;
+}
+.fe-btn-distribute {
+  background: #FFF7ED;
+  color: #F97316;
+  border: 1rpx solid #FED7AA;
+}
+
+/* ===== 成员贡献榜 ===== */
+.members-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+}
+.members-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+.members-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.members-toggle {
+  font-size: 24rpx;
+  color: #F97316;
+}
+.member-list {
+  display: flex;
+  flex-direction: column;
+}
+.member-item {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #FFF7ED;
+}
+.member-item:last-child {
+  border-bottom: none;
+}
+.member-rank {
+  width: 48rpx;
+  flex-shrink: 0;
+  text-align: center;
+}
+.rank-num {
+  font-size: 28rpx;
+}
+.rank-plain {
+  font-size: 24rpx;
+  color: #94A3B8;
+  font-weight: 500;
+}
+.member-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+  background: #FEF3C7;
+}
+.member-avatar-placeholder {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+  background: #FEF3C7;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24rpx;
+  color: #F97316;
+  text-align: center;
+  line-height: 56rpx;
+}
+.member-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8rpx;
+}
+.member-name {
+  font-size: 26rpx;
+  color: #1E293B;
+  font-weight: 500;
+  max-width: 140rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.member-role-tag {
+  font-size: 18rpx;
+  color: #fff;
+  background: #F97316;
+  padding: 2rpx 10rpx;
+  border-radius: 12rpx;
+}
+.member-balance-tag {
+  font-size: 18rpx;
+  color: #10B981;
+  background: #D1FAE5;
+  padding: 2rpx 10rpx;
+  border-radius: 12rpx;
+}
+.member-balance-tag.disabled-tag {
+  color: #94A3B8;
+  background: #F1F5F9;
+}
+.member-amounts {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+}
+.member-amount {
+  font-size: 20rpx;
+  line-height: 1.6;
+}
+.member-amount.allocated { color: #10B981; }
+.member-amount.consumed { color: #EF4444; }
+.member-amount.available { color: #F97316; font-weight: 600; }
+.member-switch {
+  transform: scale(0.7);
+  flex-shrink: 0;
+}
+
+/* ===== 弹窗 ===== */
+.sheet-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.4);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+}
+.sheet-content {
+  background: #fff;
+  border-radius: 30rpx 30rpx 0 0;
+  padding: 40rpx 32rpx;
+  padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
+  width: 100%;
+  max-width: 750rpx;
+  box-sizing: border-box;
+}
+.sheet-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1E293B;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.sheet-hint {
+  font-size: 24rpx;
+  color: #94A3B8;
+  display: block;
+  margin-bottom: 24rpx;
+}
+.sheet-input {
+  height: 80rpx;
+  border: 1rpx solid #FED7AA;
+  border-radius: 16rpx;
+  padding: 0 24rpx;
+  font-size: 28rpx;
+  color: #1E293B;
+  margin-bottom: 24rpx;
+}
+.sheet-picker {
+  height: 80rpx;
+  border: 1rpx solid #FED7AA;
+  border-radius: 16rpx;
+  padding: 0 24rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+  margin-bottom: 24rpx;
+  display: flex;
+  align-items: center;
+}
+.sheet-actions {
+  display: flex;
+  gap: 20rpx;
+}
+.sheet-cancel {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  background: #F1F5F9;
+  color: #64748B;
+  font-size: 28rpx;
+  border-radius: 36rpx;
+}
+.sheet-cancel::after { border: none; }
+.sheet-confirm {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  background: #F97316;
+  color: #fff;
+  font-size: 28rpx;
+  border-radius: 36rpx;
+}
+.sheet-confirm::after { border: none; }
+.sheet-confirm[disabled] {
+  opacity: 0.5;
+}
+
 /* ===== 统计卡片 ===== */
 .stats-cards {
   display: flex;
@@ -460,7 +1008,7 @@ export default {
   box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
 }
 .menu-item {
-  width: 50%;
+  width: 33.33%;
   display: flex;
   flex-direction: column;
   align-items: center;
@@ -481,6 +1029,7 @@ export default {
 .icon-invite { background: #D1FAE5; }
 .icon-team { background: #FCE7F3; }
 .icon-leaderboard { background: #FFF3CD; }
+.icon-records { background: #EDE9FE; }
 .menu-icon-text {
   font-size: 36rpx;
 }

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

@@ -1473,6 +1473,14 @@ export const getMyEarnings = () => request('/api/commission/my-earnings', 'POST'
 export const getCommissionRecords = (page, size) => request('/api/commission/records', 'POST', { page: page || 1, size: size || 20 })
 export const getPpointProducts = () => request('/api/commission/ppoint/products', 'POST')
 
+// ===== 家庭收益(推广中心重构) =====
+export const getFamilyEarningsSummary = () => request('/api/earnings/family/summary', 'POST')
+export const getFamilyEarningsMembers = () => request('/api/earnings/family/members', 'POST')
+export const getFamilyEarningsRecords = (data) => request('/api/earnings/family/records', 'POST', data || {})
+export const distributeToMember = (data) => request('/api/earnings/admin/distribute', 'POST', data)
+export const toggleMemberBalance = (data) => request('/api/earnings/admin/toggle-member', 'POST', data)
+export const withdrawFamilyEarnings = (data) => request('/api/earnings/admin/withdraw', 'POST', data)
+
 // ===== 缁村害浠诲姟锛堝甫 category 绛涢€夛級 =====
 export const getTodayTasksByCategory = (childId, category) => {
   return request('/api/tasks/today', 'POST', { childId: childId, category: category })

+ 25 - 0
cfc-web/src/api/admin.js

@@ -1519,3 +1519,28 @@ export function deleteEcomSupplier(id) {
     data: { id }
   })
 }
+
+// ===== 家庭收益管理 =====
+export function getFamilyEarningsList() {
+  return request({
+    url: '/api/admin/earnings/families',
+    method: 'post',
+    data: {}
+  })
+}
+
+export function getFamilyEarningsDetail(familyId) {
+  return request({
+    url: '/api/admin/earnings/family/' + familyId + '/summary',
+    method: 'post',
+    data: { familyId: familyId }
+  })
+}
+
+export function getFamilyEarningsRecords(familyId, type) {
+  return request({
+    url: '/api/admin/earnings/family/' + familyId + '/records',
+    method: 'post',
+    data: { familyId: familyId, type: type || '' }
+  })
+}

+ 12 - 0
cfc-web/src/router/index.js

@@ -538,6 +538,18 @@ const routes = [
         component: () => import('@/views/admin/PromotionManagement'),
         meta: { title: '推广管理', perm: 'marketing:promotion' }
       },
+      {
+        path: 'family-earnings',
+        name: 'FamilyEarnings',
+        component: () => import('@/views/admin/FamilyEarnings.vue'),
+        meta: { title: '家庭收益管理', perm: 'marketing:promotion' }
+      },
+      {
+        path: 'family-earnings-withdraw',
+        name: 'FamilyEarningsWithdraw',
+        component: () => import('@/views/admin/FamilyEarningsWithdraw.vue'),
+        meta: { title: '家庭收益提现审核', perm: 'audit:withdraw' }
+      },
       {
         path: 'dimension-config',
         name: 'DimensionConfig',

+ 163 - 0
cfc-web/src/views/admin/FamilyEarnings.vue

@@ -0,0 +1,163 @@
+<template>
+  <div class="family-earnings admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">家庭收益管理</h2>
+      <div class="flex items-center gap-sm">
+        <el-input v-model="searchKeyword" placeholder="搜索家庭ID..." prefix-icon="el-icon-search" style="width:200px" clearable @keyup.enter.native="loadList" @clear="loadList" />
+        <el-button type="primary" @click="loadList">刷新</el-button>
+      </div>
+    </div>
+
+    <div class="table-scroll-wrap">
+      <el-table :data="list" v-loading="loading" border stripe @row-click="showDetail">
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="familyId" label="家庭ID" width="100" />
+        <el-table-column label="累计推荐收入" width="140">
+          <template slot-scope="{ row }">
+            <span class="amount-orange">{{ fenToYuan(row.totalEarned) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="可支配余额" width="140">
+          <template slot-scope="{ row }">
+            <span class="amount-green">{{ fenToYuan(row.availableAmount) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="已提现" width="120">
+          <template slot-scope="{ row }">
+            {{ fenToYuan(row.withdrawnAmount) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="已分配" width="120">
+          <template slot-scope="{ row }">
+            {{ fenToYuan(row.distributedAmount) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="更新时间" width="160">
+          <template slot-scope="{ row }">
+            {{ row.updatedAt ? formatTime(row.updatedAt) : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click.stop="showDetail(row)">详情</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- 家庭收益详情弹窗 -->
+    <el-dialog title="家庭收益详情" :visible.sync="detailVisible" width="700px" top="5vh">
+      <div v-loading="detailLoading">
+        <el-descriptions :column="2" border v-if="detail">
+          <el-descriptions-item label="家庭ID">{{ detail.familyId || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="累计推荐收入">
+            <span class="amount-orange">{{ fenToYuan(detail.totalEarned) }}</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="可支配余额">
+            <span class="amount-green">{{ fenToYuan(detail.availableAmount) }}</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="已提现">{{ fenToYuan(detail.withdrawnAmount) }}</el-descriptions-item>
+          <el-descriptions-item label="已分配">{{ fenToYuan(detail.distributedAmount) }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4 style="margin:20px 0 10px">成员贡献</h4>
+        <el-table :data="detail.members || []" border stripe size="small" v-if="detail && detail.members && detail.members.length > 0">
+          <el-table-column label="用户ID" prop="userId" width="80" />
+          <el-table-column label="昵称" prop="nickname" min-width="120" />
+          <el-table-column label="已分配" width="120">
+            <template slot-scope="{ row }">{{ fenToYuan(row.allocatedAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="已消费" width="120">
+            <template slot-scope="{ row }">{{ fenToYuan(row.consumedAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="可用余额" width="120">
+            <template slot-scope="{ row }"><span class="amount-green">{{ fenToYuan(row.availableAmount) }}</span></template>
+          </el-table-column>
+          <el-table-column label="池权限" width="100">
+            <template slot-scope="{ row }">
+              <el-tag :type="row.canUseFamilyBalance ? 'success' : 'info'" size="mini">
+                {{ row.canUseFamilyBalance ? '可用' : '关闭' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="管理员" width="80">
+            <template slot-scope="{ row }">
+              <el-tag v-if="row.isAdmin" type="warning" size="mini">是</el-tag>
+              <span v-else>否</span>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getFamilyEarningsList, getFamilyEarningsDetail } from '@/api/admin'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      searchKeyword: '',
+      detailVisible: false,
+      detailLoading: false,
+      detail: null
+    }
+  },
+  mounted() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        var res = await getFamilyEarningsList()
+        this.list = res.data || []
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    async showDetail(row) {
+      this.detailVisible = true
+      this.detailLoading = true
+      try {
+        var res = await getFamilyEarningsDetail(row.familyId || row.id)
+        this.detail = res.data || row
+      } catch (e) {
+        this.$message.error('加载详情失败')
+        this.detail = row
+      } finally {
+        this.detailLoading = false
+      }
+    },
+    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)
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : String(t)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.family-earnings {
+  padding: 20px;
+}
+.amount-orange {
+  font-weight: bold;
+  color: #e6a23c;
+}
+.amount-green {
+  font-weight: bold;
+  color: #67c23a;
+}
+</style>

+ 125 - 0
cfc-web/src/views/admin/FamilyEarningsWithdraw.vue

@@ -0,0 +1,125 @@
+<template>
+  <div class="family-earnings-withdraw admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">家庭收益提现审核</h2>
+      <div class="flex items-center gap-sm">
+        <el-select v-model="typeFilter" placeholder="类型" @change="loadList" style="width:120px">
+          <el-option label="全部" value="" />
+          <el-option label="提现" value="withdraw" />
+          <el-option label="分配" value="distribute" />
+          <el-option label="消费" value="consume" />
+          <el-option label="收入" value="earn" />
+        </el-select>
+        <el-button type="primary" @click="loadList">刷新</el-button>
+      </div>
+    </div>
+
+    <div class="table-scroll-wrap">
+      <el-table :data="list" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="familyId" label="家庭ID" width="90" />
+        <el-table-column label="类型" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="typeTag(row.type)" size="mini">{{ typeLabel(row.type) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="金额" width="120">
+          <template slot-scope="{ row }">
+            <span :class="row.amount > 0 ? 'amount-out' : 'amount-in'">{{ row.amount > 0 ? '-' : '+' }}{{ fenToYuan(row.amount) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作人" width="120">
+          <template slot-scope="{ row }">
+            {{ row.fromUserId || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" width="160">
+          <template slot-scope="{ row }">
+            {{ row.createdAt ? formatTime(row.createdAt) : '-' }}
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <el-pagination
+      @current-change="onPageChange"
+      :current-page="page"
+      :page-size="size"
+      :total="total"
+      layout="total, prev, pager, next"
+      class="pagination-wrap"
+    />
+  </div>
+</template>
+
+<script>
+import { getFamilyEarningsRecords } from '@/api/admin'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      typeFilter: ''
+    }
+  },
+  mounted() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        var res = await getFamilyEarningsRecords(0, this.typeFilter)
+        if (res.data) {
+          this.list = Array.isArray(res.data) ? res.data : [res.data]
+          this.total = this.list.length
+        }
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    onPageChange(val) {
+      this.page = val
+      this.loadList()
+    },
+    typeTag(type) {
+      var map = { earn: 'success', withdraw: 'danger', distribute: 'warning', consume: 'info' }
+      return map[type] || 'info'
+    },
+    typeLabel(type) {
+      var map = { earn: '收入', withdraw: '提现', distribute: '分配', consume: '消费' }
+      return map[type] || type
+    },
+    fenToYuan(fen) {
+      if (fen === null || fen === undefined) return '0.00'
+      var num = Number(fen)
+      if (isNaN(num)) return '0.00'
+      return Math.abs(num / 100).toFixed(2)
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : String(t)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.family-earnings-withdraw {
+  padding: 20px;
+}
+.amount-out {
+  font-weight: bold;
+  color: #f56c6c;
+}
+.amount-in {
+  font-weight: bold;
+  color: #67c23a;
+}
+</style>