sunshibo hai 1 día
pai
achega
b776a250f8

+ 10 - 0
api/dining.js

@@ -134,6 +134,16 @@ function getUserOwnedCouponListImpl(params) {
 }
 export const GetUserOwnedCouponList = getUserOwnedCouponListImpl;
 
+// 用户持有优惠券列表(GET /dining/coupon/userOwned,入参 amount 菜品金额、storeId 门店ID)
+export const GetUserCouponUserOwned = (params) =>
+  api.get({
+    url: '/dining/coupon/userOwned',
+    params: {
+      storeId: params?.storeId,
+      amount: params?.amount
+    }
+  });
+
 // 用户优惠券列表(GET /dining/coupon/getUserCouponList,入参 storeId、tabType、page、size)
 export const GetUserCouponList = (params) =>
   api.get({

+ 1 - 1
pages.json

@@ -65,7 +65,7 @@
 		{
 			"path": "pages/checkout/index",
 			"style": {
-				"navigationBarTitleText": "确认订单"
+				"navigationBarTitleText": "确认支付"
 			}
 		},
 		{

+ 119 - 204
pages/checkout/index.vue

@@ -11,27 +11,9 @@
           <view class="info-item-label">就餐桌号</view>
           <view class="info-item-value">{{ orderInfo.tableNumber || orderInfo.tableId || '—' }}</view>
         </view>
-        <view class="info-item info-item--diners">
+        <view class="info-item">
           <view class="info-item-label">用餐人数</view>
-          <view class="diner-stepper">
-            <view
-              class="diner-stepper__btn"
-              :class="{ 'diner-stepper__btn--disabled': dinerCount <= 1 }"
-              hover-class="diner-stepper__btn--active"
-              @tap.stop="adjustDiners(-1)"
-            >
-              <view class="diner-stepper__icon diner-stepper__icon--minus" />
-            </view>
-            <text class="diner-stepper__value">{{ dinerCount }}</text>
-            <view
-              class="diner-stepper__btn"
-              :class="{ 'diner-stepper__btn--disabled': dinerCount >= MAX_DINERS }"
-              hover-class="diner-stepper__btn--active"
-              @tap.stop="adjustDiners(1)"
-            >
-              <view class="diner-stepper__icon diner-stepper__icon--plus" />
-            </view>
-          </view>
+          <view class="info-item-value">{{ dinerCount }}人</view>
         </view>
         <view class="info-item">
           <view class="info-item-label">联系电话</view>
@@ -39,7 +21,7 @@
         </view>
         <view class="info-item">
           <view class="info-item-label">备注信息</view>
-          <view class="info-item-value remark-text">{{ orderInfo.remark || '—' }}</view>
+          <view class="info-item-value remark-text">{{ (orderInfo.remark || '').trim() || '无' }}</view>
         </view>
       </view>
     </view>
@@ -91,7 +73,7 @@
         <view class="info-item info-item--coupon info-item--clickable" @click="onCouponRowClick">
           <view class="info-item-label">优惠券</view>
           <view class="info-item-value coupon-value">
-            <text v-if="(orderInfo.discountAmount ?? 0) > 0" class="coupon-amount">{{ couponDisplayText }}</text>
+            <text v-if="hasCheckoutCouponChosen" class="coupon-amount">{{ couponRowDisplayName }}</text>
             <text v-else class="coupon-placeholder">请选择</text>
             <text class="coupon-arrow">›</text>
           </view>
@@ -120,6 +102,7 @@
         :coupon-list="couponList"
         :selected-coupon-id="selectedCouponId"
         :view-only="false"
+        :dish-amount="foodSubtotalForDisplay"
         :list-loading="checkoutCouponLoading"
         :list-has-more="checkoutCouponHasMore"
         @load-more="handleCheckoutCouponLoadMore"
@@ -137,13 +120,15 @@ 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, parseCouponListPage, mergeCouponListById } from '@/utils/couponNormalize.js';
+import { normalizeUserCouponListItem, parseCouponListPage, getCouponMinSpendThreshold } from '@/utils/couponNormalize.js';
 import SelectCouponModal from '@/pages/orderFood/components/SelectCouponModal.vue';
 
 const orderId = ref('');
 /** 用餐人数上限(与选人数页「查看更多」一致) */
 const MAX_DINERS = 16;
 const MIN_DINERS = 1;
+/** 应付金额下限(元),算出低于此值时按 0.01 展示并支付 */
+const MIN_CHECKOUT_PAY_YUAN = 0.01;
 
 const orderInfo = ref({
   orderNo: '',
@@ -175,10 +160,8 @@ 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);
+const checkoutCouponHasMore = ref(false);
 
 // 菜品清单展示(排除特殊占位 id=-1)
 const displayFoodList = computed(() =>
@@ -191,21 +174,9 @@ function parseDinerCount(val) {
   return Math.min(MAX_DINERS, Math.floor(n));
 }
 
-/** 当前用餐人数(数字,用于步进器展示) */
+/** 当前用餐人数(数字,结算页只读展示) */
 const dinerCount = computed(() => parseDinerCount(orderInfo.value.diners));
 
-function adjustDiners(delta) {
-  const cur = parseDinerCount(orderInfo.value.diners);
-  const next = cur + delta;
-  if (next < MIN_DINERS || next > MAX_DINERS) return;
-  orderInfo.value.diners = String(next);
-  uni.setStorageSync('currentDiners', String(next));
-  // 本版本不参与服务费(恢复按人数重算时取消注释)
-  // if (Number(estimateFeeType.value) === 1) {
-  //   fetchServiceFeeEstimate().catch((err) => console.warn('按人数重算服务费失败:', err));
-  // }
-}
-
 // 展示用菜品小计:有明细时与行成交价一致;否则用接口 dishTotal 或 totalAmount − 服务费
 const foodSubtotalForDisplay = computed(() => {
   const list = displayFoodList.value;
@@ -223,91 +194,80 @@ const foodSubtotalForDisplay = computed(() => {
   return Math.max(0, total - fee);
 });
 
-// 优惠券展示:满减券显示 nominalValue+元,折扣券显示 discountRate+折,否则显示 couponName 或 已使用优惠券
-const couponDisplayText = computed(() => {
+/** 是否已在结算页选定优惠券(含订单带入) */
+const hasCheckoutCouponChosen = computed(() => {
   const o = orderInfo.value;
-  if ((o.discountAmount ?? 0) <= 0) return '';
+  if ((o.discountAmount ?? 0) > 0) return true;
+  if (o.couponId != null && String(o.couponId).trim() !== '') return true;
+  if (selectedCouponId.value != null && String(selectedCouponId.value).trim() !== '') return true;
+  return false;
+});
+
+/** 优惠券行文案:优先展示券名称,缺省时再展示面值/折扣简短文案 */
+const couponRowDisplayName = computed(() => {
+  const o = orderInfo.value;
+  const name = String(o.couponName ?? '').trim();
+  if (name) return name;
   const type = Number(o.couponType);
-  if (type === 1 && (o.nominalValue != null && o.nominalValue !== '')) {
+  if (type === 1 && o.nominalValue != null && o.nominalValue !== '') {
     const val = Number(o.nominalValue);
-    return Number.isNaN(val) ? (o.couponName || '已使用优惠券') : val + '元';
+    return Number.isNaN(val) ? '已选优惠券' : `${val}元`;
   }
-  if (type === 2 && (o.discountRate != null && o.discountRate !== '')) {
+  if (type === 2 && o.discountRate != null && o.discountRate !== '') {
     const rate = Number(o.discountRate);
-    return Number.isNaN(rate) ? (o.couponName || '已使用优惠券') : rate + '折';
+    return Number.isNaN(rate) ? '已选优惠券' : `${rate}折`;
   }
-  return o.couponName || '已使用优惠券';
+  return '已选优惠券';
 });
 
-// 点击优惠券行:打开选择弹窗
+// 点击优惠券行:打开选择弹窗(列表已在进入页面时拉取,不再请求接口)
 const onCouponRowClick = () => {
-  openCouponModal();
+  couponModalOpen.value = true;
 };
 
 /**
- * 拉取结算页可用优惠券(分页,每页 CHECKOUT_COUPON_PAGE_SIZE)
- * @param {{ reset?: boolean }} options
+ * 拉取结算页用户持有优惠券(GET /dining/coupon/userOwned,一次性返回)
+ * @param {{ reset?: boolean; silentNoStore?: boolean }} options
+ * silentNoStore:无门店时不 toast(用于进入页面预拉取)
  */
 async function fetchCheckoutCouponList(options = { reset: true }) {
-  const { reset = true } = options;
+  const { reset = true, silentNoStore = false } = options;
   const storeId = orderInfo.value.storeId || uni.getStorageSync('currentStoreId') || '';
   if (!storeId) {
-    uni.showToast({ title: '暂无门店信息', icon: 'none' });
+    if (!silentNoStore) {
+      uni.showToast({ title: '暂无门店信息', icon: 'none' });
+    }
     return false;
   }
 
-  if (reset) {
-    if (checkoutCouponLoading.value) return false;
-    checkoutCouponLoading.value = true;
-  } else {
-    if (
-      checkoutCouponLoading.value ||
-      !checkoutCouponHasMore.value
-    ) {
-      return true;
-    }
-    checkoutCouponLoading.value = true;
+  if (!reset) {
+    return true;
   }
 
-  const page = reset ? 1 : checkoutCouponLastPage.value + 1;
+  if (checkoutCouponLoading.value) return false;
+  checkoutCouponLoading.value = true;
 
   try {
-    const res = await diningApi.GetUserCouponList({
-      storeId,
-      tabType: 0,
-      page,
-      size: CHECKOUT_COUPON_PAGE_SIZE
-    });
-    const { list: rawList, total } = parseCouponListPage(res);
+    const amount = Math.max(0, Math.round((Number(foodSubtotalForDisplay.value) || 0) * 100) / 100);
+    const res = await diningApi.GetUserCouponUserOwned({ storeId, amount });
+    const { list: rawList } = parseCouponListPage(res);
     const arr = Array.isArray(rawList) ? rawList : [];
     const normalized = arr
       .map((item) => normalizeUserCouponListItem(item, 0))
       .filter(Boolean);
 
-    if (reset) {
-      couponList.value = normalized;
-    } else {
-      couponList.value = mergeCouponListById(couponList.value, normalized);
-    }
-    checkoutCouponLastPage.value = page;
+    couponList.value = normalized;
+    checkoutCouponHasMore.value = false;
 
-    const mergedLen = couponList.value.length;
-    if (total > 0) {
-      checkoutCouponHasMore.value = mergedLen < total;
-    } else {
-      checkoutCouponHasMore.value = normalized.length >= CHECKOUT_COUPON_PAGE_SIZE;
-    }
+    syncCheckoutSelectedCouponId();
 
     return true;
   } catch (err) {
     console.error('获取优惠券失败:', err);
-    if (reset) {
-      uni.showToast({ title: '获取优惠券失败', icon: 'none' });
-      couponList.value = [];
-      checkoutCouponHasMore.value = false;
-    } else {
-      uni.showToast({ title: '加载更多失败', icon: 'none' });
-    }
+    uni.showToast({ title: '获取优惠券失败', icon: 'none' });
+    couponList.value = [];
+    checkoutCouponHasMore.value = false;
+    syncCheckoutSelectedCouponId();
     return false;
   } finally {
     checkoutCouponLoading.value = false;
@@ -320,15 +280,6 @@ function handleCheckoutCouponLoadMore() {
   }
 }
 
-// 打开优惠券弹窗并拉取用户可用券
-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 !== '';
@@ -353,16 +304,16 @@ const handleCouponSelect = ({ coupon, selectedId }) => {
 
   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' });
+  const threshold = getCouponMinSpendThreshold(coupon);
+  // 有门槛(minimumSpendingAmount/minAmount > 0):菜品总价须 ≥ 门槛
+  if (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.couponName = String(coupon.name ?? coupon.title ?? coupon.couponName ?? '').trim();
   orderInfo.value.couponType = couponType || null;
   orderInfo.value.discountRate = coupon.discountRate != null ? coupon.discountRate : null;
   const nvRaw = coupon.nominalValue;
@@ -388,12 +339,16 @@ const handleCouponSelect = ({ coupon, selectedId }) => {
   couponModalOpen.value = false;
 };
 
-// 应付金额 = 菜品总价 + 服务费 − 优惠金额
+// 应付金额 = 菜品总价 + 服务费 − 优惠金额(不低于 0.01 元)
 const updateCheckoutPayAmount = () => {
   const food = Number(foodSubtotalForDisplay.value) || 0;
   const fee = Number(orderInfo.value.serviceFee) || 0;
   const discount = Number(orderInfo.value.discountAmount) || 0;
-  orderInfo.value.payAmount = Math.max(0, Math.round((food + fee - discount) * 100) / 100);
+  let raw = Math.round((food + fee - discount) * 100) / 100;
+  if (!Number.isFinite(raw) || raw < MIN_CHECKOUT_PAY_YUAN) {
+    raw = MIN_CHECKOUT_PAY_YUAN;
+  }
+  orderInfo.value.payAmount = raw;
 };
 
 /** 服务费变化后:折扣券(按菜品+服务费为基数)需重算优惠金额 */
@@ -551,6 +506,58 @@ function getTableIdForServiceFeeEstimate() {
   return String(uni.getStorageSync('currentTableId') || '').trim();
 }
 
+/** 根据 orderInfo.couponId 与券列表对齐选中态(弹窗单选 id 为 userCoupon 实例 id) */
+function syncCheckoutSelectedCouponId() {
+  const cid = orderInfo.value.couponId;
+  if (cid == null || String(cid).trim() === '') {
+    selectedCouponId.value = null;
+    return;
+  }
+  const s = String(cid).trim();
+  const list = couponList.value ?? [];
+  const hit = list.find(
+    (c) =>
+      String(c?.couponId ?? '') === s ||
+      String(c?.id ?? '') === s ||
+      String(c?.userCouponId ?? '') === s
+  );
+  selectedCouponId.value = hit ? String(hit.id) : null;
+}
+
+/** 从订单详情接口回显优惠券(与下单/锁单时服务端数据一致) */
+function applyCheckoutCouponFromOrderRaw(raw) {
+  const cid = raw?.couponId ?? raw?.userCouponId ?? raw?.memberCouponId ?? null;
+  const discountAmt =
+    Number(raw?.discountAmount ?? raw?.couponAmount ?? raw?.couponDiscount ?? 0) || 0;
+  const hasCoupon =
+    (cid != null && String(cid).trim() !== '') ||
+    discountAmt > 0 ||
+    (String(raw?.couponName ?? '').trim() !== '');
+
+  if (!hasCoupon) {
+    orderInfo.value.couponId = null;
+    orderInfo.value.couponName = '';
+    orderInfo.value.couponType = null;
+    orderInfo.value.discountRate = null;
+    orderInfo.value.nominalValue = null;
+    orderInfo.value.discountAmount = 0;
+    selectedCouponId.value = null;
+    updateCheckoutPayAmount();
+    return;
+  }
+
+  orderInfo.value.couponId = cid != null && String(cid).trim() !== '' ? String(cid).trim() : null;
+  orderInfo.value.couponName = String(raw?.couponName ?? '').trim();
+  orderInfo.value.couponType = raw?.couponType ?? null;
+  const rawRate = raw?.discountRate;
+  orderInfo.value.discountRate =
+    rawRate != null && rawRate !== '' ? (Number(rawRate) || 0) / 10 : null;
+  orderInfo.value.nominalValue = raw?.nominalValue ?? null;
+  orderInfo.value.discountAmount = discountAmt;
+  recalcDiscountAfterServiceFeeChange();
+  updateCheckoutPayAmount();
+}
+
 const fetchOrderDetail = async () => {
   const id = orderId.value || '';
   if (!id) return;
@@ -575,22 +582,11 @@ const fetchOrderDetail = async () => {
     const total = Number(raw?.totalAmount ?? raw?.orderAmount ?? raw?.foodAmount ?? 0) || 0;
     orderInfo.value.totalAmount = total;
     orderInfo.value.dishTotal = raw?.dishTotal != null ? Number(raw.dishTotal) : null;
-    orderInfo.value.couponId = raw?.couponId ?? null;
-    orderInfo.value.couponName = raw?.couponName ?? '';
-    orderInfo.value.couponType = raw?.couponType ?? null;
-    const rawRate = raw?.discountRate;
-    orderInfo.value.discountRate = rawRate != null && rawRate !== '' ? (Number(rawRate) || 0) / 10 : null;
-    orderInfo.value.nominalValue = raw?.nominalValue ?? null;
-    orderInfo.value.discountAmount = Number(raw?.discountAmount ?? raw?.couponAmount ?? raw?.couponDiscount ?? 0) || 0;
-    orderInfo.value.payAmount = Number(raw?.payAmount ?? raw?.totalAmount ?? raw?.totalPrice ?? 0) || 0;
     // 本版本不参与服务费:不从订单拉取服务费
-    // orderInfo.value.serviceFee =
-    //   Number(raw?.serviceFee ?? raw?.serviceCharge ?? raw?.tablewareFee ?? 0) || 0;
     orderInfo.value.serviceFee = 0;
     const list = raw?.orderItemList ?? raw?.orderItems ?? raw?.items ?? raw?.detailList ?? [];
     foodList.value = (Array.isArray(list) ? list : []).map(normalizeOrderItem);
-    selectedCouponId.value = orderInfo.value.couponId != null && orderInfo.value.couponId !== '' ? String(orderInfo.value.couponId) : null;
-    updateCheckoutPayAmount();
+    applyCheckoutCouponFromOrderRaw(raw);
     await fetchServiceFeeEstimate(); // 本版为 no-op(不参与服务费)
   } catch (err) {
     console.error('获取订单详情失败:', err);
@@ -611,7 +607,7 @@ const handleConfirmPay = async () => {
     return;
   }
   const payAmountVal = Math.round((Number(orderInfo.value.payAmount ?? 0) || 0) * 100) / 100;
-  if (payAmountVal <= 0) {
+  if (payAmountVal < MIN_CHECKOUT_PAY_YUAN) {
     uni.showToast({ title: '订单金额异常', icon: 'none' });
     return;
   }
@@ -699,11 +695,13 @@ onLoad(async (options) => {
   }
   if (options?.remark != null && options?.remark !== '') orderInfo.value.remark = decodeURIComponent(options.remark);
   if (options?.totalAmount != null && options?.totalAmount !== '') {
-    orderInfo.value.payAmount = Number(options.totalAmount) || 0;
+    const p = Math.round((Number(options.totalAmount) || 0) * 100) / 100;
+    orderInfo.value.payAmount = p < MIN_CHECKOUT_PAY_YUAN ? MIN_CHECKOUT_PAY_YUAN : p;
   }
   if (id) {
     await fetchOrderDetail();
   }
+  await fetchCheckoutCouponList({ reset: true, silentNoStore: true });
 });
 
 onShow(() => {
@@ -796,89 +794,6 @@ onUnload(() => {
     &--clickable {
       cursor: pointer;
     }
-
-    &--diners {
-      align-items: center;
-    }
-  }
-
-  /* 用餐人数:胶囊步进器(与设计稿一致) */
-  .diner-stepper {
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    justify-content: space-between;
-    background: #f2f3f5;
-    border-radius: 999rpx;
-    padding: 3rpx 6rpx;
-    min-width: 196rpx;
-    box-sizing: border-box;
-  }
-
-  .diner-stepper__btn {
-    width: 44rpx;
-    height: 44rpx;
-    border-radius: 50%;
-    background: #ffffff;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    flex-shrink: 0;
-    box-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.06);
-  }
-
-  .diner-stepper__btn--active:active {
-    opacity: 0.88;
-  }
-
-  .diner-stepper__btn--disabled {
-    opacity: 0.35;
-    pointer-events: none;
-  }
-
-  .diner-stepper__value {
-    flex: 1;
-    text-align: center;
-    font-size: 26rpx;
-    font-weight: 500;
-    color: #151515;
-    line-height: 44rpx;
-    min-width: 36rpx;
-  }
-
-  .diner-stepper__icon--minus {
-    width: 20rpx;
-    height: 2rpx;
-    background: #c8c8c8;
-    border-radius: 2rpx;
-  }
-
-  .diner-stepper__icon--plus {
-    position: relative;
-    width: 18rpx;
-    height: 18rpx;
-  }
-
-  .diner-stepper__icon--plus::before,
-  .diner-stepper__icon--plus::after {
-    content: '';
-    position: absolute;
-    left: 50%;
-    top: 50%;
-    background: #fc743d;
-    border-radius: 2rpx;
-  }
-
-  .diner-stepper__icon--plus::before {
-    width: 18rpx;
-    height: 2rpx;
-    transform: translate(-50%, -50%);
-  }
-
-  .diner-stepper__icon--plus::after {
-    width: 2rpx;
-    height: 18rpx;
-    transform: translate(-50%, -50%);
   }
 
   .price-line {

+ 1 - 155
pages/coupon/components/RulesModal.vue

@@ -25,41 +25,6 @@
           </view>
         </view>
 
-        <!-- 使用凭证:二维码 -->
-        <view class="card card--voucher">
-          <text class="card-title">使用凭证</text>
-          <view class="qr-wrap">
-            <image
-              v-if="qrImageSrc"
-              :src="qrImageSrc"
-              mode="aspectFit"
-              class="qr-image"
-              :style="{ width: qrBoxRpx + 'rpx', height: qrBoxRpx + 'rpx' }"
-            />
-            <view
-              v-else-if="qrMatrix.length"
-              class="qr-grid"
-              :style="{ width: qrBoxRpx + 'rpx', height: qrBoxRpx + 'rpx' }"
-            >
-              <view
-                v-for="(row, ri) in qrMatrix"
-                :key="ri"
-                class="qr-row"
-                :style="{ height: qrCellRpx + 'rpx' }"
-              >
-                <view
-                  v-for="(dark, ci) in row"
-                  :key="ci"
-                  class="qr-cell"
-                  :style="{ width: qrCellRpx + 'rpx', height: qrCellRpx + 'rpx' }"
-                  :class="{ 'qr-cell--dark': dark }"
-                />
-              </view>
-            </view>
-            <view v-else class="qr-placeholder">暂无核销码</view>
-          </view>
-        </view>
-
         <!-- 使用须知 -->
         <view class="card card--notice">
           <text class="card-title">使用须知</text>
@@ -78,11 +43,8 @@
 </template>
 
 <script setup>
-import { computed, ref, watch } from 'vue';
+import { computed } from 'vue';
 import BasicModal from '@/components/Modal/BasicModal.vue';
-import { getFileUrl } from '@/utils/file.js';
-import UQRCodeModule from 'uqrcodejs';
-const UQRCode = UQRCodeModule?.default || UQRCodeModule;
 
 const props = defineProps({
   open: {
@@ -102,10 +64,6 @@ const getOpen = computed({
   set: (val) => emit('update:open', val)
 });
 
-const qrMatrix = ref([]);
-/** 二维码展示边长(rpx),约卡片宽 30%~40%,与设计稿一致 */
-const qrBoxRpx = 220;
-
 const conditionLine = computed(() => {
   const c = props.couponData;
   if (c?.conditionText) return c.conditionText;
@@ -155,77 +113,6 @@ const supplementText = computed(() => {
   return '暂无说明';
 });
 
-const qrImageSrc = computed(() => {
-  const raw = props.couponData?.qrCodeUrl ?? props.couponData?.qrcodeUrl ?? '';
-  if (!raw) return '';
-  if (typeof raw === 'string' && (raw.startsWith('http') || raw.startsWith('//'))) return raw;
-  return getFileUrl(raw);
-});
-
-const qrPayload = computed(() => {
-  const c = props.couponData || {};
-  const id = c.id ?? c.userCouponId ?? c.couponId ?? '';
-  const code = c.verificationCode ?? c.couponCode ?? '';
-  const sid = uni.getStorageSync('currentStoreId') || '';
-  if (code) return String(code);
-  if (id) return `coupon:${id}:${sid}`;
-  return 'coupon';
-});
-
-const qrCellRpx = computed(() => {
-  const n = qrMatrix.value.length;
-  if (!n) return 0;
-  return qrBoxRpx / n;
-});
-
-function buildQrMatrix(text) {
-  try {
-    const qr = new UQRCode();
-    qr.data = String(text || ' ');
-    qr.size = 200;
-    qr.make();
-    const n = qr.moduleCount;
-    const mods = qr.modules;
-    if (!n || !mods) return [];
-    const rows = [];
-    for (let r = 0; r < n; r++) {
-      const row = [];
-      for (let c = 0; c < n; c++) {
-        const cell = mods[r][c];
-        const dark =
-          typeof cell === 'object' && cell !== null ? !!cell.isBlack : !!cell;
-        row.push(dark);
-      }
-      rows.push(row);
-    }
-    return rows;
-  } catch (e) {
-    console.warn('生成二维码失败:', e);
-    return [];
-  }
-}
-
-watch(
-  () => ({
-    open: props.open,
-    id: props.couponData?.id,
-    qrUrl: props.couponData?.qrCodeUrl ?? props.couponData?.qrcodeUrl,
-    payload: qrPayload.value
-  }),
-  ({ open }) => {
-    if (!open) {
-      qrMatrix.value = [];
-      return;
-    }
-    if (qrImageSrc.value) {
-      qrMatrix.value = [];
-      return;
-    }
-    qrMatrix.value = buildQrMatrix(qrPayload.value);
-  },
-  { flush: 'post', deep: true }
-);
-
 function handleClose() {
   getOpen.value = false;
 }
@@ -388,47 +275,6 @@ function handleClose() {
   color: #999999;
 }
 
-/* 使用凭证 */
-.card--voucher {
-  padding: 28rpx 24rpx 32rpx;
-}
-
-.qr-wrap {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 8rpx 0 4rpx;
-}
-
-.qr-image {
-  display: block;
-}
-
-.qr-grid {
-  background: #ffffff;
-  overflow: hidden;
-}
-
-.qr-row {
-  display: flex;
-  flex-direction: row;
-}
-
-.qr-cell {
-  flex-shrink: 0;
-  box-sizing: border-box;
-  background: #ffffff;
-
-  &--dark {
-    background: #151515;
-  }
-}
-
-.qr-placeholder {
-  font-size: 26rpx;
-  color: #aaaaaa;
-}
-
 /* 使用须知 */
 .card--notice {
   padding: 28rpx 24rpx 32rpx;

+ 134 - 108
pages/coupon/index.vue

@@ -2,33 +2,40 @@
   <!-- 优惠券页面 -->
   <view class="page">
 
-    <!-- 主 Tab + 右侧「全部」类型筛选 -->
+    <!-- 券状态横向滚动 + 右侧固定「全部」(覆盖滚动内容) -->
     <view class="tabs-wrap">
       <view class="tabs-row">
-        <view class="tabs-main">
-          <view
-            v-for="(tab, index) in tabs"
-            :key="index"
-            :class="['tab-item', { 'tab-item--active': currentTab === index }]"
-            @click="handleTabChange(index)"
-          >
-            {{ tab }}
+        <scroll-view
+          class="tabs-scroll"
+          scroll-x
+          :show-scrollbar="false"
+          enable-flex
+        >
+          <view class="tabs-scroll-inner">
+            <view
+              v-for="(tab, index) in mainTabs"
+              :key="'main-' + index"
+              :class="['tab-item', { 'tab-item--active': currentTab === index }]"
+              @click="handleTabChange(index)"
+            >
+              {{ tab }}
+            </view>
+          </view>
+        </scroll-view>
+        <view
+          class="tabs-type-trigger"
+          :class="{
+            'tabs-type-trigger--open': typePanelOpen,
+            'tabs-type-trigger--filtered': !typePanelOpen && typeFilter !== 'all'
+          }"
+          @click.stop="toggleTypePanel"
+        >
+          <view class="tabs-type-trigger__inner">
+            <text class="tabs-type-trigger__text">{{ typeTriggerLabel }}</text>
+            <view
+              :class="['tabs-type-trigger__chevron', { 'tabs-type-trigger__chevron--open': typePanelOpen }]"
+            />
           </view>
-        </view>
-        <view class="tabs-trigger-gap" />
-        <view class="tabs-trigger" @click="toggleTypePanel">
-          <text
-            :class="[
-              'tabs-trigger__text',
-              {
-                'tabs-trigger__text--open': typePanelOpen,
-                'tabs-trigger__text--filtered': !typePanelOpen && typeFilter !== 'all'
-              }
-            ]"
-          >{{ typeTriggerLabel }}</text>
-          <view
-            :class="['tabs-trigger__chevron', { 'tabs-trigger__chevron--open': typePanelOpen }]"
-          />
         </view>
       </view>
       <view v-if="typePanelOpen" class="type-filter-row">
@@ -110,8 +117,8 @@ import { normalizeUserCouponListItem, parseCouponListPage, mergeCouponListById }
 import RulesModal from "./components/RulesModal.vue";
 import * as diningApi from "@/api/dining.js";
 
-// 标签页:0未使用 1即将过期 2已使用 3已过期
-const tabs = ['未使用', '即将过期', '已使用', '已过期'];
+// 状态 Tab:0未使用 1即将过期 2已使用 3已过期;右侧「全部」为券类型筛选入口
+const mainTabs = ['未使用', '即将过期', '已使用', '已过期'];
 const currentTab = ref(0);
 
 /** 右侧:展开折扣券 / 满减券筛选(couponType 1 满减 2 折扣) */
@@ -139,8 +146,9 @@ function closeTypePanel() {
 
 function selectTypeFilter(value) {
   typeFilter.value = value;
-  // 仅在「切换券类型」流程中展示选项,选完后收起
   typePanelOpen.value = false;
+  // 类型为前端筛选;与列表请求共用队列,保证「切换类型」排在进行中的请求之后(后续 Tab/加载更多按序执行)
+  enqueueListFetch(() => Promise.resolve());
 }
 
 // 弹窗控制
@@ -168,8 +176,13 @@ const COUPON_PAGE_SIZE = 10;
 const listHasMore = ref(true);
 const lastCouponPageLoaded = ref(0);
 
-function normalizeCouponItem(raw) {
-  return normalizeUserCouponListItem(raw, currentTab.value);
+/** 列表相关请求串行:上一请求结束(成功/失败)后再执行下一请求,避免切换状态/类型时竞态 */
+let listFetchQueue = Promise.resolve();
+
+function enqueueListFetch(task) {
+  const next = listFetchQueue.then(() => task());
+  listFetchQueue = next.catch(() => {});
+  return next;
 }
 
 // 列表:主 Tab 数据 + 类型筛选(全部 / 折扣券 / 满减券)
@@ -185,16 +198,16 @@ const filteredCoupons = computed(() => {
 const handleTabChange = (index) => {
   currentTab.value = index;
   typePanelOpen.value = false;
-  fetchCouponList({ reset: true });
+  enqueueListFetch(() => fetchCouponList({ reset: true, tabType: index }));
 };
 
 // 拉取优惠券列表(分页,每页 COUPON_PAGE_SIZE)
-const fetchCouponList = async (options = { reset: true }) => {
-  const { reset = true } = options;
+const fetchCouponList = async (options = {}) => {
+  const { reset = true, tabType: tabTypeOpt } = options;
+  const tabForRequest = tabTypeOpt != null ? tabTypeOpt : currentTab.value;
   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;
@@ -206,13 +219,13 @@ const fetchCouponList = async (options = { reset: true }) => {
   try {
     const res = await diningApi.GetUserCouponList({
       storeId,
-      tabType: currentTab.value,
+      tabType: tabForRequest,
       page,
       size: COUPON_PAGE_SIZE
     });
     const { list: rawList, total } = parseCouponListPage(res);
     const arr = Array.isArray(rawList) ? rawList : [];
-    const normalized = arr.map(normalizeCouponItem).filter(Boolean);
+    const normalized = arr.map((raw) => normalizeUserCouponListItem(raw, tabForRequest)).filter(Boolean);
 
     if (reset) {
       couponList.value = normalized;
@@ -247,7 +260,7 @@ const fetchCouponList = async (options = { reset: true }) => {
 
 function handleCouponScrollToLower() {
   if (typePanelOpen.value) return;
-  fetchCouponList({ reset: false });
+  enqueueListFetch(() => fetchCouponList({ reset: false }));
 }
 
 /** longTermValid=1 显示长期有效;=0 显示 expirationTime + 到期 */
@@ -272,7 +285,7 @@ const handleShowRules = (coupon) => {
 
 // 使用 onShow 拉取数据(onLoad 在 uni-app Vue3 组合式 API 下可能不触发,onShow 更可靠)
 onShow(() => {
-  fetchCouponList({ reset: true });
+  enqueueListFetch(() => fetchCouponList({ reset: true }));
 });
 </script>
 
@@ -286,19 +299,6 @@ onShow(() => {
   overflow: hidden;
 }
 
-.header {
-  background: #FFFFFF;
-  padding: 20rpx 30rpx;
-  padding-top: calc(20rpx + env(safe-area-inset-top));
-
-  .header-title {
-    font-size: 36rpx;
-    font-weight: bold;
-    color: #151515;
-    text-align: center;
-  }
-}
-
 .tabs-wrap {
   flex-shrink: 0;
   background: #ffffff;
@@ -326,67 +326,122 @@ onShow(() => {
 }
 
 .tabs-row {
+  position: relative;
   display: flex;
   flex-direction: row;
   align-items: flex-end;
-  padding: 0 24rpx 0 20rpx;
+  min-height: 96rpx;
   box-sizing: border-box;
 }
 
-.tabs-main {
+/* 券状态:横向滚动,内容可滑到「全部」下方被遮挡 */
+.tabs-scroll {
+  width: 100%;
   flex: 1;
   min-width: 0;
-  display: flex;
+  height: 96rpx;
+  white-space: nowrap;
+}
+
+.tabs-scroll-inner {
+  display: inline-flex;
   flex-direction: row;
   align-items: flex-end;
+  flex-wrap: nowrap;
+  column-gap: 28rpx;
+  min-height: 96rpx;
+  padding: 0 0 0 28rpx;
+  /* 右侧留白:最后一项可向左滑出「全部」遮挡区,便于看清 */
+  padding-right: 120rpx;
+  box-sizing: border-box;
 }
 
-.tabs-trigger-gap {
-  flex-shrink: 0;
-  width: 24rpx;
+/* 右侧固定:白底盖在滚动层之上;与 .tab-item 相同上下内边距与行高,保证与券状态垂直对齐 */
+.tabs-type-trigger {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  z-index: 3;
+  display: flex;
+  flex-direction: row;
+  align-items: flex-end;
+  justify-content: flex-end;
+  height: 96rpx;
+  min-height: 96rpx;
+  /* 与 .tab-item 一致:上 28rpx / 下 24rpx,文字行高 40rpx */
+  padding: 28rpx 24rpx 24rpx 28rpx;
+  box-sizing: border-box;
+  background: #ffffff;
+  box-shadow: -16rpx 0 20rpx -8rpx #ffffff;
 }
 
-.tabs-trigger {
-  flex-shrink: 0;
+.tabs-type-trigger__inner {
   display: flex;
   flex-direction: row;
   align-items: center;
-  justify-content: flex-end;
-  padding: 28rpx 0 24rpx 8rpx;
-  box-sizing: border-box;
+  flex-wrap: nowrap;
+  height: 40rpx;
 }
 
-.tabs-trigger__text {
+.tabs-type-trigger__text {
   font-size: 26rpx;
+  line-height: 40rpx;
+  height: 40rpx;
   color: #151515;
-  line-height: 1;
+  white-space: nowrap;
 }
 
-.tabs-trigger__text--open {
+.tabs-type-trigger--open .tabs-type-trigger__text,
+.tabs-type-trigger--filtered .tabs-type-trigger__text {
   color: #f47d1f;
 }
 
-/* 已选折扣券/满减券且面板收起时,右上角保持主题色提示当前筛选 */
-.tabs-trigger__text--filtered {
-  color: #f47d1f;
-}
-
-.tabs-trigger__chevron {
+.tabs-type-trigger__chevron {
+  flex-shrink: 0;
   width: 0;
   height: 0;
   margin-left: 8rpx;
-  border-left: 8rpx solid transparent;
-  border-right: 8rpx solid transparent;
-  border-top: 10rpx solid #999999;
-  transform: translateY(2rpx);
+  border-left: 7rpx solid transparent;
+  border-right: 7rpx solid transparent;
+  border-top: 9rpx solid #999999;
 }
 
-.tabs-trigger__chevron--open {
+.tabs-type-trigger__chevron--open {
   border-top: none;
-  border-bottom: 10rpx solid #f47d1f;
-  border-left: 8rpx solid transparent;
-  border-right: 8rpx solid transparent;
-  transform: translateY(-2rpx);
+  border-bottom: 9rpx solid #f47d1f;
+  border-left: 7rpx solid transparent;
+  border-right: 7rpx solid transparent;
+}
+
+.tab-item {
+  flex-shrink: 0;
+  text-align: center;
+  font-size: 26rpx;
+  line-height: 40rpx;
+  color: #666666;
+  padding: 28rpx 8rpx 24rpx;
+  margin: 0 25rpx;
+  position: relative;
+  transition: color 0.2s;
+  box-sizing: border-box;
+  white-space: nowrap;
+
+  &--active {
+    color: #151515;
+    font-weight: bold;
+
+    &::after {
+      content: '';
+      position: absolute;
+      bottom: 16rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 40rpx;
+      height: 6rpx;
+      background: #ff8a00;
+      border-radius: 3rpx;
+    }
+  }
 }
 
 .type-filter-row {
@@ -415,35 +470,6 @@ onShow(() => {
   font-weight: 500;
 }
 
-.tab-item {
-  flex: 1;
-  min-width: 0;
-  text-align: center;
-  font-size: 26rpx;
-  color: #666666;
-  padding: 28rpx 4rpx 24rpx;
-  position: relative;
-  transition: color 0.2s;
-  box-sizing: border-box;
-
-  &--active {
-    color: #151515;
-    font-weight: bold;
-
-    &::after {
-      content: '';
-      position: absolute;
-      bottom: 16rpx;
-      left: 50%;
-      transform: translateX(-50%);
-      width: 48rpx;
-      height: 6rpx;
-      background: linear-gradient(90deg, #ff8a57 0%, #f47d1f 100%);
-      border-radius: 3rpx;
-    }
-  }
-}
-
 .content {
   flex: 1;
   min-height: 0;

+ 15 - 1
pages/orderFood/components/CartModal.vue

@@ -5,6 +5,11 @@
             <view class="cart-modal__header">
                 <text class="header-title">已选菜品</text>
                 <view class="header-clear" @click="handleClear" v-if="cartList.length > 0">
+                    <image
+                        class="clear-icon-img"
+                        :src="getFileUrl('/static/delete.png')"
+                        mode="aspectFit"
+                    />
                     <text class="clear-text">清空</text>
                 </view>
             </view>
@@ -180,7 +185,7 @@ const handleClear = () => {
         .header-clear {
             display: flex;
             align-items: center;
-            gap: 8rpx;
+            gap: 10rpx;
             color: #AAAAAA;
             font-size: 28rpx;
             padding: 8rpx 16rpx;
@@ -191,8 +196,17 @@ const handleClear = () => {
                 background-color: #F5F5F5;
             }
 
+            /* 清空:左侧垃圾箱图 static/delete.png */
+            .clear-icon-img {
+                width: 32rpx;
+                height: 32rpx;
+                flex-shrink: 0;
+                display: block;
+            }
+
             .clear-text {
                 font-size: 28rpx;
+                line-height: 40rpx;
                 color: #AAAAAA;
             }
         }

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

@@ -21,7 +21,10 @@
           v-for="(coupon, index) in couponList"
           :key="'coupon-row-' + index + '-' + String(coupon.id ?? '')"
           class="coupon-card"
-          :class="{ 'coupon-card--view-only': viewOnly }"
+          :class="{
+            'coupon-card--view-only': viewOnly,
+            'coupon-card--disabled': !viewOnly && isCouponBelowMinSpend(coupon)
+          }"
           @click="handleCardClick(coupon, index)"
         >
           <!-- 左侧金额信息:数字大号,“元/折”小号 -->
@@ -62,6 +65,7 @@
 import { computed, ref } from 'vue';
 import BasicModal from '@/components/Modal/BasicModal.vue';
 import { getFileUrl } from '@/utils/file.js';
+import { getCouponMinSpendThreshold } from '@/utils/couponNormalize.js';
 
 const props = defineProps({
   open: {
@@ -88,6 +92,11 @@ const props = defineProps({
   listHasMore: {
     type: Boolean,
     default: true
+  },
+  /** 菜品金额(元);传入时与券的 minimumSpendingAmount / minAmount 比较,未满门槛则置灰不可点 */
+  dishAmount: {
+    type: Number,
+    default: undefined
   }
 });
 
@@ -131,9 +140,19 @@ const isSelected = (coupon) => {
   return String(coupon.id) === String(props.selectedCouponId);
 };
 
-// 卡片点击:仅查看模式下不触发选择;否则按单选逻辑
+/** 菜品金额是否低于该券使用门槛(未传 dishAmount 时不启用);minimumSpendingAmount 为 0 或无门槛时不置灰 */
+function isCouponBelowMinSpend(coupon) {
+  if (props.dishAmount === undefined || props.dishAmount === null) return false;
+  const min = getCouponMinSpendThreshold(coupon);
+  if (min <= 0) return false;
+  const dish = Math.round((Number(props.dishAmount) || 0) * 100) / 100;
+  return dish < min;
+}
+
+// 卡片点击:仅查看模式下不触发选择;未满门槛不可点
 const handleCardClick = (coupon, index) => {
   if (props.viewOnly) return;
+  if (isCouponBelowMinSpend(coupon)) return;
   const newSelectedId = isSelected(coupon) ? null : coupon.id;
   emit('select', { coupon, index, selectedId: newSelectedId });
 };
@@ -256,6 +275,15 @@ const handleClose = () => {
     opacity: 1;
   }
 
+  &--disabled {
+    opacity: 0.42;
+    pointer-events: none;
+  }
+
+  &--disabled:active {
+    opacity: 0.42;
+  }
+
   &__left {
     display: flex;
     flex-direction: column;

+ 106 - 111
pages/orderInfo/orderDetail.vue

@@ -51,36 +51,22 @@
           <view class="info-item-value">¥{{ priceDetail.serviceFee }}</view>
         </view>
         -->
-        <!-- 已完成订单:优惠金额(满减用 nominalValue;折扣按菜品总价与折扣率计算,与结算页选券逻辑一致) -->
-        <view
-          v-if="orderDetail.orderStatus === 3 && completedOrderDiscountDisplay > 0"
-          class="info-item info-item--coupon"
-        >
-          <view class="info-item-label">优惠劵</view>
-          <view class="info-item-value coupon-value">
-            <text class="coupon-amount">-¥{{ formatPrice(completedOrderDiscountDisplay) }}</text>
-          </view>
-        </view>
         <view class="price-line">
           <view class="price-line-label">合计</view>
-          <view class="price-line-value">¥{{ formatPrice(orderSummaryDisplayAmount) }}</view>
+          <view class="price-line-value">¥{{ formatPrice(actualPayAmount) }}</view>
         </view>
        
       </view>
     </view>
-    <!-- 已支付 / 已完成:支付信息(结算方式、支付方式、支付时间) -->
+    <!-- 已支付 / 已完成:支付信息(支付方式、支付时间) -->
     <view v-if="showPaidOrderPaymentCard" class="card">
       <view class="card-header">
         <view class="card-header-title">支付信息</view>
       </view>
       <view class="card-content">
         <view class="info-item">
-          <view class="info-item-label">结算方式</view>
-          <view class="info-item-value">{{ settlementMethodText }}</view>
-        </view>
-        <view class="info-item">
           <view class="info-item-label">支付方式</view>
-          <view class="info-item-value">{{ payMethodText }}</view>
+          <view class="info-item-value">微信支付</view>
         </view>
         <view class="info-item">
           <view class="info-item-label">支付时间</view>
@@ -117,7 +103,7 @@
         </view>
         <view class="info-item">
           <view class="info-item-label">备注信息</view>
-          <view class="info-item-value">{{ orderDetail.remark || '—' }}</view>
+          <view class="info-item-value">{{ (orderDetail.remark || '').trim() || '无' }}</view>
         </view>
       </view>
     </view>
@@ -156,8 +142,6 @@ const orderDetail = ref({
   remark: '',
   orderStatus: null,
   payType: '', // 支付方式编码,如 wechatPayMininProgram
-  /** 结算方式文案,接口无则默认「手机支付」 */
-  settlementMethod: '',
   /** 支付完成时间 */
   payTime: ''
 });
@@ -173,95 +157,97 @@ const priceDetail = ref({
   couponType: null,
   nominalValue: null,
   discountRate: null,
-  total: '0.00'
+  total: '0.00',
+  /** 接口实付(元),详情页「合计」以前端计算为准 */
+  payAmount: 0
 });
 
-// 已支付(1) / 已完成(3) 展示支付信息卡片
-const showPaidOrderPaymentCard = computed(() => {
-  const s = orderDetail.value?.orderStatus;
-  return s === 1 || s === 3;
-});
+// 菜品列表(接口订单明细)
+const foodList = ref([]);
 
-// 结算方式:优先接口 settlementMethod / settlementTypeName,小程序场景默认「手机支付」
-const settlementMethodText = computed(() => {
-  const m = orderDetail.value?.settlementMethod;
-  if (m != null && String(m).trim() !== '') return String(m).trim();
-  return '手机支付';
-});
+// 菜品详情展示(排除特殊占位 id=-1)
+const displayFoodList = computed(() =>
+  (foodList.value ?? []).filter((it) => Number(it?.id ?? it?.cuisineId) !== -1)
+);
 
-// 支付方式:根据 payType 转为中文
-const payMethodText = computed(() => {
-  const t = orderDetail.value?.payType ?? '';
-  if (/wechat|微信/i.test(t)) return '微信支付';
-  if (/alipay|支付宝/i.test(t)) return '支付宝';
-  return t && String(t).trim() !== '' ? String(t).trim() : '微信支付';
+// 未支付订单:按明细行累计菜品总价(与 checkout 行小计口径一致)
+const calculatedDishTotalFromItems = computed(() => {
+  const list = displayFoodList.value;
+  let sum = 0;
+  for (const it of list) {
+    const ls = Number(it?.lineSubtotal);
+    // 行小计为 0 时仍可能单价×数量>0(接口占位 0),不要用 0 覆盖真实金额
+    if (Number.isFinite(ls) && ls > 0) {
+      sum += ls;
+      continue;
+    }
+    const q = Number(it?.quantity) || 1;
+    const u = Number(it?.price) || 0;
+    sum += u * q;
+  }
+  return Math.max(0, Math.round(sum * 100) / 100);
 });
 
-// 支付时间
-const payTimeDisplay = computed(() => {
-  const t = orderDetail.value?.payTime;
-  if (t != null && String(t).trim() !== '') return String(t).trim();
-  return '—';
+// 合计/优惠计算用菜品基数:有明细则按行累计,否则用接口菜品总价
+const dishTotalBaseForOrder = computed(() => {
+  if (displayFoodList.value.length > 0) return calculatedDishTotalFromItems.value;
+  const dish = Number(priceDetail.value?.dishTotal);
+  return Number.isFinite(dish) ? Math.max(0, dish) : 0;
 });
 
-// 优惠券展示:满减券显示 nominalValue+元,折扣券显示 discountRate+折,否则显示 couponName 或 已使用优惠券(与 checkout 一致)
-const couponDisplayText = computed(() => {
-  const p = priceDetail.value;
-  const amount = Number(p.discountAmount ?? p.couponDiscount ?? 0) || 0;
-  if (amount <= 0) return '—';
-  const type = Number(p.couponType);
-  if (type === 1 && (p.nominalValue != null && p.nominalValue !== '')) {
-    const val = Number(p.nominalValue);
-    return Number.isNaN(val) ? (p.couponName || '已使用优惠券') : val + '元';
-  }
-  if (type === 2 && (p.discountRate != null && p.discountRate !== '')) {
-    const rate = Number(p.discountRate);
-    return Number.isNaN(rate) ? (p.couponName || '已使用优惠券') : rate + '折';
-  }
-  return p.couponName || '已使用优惠券';
-});
+/** 已支付:orderStatus 1 已支付、3 已完成 */
+function isPaidOrderStatus(status) {
+  const s = Number(status);
+  return s === 1 || s === 3;
+}
 
-// 已完成订单展示用优惠金额:与 pages/checkout 选券后 discountAmount 计算一致
-// couponType 1 满减:取 nominalValue;2 折扣:菜品总价 × (1 - discountRate/10),discountRate 已为接口值/10
-const completedOrderDiscountDisplay = computed(() => {
-  if (orderDetail.value.orderStatus !== 3) return 0;
+// 待支付(0) / 已支付(1) / 已完成(3) 优惠金额(仅用于合计计算,页面不展示优惠券行)
+const orderDiscountDisplay = computed(() => {
+  const st = orderDetail.value.orderStatus;
+  if (st !== 0 && st !== 1 && st !== 3) return 0;
   const p = priceDetail.value;
-  const dishTotalNum = Number(p.dishTotal) || 0;
+  const dishBase = dishTotalBaseForOrder.value;
   const type = Number(p.couponType);
 
+  let raw = 0;
   if (type === 1) {
     if (p.nominalValue != null && p.nominalValue !== '') {
       const nv = Number(p.nominalValue);
-      return Number.isNaN(nv) ? 0 : Math.max(0, nv);
+      raw = Number.isNaN(nv) ? 0 : Math.max(0, nv);
+    } else {
+      // 无面额时不用裸 discountAmount,避免无券订单接口噪声把合计减成 0
+      const name = (p.couponName && String(p.couponName).trim()) || '';
+      if (name) raw = Math.max(0, Number(p.discountAmount) || 0);
+    }
+  } else if (type === 2) {
+    if (p.discountRate != null && p.discountRate !== '' && dishBase > 0) {
+      const rate = Number(p.discountRate) || 0;
+      raw = Math.round(dishBase * (1 - rate / 10) * 100) / 100;
     }
-    return Math.max(0, Number(p.discountAmount) || 0);
-  }
-  if (type === 2) {
-    if (p.discountRate == null || p.discountRate === '' || dishTotalNum <= 0) return 0;
-    const rate = Number(p.discountRate) || 0;
-    return Math.round(dishTotalNum * (1 - rate / 10) * 100) / 100;
   }
-  return Math.max(0, Number(p.discountAmount) || 0);
+  const capped = Math.min(Math.max(0, raw), Math.max(0, dishBase));
+  return Math.max(0, Math.round(capped * 100) / 100);
 });
 
-// 合计 / 已完成实付展示:已完成 = 菜品总价 − 优惠(与结算页 payAmount 一致);未完成 = 接口应付 total
-const orderSummaryDisplayAmount = computed(() => {
-  const p = priceDetail.value;
-  if (orderDetail.value.orderStatus === 3) {
-    const dish = Number(p.dishTotal) || 0;
-    const discount = completedOrderDiscountDisplay.value;
-    return Math.max(0, Math.round((dish - discount) * 100) / 100);
-  }
-  return Number(p.total) || 0;
+// 合计:未支付 / 已支付 / 已完成均按「菜品基数 − 优惠」计算,不直接绑定接口 payAmount
+const actualPayAmount = computed(() => {
+  const base = dishTotalBaseForOrder.value;
+  const d = orderDiscountDisplay.value;
+  return Math.max(0, Math.round((base - d) * 100) / 100);
 });
 
-// 菜品列表(接口订单明细)
-const foodList = ref([]);
+// 已支付(1) / 已完成(3) 展示支付信息卡片
+const showPaidOrderPaymentCard = computed(() => {
+  const s = orderDetail.value?.orderStatus;
+  return s === 1 || s === 3;
+});
 
-// 菜品详情展示(排除特殊占位 id=-1)
-const displayFoodList = computed(() =>
-  (foodList.value ?? []).filter((it) => Number(it?.id ?? it?.cuisineId) !== -1)
-);
+// 支付时间
+const payTimeDisplay = computed(() => {
+  const t = orderDetail.value?.payTime;
+  if (t != null && String(t).trim() !== '') return String(t).trim();
+  return '—';
+});
 
 // 取第一张图:逗号分隔取首段,数组取首项,对象取 url/path/src(与 orderInfo/index、FoodCard 一致)
 function firstImage(val) {
@@ -311,11 +297,30 @@ function normalizeTags(raw) {
   }).filter((t) => t.text !== '' && t.text != null);
 }
 
-// 将接口订单项转为列表项(图片取首张,逗号/数组/对象与 orderInfo/index 一致
+// 将接口订单项转为列表项(图片取首张;行小计与 checkout 一致,便于未支付订单合计按明细计算
 function normalizeOrderItem(item) {
   const rawTags = item?.tags ?? item?.tagList ?? item?.tagNames ?? item?.labels ?? item?.tag;
   const rawImg = item?.images ?? item?.image ?? item?.cuisineImage ?? item?.imageUrl ?? item?.pic ?? item?.cover ?? '';
   const imageUrl = firstImage(rawImg) || (typeof rawImg === 'string' ? rawImg.split(/[,,]/)[0]?.trim() : '') || 'img/icon/shop.png';
+  const qty = Number(item?.quantity ?? 1) || 1;
+  let lineSubtotal = 0;
+  if (item?.subtotalAmount != null && item.subtotalAmount !== '') {
+    lineSubtotal = Number(item.subtotalAmount) || 0;
+  } else if (item?.totalPrice != null && item.totalPrice !== '') {
+    lineSubtotal = Number(item.totalPrice) || 0;
+  } else {
+    const unit = Number(item?.unitPrice ?? item?.price ?? item?.salePrice ?? 0) || 0;
+    lineSubtotal = unit * qty;
+  }
+  // 接口可能把小计写成 0,但单价/数量有效,回退为单价×数量,与列表展示一致
+  if (lineSubtotal <= 0) {
+    const unit = Number(item?.unitPrice ?? item?.price ?? item?.salePrice ?? 0) || 0;
+    const fallback = unit * qty;
+    if (fallback > 0) lineSubtotal = fallback;
+  }
+  const unitForDisplay =
+    Number(item?.unitPrice ?? item?.price ?? 0) ||
+    (qty > 0 ? lineSubtotal / qty : 0);
   return {
     id: item?.id ?? item?.cuisineId ?? item?.orderItemId ?? item?.skuId,
     name:
@@ -325,9 +330,10 @@ function normalizeOrderItem(item) {
       item?.skuName ??
       item?.productName ??
       '',
-    price: item?.totalPrice ?? item?.unitPrice ?? item?.price ?? item?.salePrice ?? 0,
+    price: unitForDisplay,
+    lineSubtotal,
     image: imageUrl,
-    quantity: item?.quantity ?? 1,
+    quantity: qty,
     tags: normalizeTags(rawTags)
   };
 }
@@ -349,12 +355,6 @@ function applyOrderData(data) {
     remark: raw?.remark ?? '',
     orderStatus: raw?.orderStatus ?? raw?.status ?? null,
     payType: raw?.payType ?? raw?.payMethod ?? '',
-    settlementMethod:
-      raw?.settlementMethod ??
-      raw?.settlementTypeName ??
-      raw?.settlementTypeDesc ??
-      raw?.payChannelName ??
-      '',
     payTime:
       raw?.payTime ??
       raw?.paymentTime ??
@@ -367,7 +367,13 @@ function applyOrderData(data) {
   const dishTotal = raw?.totalAmount ?? raw?.dishTotal ?? raw?.orderAmount ?? raw?.foodAmount ?? 0;
   const couponDiscount = raw?.couponAmount ?? raw?.couponDiscount ?? 0;
   const discountAmountVal = Number(raw?.discountAmount ?? raw?.couponAmount ?? raw?.couponDiscount ?? 0) || 0;
-  const total = raw?.payAmount ?? raw?.totalAmount ?? raw?.totalPrice ?? 0;
+  const totalFallback =
+    Number(raw?.totalAmount ?? raw?.totalPrice ?? raw?.orderAmount ?? raw?.foodAmount ?? 0) || 0;
+  const explicitPay = raw?.payAmount ?? raw?.realPayAmount ?? raw?.paidAmount;
+  const payAmountNum =
+    explicitPay != null && explicitPay !== ''
+      ? Number(explicitPay) || 0
+      : totalFallback;
   const serviceFeeRaw =
     Number(raw?.serviceFee ?? raw?.serviceCharge ?? raw?.tablewareFee ?? 0) || 0;
   const rawDiscountRate =
@@ -383,7 +389,8 @@ function applyOrderData(data) {
     couponType: raw?.couponType ?? couponInfo?.couponType ?? null,
     nominalValue: raw?.nominalValue ?? couponInfo?.nominalValue ?? couponInfo?.amount ?? null,
     discountRate: discountRateVal,
-    total: formatPrice(total)
+    total: formatPrice(payAmountNum),
+    payAmount: payAmountNum
   };
   const list =
     raw?.orderItemList ??
@@ -420,7 +427,7 @@ const handleConfirmPay = () => {
   const tableId = orderDetail.value?.tableId || orderDetail.value?.tableNo || '';
   const tableNumber = orderDetail.value?.tableNo ?? orderDetail.value?.tableNumber ?? '';
   const diners = orderDetail.value?.dinerCount ?? '';
-  const totalAmount = priceDetail.value?.total ?? '';
+  const totalAmount = formatPrice(actualPayAmount.value);
   const remark = (orderDetail.value?.remark ?? '').trim().slice(0, 30);
   const q = [];
   if (orderId !== '') q.push(`orderId=${encodeURIComponent(String(orderId))}`);
@@ -502,18 +509,6 @@ onLoad(async (e) => {
     .info-item-value {
       color: #151515;
     }
-
-    &--coupon .coupon-value {
-      display: flex;
-      align-items: center;
-      gap: 8rpx;
-    }
-    .coupon-amount {
-      color: #E61F19;
-    }
-    .coupon-placeholder {
-      color: #999999;
-    }
   }
 
   .info-item-textarea {

BIN=BIN
static/delete.png


+ 23 - 1
utils/couponNormalize.js

@@ -52,10 +52,32 @@ export function normalizeUserCouponListItem(raw, tabStatus = 0) {
     supplementaryInstruction: raw.supplementaryInstruction ?? raw.description ?? '',
     qrCodeUrl: raw.qrCodeUrl ?? raw.qrcodeUrl ?? raw.qrUrl ?? '',
     verificationCode:
-      raw.verificationCode ?? raw.verifyCode ?? raw.couponCode ?? raw.pickUpCode ?? ''
+      raw.verificationCode ?? raw.verifyCode ?? raw.couponCode ?? raw.pickUpCode ?? '',
+    /** 接口原始门槛;与 minAmount 并存,供 getCouponMinSpendThreshold 优先解析(0 表示无门槛) */
+    minimumSpendingAmount:
+      raw.minimumSpendingAmount !== undefined && raw.minimumSpendingAmount !== null
+        ? Number(raw.minimumSpendingAmount)
+        : undefined
   };
 }
 
+/**
+ * 优惠券使用门槛金额(元)。
+ * 优先 minimumSpendingAmount:显式为 0 视为无门槛(可选);字段未给出时用 minAmount。
+ * @param {object} coupon 原始项或 normalizeUserCouponListItem 结果
+ */
+export function getCouponMinSpendThreshold(coupon) {
+  if (!coupon || typeof coupon !== 'object') return 0;
+  const raw = coupon.minimumSpendingAmount;
+  const explicit = raw !== undefined && raw !== null && raw !== '';
+  if (explicit) {
+    const v = Number(raw);
+    return Number.isFinite(v) && v > 0 ? v : 0;
+  }
+  const fb = Number(coupon.minAmount ?? 0);
+  return Number.isFinite(fb) && fb > 0 ? fb : 0;
+}
+
 /** 解析 getUserCouponList 分页响应(数组或 { records/list, total }) */
 export function parseCouponListPage(res) {
   if (res == null) return { list: [], total: 0 };