Browse Source

feat(membership): 购物车/结算页展示赠会员累计进度

- 新增 POST /api/membership/gift-membership-progress 返回家庭累计
  金额、阈值、已赠送状态
- GiftMembershipProgress.threshold 标记 @TableField(exist = false),
  避免非数据库列被拼进 SELECT 报 Unknown column
- cart.vue / checkout.vue 增加进度条、已累/还差金额、达标提示
- API_REFERENCE.md 补充新接口说明
Sisyphus 1 day ago
parent
commit
ea0372c662

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@@ -10,7 +10,10 @@ import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.entity.GiftMembershipProgress;
+import com.etotem.cfc.service.GiftMembershipProgressService;
 import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.SysConfigService;
 import com.etotem.cfc.service.VirtualPayService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,6 +47,12 @@ public class MembershipController {
     @Resource
     private VirtualPayService virtualPayService;
 
+    @Resource
+    private GiftMembershipProgressService giftMembershipProgressService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
     /**
      * 获取所有会员等级
      */
@@ -420,4 +429,26 @@ public class MembershipController {
         return Result.success(benefits);
     }
 
+    /**
+     * 获取家庭赠会员累计进度
+     */
+    @Operation(summary = "获取家庭赠会员累计进度")
+    @PostMapping("/gift-membership-progress")
+    public Result<GiftMembershipProgress> getFamilyGiftMembershipProgress(
+            @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+        Long familyId = membershipService.getUserFamilyId(userId);
+        if (familyId == null) {
+            return Result.success(new GiftMembershipProgress());
+        }
+        GiftMembershipProgress progress = giftMembershipProgressService.getOrCreate(familyId);
+        // 读取全局阈值
+        String thresholdStr = sysConfigService.getValue("gift_membership_threshold");
+        int threshold = thresholdStr != null ? Integer.parseInt(thresholdStr) : 0;
+        progress.setThreshold(threshold);
+        return Result.success(progress);
+    }
+
 }

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GiftMembershipProgress.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -27,6 +28,10 @@ public class GiftMembershipProgress implements Serializable {
 
     private Date grantedAt;              // 赠送时间
 
+    // 以下字段非数据库列,仅供接口返回使用
+    @TableField(exist = false)
+    private Integer threshold;           // 阈值(分),由接口动态填入
+
     private Date createdAt;
 
     private Date updatedAt;

+ 132 - 2
cfc-frontend/pages/shop/cart/cart.vue

@@ -72,6 +72,30 @@
       <text class="member-hint-action">去开通 ›</text>
     </view>
 
+    <!-- Gift Membership Progress -->
+    <view v-if="giftProgress && !giftIsGranted && giftProgress.threshold > 0" class="gift-progress-section">
+      <view class="gift-progress-header">
+        <text class="gift-progress-icon">🎁</text>
+        <text class="gift-progress-title">购会员商品累计进度</text>
+        <text class="gift-progress-percent">{{ giftProgressPercent }}%</text>
+      </view>
+      <view class="gift-progress-bar-bg">
+        <view class="gift-progress-bar-fill" :style="{ width: giftProgressPercent + '%' }"></view>
+      </view>
+      <view class="gift-progress-info">
+        <text class="gift-progress-accumulated">已累 {{ giftAccumulatedYuan }} 元</text>
+        <text class="gift-progress-remaining">还差 {{ giftRemainingYuan }} 元送会员</text>
+      </view>
+      <view v-if="giftProgress.grantedLevel" class="gift-progress-level">
+        <text>达标将赠送 {{ giftProgress.grantedLevel === 'FAMILY' ? '家庭会员' : giftProgress.grantedLevel }} 权益</text>
+      </view>
+    </view>
+
+    <view v-else-if="giftIsGranted" class="gift-granted-hint">
+      <text class="gift-granted-icon">✅</text>
+      <text class="gift-granted-text">本家庭已赠送会员,请前往会员中心查看</text>
+    </view>
+
     <view class="bottom-bar" v-if="items.length > 0">
       <view class="bottom-left">
         <text class="selected-count">已选 {{ selectedCount }} 件</text>
@@ -101,7 +125,7 @@
 
 <script>
 import config from '@/config.js'
-import { getMyMembership } from '@/utils/api.js'
+import { getMyMembership, getGiftMembershipProgress } from '@/utils/api.js'
 
 export default {
   data() {
@@ -110,7 +134,8 @@ export default {
       loading: false,
       refreshing: false,
       userId: null,
-      isMember: false
+      isMember: false,
+      giftProgress: null  // 赠会员累计进度
     }
   },
   computed: {
@@ -175,12 +200,35 @@ export default {
         }
       }
       return names
+    },
+    // 累计进度相关计算
+    giftAccumulatedYuan() {
+      if (!this.giftProgress || !this.giftProgress.accumulatedAmount) return 0
+      return this.giftProgress.accumulatedAmount / 100
+    },
+    giftThresholdYuan() {
+      if (!this.giftProgress || !this.giftProgress.threshold) return 0
+      return this.giftProgress.threshold / 100
+    },
+    giftRemainingYuan() {
+      if (!this.giftProgress || !this.giftProgress.threshold || !this.giftProgress.accumulatedAmount) return 0
+      var remaining = this.giftProgress.threshold - this.giftProgress.accumulatedAmount
+      return remaining > 0 ? remaining / 100 : 0
+    },
+    giftProgressPercent() {
+      if (!this.giftProgress || !this.giftProgress.threshold || this.giftProgress.threshold <= 0) return 0
+      if (!this.giftProgress.accumulatedAmount) return 0
+      return Math.min(100, Math.round(this.giftProgress.accumulatedAmount / this.giftProgress.threshold * 100))
+    },
+    giftIsGranted() {
+      return this.giftProgress && this.giftProgress.granted === 1
     }
   },
   onLoad() {
     this.userId = uni.getStorageSync('userId')
     this.checkMemberStatus()
     this.loadCart()
+    this.loadGiftProgress()
     this._onLoadFired = true
   },
   onShow() {
@@ -188,6 +236,7 @@ export default {
       this._onLoadFired = false
     } else if (this.items.length > 0) {
       this.loadCart()
+      this.loadGiftProgress()
     }
   },
   methods: {
@@ -199,6 +248,14 @@ export default {
         }
       }).catch(function() {})
     },
+    loadGiftProgress() {
+      var that = this
+      getGiftMembershipProgress().then(function(res) {
+        if (res.data && res.data.code === 200) {
+          that.giftProgress = res.data.data || null
+        }
+      }).catch(function() {})
+    },
     goUpgrade() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
@@ -609,4 +666,77 @@ export default {
   display: flex;
   align-items: baseline;
 }
+/* Gift membership progress */
+.gift-progress-section {
+  padding: 20rpx 30rpx;
+  background: #FFF7ED;
+  border-top: 2rpx solid #FED7AA;
+}
+.gift-progress-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.gift-progress-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-progress-title {
+  flex: 1;
+  font-size: 26rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-percent {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+}
+.gift-progress-bar-bg {
+  height: 20rpx;
+  background: #FED7AA;
+  border-radius: 10rpx;
+  overflow: hidden;
+  margin-bottom: 12rpx;
+}
+.gift-progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #F97316, #FB923C);
+  border-radius: 10rpx;
+  transition: width 0.3s ease;
+}
+.gift-progress-info {
+  display: flex;
+  justify-content: space-between;
+}
+.gift-progress-accumulated {
+  font-size: 22rpx;
+  color: #9A3412;
+}
+.gift-progress-remaining {
+  font-size: 22rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-level {
+  margin-top: 12rpx;
+  font-size: 22rpx;
+  color: #F97316;
+}
+.gift-granted-hint {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  background: #F0FDF4;
+  border-top: 2rpx solid #BBF7D0;
+}
+.gift-granted-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-granted-text {
+  flex: 1;
+  font-size: 26rpx;
+  color: #15803D;
+}
 </style>

+ 134 - 3
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -158,6 +158,32 @@
         </view>
       </view>
 
+      <!-- 赠会员累计进度 -->
+      <view v-if="giftProgress && !giftIsGranted && giftProgress.threshold > 0" class="section gift-progress-section">
+        <view class="gift-progress-header">
+          <text class="gift-progress-icon">🎁</text>
+          <text class="gift-progress-title">购会员商品累计进度</text>
+          <text class="gift-progress-percent">{{ giftProgressPercent }}%</text>
+        </view>
+        <view class="gift-progress-bar-bg">
+          <view class="gift-progress-bar-fill" :style="{ width: giftProgressPercent + '%' }"></view>
+        </view>
+        <view class="gift-progress-info">
+          <text class="gift-progress-accumulated">已累 {{ giftAccumulatedYuan }} 元</text>
+          <text class="gift-progress-remaining">还差 {{ giftRemainingYuan }} 元送会员</text>
+        </view>
+        <view v-if="giftProgress.grantedLevel" class="gift-progress-level">
+          <text>达标将赠送 {{ giftProgress.grantedLevel === 'FAMILY' ? '家庭会员' : giftProgress.grantedLevel }} 权益</text>
+        </view>
+      </view>
+
+      <view v-else-if="giftIsGranted" class="section gift-granted-section">
+        <view class="gift-granted-row">
+          <text class="gift-granted-icon">✅</text>
+          <text class="gift-granted-text">本家庭已赠送会员,可前往会员中心查看</text>
+        </view>
+      </view>
+
       <!-- Freight Section -->
       <view class="section">
         <view class="info-row">
@@ -430,7 +456,7 @@
 
 <script>
 import config from '@/config.js'
-import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate } from '../../../utils/api.js'
+import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, getGiftMembershipProgress, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate } from '../../../utils/api.js'
 import { parseDate } from '@/utils/format.js'
 import CountdownTag from '@/components/countdown-tag.vue'
 
@@ -473,11 +499,33 @@ export default {
       showTimeSlotModal: false,
       showServicePersonModal: false,
        showOnlineSlotModal: false,
-       giftItemIds: [],
-       discountInfo: null
+      giftItemIds: [],
+      giftProgress: null,   // 赠会员累计进度
+      discountInfo: null
     }
   },
   computed: {
+    giftAccumulatedYuan() {
+      if (!this.giftProgress || !this.giftProgress.accumulatedAmount) return 0
+      return this.giftProgress.accumulatedAmount / 100
+    },
+    giftThresholdYuan() {
+      if (!this.giftProgress || !this.giftProgress.threshold) return 0
+      return this.giftProgress.threshold / 100
+    },
+    giftRemainingYuan() {
+      if (!this.giftProgress || !this.giftProgress.threshold || !this.giftProgress.accumulatedAmount) return 0
+      var remaining = this.giftProgress.threshold - this.giftProgress.accumulatedAmount
+      return remaining > 0 ? remaining / 100 : 0
+    },
+    giftProgressPercent() {
+      if (!this.giftProgress || !this.giftProgress.threshold || this.giftProgress.threshold <= 0) return 0
+      if (!this.giftProgress.accumulatedAmount) return 0
+      return Math.min(100, Math.round(this.giftProgress.accumulatedAmount / this.giftProgress.threshold * 100))
+    },
+    giftIsGranted() {
+      return this.giftProgress && this.giftProgress.granted === 1
+    },
     giftLevelNames() {
       var names = []
       for (var i = 0; i < this.items.length; i++) {
@@ -584,6 +632,7 @@ export default {
     this.loadUserPoints()
     this.loadDeliveryConfig()
     this.loadDiscount()
+    this.loadGiftProgress()
     this._onLoadFired = true
   },
   onShow() {
@@ -785,6 +834,14 @@ export default {
         }
       }).catch(function() {})
     },
+    loadGiftProgress() {
+      var that = this
+      getGiftMembershipProgress().then(function(res) {
+        if (res.data && res.data.code === 200) {
+          that.giftProgress = res.data.data || null
+        }
+      }).catch(function() {})
+    },
     goUpgrade() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
@@ -1679,4 +1736,78 @@ showOnlineSlotPicker: function() {
   color: #F97316;
   font-size: 32rpx;
 }
+/* Gift membership progress */
+.gift-progress-section {
+  background: #FFF7ED;
+  border-radius: 16rpx;
+  border: 2rpx solid #FED7AA;
+}
+.gift-progress-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.gift-progress-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-progress-title {
+  flex: 1;
+  font-size: 26rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-percent {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+}
+.gift-progress-bar-bg {
+  height: 20rpx;
+  background: #FED7AA;
+  border-radius: 10rpx;
+  overflow: hidden;
+  margin-bottom: 12rpx;
+}
+.gift-progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #F97316, #FB923C);
+  border-radius: 10rpx;
+}
+.gift-progress-info {
+  display: flex;
+  justify-content: space-between;
+}
+.gift-progress-accumulated {
+  font-size: 22rpx;
+  color: #9A3412;
+}
+.gift-progress-remaining {
+  font-size: 22rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-level {
+  margin-top: 12rpx;
+  font-size: 22rpx;
+  color: #F97316;
+}
+.gift-granted-section {
+  background: #F0FDF4;
+  border-radius: 16rpx;
+  border: 2rpx solid #BBF7D0;
+}
+.gift-granted-row {
+  display: flex;
+  align-items: center;
+}
+.gift-granted-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-granted-text {
+  flex: 1;
+  font-size: 26rpx;
+  color: #15803D;
+}
 </style>

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

@@ -476,6 +476,10 @@ export const getMyMembership = () => {
 	return request('/api/membership/my', 'POST')
 }
 
+export const getGiftMembershipProgress = () => {
+	return request('/api/membership/gift-membership-progress', 'POST')
+}
+
 export const getAllQuotaStatus = () => {
 	return request('/api/basic-quota/status/all', 'POST')
 }

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

@@ -1354,4 +1354,44 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 ---
 
+### 4.38 商品赠送会员(`/api/membership/gift-membership-progress`)
+
+家庭维度累计「赠会员商品」原价实付金额,达到阈值后自动赠送会员。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/membership/gift-membership-progress` | 获取当前家庭的赠会员累计进度 |
+
+**POST `/api/membership/gift-membership-progress`**
+
+请求:无(familyId 由 JWT userId 反查)
+
+返回:
+```json
+{
+  "code": 200,
+  "data": {
+    "familyId": 12,
+    "accumulatedAmount": 158000,
+    "granted": 0,
+    "grantedLevel": null,
+    "grantedOrderNo": null,
+    "grantedAt": null,
+    "threshold": 198000
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `accumulatedAmount` | Integer | 累计赠会员商品原价金额(分),仅统计无积分/无券/无折扣的实付订单 |
+| `granted` | Integer | 1=已赠送 0=未赠送 |
+| `grantedLevel` | String | 已赠送的会员等级(当前全局配置默认 `FAMILY`) |
+| `threshold` | Integer | 阈值(分),由 `sys_config.gift_membership_threshold` 动态填入;无家庭时返回 `0` |
+
+> **注意**:`threshold` 为 `@TableField(exist = false)` 非数据库列,仅接口返回用。
+> 阈值单位为分(1980 元 = 198000 分),实际生效值以 `sys_config` 表中 `gift_membership_threshold` 为准(`insertSysConfigSeed` 仅在缺失时插入,不覆盖已有配置)。
+
+---
+
 *文档最后更新:2026-09-13*