Bladeren bron

chore: auto bump version and changelog [skip ci]

iwt 23 uur geleden
bovenliggende
commit
8696c02c7f

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/shop/ShopDiscountController.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.controller.shop;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.DiscountCalculateRequest;
+import com.etotem.cfc.dto.DiscountCalculateVO;
+import com.etotem.cfc.service.DiscountService;
+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;
+
+@RestController
+@RequestMapping("/api/shop/discount")
+public class ShopDiscountController {
+
+    @Resource
+    private DiscountService discountService;
+
+    @PostMapping("/calculate")
+    public Result<DiscountCalculateVO> calculate(@RequestBody DiscountCalculateRequest req) {
+        return discountService.calculateAndConsume(req);
+    }
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DiscountCalculateRequest.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class DiscountCalculateRequest {
+    private List<Long> productIds;
+    private Long categoryId;
+    private Long distributionSystemId;
+    private Integer orderAmount;
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DiscountCalculateVO.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class DiscountCalculateVO {
+    private Integer discountAmount;
+    private Long ruleId;
+    private String ruleName;
+    private String ruleType;
+    private String endTime;
+    private Integer remainingSeconds;
+}

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DiscountService.java

@@ -1,12 +1,16 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.DiscountCalculateRequest;
+import com.etotem.cfc.dto.DiscountCalculateVO;
 import com.etotem.cfc.entity.DiscountRule;
 import com.etotem.cfc.entity.DiscountRule;
 import com.etotem.cfc.mapper.DiscountRuleMapper;
 import com.etotem.cfc.mapper.DiscountRuleMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
@@ -94,6 +98,76 @@ public class DiscountService {
         return Result.success(bestDiscount);
         return Result.success(bestDiscount);
     }
     }
 
 
+    /**
+     * 计算最优限时折扣并原子性扣减 used_count(CAS)。
+     * 与 calculateBestDiscount 的区别:此方法仅在匹配成功且 CAS 更新 used_count 后返回结果,
+     * 用于下单时调用,确保 not 超卖。
+     */
+    public Result<DiscountCalculateVO> calculateAndConsume(DiscountCalculateRequest req) {
+        if (req == null || req.getOrderAmount() == null || req.getOrderAmount() <= 0) {
+            return Result.success(null);
+        }
+
+        List<Long> productIds = req.getProductIds() != null ? req.getProductIds() : new ArrayList<>();
+        List<DiscountRule> allRules = discountRuleMapper.selectList(
+            new LambdaQueryWrapper<DiscountRule>()
+                .eq(DiscountRule::getEnabled, true)
+        );
+
+        Date now = new Date();
+        DiscountRule bestRule = null;
+        int bestDiscount = 0;
+
+        for (DiscountRule rule : allRules) {
+            if (!isTimeValid(rule, now)) continue;
+            if (!isCategoryMatch(rule, req.getCategoryId())) continue;
+            if (!isDistributionMatch(rule, req.getDistributionSystemId())) continue;
+            if (!isProductMatch(rule, productIds)) continue;
+            if (!isUsageValid(rule)) continue;
+            if ("THRESHOLD".equals(rule.getType())
+                && (rule.getThresholdAmount() == null || req.getOrderAmount() < rule.getThresholdAmount())) {
+                continue;
+            }
+
+            int discount = calculateDiscount(rule, req.getOrderAmount());
+            if (discount > bestDiscount) {
+                bestDiscount = discount;
+                bestRule = rule;
+            }
+        }
+
+        if (bestRule == null) {
+            return Result.success(null);
+        }
+
+        // CAS 扣减 used_count,防止并发超卖
+        UpdateWrapper<DiscountRule> updateWrapper = new UpdateWrapper<DiscountRule>()
+                .eq("id", bestRule.getId())
+                .eq("used_count", bestRule.getUsedCount())
+                .setSql("used_count = used_count + 1")
+                .apply("used_count < max_uses OR max_uses = 0");
+        int updated = discountRuleMapper.update(null, updateWrapper);
+        if (updated == 0) {
+            return Result.error("该限时折扣已用完,请稍候再试");
+        }
+
+        DiscountCalculateVO vo = new DiscountCalculateVO();
+        vo.setDiscountAmount(bestDiscount);
+        vo.setRuleId(bestRule.getId());
+        vo.setRuleName(bestRule.getName());
+        vo.setRuleType(bestRule.getType());
+        if (bestRule.getEndTime() != null) {
+            vo.setEndTime(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").format(bestRule.getEndTime()));
+        }
+        if (bestRule.getEndTime() != null) {
+            long diff = bestRule.getEndTime().getTime() - System.currentTimeMillis();
+            vo.setRemainingSeconds(diff > 0 ? (int)(diff / 1000) : 0);
+        } else {
+            vo.setRemainingSeconds(0);
+        }
+        return Result.success(vo);
+    }
+
     private boolean isTimeValid(DiscountRule rule, Date now) {
     private boolean isTimeValid(DiscountRule rule, Date now) {
         if (rule.getStartTime() != null && now.before(rule.getStartTime())) return false;
         if (rule.getStartTime() != null && now.before(rule.getStartTime())) return false;
         if (rule.getEndTime() != null && now.after(rule.getEndTime())) return false;
         if (rule.getEndTime() != null && now.after(rule.getEndTime())) return false;

+ 7 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -229,13 +229,14 @@ public class ProductOrderService {
         order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
         order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         order.setPointsCost(pointsCost);
         order.setPointsCost(pointsCost);
-        int moneyAmount = order.getTotalAmount() - pointsCost;
+        int discountAmount = dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0;
+        int moneyAmount = order.getTotalAmount() - pointsCost - discountAmount;
         order.setMoneyAmount(moneyAmount > 0 ? moneyAmount : 0);
         order.setMoneyAmount(moneyAmount > 0 ? moneyAmount : 0);
         order.setStatus("pending");
         order.setStatus("pending");
         order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
         order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
         order.setRemark(dto.getRemark());
         order.setRemark(dto.getRemark());
         order.setCoverImage(product.getCoverImage());
         order.setCoverImage(product.getCoverImage());
-        order.setDiscountAmount(dto.getDiscountAmount());
+        order.setDiscountAmount(discountAmount);
         order.setCouponId(dto.getCouponId());
         order.setCouponId(dto.getCouponId());
         order.setAddressSnapshot(dto.getAddressSnapshot());
         order.setAddressSnapshot(dto.getAddressSnapshot());
         order.setConsigneeId(dto.getConsigneeId());
         order.setConsigneeId(dto.getConsigneeId());
@@ -248,7 +249,7 @@ public class ProductOrderService {
         if (fv.unitPrice != null) {
         if (fv.unitPrice != null) {
             order.setUnitPrice(fv.unitPrice);
             order.setUnitPrice(fv.unitPrice);
             order.setTotalAmount(fv.unitPrice * dto.getQuantity());
             order.setTotalAmount(fv.unitPrice * dto.getQuantity());
-            int ma = order.getTotalAmount() - order.getPointsCost();
+            int ma = order.getTotalAmount() - order.getPointsCost() - discountAmount;
             order.setMoneyAmount(ma > 0 ? ma : 0);
             order.setMoneyAmount(ma > 0 ? ma : 0);
         }
         }
         if (fv.pickupLocationId != null) order.setPickupLocationId(fv.pickupLocationId);
         if (fv.pickupLocationId != null) order.setPickupLocationId(fv.pickupLocationId);
@@ -524,13 +525,14 @@ public class ProductOrderService {
         order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
         order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         order.setPointsCost(pointsCost);
         order.setPointsCost(pointsCost);
-        int moneyAmount = totalAmount - pointsCost;
+        int multiDiscountAmount = dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0;
+        int moneyAmount = totalAmount - pointsCost - multiDiscountAmount;
         order.setMoneyAmount(moneyAmount > 0 ? moneyAmount : 0);
         order.setMoneyAmount(moneyAmount > 0 ? moneyAmount : 0);
         order.setStatus("pending");
         order.setStatus("pending");
         order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
         order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
         order.setRemark(dto.getRemark());
         order.setRemark(dto.getRemark());
         order.setCoverImage(firstItem.getCoverImage() != null ? firstItem.getCoverImage() : firstProduct.getCoverImage());
         order.setCoverImage(firstItem.getCoverImage() != null ? firstItem.getCoverImage() : firstProduct.getCoverImage());
-        order.setDiscountAmount(dto.getDiscountAmount());
+        order.setDiscountAmount(multiDiscountAmount);
         order.setCouponId(dto.getCouponId());
         order.setCouponId(dto.getCouponId());
         order.setAddressSnapshot(dto.getAddressSnapshot());
         order.setAddressSnapshot(dto.getAddressSnapshot());
         order.setConsigneeId(dto.getConsigneeId());
         order.setConsigneeId(dto.getConsigneeId());

+ 22 - 0
cfc-backend/src/main/resources/schema.sql

@@ -5735,3 +5735,25 @@ CREATE TABLE IF NOT EXISTS doa_week (
     UNIQUE KEY uk_dw_stage_week (stage_id, week_no),
     UNIQUE KEY uk_dw_stage_week (stage_id, week_no),
     INDEX idx_dw_stage (stage_id)
     INDEX idx_dw_stage (stage_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录';
+
+CREATE TABLE IF NOT EXISTS shop_discount_rules (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    name VARCHAR(64) NOT NULL COMMENT '规则名称',
+    type VARCHAR(32) NOT NULL COMMENT '类型: PERCENT/FIXED/THRESHOLD',
+    value INT NOT NULL COMMENT '折扣值(PERCENT=百分比数字, FIXED/THRESHOLD=减免金额分)',
+    threshold_amount INT COMMENT '满减门槛(分,仅THRESHOLD)',
+    category_id BIGINT COMMENT '适用类目ID(null=全部)',
+    distribution_system_id BIGINT COMMENT '适用体系ID(null=全部)',
+    product_ids TEXT COMMENT '适用商品ID(逗号分隔,null=全部)',
+    start_time DATETIME COMMENT '开始时间',
+    end_time DATETIME COMMENT '结束时间',
+    max_uses INT DEFAULT 0 COMMENT '最大使用次数(0=不限)',
+    used_count INT DEFAULT 0 COMMENT '已使用次数',
+    enabled TINYINT(1) DEFAULT 1 COMMENT '启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_category (category_id),
+    INDEX idx_enabled (enabled),
+    INDEX idx_distribution (distribution_system_id),
+    INDEX idx_end_time (end_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时折扣规则表';

+ 74 - 0
cfc-frontend/components/countdown-tag.vue

@@ -0,0 +1,74 @@
+<template>
+  <view class="countdown-tag" :style="{ backgroundColor: color }">
+    <text class="countdown-text" :style="{ color: textColor }">{{ remainingText }}</text>
+  </view>
+</template>
+
+<script>
+import { parseDate } from '@/utils/format.js'
+
+export default {
+  name: 'CountdownTag',
+  props: {
+    endTime: { type: String, default: null },
+    color: { type: String, default: '#FF6B00' },
+    textColor: { type: String, default: '#FFFFFF' }
+  },
+  data() {
+    return { remainingText: '' }
+  },
+  created() {
+    this._timer = null
+    this._tick()
+    this._timer = setInterval(() => this._tick(), 1000)
+  },
+  beforeDestroy() {
+    if (this._timer) clearInterval(this._timer)
+  },
+  methods: {
+    _tick() {
+      if (!this.endTime) {
+        this.remainingText = ''
+        return
+      }
+      var end = parseDate(this.endTime)
+      if (!end) {
+        this.remainingText = ''
+        return
+      }
+      var diff = end.getTime() - Date.now()
+      if (diff <= 0) {
+        this.remainingText = '已结束'
+        return
+      }
+      var days = Math.floor(diff / 86400000)
+      var hours = Math.floor((diff % 86400000) / 3600000)
+      var mins = Math.floor((diff % 3600000) / 60000)
+      var secs = Math.floor((diff % 60000) / 1000)
+      if (days > 0) {
+        this.remainingText = days + '天' + hours + '时' + mins + '分' + secs + '秒'
+      } else if (hours > 0) {
+        this.remainingText = hours + '时' + mins + '分' + secs + '秒'
+      } else {
+        this.remainingText = mins + '分' + secs + '秒'
+      }
+      if (diff < 3600000) {
+        this.remainingText = '即将结束'
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.countdown-tag {
+  display: inline-block;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+  font-size: 22rpx;
+  margin-top: 6rpx;
+}
+.countdown-text {
+  font-weight: bold;
+}
+</style>

+ 37 - 4
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -167,6 +167,16 @@
         </view>
         </view>
       </view>
       </view>
 
 
+      <!-- Limited-Time Discount Section -->
+      <view v-if="discountInfo" class="section discount-section">
+        <view class="info-row">
+          <text class="info-label">限时折扣</text>
+          <text class="discount-amount">-{{ formatPriceWithSymbol(discountInfo.discountAmount) }}</text>
+        </view>
+        <view class="discount-rule-name">{{ discountInfo.ruleName || '' }}</view>
+        <countdown-tag v-if="discountInfo.endTime" :end-time="discountInfo.endTime" />
+      </view>
+
       <!-- Coupon Section -->
       <!-- Coupon Section -->
       <view class="section coupon-section" @click="openCouponPicker">
       <view class="section coupon-section" @click="openCouponPicker">
         <view class="info-row">
         <view class="info-row">
@@ -412,10 +422,14 @@
 
 
 <script>
 <script>
 import config from '@/config.js'
 import config from '@/config.js'
-import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, productDetail, getFamilyPlatformBalance, exchangeCouponByCf } from '../../../utils/api.js'
+import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate } from '../../../utils/api.js'
 import { parseDate } from '@/utils/format.js'
 import { parseDate } from '@/utils/format.js'
+import CountdownTag from '@/components/countdown-tag.vue'
 
 
 export default {
 export default {
+  components: {
+    CountdownTag
+  },
   data() {
   data() {
     return {
     return {
       items: [],
       items: [],
@@ -447,11 +461,12 @@ export default {
       selectedServicePerson: null,
       selectedServicePerson: null,
       onlineSlots: [],
       onlineSlots: [],
       // 弹窗控制
       // 弹窗控制
-      showPickupLocationModal: false,
+       showPickupLocationModal: false,
       showTimeSlotModal: false,
       showTimeSlotModal: false,
       showServicePersonModal: false,
       showServicePersonModal: false,
        showOnlineSlotModal: false,
        showOnlineSlotModal: false,
-       giftItemIds: []
+       giftItemIds: [],
+       discountInfo: null
     }
     }
   },
   },
   computed: {
   computed: {
@@ -551,6 +566,7 @@ export default {
     this.loadCfBalance()
     this.loadCfBalance()
     this.loadUserPoints()
     this.loadUserPoints()
     this.loadDeliveryConfig()
     this.loadDeliveryConfig()
+    this.loadDiscount()
     this._onLoadFired = true
     this._onLoadFired = true
   },
   },
   onShow() {
   onShow() {
@@ -782,7 +798,23 @@ export default {
         }
         }
       }).catch(function() {})
       }).catch(function() {})
     },
     },
-    onDeliveryMethodChange: function(e) {
+    loadDiscount() {
+      var that = this
+      if (this.items.length === 0) return
+      var productIds = []
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].productId) productIds.push(this.items[i].productId)
+      }
+      if (productIds.length === 0) return
+      shopDiscountCalculate({
+        productIds: productIds,
+        orderAmount: this.actualAmount
+      }).then(function(res) {
+        if (res.code === 200 && res.data) {
+          that.discountInfo = res.data
+        }
+      }).catch(function() {})
+    },
       this.deliveryMethod = parseInt(e.detail.value)
       this.deliveryMethod = parseInt(e.detail.value)
       this.selectedPickupLocation = null
       this.selectedPickupLocation = null
       this.selectedTimeSlot = null
       this.selectedTimeSlot = null
@@ -889,6 +921,7 @@ export default {
           addressId: that.selectedAddress.id,
           addressId: that.selectedAddress.id,
           purchaseInfo: that.purchaseForm,
           purchaseInfo: that.purchaseForm,
           pointsUsed: that.pointsUsed,
           pointsUsed: that.pointsUsed,
+          discountAmount: that.discountInfo ? that.discountInfo.discountAmount : 0,
           deliveryMethod: that.deliveryMethod,
           deliveryMethod: that.deliveryMethod,
           pickupLocationId: that.selectedPickupLocation ? that.selectedPickupLocation.id : null,
           pickupLocationId: that.selectedPickupLocation ? that.selectedPickupLocation.id : null,
           timeSlotId: that.selectedTimeSlot ? that.selectedTimeSlot.id : null,
           timeSlotId: that.selectedTimeSlot ? that.selectedTimeSlot.id : null,

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

@@ -3069,3 +3069,8 @@ export function generatePortrait(params = {}) {
 export function regeneratePortrait(params = {}) {
 export function regeneratePortrait(params = {}) {
   return request('/api/user/portrait/regenerate', 'POST', params)
   return request('/api/user/portrait/regenerate', 'POST', params)
 }
 }
+
+// ── 限时折扣 ──
+export const shopDiscountCalculate = (params) => {
+  return request('/api/shop/discount/calculate', 'POST', params || {})
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-21f8f674075c38c81d0841b3aa1631214effa79b
+1d3a0e545bae3e1e92792191f95be23e3827b498

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1448",
+  "version": "1.0.1449",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1448",
+      "version": "1.0.1449",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1449",
+  "version": "1.0.1450",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

+ 6 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,12 @@
 
 
 ---
 ---
 
 
+## v1.0.1450 (2026-09-20)
+
+### 新功能
+- 会员中心支持原价展示与编辑,后端补齐原价字段保存
+
+
 ## v1.0.1449 (2026-09-20)
 ## v1.0.1449 (2026-09-20)
 
 
 ### 新功能
 ### 新功能

+ 7 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1449
+> 当前版本: v1.0.1450
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,12 @@
 
 
 ---
 ---
 
 
+## v1.0.1450 (2026-09-20)
+
+### 新功能
+- 会员中心支持原价展示与编辑,后端补齐原价字段保存
+
+
 ## v1.0.1449 (2026-09-20)
 ## v1.0.1449 (2026-09-20)
 
 
 ### 新功能
 ### 新功能

+ 21 - 0
cfc-web/src/api/discount.js

@@ -0,0 +1,21 @@
+import request from '@/utils/request'
+
+export function getDiscountRuleList(data) {
+  return request({ url: '/api/admin/shop/discount/list', method: 'post', data })
+}
+
+export function createDiscountRule(data) {
+  return request({ url: '/api/admin/shop/discount/create', method: 'post', data })
+}
+
+export function updateDiscountRule(data) {
+  return request({ url: '/api/admin/shop/discount/update', method: 'post', data })
+}
+
+export function toggleDiscountRule(data) {
+  return request({ url: '/api/admin/shop/discount/toggle', method: 'post', data })
+}
+
+export function deleteDiscountRule(data) {
+  return request({ url: '/api/admin/shop/discount/delete', method: 'post', data })
+}

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

@@ -679,6 +679,12 @@ const routes = [
         component: () => import('@/views/admin/CouponGrantLog.vue'),
         component: () => import('@/views/admin/CouponGrantLog.vue'),
         meta: { title: '发券记录', perm: 'marketing:coupon' }
         meta: { title: '发券记录', perm: 'marketing:coupon' }
       },
       },
+      {
+        path: 'discount-rules',
+        name: 'DiscountRuleManagement',
+        component: () => import('@/views/admin/DiscountRuleManagement.vue'),
+        meta: { title: '限时折扣管理', perm: 'marketing:discount' }
+      },
       {
       {
         path: 'promotion',
         path: 'promotion',
         name: 'PromotionManagement',
         name: 'PromotionManagement',

+ 291 - 0
cfc-web/src/views/admin/DiscountRuleManagement.vue

@@ -0,0 +1,291 @@
+<template>
+  <div class="discount-rule-management admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">限时折扣管理</span>
+        <div class="admin-page-actions">
+          <el-button type="primary" size="small" @click="handleCreate">新建规则</el-button>
+        </div>
+      </div>
+
+      <div class="table-scroll-wrap-sm">
+        <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
+          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="name" label="规则名称" min-width="140" show-overflow-tooltip />
+          <el-table-column label="类型" width="90">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="typeTagType(row.type)">{{ typeLabel(row.type) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="折扣值" width="100">
+            <template slot-scope="{ row }">
+              <span v-if="row.type === 'PERCENT'">{{ row.value }}%</span>
+              <span v-else>{{ (row.value / 100).toFixed(2) }}元</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="满减门槛" width="100">
+            <template slot-scope="{ row }">
+              <span v-if="row.type === 'THRESHOLD'">{{ row.thresholdAmount ? (row.thresholdAmount / 100).toFixed(2) + '元' : '-' }}</span>
+              <span v-else>-</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="时间范围" width="260">
+            <template slot-scope="{ row }">
+              <span v-if="row.startTime">{{ formatTime(row.startTime) }}</span>
+              <span v-else>-</span>
+              <br />
+              <span v-if="row.endTime">{{ formatTime(row.endTime) }}</span>
+              <span v-else>-</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="使用次数" width="100">
+            <template slot-scope="{ row }">
+              <span>{{ row.usedCount || 0 }}/{{ row.maxUses || '不限' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="80">
+            <template slot-scope="{ row }">
+              <el-switch
+                :value="row.enabled"
+                @change="handleToggle(row)"
+                active-color="#67C23A"
+                inactive-color="#DCDFE6"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="120" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <el-pagination
+        @current-change="onPageChange"
+        @size-change="onSizeChange"
+        :current-page="page"
+        :page-size="size"
+        :total="total"
+        layout="total, prev, pager, next"
+        class="pagination-wrap"
+      />
+    </el-card>
+
+    <el-dialog :visible.sync="formDialogVisible" :title="isEdit ? '编辑折扣规则' : '新建折扣规则'" width="580px">
+      <el-form :model="form" label-width="130px" ref="ruleForm">
+        <el-form-item label="规则名称" required>
+          <el-input v-model="form.name" placeholder="请输入规则名称" />
+        </el-form-item>
+        <el-form-item label="折扣类型" required>
+          <el-select v-model="form.type" placeholder="请选择类型" style="width: 100%">
+            <el-option label="PERCENT - 按百分比减免" value="PERCENT" />
+            <el-option label="FIXED - 固定金额减免" value="FIXED" />
+            <el-option label="THRESHOLD - 满减折扣" value="THRESHOLD" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="折扣值" required>
+          <el-input-number v-model="form.value" :min="0" :step="1" style="width: 200px" />
+          <span class="form-item-tip" v-if="form.type === 'PERCENT'">(百分比,如20表示减免20%)</span>
+          <span class="form-item-tip" v-else>(金额,单位:分)</span>
+        </el-form-item>
+        <el-form-item label="满减门槛" v-if="form.type === 'THRESHOLD'">
+          <el-input-number v-model="form.thresholdAmount" :min="0" :step="100" style="width: 200px" />
+          <span class="form-item-tip">(达到此金额后方可享受折扣,单位:分)</span>
+        </el-form-item>
+        <el-form-item label="开始时间">
+          <el-date-picker v-model="form.startTimeStr" type="datetime" placeholder="选择开始时间" value-format="yyyy-MM-dd HH:mm:ss" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="结束时间">
+          <el-date-picker v-model="form.endTimeStr" type="datetime" placeholder="选择结束时间" value-format="yyyy-MM-dd HH:mm:ss" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="最大使用次数">
+          <el-input-number v-model="form.maxUses" :min="0" :step="1" style="width: 200px" />
+          <span class="form-item-tip">(0 表示不限)</span>
+        </el-form-item>
+        <el-form-item label="适用类目ID">
+          <el-input-number v-model="form.categoryId" :min="1" :step="1" style="width: 200px" placeholder="留空=全部类目" />
+        </el-form-item>
+        <el-form-item label="适用体系ID">
+          <el-input-number v-model="form.distributionSystemId" :min="1" :step="1" style="width: 200px" placeholder="留空=全部体系" />
+        </el-form-item>
+        <el-form-item label="适用商品ID">
+          <el-input v-model="form.productIds" placeholder="逗号分隔的商品ID,如1,2,3;留空=全部商品" style="width: 100%" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="formDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getDiscountRuleList, createDiscountRule, updateDiscountRule, toggleDiscountRule, deleteDiscountRule } from '@/api/discount'
+
+export default {
+  name: 'DiscountRuleManagement',
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 300
+    }
+  },
+  data() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      formDialogVisible: false,
+      isEdit: false,
+      submitting: false,
+      form: this.getEmptyForm()
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    typeLabel(val) {
+      const map = { PERCENT: '百分比', FIXED: '固定金额', THRESHOLD: '满减' }
+      return map[val] || val
+    },
+    typeTagType(val) {
+      const map = { PERCENT: 'warning', FIXED: 'success', THRESHOLD: '' }
+      return map[val] || ''
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return String(t).replace('T', ' ').substring(0, 19)
+    },
+    getEmptyForm() {
+      return {
+        id: null,
+        name: '',
+        type: 'PERCENT',
+        value: 20,
+        thresholdAmount: 0,
+        startTimeStr: '',
+        endTimeStr: '',
+        maxUses: 0,
+        categoryId: null,
+        distributionSystemId: null,
+        productIds: ''
+      }
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        const res = await getDiscountRuleList({})
+        if (res.data) {
+          this.list = res.data
+          this.total = (res.data.length) || 0
+        }
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.loading = false
+      }
+    },
+    onPageChange(p) {
+      this.page = p
+      this.loadData()
+    },
+    onSizeChange(val) {
+      this.size = val
+      this.page = 1
+      this.loadData()
+    },
+    handleCreate() {
+      this.isEdit = false
+      this.form = this.getEmptyForm()
+      this.formDialogVisible = true
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = {
+        id: row.id,
+        name: row.name,
+        type: row.type,
+        value: row.value,
+        thresholdAmount: row.thresholdAmount || 0,
+        startTimeStr: row.startTime ? this.formatTime(row.startTime) : '',
+        endTimeStr: row.endTime ? this.formatTime(row.endTime) : '',
+        maxUses: row.maxUses || 0,
+        categoryId: row.categoryId || null,
+        distributionSystemId: row.distributionSystemId || null,
+        productIds: row.productIds || ''
+      }
+      this.formDialogVisible = true
+    },
+    handleSubmit() {
+      if (!this.form.name) {
+        this.$message.warning('请输入规则名称')
+        return
+      }
+      this.submitting = true
+      var payload = {
+        id: this.form.id,
+        name: this.form.name,
+        type: this.form.type,
+        value: this.form.value,
+        thresholdAmount: this.form.thresholdAmount,
+        maxUses: this.form.maxUses,
+        categoryId: this.form.categoryId,
+        distributionSystemId: this.form.distributionSystemId,
+        productIds: this.form.productIds || null
+      }
+      if (this.form.startTimeStr) {
+        payload.startTime = this.form.startTimeStr
+      }
+      if (this.form.endTimeStr) {
+        payload.endTime = this.form.endTimeStr
+      }
+      var apiFn = this.isEdit ? updateDiscountRule : createDiscountRule
+      apiFn(payload).then((res) => {
+        this.$message.success(res.message || '保存成功')
+        this.formDialogVisible = false
+        this.loadData()
+      }).catch(() => {
+        this.$message.error('操作失败')
+      }).finally(() => {
+        this.submitting = false
+      })
+    },
+    handleToggle(row) {
+      toggleDiscountRule({ id: row.id }).then((res) => {
+        this.$message.success(res.message || '操作成功')
+        this.loadData()
+      }).catch(() => {})
+    },
+    handleDelete(row) {
+      this.$confirm('确认删除该折扣规则?删除后不可恢复。', '提示', { type: 'warning' }).then(() => {
+        deleteDiscountRule({ id: row.id }).then(() => {
+          this.$message.success('删除成功')
+          this.loadData()
+        }).catch(() => {})
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.admin-page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.form-item-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-left: 10px;
+}
+.pagination-wrap {
+  margin-top: 16px;
+  text-align: right;
+}
+</style>