Przeglądaj źródła

feat(cf): F-7 富维度改读cf_balance+wealth-cf页接C端接口+管理端规则配置页

Sisyphus 2 tygodni temu
rodzic
commit
9cc4e9d3e1

+ 38 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.CfBalanceView;
 import com.etotem.cfc.dto.EnergySandboxDTO;
 import com.etotem.cfc.dto.MemberEnergyDTO;
 import com.etotem.cfc.entity.*;
@@ -10,6 +11,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -71,8 +73,16 @@ public class EnergyService {
     @Resource
     private HealthDimensionScoreService healthDimensionScoreService;
 
+    /**
+     * CF 值统一账本(F-2)。家长富维度「财务积累」改读个人账本 cf_balance.total_earned。
+     *
+     * <p>{@code @Lazy} 延迟注入:CfBalanceServiceImpl → MembershipService → … → CfBalanceService
+     * 存在启动期循环依赖风险(见 commit c4813f72d / 02ca6aa0c),此处用代理延迟解析,
+     * 运行时调用才真正取 bean,避免应用启动失败。</p>
+     */
     @Resource
-    private FamilyPlatformBalanceMapper familyPlatformBalanceMapper;
+    @Lazy
+    private CfBalanceService cfBalanceService;
 
     @Resource
     private ReferralTreeMapper referralTreeMapper;
@@ -453,17 +463,38 @@ public class EnergyService {
         return clamp(20 + (int) ((count - 1) * 15), 0, 100);
     }
 
-    /** 家长富-财务积累:家庭CF值累计获得(totalEarned)折算,5000CF=100分 */
+    /**
+     * 家长富-财务积累:个人 CF 值累计获得(cf_balance.total_earned)折算,5000CF=100分。
+     *
+     * <p>F-7 口径修复:CF 值已统一为个人维度账本(cf_balance,成员维度 family_members.id),
+     * 此处不再读家庭池 family_platform_balance,避免与 C 端展示口径不一致。</p>
+     */
     private int calcParentWealthIncome(User parent) {
-        if (parent.getFamilyId() == null) return 0;
-        FamilyPlatformBalance balance = familyPlatformBalanceMapper.selectOne(
-                new LambdaQueryWrapper<FamilyPlatformBalance>()
-                        .eq(FamilyPlatformBalance::getFamilyId, parent.getFamilyId())
-                        .last("LIMIT 1"));
+        if (parent == null || parent.getId() == null) return 0;
+        Long memberId = resolveMemberId(parent.getId());
+        if (memberId == null) return 0;
+        CfBalanceView balance = cfBalanceService.getBalance(memberId);
         if (balance == null || balance.getTotalEarned() == null) return 0;
         return clamp(balance.getTotalEarned() / 50, 0, 100);
     }
 
+    /**
+     * 登录用户 → 家庭成员(family_members.id)。
+     *
+     * <p>口径与 {@code CfEarnDispatchService}(F-3a)/ {@code CfBalanceController}(F-7a)一致:
+     * user_id 匹配 + 按 id 升序取第一条(LIMIT 1),未命中返回 null。</p>
+     */
+    private Long resolveMemberId(Long userId) {
+        if (userId == null || userId <= 0) {
+            return null;
+        }
+        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getUserId, userId)
+                .orderByAsc(FamilyMember::getId)
+                .last("LIMIT 1"));
+        return member == null ? null : member.getId();
+    }
+
     /** 家长富-财务素养:记账/理财/储蓄类任务完成率 */
     private int calcParentWealthFx(User parent) {
         return calcTaskCompletionRate(parent.getId(), "parent",

+ 120 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/EnergyServiceWealthIncomeTest.java

@@ -0,0 +1,120 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.CfBalanceView;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+
+/**
+ * 家长富维度「财务积累」口径测试(F-7)。
+ *
+ * <p>背景:CF 值已统一为个人维度账本 cf_balance(成员维度 family_members.id),
+ * calcParentWealthIncome 必须读 {@code cfBalanceService.getBalance(memberId).getTotalEarned()}
+ * 而非家庭池 family_platform_balance;折算系数保持 5000CF=100 分。</p>
+ */
+class EnergyServiceWealthIncomeTest {
+
+    @Test
+    void parentWealthIncome_readsPersonalCfBalanceTotalEarned() throws Exception {
+        // userId=12 解析出 memberId=50(复用 F-3a / F-7a resolveMemberId 口径)
+        EnergyService service = new EnergyService();
+        FamilyMemberMapper familyMemberMapper = Mockito.mock(FamilyMemberMapper.class);
+        CfBalanceService cfBalanceService = Mockito.mock(CfBalanceService.class);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "cfBalanceService", cfBalanceService);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(50L);
+        member.setUserId(12L);
+        Mockito.when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        // 5000CF = 100 分(封顶);2500CF = 50 分;600000CF 超出封顶仍为 100
+        Mockito.when(cfBalanceService.getBalance(50L)).thenReturn(balance(5000));
+        assertEquals(100, invoke(service, user(12L)));
+        Mockito.when(cfBalanceService.getBalance(50L)).thenReturn(balance(2500));
+        assertEquals(50, invoke(service, user(12L)));
+        Mockito.when(cfBalanceService.getBalance(50L)).thenReturn(balance(600000));
+        assertEquals(100, invoke(service, user(12L)));
+
+        // 读的是成员账本:getBalance 必须以解析出的 memberId 调用
+        Mockito.verify(cfBalanceService, Mockito.times(3)).getBalance(50L);
+    }
+
+    @Test
+    void parentWealthIncome_noMemberRecord_returnsZero() throws Exception {
+        EnergyService service = new EnergyService();
+        FamilyMemberMapper familyMemberMapper = Mockito.mock(FamilyMemberMapper.class);
+        CfBalanceService cfBalanceService = Mockito.mock(CfBalanceService.class);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "cfBalanceService", cfBalanceService);
+
+        Mockito.when(familyMemberMapper.selectOne(any())).thenReturn(null);
+        assertEquals(0, invoke(service, user(12L)));
+        Mockito.verify(cfBalanceService, Mockito.never()).getBalance(Mockito.anyLong());
+    }
+
+    @Test
+    void parentWealthIncome_nullBalanceOrTotalEarned_returnsZero() throws Exception {
+        EnergyService service = new EnergyService();
+        FamilyMemberMapper familyMemberMapper = Mockito.mock(FamilyMemberMapper.class);
+        CfBalanceService cfBalanceService = Mockito.mock(CfBalanceService.class);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "cfBalanceService", cfBalanceService);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(50L);
+        member.setUserId(12L);
+        Mockito.when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        Mockito.when(cfBalanceService.getBalance(50L)).thenReturn(null);
+        assertEquals(0, invoke(service, user(12L)));
+
+        CfBalanceView empty = new CfBalanceView();
+        Mockito.when(cfBalanceService.getBalance(50L)).thenReturn(empty);
+        assertEquals(0, invoke(service, user(12L)));
+    }
+
+    @Test
+    void parentWealthIncome_nullParentId_returnsZero() throws Exception {
+        EnergyService service = new EnergyService();
+        CfBalanceService cfBalanceService = Mockito.mock(CfBalanceService.class);
+        FamilyMemberMapper familyMemberMapper = Mockito.mock(FamilyMemberMapper.class);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "cfBalanceService", cfBalanceService);
+        assertEquals(0, invoke(service, new User()));
+        Mockito.verify(cfBalanceService, Mockito.never()).getBalance(Mockito.anyLong());
+    }
+
+    private CfBalanceView balance(int totalEarned) {
+        CfBalanceView view = new CfBalanceView();
+        view.setMemberId(50L);
+        view.setTotalEarned(totalEarned);
+        return view;
+    }
+
+    private User user(Long id) {
+        User u = new User();
+        u.setId(id);
+        return u;
+    }
+
+    private int invoke(EnergyService service, User parent) throws Exception {
+        Method method = EnergyService.class.getDeclaredMethod("calcParentWealthIncome", User.class);
+        method.setAccessible(true);
+        return (Integer) method.invoke(service, parent);
+    }
+
+    private void inject(Object target, String fieldName, Object value) throws Exception {
+        Field field = EnergyService.class.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+}

+ 106 - 7
cfc-frontend/pages/wealth-cf/index.vue

@@ -6,8 +6,8 @@
     <template v-if="!isLoggedIn">
       <view class="empty-state">
         <text class="empty-icon">💎</text>
-        <text class="empty-title">登录以查看家庭CF值</text>
-        <text class="empty-desc">CF值是家庭共享的平台积分,购买商品、参与活动即可获得</text>
+        <text class="empty-title">登录以查看我的CF值</text>
+        <text class="empty-desc">CF值是个人平台积分,购买商品、参与活动即可获得</text>
         <button class="login-btn" @click="goLogin">去登录</button>
       </view>
     </template>
@@ -16,7 +16,7 @@
     <template v-else>
       <!-- 余额卡片 -->
       <view class="balance-card">
-        <view class="balance-label">家庭CF值余额</view>
+        <view class="balance-label">我的CF值余额</view>
         <view class="balance-value">{{ balance.available }}</view>
         <view class="balance-unit">CF值(1元=1积分)</view>
         <view class="balance-row">
@@ -28,6 +28,10 @@
             <text class="balance-item-label">已消费</text>
             <text class="balance-item-value">{{ balance.exchanged }}</text>
           </view>
+          <view class="balance-item">
+            <text class="balance-item-label">冻结中</text>
+            <text class="balance-item-value">{{ balance.frozen }}</text>
+          </view>
         </view>
       </view>
 
@@ -67,6 +71,28 @@
           <text>暂无流水记录</text>
         </view>
       </view>
+
+      <!-- 家庭共享优惠券(家庭内成员共享可见) -->
+      <view class="section">
+        <view class="section-header">
+          <text class="section-title">家庭共享券</text>
+          <text class="section-more" @click="refreshCoupons" v-if="familyCoupons.length > 0">刷新</text>
+        </view>
+        <view class="fc-list" v-if="familyCoupons.length > 0">
+          <view class="fc-item" v-for="coupon in familyCoupons" :key="coupon.id">
+            <view class="fc-left">
+              <text class="fc-name">{{ coupon.name }}</text>
+              <text class="fc-desc">{{ couponDesc(coupon) }} · {{ couponStatusLabel(coupon.status) }}</text>
+            </view>
+            <view class="fc-right">
+              <text class="fc-time">{{ formatDate(coupon.validUntil) }}</text>
+            </view>
+          </view>
+        </view>
+        <view class="empty-logs" v-else>
+          <text>暂无家庭共享券</text>
+        </view>
+      </view>
     </template>
 
     <!-- 兑换优惠券弹窗 -->
@@ -96,7 +122,7 @@
 </template>
 
 <script>
-import { getFamilyPlatformBalance, getFamilyPlatformLogs, exchangeCouponByCf } from '@/utils/api.js'
+import { getCfBalance, getCfLogs, getFamilyCoupons, getExchangeableCoupons, exchangeCouponByCf } from '@/utils/api.js'
 import { parseDate } from '@/utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
@@ -112,13 +138,15 @@ export default {
       hasMore: false,
       exchangeRecords: [],
       showExchangeModal: false,
-      exchangeableCoupons: []
+      exchangeableCoupons: [],
+      familyCoupons: []
     }
   },
   onLoad() {
     this.checkLogin()
     this.loadBalance()
     this.loadLogs()
+    this.loadFamilyCoupons()
     this._onLoadFired = true
   },
   onShow() {
@@ -142,11 +170,13 @@ export default {
       if (this.isLoggedIn) {
         this.loadBalance()
         this.loadLogs()
+        this.loadFamilyCoupons()
       }
     },
+    // 个人 CF 值余额快照(/api/cf/balance,个人成员账本 cf_balance)
     async loadBalance() {
       try {
-        const res = await getFamilyPlatformBalance()
+        const res = await getCfBalance()
         if (res.code === 200 && res.data) {
           this.balance = res.data
         }
@@ -154,9 +184,10 @@ export default {
         console.error('加载余额失败', e)
       }
     },
+    // 个人 CF 值流水分页(/api/cf/logs,page/size 与 CfBalanceLogVO 分页一致)
     async loadLogs() {
       try {
-        const res = await getFamilyPlatformLogs({ page: this.page, size: this.size })
+        const res = await getCfLogs({ page: this.page, size: this.size })
         if (res.code === 200 && res.data) {
           if (this.page === 1) {
             this.logs = res.data.records || []
@@ -169,6 +200,20 @@ export default {
         console.error('加载流水失败', e)
       }
     },
+    // 家庭共享优惠券(/api/cf/family-coupons,家庭内成员共享可见)
+    async loadFamilyCoupons() {
+      try {
+        const res = await getFamilyCoupons()
+        if (res.code === 200) {
+          this.familyCoupons = res.data || []
+        }
+      } catch (e) {
+        console.error('加载家庭共享券失败', e)
+      }
+    },
+    refreshCoupons() {
+      this.loadFamilyCoupons()
+    },
     loadMoreLogs() {
       if (this.hasMore) {
         this.page++
@@ -179,6 +224,7 @@ export default {
       this.page = 1
       this.loadBalance()
       this.loadLogs()
+      this.loadFamilyCoupons()
     },
     async openExchangeModal() {
       this.showExchangeModal = true
@@ -230,6 +276,20 @@ export default {
       }
       return '立减' + (coupon.value / 100).toFixed(2) + '元'
     },
+    couponStatusLabel(status) {
+      const map = {
+        AVAILABLE: '可使用',
+        USED: '已使用',
+        EXPIRED: '已过期',
+        DISABLED: '已停用'
+      }
+      return map[status] || status || ''
+    },
+    // 有效期截止日(仅日期场景,yyyy-MM-dd)
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      return String(dateStr).substring(0, 10)
+    },
     formatTime(dateStr) {
       if (!dateStr) return ''
       const d = parseDate(dateStr)
@@ -421,6 +481,45 @@ $c-tint-indigo-border: #e8eaff; // indigo 极浅边框(单页专有)
   background: $app-surface;
   border-radius: 16rpx;
 }
+.fc-list {
+  background: $app-surface;
+  border-radius: 16rpx;
+  overflow: hidden;
+}
+.fc-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  border-bottom: 1rpx solid $facade-fill;
+}
+.fc-item:last-child {
+  border-bottom: none;
+}
+.fc-left {
+  flex: 1;
+  margin-right: 20rpx;
+}
+.fc-name {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+  display: block;
+  margin-bottom: 4rpx;
+}
+.fc-desc {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  display: block;
+}
+.fc-right {
+  text-align: right;
+}
+.fc-time {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $facade-text-ghost-light;
+  display: block;
+}
 .exchange-modal {
   background: $app-surface;
   border-radius: 24rpx 24rpx 0 0;

+ 10 - 1
cfc-frontend/utils/api.js

@@ -3085,11 +3085,20 @@ export const getHealthFoods = (reportId, memberId) => {
   return request('/api/health/foods', 'POST', data)
 }
 
-// ===== 家庭平台积分(CF值)=====
+// ===== 家庭平台积分(CF值) =====
+// 已迁移至个人维度统一账本:余额/流水/家庭券请用下方 F-7a 的 getCfBalance / getCfLogs / getFamilyCoupons
+/** @deprecated 已改用 getCfBalance(/api/cf/balance,个人成员账本 cf_balance) */
 export const getFamilyPlatformBalance = () => request('/api/family-platform/balance', 'POST', {})
+/** @deprecated 已改用 getCfLogs(/api/cf/logs) */
 export const getFamilyPlatformLogs = (data) => request('/api/family-platform/logs', 'POST', data)
 export const exchangeCouponByCf = (data) => request('/api/family-platform/exchange-coupon', 'POST', data)
 
+// ===== CF 值统一账本(个人成员维度 · F-7a C 端只读接口)=====
+// 后端接口为 GET:余额 /api/cf/balance、流水 /api/cf/logs?page=&size=、家庭券 /api/cf/family-coupons
+export const getCfBalance = () => request('/api/cf/balance', 'GET', {})
+export const getCfLogs = (params) => request('/api/cf/logs', 'GET', params || { page: 1, size: 20 })
+export const getFamilyCoupons = () => request('/api/cf/family-coupons', 'GET', {})
+
 // ===== 站内通知 =====
 export const getNotificationList = () => request('/api/notification/list', 'POST', {})
 export const readNotification = (data) => request('/api/notification/read', 'POST', data)

+ 31 - 0
cfc-web/src/api/cfRule.js

@@ -0,0 +1,31 @@
+import request from '@/utils/request'
+
+// ===== CF 值获取规则(cf_earn_rule,L2 规则配置) =====
+export function getCfEarnRuleList(data) {
+  return request({ url: '/api/admin/cf-earn-rule/list', method: 'post', data })
+}
+
+export function saveCfEarnRule(data) {
+  return request({ url: '/api/admin/cf-earn-rule/save', method: 'post', data })
+}
+
+export function toggleCfEarnRule(data) {
+  return request({ url: '/api/admin/cf-earn-rule/toggle', method: 'post', data })
+}
+
+export function getCfEarnRuleHistory(data) {
+  return request({ url: '/api/admin/cf-earn-rule/history', method: 'post', data })
+}
+
+// ===== CF 销售奖档位(cf_rate_tier,销售奖档位配置) =====
+export function getCfRateTierList() {
+  return request({ url: '/api/admin/cf-rate-tier/list', method: 'post' })
+}
+
+export function saveCfRateTier(data) {
+  return request({ url: '/api/admin/cf-rate-tier/save', method: 'post', data })
+}
+
+export function deleteCfRateTier(data) {
+  return request({ url: '/api/admin/cf-rate-tier/delete', method: 'post', data })
+}

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

@@ -483,6 +483,12 @@ const routes = [
         component: () => import('@/views/admin/CfValueManage.vue'),
         meta: { title: 'CF值管理', perm: 'finance:cf-value' }
       },
+      {
+        path: 'cf-rule-config',
+        name: 'CfRuleConfig',
+        component: () => import('@/views/admin/CfRuleConfig.vue'),
+        meta: { title: 'CF规则配置', perm: 'finance:cf-value' }
+      },
       {
         path: 'supply-system',
         name: 'SupplySystemList',

+ 439 - 0
cfc-web/src/views/admin/CfRuleConfig.vue

@@ -0,0 +1,439 @@
+<template>
+  <div class="cf-rule-config admin-page">
+    <PageHeader title="CF 规则/档位配置" subtitle="CF 值发放规则(cf_earn_rule)与销售奖档位(cf_rate_tier)">
+      <el-button size="small" icon="el-icon-refresh" @click="refreshAll">刷新</el-button>
+    </PageHeader>
+
+    <el-tabs v-model="activeTab">
+      <!-- ==================== 发放规则 ==================== -->
+      <el-tab-pane label="发放规则" name="rules">
+        <div class="filters">
+          <el-select v-model="ruleQuery.category" placeholder="分类" clearable size="small" class="filter-input-160" @change="loadRules">
+            <el-option label="消费返佣" value="consume" />
+            <el-option label="成长行为" value="behavior" />
+            <el-option label="管理员" value="admin" />
+          </el-select>
+          <el-select v-model="ruleQuery.enabled" placeholder="状态" clearable size="small" class="filter-input-120" @change="loadRules">
+            <el-option label="启用" :value="1" />
+            <el-option label="停用" :value="0" />
+          </el-select>
+          <el-button type="primary" size="small" @click="loadRules">查询</el-button>
+          <el-button type="success" size="small" @click="openRuleDialog()">新建规则</el-button>
+        </div>
+
+        <div class="adm-table-wrap">
+          <el-table :data="ruleList" v-loading="ruleLoading" border stripe height="100%">
+            <el-table-column prop="code" label="编码" width="90">
+              <template slot-scope="{ row }">
+                <span class="rule-code">{{ row.code }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column prop="name" label="途径名称" min-width="150" show-overflow-tooltip />
+            <el-table-column label="分类" width="90">
+              <template slot-scope="{ row }">
+                <StatusTag :type="categoryType(row.category)" :text="categoryLabel(row.category)" size="small" />
+              </template>
+            </el-table-column>
+            <el-table-column label="类型" width="90">
+              <template slot-scope="{ row }">{{ ruleTypeLabel(row.ruleType) }}</template>
+            </el-table-column>
+            <el-table-column label="数量/公式" min-width="140" show-overflow-tooltip>
+              <template slot-scope="{ row }">{{ amountText(row) }}</template>
+            </el-table-column>
+            <el-table-column label="日限/累计/冷却" width="130">
+              <template slot-scope="{ row }">
+                <span class="limit-text">{{ row.dailyLimitPerMember || '∞' }}/{{ row.totalLimitPerMember || '∞' }}/{{ cooldownText(row.cooldownSeconds) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="生效期" width="180">
+              <template slot-scope="{ row }">
+                <span v-if="row.effectiveFrom || row.effectiveTo" class="limit-text">
+                  {{ dateText(row.effectiveFrom) }} ~ {{ dateText(row.effectiveTo) }}
+                </span>
+                <span v-else>-</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="状态" width="80">
+              <template slot-scope="{ row }">
+                <el-switch :value="row.enabled === 1" @change="v => onToggleRule(row, v)" active-color="var(--adm-success)" inactive-color="var(--adm-border-base)" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="sortOrder" label="排序" width="70" />
+            <el-table-column label="操作" width="160" fixed="right">
+              <template slot-scope="{ row }">
+                <el-button size="mini" type="primary" @click="openRuleDialog(row)">编辑</el-button>
+                <el-button size="mini" @click="openHistory(row)">历史</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </el-tab-pane>
+
+      <!-- ==================== 销售奖档位 ==================== -->
+      <el-tab-pane label="销售奖档位" name="tiers">
+        <div class="filters">
+          <el-button type="success" size="small" @click="openTierDialog()">新建档位</el-button>
+          <el-button size="small" @click="loadTiers">刷新</el-button>
+        </div>
+        <el-alert
+          type="info"
+          :closable="false"
+          show-icon
+          class="tier-tip"
+          title="cf_rate_tier 为历史「团队规模阶梯」档位表(F-4 后仅作等级展示/历史查询);F03a/F03b 两级销售奖实际档位在 cf_direct_sales_tier / cf_indirect_sales_tier(管理端暂未开放配置,见待补项)。"
+        />
+        <div class="adm-table-wrap" style="margin-top: 12px;">
+          <el-table :data="tierList" v-loading="tierLoading" border stripe height="100%">
+            <el-table-column prop="id" label="ID" width="80" />
+            <el-table-column prop="tierName" label="档位名" min-width="140" />
+            <el-table-column prop="minTeamSize" label="团队规模下限" width="130" />
+            <el-table-column prop="ratePercent" label="返佣比例%" width="100" />
+            <el-table-column prop="sortOrder" label="排序" width="80" />
+            <el-table-column label="状态" width="80">
+              <template slot-scope="{ row }">
+                <StatusTag :type="row.enabled === 1 ? 'success' : 'info'" :text="row.enabled === 1 ? '启用' : '停用'" size="small" />
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="150" fixed="right">
+              <template slot-scope="{ row }">
+                <el-button size="mini" type="primary" @click="openTierDialog(row)">编辑</el-button>
+                <el-button size="mini" type="danger" @click="onDeleteTier(row)">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+
+    <!-- ==================== 规则编辑弹窗 ==================== -->
+    <el-dialog :visible.sync="ruleDialogVisible" :title="ruleForm.id ? '编辑发放规则' : '新建发放规则'" width="640px" @closed="ruleForm = {}">
+      <el-form :model="ruleForm" label-width="130px" size="small">
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="规则编码" required>
+              <el-input v-model="ruleForm.code" :disabled="!!ruleForm.id" placeholder="如 F07" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="途径名称" required>
+              <el-input v-model="ruleForm.name" placeholder="发放途径名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="分类">
+              <el-select v-model="ruleForm.category" placeholder="分类" style="width: 100%">
+                <el-option label="消费返佣" value="consume" />
+                <el-option label="成长行为" value="behavior" />
+                <el-option label="管理员" value="admin" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="规则类型">
+              <el-select v-model="ruleForm.ruleType" placeholder="类型" style="width: 100%">
+                <el-option label="固定数量" value="fixed" />
+                <el-option label="公式" value="formula" />
+                <el-option label="逐人发放" value="per_item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="固定数量">
+              <el-input-number v-model="ruleForm.amount" :min="0" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="计算公式">
+              <el-input v-model="ruleForm.formula" placeholder="rule_type=formula 时填写" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="日限/成员">
+              <el-input-number v-model="ruleForm.dailyLimitPerMember" :min="0" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="累计上限/成员">
+              <el-input-number v-model="ruleForm.totalLimitPerMember" :min="0" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="冷却秒数">
+              <el-input-number v-model="ruleForm.cooldownSeconds" :min="0" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="幂等 refType">
+              <el-input v-model="ruleForm.dedupRefType" placeholder="写入 cf_balance_log" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="排序">
+              <el-input-number v-model="ruleForm.sortOrder" :min="0" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="启用">
+          <el-switch v-model="ruleForm.enabled" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="参数 JSON">
+          <el-input v-model="ruleForm.paramsJson" type="textarea" :rows="3" placeholder='{"bps": 50, "max_rate_percent": 70}' />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="ruleForm.remark" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="变更原因">
+          <el-input v-model="ruleForm.changeReason" type="textarea" :rows="2" placeholder="留痕到 cf_earn_rule_history" />
+        </el-form-item>
+        <el-alert
+          v-if="ruleForm.id"
+          type="warning"
+          :closable="false"
+          show-icon
+          title="待补项:后端 save 接口未开放 effectiveFrom/effectiveTo 参数,编辑保存会把「生效期」重置为不限(长期生效);如需保留生效期,请先确认规则当前无生效期限制。"
+        />
+      </el-form>
+      <div slot="footer">
+        <el-button size="small" @click="ruleDialogVisible = false">取消</el-button>
+        <el-button size="small" type="primary" :loading="ruleSaving" @click="saveRule">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- ==================== 档位编辑弹窗 ==================== -->
+    <el-dialog :visible.sync="tierDialogVisible" :title="tierForm.id ? '编辑档位' : '新建档位'" width="480px" @closed="tierForm = {}">
+      <el-form :model="tierForm" label-width="130px" size="small">
+        <el-form-item label="档位名" required>
+          <el-input v-model="tierForm.tierName" placeholder="如 青铜/白银/黄金" />
+        </el-form-item>
+        <el-form-item label="团队规模下限">
+          <el-input-number v-model="tierForm.minTeamSize" :min="0" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="返佣比例%">
+          <el-input-number v-model="tierForm.ratePercent" :min="0" :max="100" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="tierForm.sortOrder" :min="0" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="tierForm.enabled" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button size="small" @click="tierDialogVisible = false">取消</el-button>
+        <el-button size="small" type="primary" :loading="tierSaving" @click="saveTier">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- ==================== 规则历史弹窗 ==================== -->
+    <el-dialog :visible.sync="historyVisible" :title="'规则历史:' + historyCode" width="720px">
+      <el-table :data="historyList" v-loading="historyLoading" border stripe max-height="480">
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="changeReason" label="变更原因" min-width="160" show-overflow-tooltip />
+        <el-table-column label="操作人" width="100">
+          <template slot-scope="{ row }">{{ row.changedBy || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="时间" width="160">
+          <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="变更" width="110">
+          <template slot-scope="{ row }">
+            <el-tooltip :content="historyDiff(row)" placement="top">
+              <el-button size="mini" type="text">查看差异</el-button>
+            </el-tooltip>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getCfEarnRuleList, saveCfEarnRule, toggleCfEarnRule, getCfEarnRuleHistory, getCfRateTierList, saveCfRateTier, deleteCfRateTier } from '@/api/cfRule'
+
+export default {
+  name: 'CfRuleConfig',
+  data() {
+    return {
+      activeTab: 'rules',
+      // ---- rules ----
+      ruleList: [],
+      ruleLoading: false,
+      ruleSaving: false,
+      ruleQuery: { category: '', enabled: null },
+      ruleDialogVisible: false,
+      ruleForm: {},
+      // ---- history ----
+      historyVisible: false,
+      historyCode: '',
+      historyList: [],
+      historyLoading: false,
+      // ---- tiers ----
+      tierList: [],
+      tierLoading: false,
+      tierSaving: false,
+      tierDialogVisible: false,
+      tierForm: {}
+    }
+  },
+  mounted() {
+    this.loadRules()
+    this.loadTiers()
+  },
+  methods: {
+    refreshAll() {
+      this.loadRules()
+      this.loadTiers()
+    },
+    // ============ 发放规则 ============
+    loadRules() {
+      this.ruleLoading = true
+      getCfEarnRuleList({ category: this.ruleQuery.category || undefined, enabled: this.ruleQuery.enabled === '' ? undefined : this.ruleQuery.enabled })
+        .then(res => {
+          this.ruleList = res.data || []
+        })
+        .catch(() => {})
+        .finally(() => { this.ruleLoading = false })
+    },
+    openRuleDialog(row) {
+      if (row) {
+        this.ruleForm = Object.assign({}, row, { enabled: row.enabled === 1 ? 1 : 0, changeReason: '' })
+      } else {
+        this.ruleForm = { enabled: 1, ruleType: 'fixed', sortOrder: 0, changeReason: '' }
+      }
+      this.ruleDialogVisible = true
+    },
+    saveRule() {
+      if (!this.ruleForm.code || !this.ruleForm.name) {
+        this.$message.warning('编码和名称必填')
+        return
+      }
+      this.ruleSaving = true
+      const adminId = Number(localStorage.getItem('adminId')) || null
+      const payload = Object.assign({}, this.ruleForm, { changedBy: adminId })
+      saveCfEarnRule(payload)
+        .then(() => {
+          this.$message.success('保存成功')
+          this.ruleDialogVisible = false
+          this.loadRules()
+        })
+        .catch(() => {})
+        .finally(() => { this.ruleSaving = false })
+    },
+    onToggleRule(row, val) {
+      const adminId = Number(localStorage.getItem('adminId')) || null
+      toggleCfEarnRule({ code: row.code, enabled: val ? 1 : 0, changedBy: adminId, changeReason: '管理端启停:' + (val ? '启用' : '停用') })
+        .then(() => {
+          this.$message.success(val ? '已启用' : '已停用')
+          this.loadRules()
+        })
+        .catch(() => {})
+    },
+    openHistory(row) {
+      this.historyCode = row.code
+      this.historyVisible = true
+      this.historyLoading = true
+      getCfEarnRuleHistory({ code: row.code, limit: 50 })
+        .then(res => { this.historyList = res.data || [] })
+        .catch(() => { this.historyList = [] })
+        .finally(() => { this.historyLoading = false })
+    },
+    historyDiff(row) {
+      try {
+        const oldJ = JSON.parse(row.oldJson || '{}')
+        const newJ = JSON.parse(row.newJson || '{}')
+        return 'enabled: ' + (oldJ.enabled == null ? '-' : oldJ.enabled) + '→' + (newJ.enabled == null ? '-' : newJ.enabled)
+          + ';amount: ' + (oldJ.amount == null ? '-' : oldJ.amount) + '→' + (newJ.amount == null ? '-' : newJ.amount)
+          + ';formula: ' + (oldJ.formula || '-') + '→' + (newJ.formula || '-')
+      } catch (e) {
+        return 'JSON 快照'
+      }
+    },
+    // ============ 档位 ============
+    loadTiers() {
+      this.tierLoading = true
+      getCfRateTierList()
+        .then(res => { this.tierList = res.data || [] })
+        .catch(() => {})
+        .finally(() => { this.tierLoading = false })
+    },
+    openTierDialog(row) {
+      if (row) {
+        this.tierForm = Object.assign({}, row)
+      } else {
+        this.tierForm = { enabled: 1, minTeamSize: 0, ratePercent: 0, sortOrder: 0 }
+      }
+      this.tierDialogVisible = true
+    },
+    saveTier() {
+      if (!this.tierForm.tierName) {
+        this.$message.warning('档位名必填')
+        return
+      }
+      this.tierSaving = true
+      saveCfRateTier(this.tierForm)
+        .then(() => {
+          this.$message.success('保存成功')
+          this.tierDialogVisible = false
+          this.loadTiers()
+        })
+        .catch(() => {})
+        .finally(() => { this.tierSaving = false })
+    },
+    onDeleteTier(row) {
+      this.$confirm('确认删除档位「' + row.tierName + '」?', '提示', { type: 'warning' })
+        .then(() => deleteCfRateTier({ id: row.id }))
+        .then(() => { this.$message.success('已删除'); this.loadTiers() })
+        .catch(() => {})
+    },
+    // ============ 展示工具 ============
+    categoryLabel(c) {
+      return { consume: '消费返佣', behavior: '成长行为', admin: '管理员' }[c] || c || '-'
+    },
+    categoryType(c) {
+      return { consume: 'warning', behavior: 'success', admin: 'info' }[c] || 'info'
+    },
+    ruleTypeLabel(t) {
+      return { fixed: '固定', formula: '公式', per_item: '逐人' }[t] || t || '-'
+    },
+    amountText(row) {
+      if (row.ruleType === 'formula') return row.formula || '-'
+      if (row.ruleType === 'per_item') return '逐人'
+      return row.amount != null ? row.amount + ' CF' : '-'
+    },
+    cooldownText(s) {
+      if (!s) return '∞'
+      if (s % 86400 === 0) return (s / 86400) + '天'
+      if (s % 3600 === 0) return (s / 3600) + '时'
+      return s + 's'
+    },
+    dateText(v) {
+      return v ? String(v).substring(0, 10) : '-'
+    },
+    formatTime(t) {
+      return t ? String(t).replace('T', ' ').substring(0, 19) : '-'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.cf-rule-config { padding: 20px; }
+.filters {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 12px;
+  flex-wrap: wrap;
+  align-items: center;
+}
+.filter-input-160 { width: 160px; }
+.filter-input-120 { width: 120px; }
+.rule-code {
+  font-family: monospace;
+  font-weight: 600;
+}
+.limit-text {
+  font-size: 12px;
+  color: #595959;
+}
+.tier-tip { margin-bottom: 0; }
+</style>