sunshibo 1 dia atrás
pai
commit
166a16c656

+ 9 - 6
components/TabBar.vue

@@ -35,6 +35,7 @@ import { parseQrScanResult, isScanEntryAllowed } from '@/utils/qrScene.js';
 import { syncM2GenericPricingStorage } from '@/utils/m2GenericApiPath.js';
 import { useUserStore } from '@/store/user.js';
 import { TOKEN } from '@/settings/enums.js';
+import { runTableDiningStatusAndRedirect } from '@/utils/tableDiningLaunch.js';
 
 const menus = [
 	{ title: '首页', link: '/pages/index/index', img: 'img/tabbar/index1.png', imgon: 'img/tabbar/index2.png' },
@@ -60,7 +61,7 @@ function handleScanOrder() {
 	}
 	uni.scanCode({
 		scanType: ['wxCode', 'qrCode', 'barCode'],
-		success: (res) => {
+		success: async (res) => {
 			const result = (res?.path || res?.result || '').trim();
 			const { storeId, tableId, m } = parseQrScanResult(result);
 			const payload = { raw: result, storeId, tableId, m };
@@ -72,15 +73,17 @@ function handleScanOrder() {
 			}
 			if (storeId) uni.setStorageSync('currentStoreId', storeId);
 			if (tableId) uni.setStorageSync('currentTableId', tableId);
-			const diners = uni.getStorageSync('currentDiners') || '1';
-			uni.setStorageSync('currentDiners', diners);
 			if (!tableId) {
 				uni.showToast({ title: '未识别到桌号,请扫描正确的桌号二维码', icon: 'none' });
 				return;
 			}
-			uni.reLaunch({
-				url: `/pages/orderFood/index?tableid=${encodeURIComponent(tableId)}&diners=${encodeURIComponent(diners)}`
-			});
+
+			uni.showLoading({ title: '加载中...', mask: true });
+			try {
+				await runTableDiningStatusAndRedirect(tableId);
+			} finally {
+				uni.hideLoading();
+			}
 		},
 		fail: (err) => {
 			const msg = err?.errMsg || '';

+ 78 - 8
pages/checkout/index.vue

@@ -78,7 +78,7 @@
             <text class="coupon-arrow">›</text>
           </view>
         </view>
-        <view v-if="(orderInfo.discountAmount ?? 0) > 0" class="info-item info-item--coupon">
+        <view v-if="showCheckoutDiscountRow" class="info-item info-item--coupon">
           <view class="info-item-label">优惠金额</view>
           <view class="info-item-value coupon-value">
             <text class="coupon-amount">-¥{{ formatPrice(orderInfo.discountAmount) }}</text>
@@ -181,7 +181,17 @@ const dinerCount = computed(() => parseDinerCount(orderInfo.value.diners));
 const foodSubtotalForDisplay = computed(() => {
   const list = displayFoodList.value;
   if (list.length > 0) {
-    const sum = list.reduce((s, it) => s + (Number(it.lineSubtotal) || 0), 0);
+    let sum = 0;
+    for (const it of list) {
+      const ls = Number(it.lineSubtotal);
+      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 dt = orderInfo.value.dishTotal;
@@ -194,6 +204,18 @@ const foodSubtotalForDisplay = computed(() => {
   return Math.max(0, total - fee);
 });
 
+/** 是否展示「优惠金额」行:有优惠额,或已选折扣券且 discountRate 有效 */
+const showCheckoutDiscountRow = computed(() => {
+  const o = orderInfo.value;
+  if ((Number(o.discountAmount) || 0) > 0) return true;
+  return (
+    Number(o.couponType) === 2 &&
+    o.couponId != null &&
+    String(o.couponId).trim() !== '' &&
+    Number(o.discountRate) > 0
+  );
+});
+
 /** 是否已在结算页选定优惠券(含订单带入) */
 const hasCheckoutCouponChosen = computed(() => {
   const o = orderInfo.value;
@@ -253,7 +275,18 @@ async function fetchCheckoutCouponList(options = { reset: true }) {
     const { list: rawList } = parseCouponListPage(res);
     const arr = Array.isArray(rawList) ? rawList : [];
     const normalized = arr
-      .map((item) => normalizeUserCouponListItem(item, 0))
+      .map((item) => {
+        const n = normalizeUserCouponListItem(item, 0);
+        if (!n) return null;
+        const apiRate = item?.discountRate ?? item?.discount_rate;
+        if (Number(n.couponType) === 2 && apiRate != null && apiRate !== '') {
+          const num = Number(apiRate);
+          if (Number.isFinite(num)) {
+            return { ...n, _sourceDiscountRate: num };
+          }
+        }
+        return n;
+      })
       .filter(Boolean);
 
     couponList.value = normalized;
@@ -280,6 +313,30 @@ function handleCheckoutCouponLoadMore() {
   }
 }
 
+/**
+ * 将接口/列表里的「折」统一为应付公式用的刻度:payRatio = rate/10,优惠 = 基数×(1−rate/10)。
+ * 如 88(八八折整数)、8.8、0.88 均归一为 8.8。
+ */
+function normalizeZheRateForCheckoutFormula(r) {
+  if (!Number.isFinite(r) || r <= 0) return 0;
+  if (r > 10 && r <= 100) return Math.round((r / 10) * 100) / 100;
+  if (r > 0 && r < 1) return Math.round(r * 10 * 100) / 100;
+  return Math.round(r * 100) / 100;
+}
+
+/** 折扣券:仅用接口/券上的 discountRate(及 _sourceDiscountRate 保留的原始值)计算折标,不用 amount 代替 */
+function resolveCheckoutDiscountCouponRate(coupon) {
+  if (!coupon || Number(coupon.couponType) !== 2) return 0;
+  const rawVal =
+    coupon._sourceDiscountRate ??
+    coupon.discountRate ??
+    coupon.discount_rate;
+  if (rawVal == null || rawVal === '') return 0;
+  const dr = Number(rawVal);
+  if (!Number.isFinite(dr) || dr <= 0) return 0;
+  return normalizeZheRateForCheckoutFormula(dr);
+}
+
 // 选择优惠券后更新订单优惠与应付金额
 const handleCouponSelect = ({ coupon, selectedId }) => {
   const hasSelected = selectedId != null && selectedId !== '';
@@ -315,7 +372,6 @@ const handleCouponSelect = ({ coupon, selectedId }) => {
   orderInfo.value.couponId = coupon?.couponId ?? coupon?.id ?? String(selectedId) ?? null;
   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;
   if (nvRaw != null && nvRaw !== '') {
     const nv = Number(nvRaw);
@@ -328,10 +384,18 @@ const handleCouponSelect = ({ coupon, selectedId }) => {
   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;
+
+  if (couponType === 2) {
+    const rate = resolveCheckoutDiscountCouponRate(coupon);
+    orderInfo.value.discountRate = rate > 0 ? rate : null;
+    if (rate > 0 && baseForDiscount > 0) {
+      const rawDiscount = Math.round(baseForDiscount * (1 - rate / 10) * 100) / 100;
+      orderInfo.value.discountAmount = Math.max(0, Math.min(rawDiscount, baseForDiscount));
+    } else {
+      orderInfo.value.discountAmount = 0;
+    }
   } else {
+    orderInfo.value.discountRate = null;
     orderInfo.value.discountAmount =
       Number(coupon.nominalValue) || Number(coupon.amount) || 0;
   }
@@ -360,7 +424,8 @@ function recalcDiscountAfterServiceFeeChange() {
   const base = food + fee;
   const rate = Number(orderInfo.value.discountRate) || 0;
   if (base <= 0) return;
-  orderInfo.value.discountAmount = Math.round(base * (1 - rate / 10) * 100) / 100;
+  const rawDiscount = Math.round(base * (1 - rate / 10) * 100) / 100;
+  orderInfo.value.discountAmount = Math.max(0, Math.min(rawDiscount, base));
 }
 
 /**
@@ -485,6 +550,11 @@ function normalizeOrderItem(item) {
     const unit = Number(item?.unitPrice ?? item?.price ?? 0) || 0;
     lineSubtotal = unit * qty;
   }
+  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);

+ 36 - 71
pages/coupon/index.vue

@@ -56,9 +56,9 @@
       <scroll-view
         class="content"
         scroll-y
+        :scroll-top="listScrollTop"
+        :scroll-with-animation="false"
         :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"
@@ -91,11 +91,6 @@
         </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-if="filteredCoupons.length === 0">
         <image :src="getFileUrl('img/icon/noCoupon.png')" mode="widthFix" class="empty-icon"></image>
@@ -111,9 +106,9 @@
 
 <script setup>
 import { onShow } from "@dcloudio/uni-app";
-import { ref, computed } from "vue";
+import { ref, computed, nextTick } from "vue";
 import { getFileUrl } from "@/utils/file.js";
-import { normalizeUserCouponListItem, parseCouponListPage, mergeCouponListById } from "@/utils/couponNormalize.js";
+import { normalizeUserCouponListItem, parseCouponListPage } from "@/utils/couponNormalize.js";
 import RulesModal from "./components/RulesModal.vue";
 import * as diningApi from "@/api/dining.js";
 
@@ -149,6 +144,9 @@ function selectTypeFilter(value) {
   typePanelOpen.value = false;
   // 类型为前端筛选;与列表请求共用队列,保证「切换类型」排在进行中的请求之后(后续 Tab/加载更多按序执行)
   enqueueListFetch(() => Promise.resolve());
+  void nextTick(() => {
+    void scrollCouponListToTop();
+  });
 }
 
 // 弹窗控制
@@ -168,13 +166,21 @@ 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);
+/** 列表纵向滚动位置:刷新后归零,避免 scroll-view 仍停在旧滚动位置 */
+const listScrollTop = ref(0);
+/** 与后端约定的一次拉取上限(不分页) */
+const COUPON_LIST_SIZE = 999;
+
+/** 小程序 scroll-view 对 scroll-top 需「变化」才生效,先置非 0 再回 0 */
+async function scrollCouponListToTop() {
+  listScrollTop.value = 1;
+  await nextTick();
+  listScrollTop.value = 0;
+  await nextTick();
+}
 
 /** 列表相关请求串行:上一请求结束(成功/失败)后再执行下一请求,避免切换状态/类型时竞态 */
 let listFetchQueue = Promise.resolve();
@@ -198,71 +204,40 @@ const filteredCoupons = computed(() => {
 const handleTabChange = (index) => {
   currentTab.value = index;
   typePanelOpen.value = false;
-  enqueueListFetch(() => fetchCouponList({ reset: true, tabType: index }));
+  enqueueListFetch(() => fetchCouponList({ tabType: index }));
 };
 
-// 拉取优惠券列表(分页,每页 COUPON_PAGE_SIZE)
+// 拉取优惠券列表(不分页:page=1、size=COUPON_LIST_SIZE)
 const fetchCouponList = async (options = {}) => {
-  const { reset = true, tabType: tabTypeOpt } = options;
+  const { tabType: tabTypeOpt } = options;
   const tabForRequest = tabTypeOpt != null ? tabTypeOpt : currentTab.value;
   const storeId = uni.getStorageSync('currentStoreId') || '';
 
-  if (reset) {
-    loading.value = true;
-  } else {
-    if (loadMoreLoading.value || loading.value || !listHasMore.value || typePanelOpen.value) return;
-    loadMoreLoading.value = true;
-  }
-
-  const page = reset ? 1 : lastCouponPageLoaded.value + 1;
+  loading.value = true;
 
   try {
     const res = await diningApi.GetUserCouponList({
       storeId,
       tabType: tabForRequest,
-      page,
-      size: COUPON_PAGE_SIZE
+      page: 1,
+      size: COUPON_LIST_SIZE
     });
-    const { list: rawList, total } = parseCouponListPage(res);
+    const { list: rawList } = parseCouponListPage(res);
     const arr = Array.isArray(rawList) ? rawList : [];
-    const normalized = arr.map((raw) => normalizeUserCouponListItem(raw, tabForRequest)).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;
-    }
+    couponList.value = arr.map((raw) => normalizeUserCouponListItem(raw, tabForRequest)).filter(Boolean);
+    await nextTick();
+    await scrollCouponListToTop();
   } catch (err) {
     console.error('获取优惠券列表失败:', err);
-    if (reset) {
-      uni.showToast({ title: '加载失败', icon: 'none' });
-      couponList.value = [];
-      listHasMore.value = false;
-    } else {
-      uni.showToast({ title: '加载更多失败', icon: 'none' });
-    }
+    uni.showToast({ title: '加载失败', icon: 'none' });
+    couponList.value = [];
+    await nextTick();
+    await scrollCouponListToTop();
   } finally {
-    if (reset) {
-      loading.value = false;
-    } else {
-      loadMoreLoading.value = false;
-    }
+    loading.value = false;
   }
 };
 
-function handleCouponScrollToLower() {
-  if (typePanelOpen.value) return;
-  enqueueListFetch(() => fetchCouponList({ reset: false }));
-}
-
 /** longTermValid=1 显示长期有效;=0 显示 expirationTime + 到期 */
 function formatCouponExpireLine(coupon) {
   if (Number(coupon?.longTermValid) === 1) return '长期有效';
@@ -285,7 +260,7 @@ const handleShowRules = (coupon) => {
 
 // 使用 onShow 拉取数据(onLoad 在 uni-app Vue3 组合式 API 下可能不触发,onShow 更可靠)
 onShow(() => {
-  enqueueListFetch(() => fetchCouponList({ reset: true }));
+  enqueueListFetch(() => fetchCouponList());
 });
 </script>
 
@@ -484,16 +459,6 @@ 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;
 

+ 25 - 1
pages/index/index.vue

@@ -86,8 +86,15 @@ import LoginModal from "@/pages/components/LoginModal.vue";
 import { go } from "@/utils/utils.js";
 import { getFileUrl } from '@/utils/file.js';
 import { useUserStore } from '@/store/user.js';
-import { TOKEN } from '@/settings/enums.js';
+import { TOKEN, SCAN_QR_CACHE } from '@/settings/enums.js';
 import { GetStoreDetail } from '@/api/dining.js';
+import { isScanEntryAllowed } from '@/utils/qrScene.js';
+import { syncM2GenericPricingStorage } from '@/utils/m2GenericApiPath.js';
+import {
+  getScanEntryIdsFromOptions,
+  runTableDiningStatusAndRedirect,
+  trim
+} from '@/utils/tableDiningLaunch.js';
 
 const userStore = useUserStore();
 const showLoginModal = ref(false);
@@ -194,6 +201,23 @@ const handleFoodClick = (item) => {
 };
 
 onLoad(async (options) => {
+  /** 太阳码若直达首页(非 launch),须同样走 table-dining-status */
+  let { tableid, storeId: scanStoreId, m } = getScanEntryIdsFromOptions(options);
+  if (trim(m) === '') {
+    try {
+      const c = uni.getStorageSync(SCAN_QR_CACHE);
+      if (c) m = trim(JSON.parse(c).m ?? '');
+    } catch (_) {}
+  }
+  syncM2GenericPricingStorage(m);
+  const fromScan = trim(tableid);
+  if (fromScan && isScanEntryAllowed(m)) {
+    uni.setStorageSync('currentTableId', fromScan);
+    if (trim(scanStoreId)) uni.setStorageSync('currentStoreId', trim(scanStoreId));
+    await runTableDiningStatusAndRedirect(fromScan);
+    return;
+  }
+
   const storeId = options?.storeId ?? uni.getStorageSync('currentStoreId') ?? '';
   if (storeId) {
     try {

+ 20 - 67
pages/launch/index.vue

@@ -7,39 +7,17 @@
 
 <script setup>
 import { onLoad } from '@dcloudio/uni-app';
-import * as diningApi from '@/api/dining.js';
-import { useUserStore } from '@/store/user.js';
-import { TOKEN } from '@/settings/enums.js';
-import { parseSceneToStoreTable, isScanEntryAllowed } from '@/utils/qrScene.js';
+import { isScanEntryAllowed } from '@/utils/qrScene.js';
 import { SCAN_QR_CACHE } from '@/settings/enums.js';
 import { syncM2GenericPricingStorage } from '@/utils/m2GenericApiPath.js';
-
-const userStore = useUserStore();
-
-function trim(v) {
-  return v == null ? '' : String(v).trim();
-}
-
-/** 从启动参数/query 中解析 tableid、storeId、m(m=1 美食,走 dining) */
-function getIdsFromOptions(options) {
-  if (!options || typeof options !== 'object') return { tableid: '', storeId: '', m: '' };
-  const q = options.query || options;
-  let m = trim(q.m ?? q.mode ?? '');
-  const directTable = q.tableId ?? q.tableid ?? q.table_id ?? q.t ?? '';
-  if (directTable) {
-    const directStore = q.storeId ?? q.storeid ?? q.s ?? '';
-    return { tableid: String(directTable).trim(), storeId: String(directStore || '').trim(), m };
-  }
-  const scene = q.scene ?? q.q ?? '';
-  if (scene) {
-    const { storeId: s, tableId: t, m: sm } = parseSceneToStoreTable(scene);
-    return { tableid: t, storeId: s, m: m || sm || '' };
-  }
-  return { tableid: '', storeId: '', m };
-}
+import {
+  getScanEntryIdsFromOptions,
+  runTableDiningStatusAndRedirect,
+  trim
+} from '@/utils/tableDiningLaunch.js';
 
 async function doRedirect(options = {}) {
-  let { tableid, storeId: optStore, m } = getIdsFromOptions(options);
+  let { tableid, storeId: optStore, m } = getScanEntryIdsFromOptions(options);
   if (trim(m) === '') {
     try {
       const c = uni.getStorageSync(SCAN_QR_CACHE);
@@ -53,50 +31,25 @@ async function doRedirect(options = {}) {
     return;
   }
 
-  let tableidFinal = uni.getStorageSync('currentTableId') || '';
-  if (!tableidFinal) {
-    tableidFinal = tableid;
-    if (tableidFinal) uni.setStorageSync('currentTableId', tableidFinal);
-    if (optStore) uni.setStorageSync('currentStoreId', optStore);
-  }
-  const tableidResolved = tableidFinal;
+  /** 本次启动参数里的桌号(新扫太阳码)优先于本地缓存,避免仍用上一桌 */
+  const fromScan = trim(tableid);
+  const stored = trim(uni.getStorageSync('currentTableId') || '');
+  const tableidResolved = fromScan || stored;
   if (!tableidResolved) {
     console.log('[launch] 无 tableid,跳转首页');
     uni.reLaunch({ url: '/pages/index/index' });
     return;
   }
-  try {
-    console.log('[launch] 调用 GetTableDiningStatus, tableid:', tableidResolved);
-    const res = await diningApi.GetTableDiningStatus(tableidResolved);
-    const raw = (res && typeof res === 'object') ? res : {};
-    // 兼容多种返回:{ inDining: true }、{ data: { inDining: true } }、直接返回 true
-    const inDining =
-      res === true ||
-      res === 'true' ||
-      raw?.inDining === true ||
-      raw?.inDining === 'true' ||
-      raw?.data?.inDining === true ||
-      raw?.data?.inDining === 'true';
-    const token = userStore.getToken || uni.getStorageSync(TOKEN) || '';
-    const dinerCount =
-      raw?.dinerCount ?? raw?.diner ?? raw?.data?.dinerCount ?? raw?.data?.diner ?? uni.getStorageSync('currentDiners') ?? 1;
-    if (inDining) {
-      uni.setStorageSync('currentDiners', dinerCount);    
-      uni.reLaunch({
-        url: `/pages/orderFood/index?tableid=${encodeURIComponent(tableidResolved)}&diners=${encodeURIComponent(dinerCount)}`
-      });
-      
-    }
-    else{
-      uni.reLaunch({
-          url: `/pages/numberOfDiners/index?inDining=1&tableid=${encodeURIComponent(tableidResolved)}&diners=${encodeURIComponent(dinerCount)}`
-        });
-    }
-
-  } catch (err) {
-    console.warn('查询桌位就餐状态失败,进入选择人数页', err);
-    uni.reLaunch({ url: '/pages/numberOfDiners/index' });
+  if (fromScan) {
+    uni.setStorageSync('currentTableId', tableidResolved);
+    if (optStore) uni.setStorageSync('currentStoreId', optStore);
+  } else if (!stored) {
+    uni.setStorageSync('currentTableId', tableidResolved);
+    if (optStore) uni.setStorageSync('currentStoreId', optStore);
   }
+
+  console.log('[launch] 调用 GetTableDiningStatus, tableid:', tableidResolved);
+  await runTableDiningStatusAndRedirect(tableidResolved);
 }
 
 onLoad((options) => {

+ 28 - 16
pages/orderFood/components/SelectCouponModal.vue

@@ -42,8 +42,8 @@
             <text class="expire-text">{{ formatExpireLine(coupon) }}</text>
           </view>
 
-          <!-- 右侧单选(仅在选择模式下显示) -->
-          <view class="coupon-card__right" v-if="!viewOnly">
+          <!-- 右侧单选:不可用券不展示选择框 -->
+          <view class="coupon-card__right" v-if="!viewOnly && !isCouponBelowMinSpend(coupon)">
             <image :src="getFileUrl('img/icon/sele1.png')" mode="widthFix" class="selected-icon" v-show="!isSelected(coupon)"></image>
             <image :src="getFileUrl('img/icon/sele2.png')" mode="widthFix" class="selected-icon" v-show="isSelected(coupon)"></image>
           </view>
@@ -267,23 +267,10 @@ const handleClose = () => {
     margin-bottom: 0;
   }
 
-  &:active {
+  &:active:not(&--disabled):not(&--view-only) {
     opacity: 0.8;
   }
 
-  &--view-only:active {
-    opacity: 1;
-  }
-
-  &--disabled {
-    opacity: 0.42;
-    pointer-events: none;
-  }
-
-  &--disabled:active {
-    opacity: 0.42;
-  }
-
   &__left {
     display: flex;
     flex-direction: column;
@@ -353,6 +340,31 @@ const handleClose = () => {
     }
 
   }
+
+  /* 置后覆盖 &__left / &__center 默认色;整卡 opacity 置灰表示不可用 */
+  &--disabled {
+    opacity: 0.42;
+    pointer-events: none;
+
+    .coupon-card__left {
+      .amount-num,
+      .amount-unit,
+      .condition-text {
+        color: #151515;
+      }
+    }
+
+    .coupon-card__center {
+      .name-text,
+      .expire-text {
+        color: #151515;
+      }
+    }
+  }
+
+  &--disabled:active {
+    opacity: 0.42;
+  }
 }
 </style>
 

+ 4 - 2
pages/orderFood/index.vue

@@ -929,11 +929,13 @@ const handleOrderClick = () => {
 };
 
 onLoad(async (options) => {
-  // 获取上一页(选择就餐人数页)传来的桌号和就餐人数
-  const tableid = options.tableid || '';
+  // 获取上一页传来的桌台主键(兼容 tableid / tableId)与就餐人数
+  const tableid = String(options.tableid || options.tableId || '').trim();
   const diners = options.diners || '';
+  const storeIdFromRoute = String(options.storeId || '').trim();
   tableId.value = tableid;
   currentDiners.value = diners;
+  if (storeIdFromRoute) uni.setStorageSync('currentStoreId', storeIdFromRoute);
   if (tableid) uni.setStorageSync('currentTableId', tableid);
   if (diners) uni.setStorageSync('currentDiners', diners);
   console.log('点餐页接收参数 - 桌号(tableid):', tableid, '就餐人数(diners):', diners);

+ 24 - 7
pages/orderInfo/index.vue

@@ -130,8 +130,8 @@ function normalizeOrder(record) {
     orderNo: order?.orderNo ?? order?.orderNumber ?? order?.orderId ?? '',
     storeId: order?.storeId,
     storeName: order?.storeName ?? order?.store_name ?? record?.storeName ?? record?.store_name ?? '—',
-    tableId: order?.tableId,
-    tableNumber: order?.tableNumber ?? order?.tableId ?? '—',
+    tableId: order?.tableId ?? order?.storeTableId ?? order?.diningTableId,
+    tableNumber: order?.tableNumber ?? order?.tableNo ?? '—',
     contactPhone: order?.contactPhone ?? '',
     createTime: order?.createdTime ?? order?.createTime ?? order?.orderTime ?? '—',
     totalPrice: order?.payAmount ?? order?.totalPrice ?? order?.totalAmount ?? 0,
@@ -284,14 +284,31 @@ const switchTab = (tab) => {
   activeTab.value = tab;
 };
 
-// 处理加餐:带上该订单的桌号和人数,点餐页依赖 tableid、diners 拉取列表和购物车
+// 处理加餐:携带订单桌台主键(非展示用桌号),点餐页用其拉购物车;redirectTo 保证重新 onLoad 避免沿用上一桌缓存
 const handleAddFood = (order) => {
-  const tableid = order?.tableId ?? order?.tableNumber ?? uni.getStorageSync(STORAGE_TABLE_ID) ?? '';
+  const tableid =
+    order?.tableId ??
+    order?.storeTableId ??
+    order?.diningTableId ??
+    '';
+  const tid = tableid != null && tableid !== '' ? String(tableid).trim() : '';
+  if (!tid) {
+    uni.showToast({ title: '订单缺少桌台信息,无法加餐', icon: 'none' });
+    return;
+  }
   const diners = order?.dinerCount ?? order?.diners ?? uni.getStorageSync('currentDiners') ?? '';
-  const q = [];
-  if (tableid !== '' && tableid != null) q.push(`tableid=${encodeURIComponent(String(tableid))}`);
+  const storeId = order?.storeId ?? '';
+  if (storeId !== '' && storeId != null) {
+    uni.setStorageSync(STORAGE_STORE_ID, String(storeId));
+  }
+  uni.setStorageSync(STORAGE_TABLE_ID, tid);
+  const q = [
+    `tableid=${encodeURIComponent(tid)}`,
+    `tableId=${encodeURIComponent(tid)}`
+  ];
   if (diners !== '' && diners != null) q.push(`diners=${encodeURIComponent(String(diners))}`);
-  go(q.length ? `/pages/orderFood/index?${q.join('&')}` : '/pages/orderFood/index');
+  if (storeId !== '' && storeId != null) q.push(`storeId=${encodeURIComponent(String(storeId))}`);
+  go(`/pages/orderFood/index?${q.join('&')}`, 'redirectTo');
 };
 
 // 处理结算:带上订单ID、桌号、人数、订单金额、备注,确认订单页用于调起支付

+ 61 - 15
pages/orderInfo/orderDetail.vue

@@ -51,6 +51,15 @@
           <view class="info-item-value">¥{{ priceDetail.serviceFee }}</view>
         </view>
         -->
+        <view
+          v-if="isOrderCompleted && orderCompletedDiscountAmount > 0"
+          class="info-item"
+        >
+          <view class="info-item-label">优惠金额</view>
+          <view class="info-item-value">
+            <text class="discount-amount-num">-¥{{ formatPrice(priceDetail.discountAmount) }}</text>
+          </view>
+        </view>
         <view class="price-line">
           <view class="price-line-label">合计</view>
           <view class="price-line-value">¥{{ formatPrice(actualPayAmount) }}</view>
@@ -121,6 +130,7 @@
 import { onLoad } from "@dcloudio/uni-app";
 import { ref, computed } from "vue";
 import { getFileUrl } from "@/utils/file.js";
+import { go } from "@/utils/utils.js";
 import { GetOrderInfo } from "@/api/dining.js";
 
 const payType = ref('confirmOrder'); // confirmOrder 确认下单 confirmPay 确认支付
@@ -158,7 +168,7 @@ const priceDetail = ref({
   nominalValue: null,
   discountRate: null,
   total: '0.00',
-  /** 接口实付(元),详情页「合计」以前端计算为准 */
+  /** 接口实付(元);已完成订单详情「合计」直接绑定此字段 */
   payAmount: 0
 });
 
@@ -201,10 +211,13 @@ function isPaidOrderStatus(status) {
   return s === 1 || s === 3;
 }
 
-// 待支付(0) / 已支付(1) / 已完成(3) 优惠金额(仅用于合计计算,页面不展示优惠券行)
+/** 订单已完成 orderStatus === 3 */
+const isOrderCompleted = computed(() => Number(orderDetail.value?.orderStatus) === 3);
+
+// 待支付(0) / 已支付(1) 优惠金额(用于非已完成订单的合计计算)
 const orderDiscountDisplay = computed(() => {
   const st = orderDetail.value.orderStatus;
-  if (st !== 0 && st !== 1 && st !== 3) return 0;
+  if (st !== 0 && st !== 1) return 0;
   const p = priceDetail.value;
   const dishBase = dishTotalBaseForOrder.value;
   const type = Number(p.couponType);
@@ -229,8 +242,22 @@ const orderDiscountDisplay = computed(() => {
   return Math.max(0, Math.round(capped * 100) / 100);
 });
 
-// 合计:未支付 / 已支付 / 已完成均按「菜品基数 − 优惠」计算,不直接绑定接口 payAmount
+/** 已完成订单:接口 discountAmount(用于展示「优惠金额」行) */
+const orderCompletedDiscountAmount = computed(() => {
+  if (!isOrderCompleted.value) return 0;
+  const d = Number(priceDetail.value?.discountAmount);
+  if (!Number.isFinite(d) || d <= 0) return 0;
+  return Math.round(d * 100) / 100;
+});
+
+// 合计:已完成绑定接口 payAmount;其余状态按「菜品基数 − 优惠」计算
 const actualPayAmount = computed(() => {
+  if (isOrderCompleted.value) {
+    const pa = Number(priceDetail.value?.payAmount);
+    if (Number.isFinite(pa) && pa >= 0) return Math.round(pa * 100) / 100;
+    const t = Number(priceDetail.value?.total);
+    return Number.isFinite(t) ? Math.max(0, Math.round(t * 100) / 100) : 0;
+  }
   const base = dishTotalBaseForOrder.value;
   const d = orderDiscountDisplay.value;
   return Math.max(0, Math.round((base - d) * 100) / 100);
@@ -341,13 +368,21 @@ function normalizeOrderItem(item) {
 // 从接口数据填充 orderDetail、priceDetail、foodList
 function applyOrderData(data) {
   const raw = data?.data ?? data ?? {};
+  const nested = raw?.order && typeof raw.order === 'object' ? raw.order : {};
   const store = raw?.storeInfo ?? raw?.store ?? {};
   orderDetail.value = {
     storeName: store?.storeName ?? raw?.storeName ?? '',
     storeAddress: store?.storeAddress ?? store?.address ?? raw?.storeAddress ?? raw?.address ?? '',
-    storeId: store?.storeId ?? store?.id ?? raw?.storeId ?? raw?.store_id ?? '',
-    orderNo: raw?.orderNo ?? raw?.orderId ?? '',
-    tableId: raw?.tableId ?? raw?.tableNumber ?? '',  // 加餐跳转用
+    storeId: store?.storeId ?? store?.id ?? raw?.storeId ?? raw?.store_id ?? nested?.storeId ?? '',
+    orderNo: raw?.orderNo ?? raw?.orderId ?? nested?.orderNo ?? '',
+    tableId:
+      raw?.tableId ??
+      nested?.tableId ??
+      raw?.storeTableId ??
+      nested?.storeTableId ??
+      raw?.diningTableId ??
+      nested?.diningTableId ??
+      '', // 桌台主键,加餐/购物车用
     tableNo: raw?.tableNumber ?? raw?.tableNo ?? raw?.tableName ?? '',
     dinerCount: raw?.dinerCount ?? '',
     createTime: raw?.createdTime ?? raw?.createTime ?? raw?.orderTime ?? '',
@@ -404,20 +439,27 @@ function applyOrderData(data) {
   foodList.value = (Array.isArray(list) ? list : []).map(normalizeOrderItem);
 }
 
-// 去加餐:跳转点餐页,携带桌号与就餐人数
+// 去加餐:使用本单桌台主键(与列表页一致),redirectTo 触发点餐页重新 onLoad 拉取该桌购物车
 const handleConfirmOrder = () => {
-  const tableid = uni.getStorageSync('currentTableId');
-  const diners = orderDetail.value?.dinerCount ?? '';
-  const storeId = orderDetail.value?.storeId ?? '';
+  const od = orderDetail.value;
+  const tableid = String(od?.tableId ?? '').trim();
+  const diners = od?.dinerCount ?? '';
+  const storeId = od?.storeId ?? '';
   if (!tableid) {
-    uni.showToast({ title: '订单缺少桌信息,无法加餐', icon: 'none' });
+    uni.showToast({ title: '订单缺少桌信息,无法加餐', icon: 'none' });
     return;
   }
-  const q = [];
-  q.push(`tableid=${encodeURIComponent(String(tableid))}`);
+  if (storeId !== '' && storeId != null) {
+    uni.setStorageSync('currentStoreId', String(storeId));
+  }
+  uni.setStorageSync('currentTableId', tableid);
+  const q = [
+    `tableid=${encodeURIComponent(tableid)}`,
+    `tableId=${encodeURIComponent(tableid)}`
+  ];
   if (diners !== '' && diners != null) q.push(`diners=${encodeURIComponent(String(diners))}`);
   if (storeId !== '' && storeId != null) q.push(`storeId=${encodeURIComponent(String(storeId))}`);
-  uni.navigateTo({ url: `/pages/orderFood/index?${q.join('&')}` });
+  go(`/pages/orderFood/index?${q.join('&')}`, 'redirectTo');
 };
 
 // 去结算:跳转确认订单页,携带订单ID、订单号、桌号、就餐人数、订单金额(用于调起支付)
@@ -508,6 +550,10 @@ onLoad(async (e) => {
 
     .info-item-value {
       color: #151515;
+
+      .discount-amount-num {
+        color: #e61f19;
+      }
     }
   }
 

+ 88 - 0
utils/tableDiningLaunch.js

@@ -0,0 +1,88 @@
+/**
+ * 扫码/太阳码进入后:调桌位就餐状态并 reLaunch 到点餐或选人数页。
+ * 供 launch、首页(太阳码直达首页)、TabBar 等共用。
+ */
+import * as diningApi from '@/api/dining.js';
+import { parseSceneToStoreTable, isScanEntryAllowed } from '@/utils/qrScene.js';
+
+function trim(v) {
+  return v == null ? '' : String(v).trim();
+}
+
+function tryDecodeURIComponent(s) {
+  if (s == null || s === '') return '';
+  try {
+    return decodeURIComponent(String(s).trim());
+  } catch (_) {
+    return String(s).trim();
+  }
+}
+
+/**
+ * 从页面 onLoad 参数解析 tableId、storeId、m(与 pages/launch 一致;兼容 query 嵌套与平铺)
+ */
+export function getScanEntryIdsFromOptions(options) {
+  if (!options || typeof options !== 'object') return { tableid: '', storeId: '', m: '' };
+  const flat = { ...options, ...(options.query && typeof options.query === 'object' ? options.query : {}) };
+  let m = trim(flat.m ?? flat.mode ?? '');
+  const directTable =
+    flat.tableId ?? flat.tableid ?? flat.table_id ?? flat.t ?? '';
+  if (directTable) {
+    const directStore = flat.storeId ?? flat.storeid ?? flat.s ?? '';
+    return { tableid: String(directTable).trim(), storeId: String(directStore || '').trim(), m };
+  }
+  const sceneEnc = flat.scene ?? flat.q ?? '';
+  const scene = sceneEnc ? tryDecodeURIComponent(sceneEnc) : '';
+  if (scene) {
+    const { storeId: s, tableId: t, m: sm } = parseSceneToStoreTable(scene);
+    return { tableid: trim(t), storeId: trim(s), m: m || trim(sm) || '' };
+  }
+  return { tableid: '', storeId: '', m };
+}
+
+/**
+ * 查询 /store/dining/table-dining-status 后跳转(与 launch 页逻辑一致)
+ * @param {string} tableidResolved 桌台主键 id
+ */
+export async function runTableDiningStatusAndRedirect(tableidResolved) {
+  const tid = trim(tableidResolved);
+  if (!tid) {
+    uni.reLaunch({ url: '/pages/index/index' });
+    return;
+  }
+  try {
+    const res = await diningApi.GetTableDiningStatus(tid);
+    const raw = res && typeof res === 'object' ? res : {};
+    const inDining =
+      res === true ||
+      res === 'true' ||
+      raw?.inDining === true ||
+      raw?.inDining === 'true' ||
+      raw?.data?.inDining === true ||
+      raw?.data?.inDining === 'true';
+    const dinerCount =
+      raw?.dinerCount ??
+      raw?.diner ??
+      raw?.data?.dinerCount ??
+      raw?.data?.diner ??
+      uni.getStorageSync('currentDiners') ??
+      1;
+    const dinersStr = String(dinerCount);
+    if (inDining) {
+      uni.setStorageSync('currentDiners', dinersStr);
+      uni.reLaunch({
+        url: `/pages/orderFood/index?tableid=${encodeURIComponent(tid)}&diners=${encodeURIComponent(dinersStr)}`
+      });
+    } else {
+      uni.reLaunch({
+        url: `/pages/numberOfDiners/index?inDining=1&tableid=${encodeURIComponent(tid)}&diners=${encodeURIComponent(dinersStr)}`
+      });
+    }
+  } catch (err) {
+    console.warn('[tableDiningLaunch] 查询桌位就餐状态失败,进入选择人数页', err);
+    const fallbackDiners = uni.getStorageSync('currentDiners') || '1';
+    uni.reLaunch({
+      url: `/pages/numberOfDiners/index?inDining=1&tableid=${encodeURIComponent(tid)}&diners=${encodeURIComponent(fallbackDiners)}`
+    });
+  }
+}