sunshibo 1 день тому
батько
коміт
910b6c5d39

+ 123 - 38
pages/checkout/index.vue

@@ -120,6 +120,9 @@
         :coupon-list="couponList"
         :selected-coupon-id="selectedCouponId"
         :view-only="false"
+        :list-loading="checkoutCouponLoading"
+        :list-has-more="checkoutCouponHasMore"
+        @load-more="handleCheckoutCouponLoadMore"
         @select="handleCouponSelect"
         @close="couponModalOpen = false"
       />
@@ -134,7 +137,7 @@ import { go } from '@/utils/utils.js';
 import { getFileUrl } from '@/utils/file.js';
 import { useUserStore } from '@/store/user.js';
 import * as diningApi from '@/api/dining.js';
-import { normalizeUserCouponListItem } from '@/utils/couponNormalize.js';
+import { normalizeUserCouponListItem, parseCouponListPage, mergeCouponListById } from '@/utils/couponNormalize.js';
 import SelectCouponModal from '@/pages/orderFood/components/SelectCouponModal.vue';
 
 const orderId = ref('');
@@ -172,6 +175,11 @@ const couponModalOpen = ref(false);
 const couponList = ref([]);
 const selectedCouponId = ref(null);
 
+const CHECKOUT_COUPON_PAGE_SIZE = 10;
+const checkoutCouponLoading = ref(false);
+const checkoutCouponHasMore = ref(true);
+const checkoutCouponLastPage = ref(0);
+
 // 菜品清单展示(排除特殊占位 id=-1)
 const displayFoodList = computed(() =>
   (foodList.value ?? []).filter((it) => Number(it?.id ?? it?.cuisineId) !== -1)
@@ -236,68 +244,145 @@ const onCouponRowClick = () => {
   openCouponModal();
 };
 
-// 打开优惠券弹窗并拉取用户可用券
-const openCouponModal = async () => {
+/**
+ * 拉取结算页可用优惠券(分页,每页 CHECKOUT_COUPON_PAGE_SIZE)
+ * @param {{ reset?: boolean }} options
+ */
+async function fetchCheckoutCouponList(options = { reset: true }) {
+  const { reset = true } = options;
   const storeId = orderInfo.value.storeId || uni.getStorageSync('currentStoreId') || '';
-  couponModalOpen.value = false;
   if (!storeId) {
     uni.showToast({ title: '暂无门店信息', icon: 'none' });
-    return;
+    return false;
   }
+
+  if (reset) {
+    if (checkoutCouponLoading.value) return false;
+    checkoutCouponLoading.value = true;
+  } else {
+    if (
+      checkoutCouponLoading.value ||
+      !checkoutCouponHasMore.value
+    ) {
+      return true;
+    }
+    checkoutCouponLoading.value = true;
+  }
+
+  const page = reset ? 1 : checkoutCouponLastPage.value + 1;
+
   try {
     const res = await diningApi.GetUserCouponList({
       storeId,
       tabType: 0,
-      page: 1,
-      size: 20
+      page,
+      size: CHECKOUT_COUPON_PAGE_SIZE
     });
-    const list = Array.isArray(res) ? res : (res?.data ?? res?.records ?? res?.list ?? []);
-    const normalized = (Array.isArray(list) ? list : [])
+    const { list: rawList, total } = parseCouponListPage(res);
+    const arr = Array.isArray(rawList) ? rawList : [];
+    const normalized = arr
       .map((item) => normalizeUserCouponListItem(item, 0))
       .filter(Boolean);
-    couponList.value = normalized;
-    couponModalOpen.value = true;
+
+    if (reset) {
+      couponList.value = normalized;
+    } else {
+      couponList.value = mergeCouponListById(couponList.value, normalized);
+    }
+    checkoutCouponLastPage.value = page;
+
+    const mergedLen = couponList.value.length;
+    if (total > 0) {
+      checkoutCouponHasMore.value = mergedLen < total;
+    } else {
+      checkoutCouponHasMore.value = normalized.length >= CHECKOUT_COUPON_PAGE_SIZE;
+    }
+
+    return true;
   } catch (err) {
     console.error('获取优惠券失败:', err);
-    uni.showToast({ title: '获取优惠券失败', icon: 'none' });
+    if (reset) {
+      uni.showToast({ title: '获取优惠券失败', icon: 'none' });
+      couponList.value = [];
+      checkoutCouponHasMore.value = false;
+    } else {
+      uni.showToast({ title: '加载更多失败', icon: 'none' });
+    }
+    return false;
+  } finally {
+    checkoutCouponLoading.value = false;
+  }
+}
+
+function handleCheckoutCouponLoadMore() {
+  if (!checkoutCouponLoading.value && checkoutCouponHasMore.value) {
+    fetchCheckoutCouponList({ reset: false });
+  }
+}
+
+// 打开优惠券弹窗并拉取用户可用券
+const openCouponModal = async () => {
+  couponModalOpen.value = false;
+  const ok = await fetchCheckoutCouponList({ reset: true });
+  if (ok) {
+    couponModalOpen.value = true;
   }
 };
 
 // 选择优惠券后更新订单优惠与应付金额
 const handleCouponSelect = ({ coupon, selectedId }) => {
   const hasSelected = selectedId != null && selectedId !== '';
-  selectedCouponId.value = hasSelected ? String(selectedId) : null;
-  orderInfo.value.couponId = hasSelected ? (coupon?.couponId ?? coupon?.id ?? selectedCouponId.value) : null;
+
   if (!hasSelected) {
+    selectedCouponId.value = null;
+    orderInfo.value.couponId = null;
     orderInfo.value.discountAmount = 0;
     orderInfo.value.couponName = '';
     orderInfo.value.couponType = null;
     orderInfo.value.discountRate = null;
     orderInfo.value.nominalValue = null;
-  } else if (coupon) {
-    const couponType = Number(coupon.couponType) || 0;
-    orderInfo.value.couponName = coupon.name ?? '';
-    orderInfo.value.couponType = couponType || null;
-    orderInfo.value.discountRate = coupon.discountRate != null ? coupon.discountRate : null;
-    const nvRaw = coupon.nominalValue;
-    if (nvRaw != null && nvRaw !== '') {
-      const nv = Number(nvRaw);
-      orderInfo.value.nominalValue = Number.isFinite(nv) ? nv : null;
-    } else if (couponType === 1) {
-      orderInfo.value.nominalValue = Number(coupon.amount) || null;
-    } else {
-      orderInfo.value.nominalValue = null;
-    }
-    const food = foodSubtotalForDisplay.value;
-    const fee = Number(orderInfo.value.serviceFee) || 0;
-    const baseForDiscount = food + fee;
-    if (couponType === 2 && coupon.discountRate != null && baseForDiscount > 0) {
-      const rate = Number(coupon.discountRate) || 0;
-      orderInfo.value.discountAmount = Math.round(baseForDiscount * (1 - rate / 10) * 100) / 100;
-    } else {
-      orderInfo.value.discountAmount =
-        Number(coupon.nominalValue) || Number(coupon.amount) || 0;
-    }
+    updateCheckoutPayAmount();
+    couponModalOpen.value = false;
+    return;
+  }
+
+  if (!coupon) {
+    couponModalOpen.value = false;
+    return;
+  }
+
+  const couponType = Number(coupon.couponType) || 0;
+  const foodSubtotal = Number(foodSubtotalForDisplay.value) || 0;
+  const threshold = Number(coupon.minAmount) || 0;
+  // 满减券(couponType=1)且有门槛:菜品总价须 ≥ 门槛,否则不可选
+  if (couponType === 1 && threshold > 0 && foodSubtotal < threshold) {
+    uni.showToast({ title: '未到满减此券不可用', icon: 'none' });
+    return;
+  }
+
+  selectedCouponId.value = String(selectedId);
+  orderInfo.value.couponId = coupon?.couponId ?? coupon?.id ?? String(selectedId) ?? null;
+  orderInfo.value.couponName = coupon.name ?? '';
+  orderInfo.value.couponType = couponType || null;
+  orderInfo.value.discountRate = coupon.discountRate != null ? coupon.discountRate : null;
+  const nvRaw = coupon.nominalValue;
+  if (nvRaw != null && nvRaw !== '') {
+    const nv = Number(nvRaw);
+    orderInfo.value.nominalValue = Number.isFinite(nv) ? nv : null;
+  } else if (couponType === 1) {
+    orderInfo.value.nominalValue = Number(coupon.amount) || null;
+  } else {
+    orderInfo.value.nominalValue = null;
+  }
+  const food = foodSubtotal;
+  const fee = Number(orderInfo.value.serviceFee) || 0;
+  const baseForDiscount = food + fee;
+  if (couponType === 2 && coupon.discountRate != null && baseForDiscount > 0) {
+    const rate = Number(coupon.discountRate) || 0;
+    orderInfo.value.discountAmount = Math.round(baseForDiscount * (1 - rate / 10) * 100) / 100;
+  } else {
+    orderInfo.value.discountAmount =
+      Number(coupon.nominalValue) || Number(coupon.amount) || 0;
   }
   updateCheckoutPayAmount();
   couponModalOpen.value = false;

+ 86 - 17
pages/coupon/index.vue

@@ -46,7 +46,13 @@
     <!-- 列表区域:展开类型筛选时盖半透明遮罩,点击关闭 -->
     <view class="page-body">
       <view v-if="typePanelOpen" class="type-mask" @tap="closeTypePanel" />
-      <scroll-view class="content" scroll-y :class="{ 'content--dimmed': typePanelOpen }">
+      <scroll-view
+        class="content"
+        scroll-y
+        :class="{ 'content--dimmed': typePanelOpen }"
+        :lower-threshold="100"
+        @scrolltolower="handleCouponScrollToLower"
+      >
       <view class="coupon-list" v-if="filteredCoupons.length > 0">
         <view v-for="(coupon, index) in filteredCoupons" :key="coupon.id || index"
           :class="['coupon-card', getCouponCardClass(coupon)]">
@@ -78,8 +84,13 @@
         </view>
       </view>
 
+      <view v-if="couponList.length > 0 && (loadMoreLoading || !listHasMore)" class="coupon-page-footer">
+        <text v-if="loadMoreLoading" class="coupon-page-footer__text">加载中...</text>
+        <text v-else class="coupon-page-footer__text">没有更多了</text>
+      </view>
+
       <!-- 空状态 -->
-      <view class="empty-state" v-else>
+      <view class="empty-state" v-if="filteredCoupons.length === 0">
         <image :src="getFileUrl('img/icon/noCoupon.png')" mode="widthFix" class="empty-icon"></image>
         <text class="empty-text">暂无优惠券</text>
       </view>
@@ -95,7 +106,7 @@
 import { onShow } from "@dcloudio/uni-app";
 import { ref, computed } from "vue";
 import { getFileUrl } from "@/utils/file.js";
-import { normalizeUserCouponListItem } from "@/utils/couponNormalize.js";
+import { normalizeUserCouponListItem, parseCouponListPage, mergeCouponListById } from "@/utils/couponNormalize.js";
 import RulesModal from "./components/RulesModal.vue";
 import * as diningApi from "@/api/dining.js";
 
@@ -149,9 +160,13 @@ const selectedCoupon = ref({
   verificationCode: ''
 });
 
-// 优惠券数据(接口返回
+// 优惠券数据(接口分页累加
 const couponList = ref([]);
 const loading = ref(false);
+const loadMoreLoading = ref(false);
+const COUPON_PAGE_SIZE = 10;
+const listHasMore = ref(true);
+const lastCouponPageLoaded = ref(0);
 
 function normalizeCouponItem(raw) {
   return normalizeUserCouponListItem(raw, currentTab.value);
@@ -170,27 +185,71 @@ const filteredCoupons = computed(() => {
 const handleTabChange = (index) => {
   currentTab.value = index;
   typePanelOpen.value = false;
-  fetchCouponList();
+  fetchCouponList({ reset: true });
 };
 
-// 拉取优惠券列表(coupon/getUserCouponList)
-const fetchCouponList = async () => {
-  loading.value = true;
+// 拉取优惠券列表(分页,每页 COUPON_PAGE_SIZE)
+const fetchCouponList = async (options = { reset: true }) => {
+  const { reset = true } = options;
+  const storeId = uni.getStorageSync('currentStoreId') || '';
+
+  if (reset) {
+    if (loading.value) return;
+    loading.value = true;
+  } else {
+    if (loadMoreLoading.value || loading.value || !listHasMore.value || typePanelOpen.value) return;
+    loadMoreLoading.value = true;
+  }
+
+  const page = reset ? 1 : lastCouponPageLoaded.value + 1;
+
   try {
-    const storeId = uni.getStorageSync('currentStoreId') || '';
-    const res = await diningApi.GetUserCouponList({ storeId, tabType: currentTab.value, page: 1, size: 20 });
-    const list = Array.isArray(res) ? res : (res?.data ?? res?.records ?? res?.list ?? []);
-    const arr = Array.isArray(list) ? list : [];
-    couponList.value = arr.map(normalizeCouponItem).filter(Boolean);
+    const res = await diningApi.GetUserCouponList({
+      storeId,
+      tabType: currentTab.value,
+      page,
+      size: COUPON_PAGE_SIZE
+    });
+    const { list: rawList, total } = parseCouponListPage(res);
+    const arr = Array.isArray(rawList) ? rawList : [];
+    const normalized = arr.map(normalizeCouponItem).filter(Boolean);
+
+    if (reset) {
+      couponList.value = normalized;
+    } else {
+      couponList.value = mergeCouponListById(couponList.value, normalized);
+    }
+    lastCouponPageLoaded.value = page;
+
+    const mergedLen = couponList.value.length;
+    if (total > 0) {
+      listHasMore.value = mergedLen < total;
+    } else {
+      listHasMore.value = normalized.length >= COUPON_PAGE_SIZE;
+    }
   } catch (err) {
     console.error('获取优惠券列表失败:', err);
-    uni.showToast({ title: '加载失败', icon: 'none' });
-    couponList.value = [];
+    if (reset) {
+      uni.showToast({ title: '加载失败', icon: 'none' });
+      couponList.value = [];
+      listHasMore.value = false;
+    } else {
+      uni.showToast({ title: '加载更多失败', icon: 'none' });
+    }
   } finally {
-    loading.value = false;
+    if (reset) {
+      loading.value = false;
+    } else {
+      loadMoreLoading.value = false;
+    }
   }
 };
 
+function handleCouponScrollToLower() {
+  if (typePanelOpen.value) return;
+  fetchCouponList({ reset: false });
+}
+
 /** longTermValid=1 显示长期有效;=0 显示 expirationTime + 到期 */
 function formatCouponExpireLine(coupon) {
   if (Number(coupon?.longTermValid) === 1) return '长期有效';
@@ -213,7 +272,7 @@ const handleShowRules = (coupon) => {
 
 // 使用 onShow 拉取数据(onLoad 在 uni-app Vue3 组合式 API 下可能不触发,onShow 更可靠)
 onShow(() => {
-  fetchCouponList();
+  fetchCouponList({ reset: true });
 });
 </script>
 
@@ -399,6 +458,16 @@ onShow(() => {
   pointer-events: none;
 }
 
+.coupon-page-footer {
+  padding: 24rpx 0 40rpx;
+  text-align: center;
+}
+
+.coupon-page-footer__text {
+  font-size: 24rpx;
+  color: #999999;
+}
+
 .coupon-list {
   position: relative;
 

+ 35 - 2
pages/orderFood/components/CouponModal.vue

@@ -10,7 +10,13 @@
       </view>
 
       <!-- 优惠券列表 -->
-      <scroll-view class="coupon-modal__list" scroll-y v-if="couponList.length > 0">
+      <scroll-view
+        v-if="couponList.length > 0"
+        class="coupon-modal__list"
+        scroll-y
+        :lower-threshold="100"
+        @scrolltolower="onScrollToLower"
+      >
         <view
           v-for="(coupon, index) in couponList"
           :key="'coupon-row-' + index + '-' + String(coupon.id ?? '')"
@@ -31,6 +37,10 @@
             <text class="expire-text">{{ formatExpireLine(coupon) }}</text>
           </view>
         </view>
+        <view v-if="listLoading || !listHasMore" class="coupon-list-footer">
+          <text v-if="listLoading" class="coupon-list-footer__text">加载中...</text>
+          <text v-else class="coupon-list-footer__text">没有更多了</text>
+        </view>
       </scroll-view>
 
       <!-- 无数据时显示 -->
@@ -57,10 +67,18 @@ const props = defineProps({
   couponList: {
     type: Array,
     default: () => []
+  },
+  listLoading: {
+    type: Boolean,
+    default: false
+  },
+  listHasMore: {
+    type: Boolean,
+    default: true
   }
 });
 
-const emit = defineEmits(['update:open', 'close']);
+const emit = defineEmits(['update:open', 'close', 'load-more']);
 
 const getOpen = computed({
   get: () => props.open,
@@ -95,6 +113,11 @@ const formatExpireDate = (date) => {
   return date;
 };
 
+const onScrollToLower = () => {
+  if (props.listLoading || !props.listHasMore) return;
+  emit('load-more');
+};
+
 // 处理关闭
 const handleClose = () => {
   getOpen.value = false;
@@ -167,6 +190,16 @@ const copyUrl = () => {
     overflow-y: auto;
     min-height: 0;
   }
+
+  .coupon-list-footer {
+    padding: 24rpx 0 32rpx;
+    text-align: center;
+  }
+
+  .coupon-list-footer__text {
+    font-size: 24rpx;
+    color: #999999;
+  }
 }
 
 .coupon-card {

+ 35 - 2
pages/orderFood/components/SelectCouponModal.vue

@@ -10,7 +10,13 @@
       </view>
 
       <!-- 优惠券列表:仅查看模式下不展示选中态、点击不触发选择 -->
-      <scroll-view class="select-coupon-modal__list" scroll-y v-if="couponList.length > 0">
+      <scroll-view
+        v-if="couponList.length > 0"
+        class="select-coupon-modal__list"
+        scroll-y
+        :lower-threshold="100"
+        @scrolltolower="onScrollToLower"
+      >
         <view
           v-for="(coupon, index) in couponList"
           :key="'coupon-row-' + index + '-' + String(coupon.id ?? '')"
@@ -39,6 +45,10 @@
             <image :src="getFileUrl('img/icon/sele2.png')" mode="widthFix" class="selected-icon" v-show="isSelected(coupon)"></image>
           </view>
         </view>
+        <view v-if="listLoading || !listHasMore" class="coupon-list-footer">
+          <text v-if="listLoading" class="coupon-list-footer__text">加载中...</text>
+          <text v-else class="coupon-list-footer__text">没有更多了</text>
+        </view>
       </scroll-view>
       <!-- 无数据时显示 -->
       <view class="select-coupon-modal__empty" v-else>
@@ -70,10 +80,18 @@ const props = defineProps({
   viewOnly: {
     type: Boolean,
     default: false
+  },
+  listLoading: {
+    type: Boolean,
+    default: false
+  },
+  listHasMore: {
+    type: Boolean,
+    default: true
   }
 });
 
-const emit = defineEmits(['update:open', 'select', 'close']);
+const emit = defineEmits(['update:open', 'select', 'close', 'load-more']);
 
 const getOpen = computed({
   get: () => props.open,
@@ -120,6 +138,11 @@ const handleCardClick = (coupon, index) => {
   emit('select', { coupon, index, selectedId: newSelectedId });
 };
 
+const onScrollToLower = () => {
+  if (props.listLoading || !props.listHasMore) return;
+  emit('load-more');
+};
+
 // 处理关闭
 const handleClose = () => {
   getOpen.value = false;
@@ -181,6 +204,16 @@ const handleClose = () => {
     min-height: 400rpx;
   }
 
+  .coupon-list-footer {
+    padding: 24rpx 0 32rpx;
+    text-align: center;
+  }
+
+  .coupon-list-footer__text {
+    font-size: 24rpx;
+    color: #999999;
+  }
+
   &__empty {
     flex: 1;
     display: flex;

+ 78 - 14
pages/orderFood/index.vue

@@ -60,7 +60,14 @@
       @order-click="handleOrderClick" />
 
     <!-- 领取优惠券弹窗 -->
-    <CouponModal v-model:open="couponModalOpen" :coupon-list="couponList" @close="handleCouponClose" />
+    <CouponModal
+      v-model:open="couponModalOpen"
+      :coupon-list="couponList"
+      :list-loading="couponListLoading"
+      :list-has-more="couponListHasMore"
+      @load-more="handleCouponLoadMore"
+      @close="handleCouponClose"
+    />
 
     <!-- 购物车弹窗:按接口格式展示 items(含 tags 从 foodList 补全) -->
     <CartModal v-model:open="cartModalOpen" :cart-list="displayCartListWithTags"
@@ -68,8 +75,17 @@
       @order-click="handleOrderClick" @close="handleCartClose" />
 
     <!-- 选择优惠券弹窗:左下角入口为仅查看,购物车内入口为可选 -->
-    <SelectCouponModal v-model:open="selectCouponModalOpen" :coupon-list="availableCoupons"
-      :selected-coupon-id="selectedCouponId" :view-only="selectCouponViewOnly" @select="handleCouponSelect" @close="handleSelectCouponClose" />
+    <SelectCouponModal
+      v-model:open="selectCouponModalOpen"
+      :coupon-list="availableCoupons"
+      :selected-coupon-id="selectedCouponId"
+      :view-only="selectCouponViewOnly"
+      :list-loading="couponListLoading"
+      :list-has-more="couponListHasMore"
+      @load-more="handleCouponLoadMore"
+      @select="handleCouponSelect"
+      @close="handleSelectCouponClose"
+    />
     </view>
   </view>
 </template>
@@ -86,7 +102,7 @@ import SelectCouponModal from "./components/SelectCouponModal.vue";
 import { go } from "@/utils/utils.js";
 import { getFileUrl } from "@/utils/file.js";
 import { DiningOrderFood, GetCategoriesWithCuisines, GetStoreDetail, getOrderSseConfig, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate, PostOrderCartClear, GetUserCouponList } from "@/api/dining.js";
-import { normalizeUserCouponListItem } from "@/utils/couponNormalize.js";
+import { normalizeUserCouponListItem, parseCouponListPage, mergeCouponListById } from "@/utils/couponNormalize.js";
 import { createSSEConnection } from "@/utils/sse.js";
 
 // 商品图片:取第一张,若为逗号分隔字符串则截取逗号前的第一张
@@ -269,6 +285,11 @@ const couponList = ref([]);
 // 门店可用优惠券(SelectCouponModal):GET /dining/coupon/getUserCouponList,tabType=0 未使用
 const storeUsableCouponList = ref([]);
 
+const COUPON_PAGE_SIZE = 10;
+const couponListLoading = ref(false);
+const couponListHasMore = ref(true);
+const lastCouponPageLoaded = ref(0);
+
 // 可用的优惠券列表(选择优惠券弹窗用,来自 storeUsableCouponList)
 const availableCoupons = computed(() => storeUsableCouponList.value);
 
@@ -691,36 +712,79 @@ const handleDecrease = (item) => {
   if (food && (food.quantity || 0) > 0) updateFoodQuantity(food, -1);
 };
 
-// 拉取用户优惠券列表(GET /dining/coupon/getUserCouponList,与券包页一致:storeId、tabType、page、size)
-const fetchUserOwnedCoupons = async () => {
+/**
+ * 拉取用户优惠券(分页,每页 COUPON_PAGE_SIZE 条)
+ * @param {{ reset?: boolean }} options reset=true 打开弹窗时重置并从第 1 页拉取
+ */
+const fetchUserOwnedCoupons = async (options = { reset: true }) => {
+  const { reset = true } = options;
+  if (couponListLoading.value) return reset ? false : true;
   const storeId = uni.getStorageSync('currentStoreId') || '';
+  if (!storeId) {
+    uni.showToast({ title: '暂无门店信息', icon: 'none' });
+    return false;
+  }
+  const page = reset ? 1 : lastCouponPageLoaded.value + 1;
+  if (!reset && !couponListHasMore.value) return true;
+
+  couponListLoading.value = true;
   try {
     const res = await GetUserCouponList({
       storeId,
       tabType: 0,
-      page: 1,
-      size: 20
+      page,
+      size: COUPON_PAGE_SIZE
     });
-    const list = Array.isArray(res) ? res : (res?.data ?? res?.records ?? res?.list ?? []);
-    const normalized = (Array.isArray(list) ? list : [])
+    const { list: rawList, total } = parseCouponListPage(res);
+    const normalized = (Array.isArray(rawList) ? rawList : [])
       .map((item) => normalizeUserCouponListItem(item, 0))
       .filter(Boolean);
-    storeUsableCouponList.value = normalized;
-    couponList.value = normalized;
+
+    if (reset) {
+      storeUsableCouponList.value = normalized;
+      couponList.value = normalized;
+    } else {
+      storeUsableCouponList.value = mergeCouponListById(storeUsableCouponList.value, normalized);
+      couponList.value = storeUsableCouponList.value;
+    }
+    lastCouponPageLoaded.value = page;
+
+    const mergedLen = storeUsableCouponList.value.length;
+    if (total > 0) {
+      couponListHasMore.value = mergedLen < total;
+    } else {
+      couponListHasMore.value = normalized.length >= COUPON_PAGE_SIZE;
+    }
+
     return true;
   } catch (err) {
     console.error('获取用户优惠券失败:', err);
-    uni.showToast({ title: '获取优惠券失败', icon: 'none' });
+    if (reset) {
+      uni.showToast({ title: '获取优惠券失败', icon: 'none' });
+      storeUsableCouponList.value = [];
+      couponList.value = [];
+      couponListHasMore.value = false;
+    } else {
+      uni.showToast({ title: '加载更多失败', icon: 'none' });
+    }
     return false;
+  } finally {
+    couponListLoading.value = false;
   }
 };
 
+function handleCouponLoadMore() {
+  if (!couponListLoading.value && couponListHasMore.value) {
+    fetchUserOwnedCoupons({ reset: false });
+  }
+}
+
 // 打开选择优惠券弹窗。viewOnly=true 仅查看(左下角),false 可选择并默认选中第一张(购物车内)
 const openSelectCouponModal = async (viewOnly = false) => {
   if (cartModalOpen.value) cartModalOpen.value = false;
   if (couponModalOpen.value) couponModalOpen.value = false;
   selectCouponViewOnly.value = viewOnly;
-  const ok = await fetchUserOwnedCoupons();
+  const ok = await fetchUserOwnedCoupons({ reset: true });
   if (ok) {
     if (!viewOnly) {
       const list = storeUsableCouponList.value;

+ 37 - 0
utils/couponNormalize.js

@@ -55,3 +55,40 @@ export function normalizeUserCouponListItem(raw, tabStatus = 0) {
       raw.verificationCode ?? raw.verifyCode ?? raw.couponCode ?? raw.pickUpCode ?? ''
   };
 }
+
+/** 解析 getUserCouponList 分页响应(数组或 { records/list, total }) */
+export function parseCouponListPage(res) {
+  if (res == null) return { list: [], total: 0 };
+  if (Array.isArray(res)) return { list: res, total: 0 };
+  const data = res.data !== undefined && res.data !== null && !Array.isArray(res) ? res.data : res;
+  if (Array.isArray(data)) return { list: data, total: 0 };
+  if (data && typeof data === 'object') {
+    const list =
+      data.records ??
+      data.list ??
+      data.rows ??
+      data.items ??
+      (Array.isArray(data.data) ? data.data : []);
+    const total =
+      Number(data.total ?? data.totalCount ?? data.totalElements ?? data.count ?? res.total ?? res.totalCount ?? 0) ||
+      0;
+    return { list: Array.isArray(list) ? list : [], total };
+  }
+  return { list: [], total: 0 };
+}
+
+/** 分页追加时按 id 去重 */
+export function mergeCouponListById(existing, incoming) {
+  const seen = new Set((existing ?? []).map((c) => String(c?.id ?? '')));
+  const out = [...(existing ?? [])];
+  for (const item of incoming ?? []) {
+    const id = String(item?.id ?? '');
+    if (id && !seen.has(id)) {
+      seen.add(id);
+      out.push(item);
+    } else if (!id) {
+      out.push(item);
+    }
+  }
+  return out;
+}