Explorar el Código

优惠券调试

skyline hace 1 mes
padre
commit
21de6d917e

+ 6 - 0
haha-entity/src/main/java/com/haha/entity/UserCoupon.java

@@ -56,6 +56,12 @@ public class UserCoupon implements Serializable {
     @TableField(exist = false)
     private BigDecimal minAmount;
 
+    @TableField(exist = false)
+    private String couponDesc;
+
+    @TableField(exist = false)
+    private Integer applyScope;
+
     @TableField(exist = false)
     private String statusLabel;
 

+ 117 - 0
haha-mp/src/api/coupon.ts

@@ -0,0 +1,117 @@
+/**
+ * 优惠券相关API
+ */
+
+import { get, post } from '../utils/request';
+
+/**
+ * 用户优惠券信息
+ */
+export interface UserCouponInfo {
+  id: string;
+  couponCode: string;
+  templateId: string;
+  userId: string;
+  orderId: string | null;
+  status: number; // 0-未使用, 1-已使用, 2-已过期
+  receiveTime: string;
+  useTime: string | null;
+  validStartTime: string;
+  validEndTime: string;
+  discountAmount: number | null;
+  couponName: string;
+  couponType: number; // 1-满减券, 2-折扣券, 3-抵扣券, 4-兑换券
+  minAmount: number;
+  couponDesc: string;
+  applyScope: number;
+  statusLabel: string;
+  statusColor: string;
+}
+
+/**
+ * 可领取优惠券模板
+ */
+export interface CouponTemplateInfo {
+  id: string;
+  couponName: string;
+  couponType: number;
+  couponDesc: string;
+  discountValue: number;
+  minAmount: number;
+  maxDiscount: number;
+  totalCount: number;
+  remainCount: number;
+  receiveLimit: number;
+  validType: number;
+  validStartTime: string;
+  validEndTime: string;
+  validDays: number;
+  applyScope: number;
+  receiveType: string;
+  status: number;
+}
+
+/**
+ * 分页结果
+ */
+export interface PageResult<T> {
+  list: T[];
+  total: number;
+  pageSize: number;
+  currentPage: number;
+  totalPages: number;
+}
+
+/**
+ * 优惠券数量统计
+ */
+export interface CouponCountInfo {
+  availableCount: number;
+}
+
+/**
+ * 获取我的优惠券列表
+ * @param status 0-未使用, 1-已使用, 2-已过期
+ * @param page 页码
+ * @param pageSize 每页数量
+ */
+export const getMyCoupons = (status: number = 0, page: number = 1, pageSize: number = 10): Promise<PageResult<UserCouponInfo>> => {
+  return get<PageResult<UserCouponInfo>>('/coupon/my', { status, page, pageSize });
+};
+
+/**
+ * 获取优惠券详情
+ * @param id 优惠券ID
+ */
+export const getCouponDetail = (id: string): Promise<UserCouponInfo> => {
+  return get<UserCouponInfo>(`/coupon/${id}`);
+};
+
+/**
+ * 领取优惠券
+ * @param templateId 优惠券模板ID
+ */
+export const receiveCoupon = (templateId: string): Promise<UserCouponInfo> => {
+  return post<UserCouponInfo>(`/coupon/receive/${templateId}`);
+};
+
+/**
+ * 获取可用优惠券数量
+ */
+export const getCouponCount = (): Promise<CouponCountInfo> => {
+  return get<CouponCountInfo>('/coupon/count');
+};
+
+/**
+ * 获取可领取优惠券列表(领券中心)
+ */
+export const getAvailableCoupons = (): Promise<CouponTemplateInfo[]> => {
+  return get<CouponTemplateInfo[]>('/coupon/available');
+};
+
+/**
+ * 获取可用优惠券列表(下单时使用)
+ */
+export const getUsableCoupons = (): Promise<UserCouponInfo[]> => {
+  return get<UserCouponInfo[]>('/coupon/usable');
+};

+ 16 - 0
haha-mp/src/pages.json

@@ -80,6 +80,22 @@
 				"navigationBarBackgroundColor": "#FFD700",
 				"navigationBarTextStyle": "black"
 			}
+		},
+		{
+			"path": "pages/coupons/coupons",
+			"style": {
+				"navigationBarTitleText": "我的优惠券",
+				"navigationBarBackgroundColor": "#FFD700",
+				"navigationBarTextStyle": "black"
+			}
+		},
+		{
+			"path": "pages/couponCenter/couponCenter",
+			"style": {
+				"navigationBarTitleText": "领券中心",
+				"navigationBarBackgroundColor": "#FFD700",
+				"navigationBarTextStyle": "black"
+			}
 		}
 	],
 	"globalStyle": {

+ 571 - 0
haha-mp/src/pages/couponCenter/couponCenter.vue

@@ -0,0 +1,571 @@
+<template>
+  <view class="page">
+    <!-- 头部提示 -->
+    <view class="header">
+      <text class="header-title">领取优惠券</text>
+      <text class="header-desc">每日更新 · 限量发放</text>
+    </view>
+
+    <!-- 优惠券列表 -->
+    <view v-if="!loading && templates.length > 0" class="coupon-list">
+      <view
+        v-for="item in templates"
+        :key="item.id"
+        class="ticket"
+      >
+        <!-- 左侧:金额 -->
+        <view :class="['ticket-face', item.couponType ? 'ticket-face--type' + item.couponType : 'ticket-face--type1']">
+          <view class="ticket-amount">
+            <text class="ticket-currency">{{ item.couponType === 2 ? '' : '¥' }}</text>
+            <text class="ticket-figure">{{ formatDiscountValue(item) }}</text>
+          </view>
+          <text class="ticket-threshold">
+            {{ item.minAmount && item.minAmount > 0 ? '满' + item.minAmount + '可用' : '无门槛' }}
+          </text>
+        </view>
+
+        <!-- 锯齿分割 -->
+        <view class="ticket-serration">
+          <view class="serration-gap serration-gap--top"></view>
+          <view class="serration-rail"></view>
+          <view class="serration-gap serration-gap--bottom"></view>
+        </view>
+
+        <!-- 右侧:信息 -->
+        <view class="ticket-body">
+          <text class="ticket-title">{{ item.couponName || '优惠券' }}</text>
+          <view class="ticket-meta">
+            <view class="ticket-badge">{{ getCouponTypeLabel(item.couponType) }}</view>
+            <text class="ticket-scope">{{ getScopeLabel(item.applyScope) }}</text>
+          </view>
+          <text v-if="item.couponDesc" class="ticket-desc">{{ item.couponDesc }}</text>
+          <text class="ticket-period">
+            <text v-if="item.validType === 1">{{ formatTime(item.validStartTime) }} ~ {{ formatTime(item.validEndTime) }}</text>
+            <text v-else>领取后{{ item.validDays }}天内有效</text>
+          </text>
+
+          <!-- 领取按钮 -->
+          <view
+            :class="['ticket-grab', canReceive(item) ? '' : 'ticket-grab--out']"
+            @click="handleReceive(item)"
+          >
+            <text class="ticket-grab-text">
+              {{ receiving[item.id] ? '...' : (canReceive(item) ? '领取' : '已抢光') }}
+            </text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-if="!loading && templates.length === 0" class="empty">
+      <view class="empty-visual">
+        <view class="empty-ticket-shadow"></view>
+        <view class="empty-ticket-body">
+          <view class="empty-ticket-left"></view>
+          <view class="empty-ticket-dots">
+            <view class="empty-dot"></view>
+            <view class="empty-dot"></view>
+          </view>
+          <view class="empty-ticket-right">
+            <view class="empty-line empty-line--long"></view>
+            <view class="empty-line empty-line--short"></view>
+          </view>
+        </view>
+      </view>
+      <text class="empty-title">暂无可领取的优惠券</text>
+      <view class="empty-cta" @click="goToMyCoupons">
+        <text class="empty-cta-text">查看我的优惠券</text>
+      </view>
+    </view>
+
+    <!-- 加载中 -->
+    <view v-if="loading" class="loading">
+      <view class="loading-spinner"></view>
+      <text class="loading-label">加载中</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import { getAvailableCoupons, receiveCoupon } from '../../api/coupon';
+import type { CouponTemplateInfo } from '../../api/coupon';
+import { checkAuth } from '../../utils/auth';
+
+const templates = ref<CouponTemplateInfo[]>([]);
+const loading = ref(false);
+const receiving = reactive<Record<string, boolean>>({});
+
+/**
+ * 格式化优惠值
+ */
+const formatDiscountValue = (item: CouponTemplateInfo): string => {
+  if (item.couponType === 2) {
+    return item.discountValue ? `${item.discountValue}折` : '';
+  }
+  return item.discountValue ? `${item.discountValue}` : '0';
+};
+
+/**
+ * 获取优惠券类型标签
+ */
+const getCouponTypeLabel = (type: number): string => {
+  const map: Record<number, string> = {
+    1: '满减',
+    2: '折扣',
+    3: '抵扣',
+    4: '兑换'
+  };
+  return map[type] || '优惠';
+};
+
+/**
+ * 获取适用范围标签
+ */
+const getScopeLabel = (scope: number): string => {
+  const map: Record<number, string> = {
+    1: '全场通用',
+    2: '指定品类',
+    3: '指定商品'
+  };
+  return map[scope] || '';
+};
+
+/**
+ * 格式化时间
+ */
+const formatTime = (timeStr: string): string => {
+  if (!timeStr) return '';
+  return timeStr.substring(0, 10);
+};
+
+/**
+ * 判断是否可领取
+ */
+const canReceive = (item: CouponTemplateInfo): boolean => {
+  return item.remainCount > 0 && item.status === 1;
+};
+
+/**
+ * 领取优惠券
+ */
+const handleReceive = async (item: CouponTemplateInfo) => {
+  if (!canReceive(item) || receiving[item.id]) return;
+
+  receiving[item.id] = true;
+  try {
+    await receiveCoupon(item.id);
+    uni.showToast({
+      title: '领取成功',
+      icon: 'success'
+    });
+    // 更新剩余数量
+    item.remainCount = Math.max(0, item.remainCount - 1);
+  } catch (error: any) {
+    console.error('领取优惠券失败:', error);
+    // 错误信息已在 request 工具中展示
+  } finally {
+    receiving[item.id] = false;
+  }
+};
+
+/**
+ * 跳转到我的优惠券
+ */
+const goToMyCoupons = () => {
+  uni.navigateTo({
+    url: '/pages/coupons/coupons'
+  });
+};
+
+/**
+ * 加载可领取优惠券列表
+ */
+const loadTemplates = async () => {
+  if (loading.value) return;
+  loading.value = true;
+
+  try {
+    const result = await getAvailableCoupons();
+    templates.value = result;
+  } catch (error) {
+    console.error('加载可领取优惠券失败:', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+onMounted(() => {
+  if (!checkAuth('/pages/couponCenter/couponCenter')) {
+    return;
+  }
+  loadTemplates();
+});
+</script>
+
+<style>
+.page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #FFF1F0 0%, #F5F5F5 30%);
+}
+
+/* ========== Header ========== */
+.header {
+  padding: 28rpx 36rpx 16rpx;
+}
+
+.header-title {
+  font-size: 36rpx;
+  font-weight: 800;
+  color: #1A1A1A;
+  display: block;
+}
+
+.header-desc {
+  font-size: 24rpx;
+  color: #BBBBBB;
+  margin-top: 4rpx;
+  display: block;
+}
+
+/* ========== Coupon List ========== */
+.coupon-list {
+  padding: 8rpx 28rpx 0;
+}
+
+/* ========== Ticket Card ========== */
+.ticket {
+  display: flex;
+  margin-bottom: 24rpx;
+  border-radius: 20rpx;
+  overflow: hidden;
+  background: #ffffff;
+  box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05), 0 1rpx 4rpx rgba(0, 0, 0, 0.03);
+}
+
+/* --- Left: Amount Face --- */
+.ticket-face {
+  width: 210rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 36rpx 12rpx;
+  flex-shrink: 0;
+  position: relative;
+}
+
+.ticket-face::after {
+  content: '';
+  position: absolute;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  width: 1rpx;
+  background: rgba(255, 255, 255, 0.25);
+}
+
+/* 类型色系 */
+.ticket-face--type1 {
+  background: linear-gradient(150deg, #FF8A00 0%, #FFA940 100%);
+}
+.ticket-face--type2 {
+  background: linear-gradient(150deg, #E8533E 0%, #FF7B6B 100%);
+}
+.ticket-face--type3 {
+  background: linear-gradient(150deg, #7B61FF 0%, #A78BFA 100%);
+}
+.ticket-face--type4 {
+  background: linear-gradient(150deg, #0EA5E9 0%, #38BDF8 100%);
+}
+
+.ticket-amount {
+  display: flex;
+  align-items: baseline;
+}
+
+.ticket-currency {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.9);
+  margin-right: 2rpx;
+}
+
+.ticket-figure {
+  font-size: 56rpx;
+  font-weight: 800;
+  color: #ffffff;
+  line-height: 1;
+  letter-spacing: -2rpx;
+}
+
+.ticket-threshold {
+  font-size: 20rpx;
+  color: rgba(255, 255, 255, 0.8);
+  margin-top: 12rpx;
+  padding: 4rpx 16rpx;
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 20rpx;
+}
+
+/* --- Serration Divider --- */
+.ticket-serration {
+  width: 36rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: relative;
+  flex-shrink: 0;
+}
+
+.serration-gap {
+  width: 36rpx;
+  height: 18rpx;
+  background: #ffffff;
+  position: absolute;
+  z-index: 2;
+}
+
+.serration-gap--top {
+  top: 0;
+  border-radius: 0 0 50% 50%;
+}
+
+.serration-gap--bottom {
+  bottom: 0;
+  border-radius: 50% 50% 0 0;
+}
+
+.serration-rail {
+  flex: 1;
+  border-left: 2rpx dashed #E0E0E0;
+  margin: 18rpx 0;
+}
+
+/* --- Right: Body Info --- */
+.ticket-body {
+  flex: 1;
+  padding: 28rpx 28rpx 28rpx 8rpx;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-width: 0;
+  position: relative;
+}
+
+.ticket-title {
+  font-size: 28rpx;
+  color: #1A1A1A;
+  font-weight: 700;
+  margin-bottom: 12rpx;
+  line-height: 1.4;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  padding-right: 120rpx;
+}
+
+.ticket-meta {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-bottom: 12rpx;
+}
+
+.ticket-badge {
+  font-size: 18rpx;
+  color: #FF8A00;
+  background: #FFF4E0;
+  padding: 4rpx 14rpx;
+  border-radius: 6rpx;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+
+.ticket-scope {
+  font-size: 20rpx;
+  color: #999999;
+}
+
+.ticket-desc {
+  font-size: 22rpx;
+  color: #999999;
+  margin-bottom: 10rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ticket-stock {
+  font-size: 20rpx;
+  color: #E8533E;
+  font-weight: 500;
+}
+
+.ticket-period {
+  font-size: 22rpx;
+  color: #BBBBBB;
+}
+
+/* --- Grab Button --- */
+.ticket-grab {
+  position: absolute;
+  top: 28rpx;
+  right: 28rpx;
+  padding: 10rpx 28rpx;
+  background: #1A1A1A;
+  border-radius: 28rpx;
+}
+
+.ticket-grab-text {
+  font-size: 24rpx;
+  color: #FFD700;
+  font-weight: 700;
+}
+
+.ticket-grab--out {
+  background: #F0F0F0;
+}
+
+.ticket-grab--out .ticket-grab-text {
+  color: #CCCCCC;
+}
+
+/* ========== Empty State ========== */
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 55vh;
+  padding: 40rpx;
+}
+
+.empty-visual {
+  margin-bottom: 40rpx;
+  position: relative;
+}
+
+.empty-ticket-shadow {
+  position: absolute;
+  top: 8rpx;
+  left: 4rpx;
+  right: 4rpx;
+  bottom: -8rpx;
+  background: #E0E0E0;
+  border-radius: 16rpx;
+}
+
+.empty-ticket-body {
+  display: flex;
+  width: 320rpx;
+  height: 140rpx;
+  background: #F5F5F5;
+  border-radius: 16rpx;
+  overflow: hidden;
+  position: relative;
+  z-index: 1;
+}
+
+.empty-ticket-left {
+  width: 110rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.empty-ticket-left::before {
+  content: '¥';
+  font-size: 44rpx;
+  font-weight: 800;
+  color: #DCDCDC;
+}
+
+.empty-ticket-dots {
+  width: 30rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 16rpx;
+}
+
+.empty-dot {
+  width: 14rpx;
+  height: 14rpx;
+  border-radius: 50%;
+  background: #F5F5F5;
+  border: 2rpx solid #E0E0E0;
+}
+
+.empty-ticket-right {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: 24rpx 20rpx;
+  gap: 14rpx;
+}
+
+.empty-line {
+  height: 10rpx;
+  border-radius: 5rpx;
+  background: #E8E8E8;
+}
+
+.empty-line--long {
+  width: 100%;
+}
+
+.empty-line--short {
+  width: 60%;
+}
+
+.empty-title {
+  font-size: 30rpx;
+  color: #999999;
+  font-weight: 600;
+  margin-bottom: 36rpx;
+}
+
+.empty-cta {
+  padding: 18rpx 64rpx;
+  background: #1A1A1A;
+  border-radius: 44rpx;
+}
+
+.empty-cta-text {
+  font-size: 28rpx;
+  color: #FFD700;
+  font-weight: 700;
+}
+
+/* ========== Loading ========== */
+.loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 55vh;
+  gap: 20rpx;
+}
+
+.loading-spinner {
+  width: 48rpx;
+  height: 48rpx;
+  border: 4rpx solid #E0E0E0;
+  border-top-color: #FFD700;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-label {
+  font-size: 24rpx;
+  color: #CCCCCC;
+}
+</style>

+ 651 - 0
haha-mp/src/pages/coupons/coupons.vue

@@ -0,0 +1,651 @@
+<template>
+  <view class="page">
+    <!-- 顶部Tab -->
+    <view class="tab-bar">
+      <view
+        v-for="tab in tabs"
+        :key="tab.value"
+        :class="['tab-pill', currentTab === tab.value ? 'tab-pill--active' : '']"
+        @click="switchTab(tab.value)"
+      >
+        <text class="tab-pill-text">{{ tab.label }}</text>
+      </view>
+    </view>
+
+    <!-- 优惠券列表 -->
+    <view v-if="!loading && coupons.length > 0" class="coupon-list">
+      <view
+        v-for="(item, index) in coupons"
+        :key="item.id"
+        :class="['ticket', item.status === 0 ? '' : 'ticket--dim']"
+      >
+        <!-- 左侧:金额 -->
+        <view :class="['ticket-face', item.couponType ? 'ticket-face--type' + item.couponType : 'ticket-face--type1']">
+          <view class="ticket-amount">
+            <text class="ticket-currency">{{ item.couponType === 2 ? '' : '¥' }}</text>
+            <text class="ticket-figure">{{ formatValue(item) }}</text>
+          </view>
+          <text class="ticket-threshold">
+            {{ item.minAmount && item.minAmount > 0 ? '满' + item.minAmount + '可用' : '无门槛' }}
+          </text>
+        </view>
+
+        <!-- 锯齿分割 -->
+        <view class="ticket-serration">
+          <view class="serration-gap serration-gap--top"></view>
+          <view class="serration-rail"></view>
+          <view class="serration-gap serration-gap--bottom"></view>
+        </view>
+
+        <!-- 右侧:信息 -->
+        <view class="ticket-body">
+          <text class="ticket-title">{{ item.couponName || '优惠券' }}</text>
+          <view class="ticket-meta">
+            <view class="ticket-badge">{{ getCouponTypeLabel(item.couponType) }}</view>
+            <text class="ticket-scope">{{ getScopeLabel(item.applyScope) }}</text>
+          </view>
+          <text v-if="item.couponDesc" class="ticket-desc">{{ item.couponDesc }}</text>
+          <text class="ticket-period">{{ formatTime(item.validStartTime) }} ~ {{ formatTime(item.validEndTime) }}</text>
+
+          <!-- 状态水印 -->
+          <view v-if="item.status !== 0" class="ticket-watermark">
+            <text class="watermark-text">{{ item.status === 1 ? '已使用' : '已过期' }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 加载更多 -->
+    <view v-if="coupons.length > 0 && hasMore" class="load-more" @click="loadMore">
+      <text class="load-more-text">{{ loadingMore ? '加载中...' : '点击加载更多' }}</text>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-if="!loading && coupons.length === 0" class="empty">
+      <view class="empty-visual">
+        <view class="empty-ticket-shadow"></view>
+        <view class="empty-ticket-body">
+          <view class="empty-ticket-left"></view>
+          <view class="empty-ticket-dots">
+            <view class="empty-dot"></view>
+            <view class="empty-dot"></view>
+          </view>
+          <view class="empty-ticket-right">
+            <view class="empty-line empty-line--long"></view>
+            <view class="empty-line empty-line--short"></view>
+          </view>
+        </view>
+      </view>
+      <text class="empty-title">{{ emptyText }}</text>
+      <view v-if="currentTab === 0" class="empty-cta" @click="goToCouponCenter">
+        <text class="empty-cta-text">去领券中心</text>
+      </view>
+    </view>
+
+    <!-- 加载中 -->
+    <view v-if="loading" class="loading">
+      <view class="loading-spinner"></view>
+      <text class="loading-label">加载中</text>
+    </view>
+
+    <!-- 底部领券入口 -->
+    <view v-if="!loading && currentTab === 0 && coupons.length > 0" class="bottom-bar">
+      <view class="bottom-btn" @click="goToCouponCenter">
+        <text class="bottom-btn-label">领券中心</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import { getMyCoupons } from '../../api/coupon';
+import type { UserCouponInfo, PageResult } from '../../api/coupon';
+import { checkAuth } from '../../utils/auth';
+
+const tabs = [
+  { label: '未使用', value: 0 },
+  { label: '已使用', value: 1 },
+  { label: '已过期', value: 2 }
+];
+
+const currentTab = ref(0);
+const coupons = ref<UserCouponInfo[]>([]);
+const loading = ref(false);
+const loadingMore = ref(false);
+const currentPage = ref(1);
+const pageSize = 10;
+const total = ref(0);
+
+const hasMore = computed(() => coupons.value.length < total.value);
+
+const emptyText = computed(() => {
+  const map: Record<number, string> = {
+    0: '暂无可用优惠券',
+    1: '暂无已使用的优惠券',
+    2: '暂无已过期的优惠券'
+  };
+  return map[currentTab.value] || '暂无优惠券';
+});
+
+/**
+ * 切换Tab
+ */
+const switchTab = (tab: number) => {
+  if (currentTab.value === tab) return;
+  currentTab.value = tab;
+  coupons.value = [];
+  currentPage.value = 1;
+  loadCoupons();
+};
+
+/**
+ * 加载优惠券列表
+ */
+const loadCoupons = async (isLoadMore = false) => {
+  if (isLoadMore) {
+    if (loadingMore.value) return;
+    loadingMore.value = true;
+  } else {
+    if (loading.value) return;
+    loading.value = true;
+  }
+
+  try {
+    const result: PageResult<UserCouponInfo> = await getMyCoupons(currentTab.value, currentPage.value, pageSize);
+    if (isLoadMore) {
+      coupons.value = [...coupons.value, ...(result.list || [])];
+    } else {
+      coupons.value = result.list || [];
+    }
+    total.value = result.total;
+  } catch (error) {
+    console.error('加载优惠券列表失败:', error);
+  } finally {
+    loading.value = false;
+    loadingMore.value = false;
+  }
+};
+
+/**
+ * 加载更多
+ */
+const loadMore = () => {
+  currentPage.value++;
+  loadCoupons(true);
+};
+
+/**
+ * 格式化优惠面值(纯数字部分)
+ */
+const formatValue = (item: UserCouponInfo): string => {
+  if (item.couponType === 2) {
+    const val = item.discountAmount;
+    return val ? `${val}折` : '折扣';
+  }
+  const val = item.discountAmount;
+  return val ? `${val}` : '--';
+};
+
+/**
+ * 获取优惠券类型标签
+ */
+const getCouponTypeLabel = (type: number): string => {
+  const map: Record<number, string> = {
+    1: '满减',
+    2: '折扣',
+    3: '抵扣',
+    4: '兑换'
+  };
+  return map[type] || '优惠';
+};
+
+/**
+ * 获取适用范围标签
+ */
+const getScopeLabel = (scope: number): string => {
+  const map: Record<number, string> = {
+    1: '全场通用',
+    2: '指定品类',
+    3: '指定商品'
+  };
+  return map[scope] || '';
+};
+
+/**
+ * 格式化时间
+ */
+const formatTime = (timeStr: string): string => {
+  if (!timeStr) return '';
+  // 取日期部分 yyyy-MM-dd
+  return timeStr.substring(0, 10);
+};
+
+/**
+ * 跳转领券中心
+ */
+const goToCouponCenter = () => {
+  uni.navigateTo({
+    url: '/pages/couponCenter/couponCenter'
+  });
+};
+
+onMounted(() => {
+  if (!checkAuth('/pages/coupons/coupons')) {
+    return;
+  }
+  loadCoupons();
+});
+</script>
+
+<style>
+.page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #FFF8E1 0%, #F5F5F5 30%);
+}
+
+/* ========== Tab Bar ========== */
+.tab-bar {
+  display: flex;
+  gap: 16rpx;
+  padding: 20rpx 32rpx 12rpx;
+  background: #FFF8E1;
+}
+
+.tab-pill {
+  padding: 12rpx 40rpx;
+  border-radius: 32rpx;
+  background: rgba(0, 0, 0, 0.04);
+}
+
+.tab-pill--active {
+  background: #1A1A1A;
+}
+
+.tab-pill-text {
+  font-size: 26rpx;
+  color: #999999;
+  font-weight: 500;
+}
+
+.tab-pill--active .tab-pill-text {
+  color: #FFD700;
+  font-weight: 700;
+}
+
+/* ========== Coupon List ========== */
+.coupon-list {
+  padding: 12rpx 28rpx 0;
+}
+
+/* ========== Ticket Card ========== */
+.ticket {
+  display: flex;
+  margin-bottom: 24rpx;
+  border-radius: 20rpx;
+  overflow: hidden;
+  background: #ffffff;
+  box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05), 0 1rpx 4rpx rgba(0, 0, 0, 0.03);
+  position: relative;
+}
+
+.ticket--dim {
+  opacity: 0.5;
+  filter: saturate(0.3);
+}
+
+/* --- Left: Amount Face --- */
+.ticket-face {
+  width: 210rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 36rpx 12rpx;
+  flex-shrink: 0;
+  position: relative;
+}
+
+.ticket-face::after {
+  content: '';
+  position: absolute;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  width: 1rpx;
+  background: rgba(255, 255, 255, 0.25);
+}
+
+/* 类型色系 */
+.ticket-face--type1 {
+  background: linear-gradient(150deg, #FF8A00 0%, #FFA940 100%);
+}
+.ticket-face--type2 {
+  background: linear-gradient(150deg, #E8533E 0%, #FF7B6B 100%);
+}
+.ticket-face--type3 {
+  background: linear-gradient(150deg, #7B61FF 0%, #A78BFA 100%);
+}
+.ticket-face--type4 {
+  background: linear-gradient(150deg, #0EA5E9 0%, #38BDF8 100%);
+}
+
+.ticket--dim .ticket-face {
+  background: linear-gradient(150deg, #B0B0B0 0%, #C8C8C8 100%);
+}
+
+.ticket-amount {
+  display: flex;
+  align-items: baseline;
+}
+
+.ticket-currency {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.9);
+  margin-right: 2rpx;
+}
+
+.ticket-figure {
+  font-size: 56rpx;
+  font-weight: 800;
+  color: #ffffff;
+  line-height: 1;
+  letter-spacing: -2rpx;
+}
+
+.ticket-threshold {
+  font-size: 20rpx;
+  color: rgba(255, 255, 255, 0.8);
+  margin-top: 12rpx;
+  padding: 4rpx 16rpx;
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 20rpx;
+}
+
+/* --- Serration Divider --- */
+.ticket-serration {
+  width: 36rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: relative;
+  flex-shrink: 0;
+}
+
+.serration-gap {
+  width: 36rpx;
+  height: 18rpx;
+  background: #ffffff;
+  border-radius: 0 0 50% 50%;
+  position: absolute;
+  z-index: 2;
+}
+
+.serration-gap--top {
+  top: 0;
+  border-radius: 0 0 50% 50%;
+}
+
+.serration-gap--bottom {
+  bottom: 0;
+  border-radius: 50% 50% 0 0;
+}
+
+.serration-rail {
+  flex: 1;
+  border-left: 2rpx dashed #E0E0E0;
+  margin: 18rpx 0;
+}
+
+/* --- Right: Body Info --- */
+.ticket-body {
+  flex: 1;
+  padding: 28rpx 28rpx 28rpx 8rpx;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-width: 0;
+  position: relative;
+}
+
+.ticket-title {
+  font-size: 28rpx;
+  color: #1A1A1A;
+  font-weight: 700;
+  margin-bottom: 12rpx;
+  line-height: 1.4;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  padding-right: 80rpx;
+}
+
+.ticket-meta {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-bottom: 8rpx;
+}
+
+.ticket-badge {
+  font-size: 18rpx;
+  color: #FF8A00;
+  background: #FFF4E0;
+  padding: 4rpx 14rpx;
+  border-radius: 6rpx;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+
+.ticket--dim .ticket-badge {
+  color: #999;
+  background: #F0F0F0;
+}
+
+.ticket-scope {
+  font-size: 20rpx;
+  color: #999999;
+}
+
+.ticket-desc {
+  font-size: 22rpx;
+  color: #999999;
+  margin-bottom: 10rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ticket-period {
+  font-size: 22rpx;
+  color: #BBBBBB;
+}
+
+/* --- Watermark --- */
+.ticket-watermark {
+  position: absolute;
+  top: 20rpx;
+  right: 20rpx;
+  border: 3rpx solid #CCCCCC;
+  border-radius: 8rpx;
+  padding: 2rpx 14rpx;
+  transform: rotate(-12deg);
+  opacity: 0.6;
+}
+
+.watermark-text {
+  font-size: 22rpx;
+  color: #BBBBBB;
+  font-weight: 700;
+}
+
+/* ========== Load More ========== */
+.load-more {
+  text-align: center;
+  padding: 16rpx 0 40rpx;
+}
+
+.load-more-text {
+  font-size: 24rpx;
+  color: #CCCCCC;
+}
+
+/* ========== Empty State ========== */
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 55vh;
+  padding: 40rpx;
+}
+
+.empty-visual {
+  margin-bottom: 40rpx;
+  position: relative;
+}
+
+.empty-ticket-shadow {
+  position: absolute;
+  top: 8rpx;
+  left: 4rpx;
+  right: 4rpx;
+  bottom: -8rpx;
+  background: #E0E0E0;
+  border-radius: 16rpx;
+}
+
+.empty-ticket-body {
+  display: flex;
+  width: 320rpx;
+  height: 140rpx;
+  background: #F5F5F5;
+  border-radius: 16rpx;
+  overflow: hidden;
+  position: relative;
+  z-index: 1;
+}
+
+.empty-ticket-left {
+  width: 110rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.empty-ticket-left::before {
+  content: '¥';
+  font-size: 44rpx;
+  font-weight: 800;
+  color: #DCDCDC;
+}
+
+.empty-ticket-dots {
+  width: 30rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 16rpx;
+}
+
+.empty-dot {
+  width: 14rpx;
+  height: 14rpx;
+  border-radius: 50%;
+  background: #F5F5F5;
+  border: 2rpx solid #E0E0E0;
+}
+
+.empty-ticket-right {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: 24rpx 20rpx;
+  gap: 14rpx;
+}
+
+.empty-line {
+  height: 10rpx;
+  border-radius: 5rpx;
+  background: #E8E8E8;
+}
+
+.empty-line--long {
+  width: 100%;
+}
+
+.empty-line--short {
+  width: 60%;
+}
+
+.empty-title {
+  font-size: 30rpx;
+  color: #999999;
+  font-weight: 600;
+  margin-bottom: 36rpx;
+}
+
+.empty-cta {
+  padding: 18rpx 64rpx;
+  background: #1A1A1A;
+  border-radius: 44rpx;
+}
+
+.empty-cta-text {
+  font-size: 28rpx;
+  color: #FFD700;
+  font-weight: 700;
+}
+
+/* ========== Loading ========== */
+.loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 55vh;
+  gap: 20rpx;
+}
+
+.loading-spinner {
+  width: 48rpx;
+  height: 48rpx;
+  border: 4rpx solid #E0E0E0;
+  border-top-color: #FFD700;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-label {
+  font-size: 24rpx;
+  color: #CCCCCC;
+}
+
+/* ========== Bottom CTA ========== */
+.bottom-bar {
+  padding: 20rpx 64rpx 56rpx;
+  display: flex;
+  justify-content: center;
+}
+
+.bottom-btn {
+  padding: 22rpx 0;
+  width: 100%;
+  background: #1A1A1A;
+  border-radius: 44rpx;
+  text-align: center;
+}
+
+.bottom-btn-label {
+  font-size: 30rpx;
+  color: #FFD700;
+  font-weight: 700;
+}
+</style>

+ 38 - 3
haha-mp/src/pages/my/my.vue

@@ -25,6 +25,14 @@
           <image class="menu-svg-icon" src="/static/icons/coupon.svg"></image>
         </view>
         <view class="menu-text">我的优惠券</view>
+        <view v-if="availableCouponCount > 0" class="menu-badge">{{ availableCouponCount }}</view>
+        <view class="menu-arrow"></view>
+      </view>
+      <view class="menu-item" @click="goToCouponCenter">
+        <view class="menu-icon">
+          <image class="menu-svg-icon" src="/static/icons/coupon.svg"></image>
+        </view>
+        <view class="menu-text">领券中心</view>
         <view class="menu-arrow"></view>
       </view>
       <view class="menu-item" @click="goToMembership">
@@ -87,15 +95,24 @@
 import { ref, onMounted } from 'vue';
 import { mockApi } from '../../utils/mock';
 import { logout as logoutApi } from '../../api/user';
+import { getCouponCount } from '../../api/coupon';
 import { clearAuth } from '../../utils/auth';
 
 const userInfo = ref<any>(null);
+const availableCouponCount = ref(0);
 
 onMounted(async () => {
   const response = await mockApi.getUserInfo();
   if (response.success) {
     userInfo.value = response.data;
   }
+  // 加载可用优惠券数量
+  try {
+    const result = await getCouponCount();
+    availableCouponCount.value = result.availableCount || 0;
+  } catch (e) {
+    console.log('获取优惠券数量失败', e);
+  }
 });
 
 const goBack = () => {
@@ -109,9 +126,14 @@ const goToOrders = () => {
 };
 
 const goToCoupons = () => {
-  uni.showToast({
-    title: '优惠券功能开发中',
-    icon: 'none'
+  uni.navigateTo({
+    url: '/pages/coupons/coupons'
+  });
+};
+
+const goToCouponCenter = () => {
+  uni.navigateTo({
+    url: '/pages/couponCenter/couponCenter'
   });
 };
 
@@ -285,6 +307,19 @@ const handleLogout = () => {
   transform: rotate(45deg);
 }
 
+.menu-badge {
+  background-color: #FF4D4F;
+  color: #ffffff;
+  font-size: 20rpx;
+  min-width: 32rpx;
+  height: 32rpx;
+  line-height: 32rpx;
+  text-align: center;
+  border-radius: 16rpx;
+  padding: 0 8rpx;
+  margin-right: 12rpx;
+}
+
 .service-phone {
   font-size: 24rpx;
   color: #666666;

+ 18 - 10
haha-service/src/main/java/com/haha/service/impl/UserCouponServiceImpl.java

@@ -123,6 +123,7 @@ public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCou
             userCoupon.setUserId(userId);
             userCoupon.setStatus(CouponStatusEnum.UNUSED.getCode());
             userCoupon.setReceiveTime(LocalDateTime.now());
+            userCoupon.setDiscountAmount(template.getDiscountValue());
 
             if (template.getValidType() == 1) {
                 userCoupon.setValidStartTime(template.getValidStartTime());
@@ -189,6 +190,7 @@ public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCou
                 userCoupon.setUserId(userId);
                 userCoupon.setStatus(CouponStatusEnum.UNUSED.getCode());
                 userCoupon.setReceiveTime(LocalDateTime.now());
+                userCoupon.setDiscountAmount(template.getDiscountValue());
 
                 if (template.getValidType() == 1) {
                     userCoupon.setValidStartTime(template.getValidStartTime());
@@ -300,6 +302,20 @@ public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCou
     }
 
     private void fillCouponLabels(UserCoupon userCoupon) {
+        // 填充优惠券模板信息
+        CouponTemplate template = templateMapper.selectById(userCoupon.getTemplateId());
+        if (template != null) {
+            userCoupon.setCouponName(template.getCouponName());
+            userCoupon.setCouponType(template.getCouponType());
+            userCoupon.setMinAmount(template.getMinAmount());
+            userCoupon.setCouponDesc(template.getCouponDesc());
+            userCoupon.setApplyScope(template.getApplyScope());
+            // 兼容历史数据:如果discountAmount为空则从模板读取
+            if (userCoupon.getDiscountAmount() == null) {
+                userCoupon.setDiscountAmount(template.getDiscountValue());
+            }
+        }
+        // 填充状态标签
         com.haha.common.vo.StatusLabel label = CouponStatusEnum.getLabelByCode(userCoupon.getStatus());
         userCoupon.setStatusLabel(label.getLabel());
         userCoupon.setStatusColor(label.getColor());
@@ -320,16 +336,8 @@ public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCou
 
         IPage<UserCoupon> result = this.page(pageParam, wrapper);
 
-        // 填充优惠券模板信息
-        result.getRecords().forEach(userCoupon -> {
-            CouponTemplate template = templateMapper.selectById(userCoupon.getTemplateId());
-            if (template != null) {
-                userCoupon.setCouponName(template.getCouponName());
-                userCoupon.setCouponType(template.getCouponType());
-                userCoupon.setMinAmount(template.getMinAmount());
-            }
-            fillCouponLabels(userCoupon);
-        });
+        // 填充优惠券模板信息和状态标签
+        result.getRecords().forEach(this::fillCouponLabels);
 
         log.debug("[优惠券服务] 查询结果 - 总数: {}, 当前页数量: {}", result.getTotal(), result.getRecords().size());