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

feat(admin): 家庭运营增加优惠券列表和使用记录(后端/family-coupons + /family-coupon-usage,前端弹窗展示)

Sisyphus 2 месяцев назад
Родитель
Сommit
405ab8913a

+ 129 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -1,20 +1,27 @@
 package com.etotem.cfc.controller.admin;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
 import com.etotem.cfc.service.CouponService;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
 
@@ -34,6 +41,9 @@ public class AdminCouponController {
     @Resource
     private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
 
+    @Resource
+    private FamilyCouponMapper familyCouponMapper;
+
     @PostMapping("/list")
     public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
@@ -153,4 +163,123 @@ public class AdminCouponController {
         wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
         return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
     }
+
+    /** 家庭券列表(family_coupon 分页,JOIN coupon 取模板信息) */
+    @PostMapping("/family-coupons")
+    public Result<Page<Map<String, Object>>> familyCoupons(@RequestBody Map<String, Object> params,
+                                                           @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        String status = params.get("status") == null ? null : params.get("status").toString();
+        int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
+        int size = params.get("size") == null ? 20 : Integer.parseInt(params.get("size").toString());
+
+        LambdaQueryWrapper<FamilyCoupon> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(FamilyCoupon::getFamilyId, familyId);
+        }
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(FamilyCoupon::getStatus, status);
+        }
+        wrapper.orderByDesc(FamilyCoupon::getReceivedAt);
+
+        Page<FamilyCoupon> fcPage = familyCouponMapper.selectPage(new Page<>(page, size), wrapper);
+
+        // 批量取券模板信息
+        Set<Long> couponIds = new LinkedHashSet<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            couponIds.add(fc.getCouponId());
+        }
+        Map<Long, Coupon> couponMap = new HashMap<>();
+        if (!couponIds.isEmpty()) {
+            List<Coupon> coupons = couponMapper.selectBatchIds(couponIds);
+            for (Coupon c : coupons) {
+                couponMap.put(c.getId(), c);
+            }
+        }
+
+        List<Map<String, Object>> voList = new ArrayList<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            Coupon c = couponMap.get(fc.getCouponId());
+            Map<String, Object> vo = new HashMap<>();
+            vo.put("id", fc.getId());
+            vo.put("familyId", fc.getFamilyId());
+            vo.put("couponId", fc.getCouponId());
+            vo.put("status", fc.getStatus());
+            vo.put("receivedAt", fc.getReceivedAt());
+            vo.put("usedAt", fc.getUsedAt());
+            vo.put("orderId", fc.getOrderId());
+            if (c != null) {
+                vo.put("name", c.getName());
+                vo.put("type", c.getType());
+                vo.put("value", c.getValue());
+                vo.put("discountRate", c.getDiscountRate());
+                vo.put("minSpend", c.getMinSpend());
+                vo.put("applicableTo", c.getApplicableTo());
+            }
+            voList.add(vo);
+        }
+
+        Page<Map<String, Object>> result = new Page<>(fcPage.getCurrent(), fcPage.getSize(), fcPage.getTotal());
+        result.setRecords(voList);
+        return Result.success(result);
+    }
+
+    /** 家庭券使用记录(status=USED,JOIN coupon 取模板信息) */
+    @PostMapping("/family-coupon-usage")
+    public Result<Page<Map<String, Object>>> familyCouponUsage(@RequestBody Map<String, Object> params,
+                                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
+        int size = params.get("size") == null ? 20 : Integer.parseInt(params.get("size").toString());
+
+        LambdaQueryWrapper<FamilyCoupon> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FamilyCoupon::getStatus, "USED");
+        if (familyId != null) {
+            wrapper.eq(FamilyCoupon::getFamilyId, familyId);
+        }
+        wrapper.orderByDesc(FamilyCoupon::getUsedAt);
+
+        Page<FamilyCoupon> fcPage = familyCouponMapper.selectPage(new Page<>(page, size), wrapper);
+
+        Set<Long> couponIds = new LinkedHashSet<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            couponIds.add(fc.getCouponId());
+        }
+        Map<Long, Coupon> couponMap = new HashMap<>();
+        if (!couponIds.isEmpty()) {
+            List<Coupon> coupons = couponMapper.selectBatchIds(couponIds);
+            for (Coupon c : coupons) {
+                couponMap.put(c.getId(), c);
+            }
+        }
+
+        List<Map<String, Object>> voList = new ArrayList<>();
+        for (FamilyCoupon fc : fcPage.getRecords()) {
+            Coupon c = couponMap.get(fc.getCouponId());
+            Map<String, Object> vo = new HashMap<>();
+            vo.put("id", fc.getId());
+            vo.put("familyId", fc.getFamilyId());
+            vo.put("couponId", fc.getCouponId());
+            vo.put("usedAt", fc.getUsedAt());
+            vo.put("orderId", fc.getOrderId());
+            if (c != null) {
+                vo.put("name", c.getName());
+                vo.put("type", c.getType());
+                vo.put("value", c.getValue());
+                vo.put("discountRate", c.getDiscountRate());
+                vo.put("minSpend", c.getMinSpend());
+            }
+            voList.add(vo);
+        }
+
+        Page<Map<String, Object>> result = new Page<>(fcPage.getCurrent(), fcPage.getSize(), fcPage.getTotal());
+        result.setRecords(voList);
+        return Result.success(result);
+    }
 }

+ 8 - 0
cfc-web/src/api/coupon.js

@@ -35,3 +35,11 @@ export function getCouponGrantLog(data) {
 export function toggleCouponStatus(id, status) {
   return request({ url: '/api/admin/coupon/toggle-status', method: 'post', data: { id, status } })
 }
+
+export function getFamilyCoupons(data) {
+  return request({ url: '/api/admin/coupon/family-coupons', method: 'post', data })
+}
+
+export function getFamilyCouponUsage(data) {
+  return request({ url: '/api/admin/coupon/family-coupon-usage', method: 'post', data })
+}

+ 180 - 2
cfc-web/src/views/Families.vue

@@ -55,6 +55,8 @@
               </el-button>
               <el-dropdown-menu slot="dropdown">
                 <el-dropdown-item command="viewMembers" icon="el-icon-user">查看成员</el-dropdown-item>
+                <el-dropdown-item command="viewCoupons" icon="el-icon-ticket">优惠券列表</el-dropdown-item>
+                <el-dropdown-item command="viewUsage" icon="el-icon-document">使用记录</el-dropdown-item>
                 <el-dropdown-item command="issueCoupon" icon="el-icon-ticket">发放优惠券</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
@@ -174,12 +176,106 @@
         <el-button type="primary" @click="confirmIssueCoupon" :loading="couponSubmitting">确认发放</el-button>
       </div>
     </el-dialog>
+
+    <!-- 优惠券列表弹窗 -->
+    <el-dialog
+      title="优惠券列表"
+      :visible.sync="couponDialogVisible"
+      width="900px"
+    >
+      <el-form :inline="true" style="margin-bottom: 12px">
+        <el-form-item label="状态">
+          <el-select v-model="couponStatusFilter" placeholder="全部" clearable size="small" style="width:120px">
+            <el-option label="可用" value="AVAILABLE"></el-option>
+            <el-option label="已使用" value="USED"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button size="small" @click="loadCoupons(currentRow.id)">刷新</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table :data="couponListData" border v-loading="couponLoading" style="width:100%" max-height="400">
+        <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
+        <el-table-column prop="type" label="类型" width="80">
+          <template slot-scope="scope">
+            <el-tag size="mini" :type="scope.row.type === 'DISCOUNT' ? '' : 'success'">{{ scope.row.type === 'DISCOUNT' ? '折扣' : '满减' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="面值/折扣" width="100">
+          <template slot-scope="scope">
+            <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
+            <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="80">
+          <template slot-scope="scope">
+            <el-tag size="mini" :type="scope.row.status === 'AVAILABLE' ? 'success' : 'info'">{{ scope.row.status === 'AVAILABLE' ? '可用' : '已使用' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="receivedAt" label="领取时间" width="160">
+          <template slot-scope="scope">{{ formatTime(scope.row.receivedAt) }}</template>
+        </el-table-column>
+        <el-table-column prop="usedAt" label="使用时间" width="160">
+          <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        style="margin-top: 12px; text-align: right"
+        @size-change="onCouponSizeChange"
+        @current-change="onCouponPageChange"
+        :current-page="couponPagination.current"
+        :page-sizes="[10, 20, 50]"
+        :page-size="couponPagination.size"
+        :total="couponPagination.total"
+        layout="total, sizes, prev, pager, next"
+      ></el-pagination>
+    </el-dialog>
+
+    <!-- 使用记录弹窗 -->
+    <el-dialog
+      title="使用记录"
+      :visible.sync="usageDialogVisible"
+      width="800px"
+    >
+      <el-table :data="usageListData" border v-loading="usageLoading" style="width:100%" max-height="400">
+        <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
+        <el-table-column prop="type" label="类型" width="80">
+          <template slot-scope="scope">
+            <el-tag size="mini" :type="scope.row.type === 'DISCOUNT' ? '' : 'success'">{{ scope.row.type === 'DISCOUNT' ? '折扣' : '满减' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="面值/折扣" width="100">
+          <template slot-scope="scope">
+            <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
+            <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="orderId" label="订单号" width="120">
+          <template slot-scope="scope">{{ scope.row.orderId || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="usedAt" label="使用时间" width="160">
+          <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        style="margin-top: 12px; text-align: right"
+        @size-change="onUsageSizeChange"
+        @current-change="onUsagePageChange"
+        :current-page="usagePagination.current"
+        :page-sizes="[10, 20, 50]"
+        :page-size="usagePagination.size"
+        :total="usagePagination.total"
+        layout="total, sizes, prev, pager, next"
+      ></el-pagination>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
-import { getCouponList, issueFamilyCoupon } from '@/api/coupon'
+import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
 
 export default {
   name: 'Families',
@@ -224,7 +320,16 @@ export default {
         quantity: 1
       },
       couponOptions: [],
-      couponSubmitting: false
+      couponSubmitting: false,
+      currentRow: null,
+      couponListData: [],
+      couponLoading: false,
+      couponPagination: { current: 1, size: 20, total: 0 },
+      couponStatusFilter: '',
+      usageListData: [],
+      usageLoading: false,
+      usagePagination: { current: 1, size: 20, total: 0 },
+      usageDialogVisible: false
     }
   },
   mounted() {
@@ -239,6 +344,8 @@ export default {
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'viewMembers': this.handleViewMembers(row); break;
+        case 'viewCoupons': this.handleViewCoupons(row); break;
+        case 'viewUsage': this.handleViewUsage(row); break;
         case 'issueCoupon': this.handleIssueCoupon(row); break;
         case 'edit': this.handleEdit(row); break;
         case 'delete': this.handleDelete(row); break;
@@ -386,6 +493,77 @@ export default {
       if (!dateStr) return '-'
       const date = new Date(dateStr)
       return date.toLocaleString('zh-CN')
+    },
+    formatTime(t) {
+      return t ? t.replace('T', ' ').substring(0, 19) : '-'
+    },
+    handleViewCoupons(row) {
+      this.currentRow = row
+      this.couponPagination = { current: 1, size: 20, total: 0 }
+      this.couponStatusFilter = ''
+      this.couponListData = []
+      this.couponDialogVisible = true
+      this.loadCoupons()
+    },
+    async loadCoupons() {
+      if (!this.currentRow) return
+      this.couponLoading = true
+      try {
+        const res = await getFamilyCoupons({
+          familyId: this.currentRow.id,
+          status: this.couponStatusFilter || undefined,
+          page: this.couponPagination.current,
+          size: this.couponPagination.size
+        })
+        this.couponListData = res.data.records || []
+        this.couponPagination.total = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || '加载优惠券列表失败')
+      } finally {
+        this.couponLoading = false
+      }
+    },
+    onCouponPageChange(val) {
+      this.couponPagination.current = val
+      this.loadCoupons()
+    },
+    onCouponSizeChange(val) {
+      this.couponPagination.size = val
+      this.couponPagination.current = 1
+      this.loadCoupons()
+    },
+    handleViewUsage(row) {
+      this.currentRow = row
+      this.usagePagination = { current: 1, size: 20, total: 0 }
+      this.usageListData = []
+      this.usageDialogVisible = true
+      this.loadUsage()
+    },
+    async loadUsage() {
+      if (!this.currentRow) return
+      this.usageLoading = true
+      try {
+        const res = await getFamilyCouponUsage({
+          familyId: this.currentRow.id,
+          page: this.usagePagination.current,
+          size: this.usagePagination.size
+        })
+        this.usageListData = res.data.records || []
+        this.usagePagination.total = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || '加载使用记录失败')
+      } finally {
+        this.usageLoading = false
+      }
+    },
+    onUsagePageChange(val) {
+      this.usagePagination.current = val
+      this.loadUsage()
+    },
+    onUsageSizeChange(val) {
+      this.usagePagination.size = val
+      this.usagePagination.current = 1
+      this.loadUsage()
     }
   }
 }