index.vue 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. <template>
  2. <!-- 点餐页:左上角永远返回主页,顶部固定 -->
  3. <view class="page-wrap">
  4. <view class="top-fixed">
  5. <NavBar :title="navTitle" only-home />
  6. <view class="top-info">桌号:{{ displayTableNumber }}</view>
  7. <view class="search-box">
  8. <view class="search-box-inner">
  9. <image :src="getFileUrl('img/personal/search.png')" mode="widthFix" class="search-icon"></image>
  10. <input type="text" placeholder="搜索菜品名称" class="search-input" />
  11. </view>
  12. </view>
  13. </view>
  14. <view class="content">
  15. <!-- 内容 -->
  16. <view class="content-box">
  17. <!-- 左侧分类列表 -->
  18. <view class="category-list-wrap">
  19. <scroll-view class="category-list" scroll-y>
  20. <view v-for="(category, index) in categories" :key="index" class="category-item"
  21. :class="{ active: currentCategoryIndex === index }" @click="selectCategory(index)">
  22. {{ category.categoryName }}
  23. </view>
  24. </scroll-view>
  25. </view>
  26. <!-- 右侧菜品列表 -->
  27. <view class="food-list-wrap">
  28. <scroll-view class="food-list" scroll-y>
  29. <FoodCard v-for="(food, index) in currentFoodList" :key="food.id || food.cuisineId || index" :food="food"
  30. :table-id="tableId" @increase="handleIncrease" @decrease="handleDecrease"/>
  31. </scroll-view>
  32. </view>
  33. </view>
  34. <!-- 底部下单:按接口格式展示 totalQuantity / totalAmount -->
  35. <BottomActionBar :cart-list="displayCartList" :total-quantity="displayTotalQuantity"
  36. :total-amount="displayTotalAmount" @coupon-click="handleCouponClick" @cart-click="handleCartClick"
  37. @order-click="handleOrderClick" />
  38. <!-- 领取优惠券弹窗 -->
  39. <CouponModal v-model:open="couponModalOpen" :coupon-list="couponList" @close="handleCouponClose" />
  40. <!-- 购物车弹窗:按接口格式展示 items(含 tags 从 foodList 补全) -->
  41. <CartModal v-model:open="cartModalOpen" :cart-list="displayCartListWithTags"
  42. @increase="handleIncrease" @decrease="handleDecrease" @clear="handleCartClear"
  43. @order-click="handleOrderClick" @close="handleCartClose" />
  44. <!-- 选择优惠券弹窗:左下角入口为仅查看,购物车内入口为可选 -->
  45. <SelectCouponModal v-model:open="selectCouponModalOpen" :coupon-list="availableCoupons"
  46. :selected-coupon-id="selectedCouponId" :view-only="selectCouponViewOnly" @select="handleCouponSelect" @close="handleSelectCouponClose" />
  47. </view>
  48. </view>
  49. </template>
  50. <script setup>
  51. import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
  52. import NavBar from "@/components/NavBar/index.vue";
  53. import { ref, computed, nextTick } from "vue";
  54. import FoodCard from "./components/FoodCard.vue";
  55. import BottomActionBar from "./components/BottomActionBar.vue";
  56. import CouponModal from "./components/CouponModal.vue";
  57. import CartModal from "./components/CartModal.vue";
  58. import SelectCouponModal from "./components/SelectCouponModal.vue";
  59. import { go } from "@/utils/utils.js";
  60. import { getFileUrl } from "@/utils/file.js";
  61. import { DiningOrderFood, GetStoreCategories, GetStoreCuisines, GetStoreDetail, getOrderSseConfig, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate, PostOrderCartClear, PutOrderCartUpdateTableware, GetUserOwnedCouponList } from "@/api/dining.js";
  62. import { createSSEConnection } from "@/utils/sse.js";
  63. const storeName = ref(''); // 店铺名称,用于导航栏标题
  64. const tableId = ref(''); // 桌号ID,来自上一页 url 参数 tableid,用于接口入参
  65. const tableNumber = ref(''); // 桌号展示,来自 dining/page-info 接口返回的 tableNumber
  66. const tableNumberFetched = ref(false); // 是否已请求过桌号(避免未返回前用 tableId 展示导致闪一下 43)
  67. const currentDiners = ref(uni.getStorageSync('currentDiners') || '');
  68. const navTitle = computed(() => storeName.value || '点餐');
  69. // 桌号展示:优先用接口返回的 tableNumber,未请求完前不显示 tableId,避免闪 43 再变 2
  70. const displayTableNumber = computed(() => {
  71. if (tableNumber.value) return tableNumber.value;
  72. if (tableNumberFetched.value && tableId.value) return tableId.value;
  73. return '—';
  74. });
  75. let sseRequestTask = null; // 订单 SSE 连接(封装后兼容小程序),页面卸载时需 abort()
  76. const currentCategoryIndex = ref(0);
  77. const foodListScrollTop = ref(0);
  78. const couponModalOpen = ref(false);
  79. const cartModalOpen = ref(false);
  80. const selectCouponModalOpen = ref(false);
  81. const selectCouponViewOnly = ref(false); // true=左下角仅查看,false=购物车内可选
  82. const fromNumberOfDiners = ref(false); // 是否从选座页(就餐人数页)进入,仅此时才调用 update-tableware 接口
  83. const discountAmount = ref(12); // 优惠金额,示例数据
  84. const selectedCouponId = ref(null); // 选中的优惠券ID
  85. // 分类列表(由接口 /store/info/categories 返回后赋值)
  86. const categories = ref([]);
  87. // 菜品列表(由接口 /store/info/cuisines 按分类拉取并合并,每项含 quantity、categoryId)
  88. const foodList = ref([]);
  89. // SSE 连接建立后拉取的购物车数据,在 foodList 就绪后合并
  90. let pendingCartData = null;
  91. // 购物车接口返回的完整数据:{ items, totalAmount, totalQuantity, tablewareFee },用于按接口格式展示
  92. const cartData = ref({ items: [], totalAmount: 0, totalQuantity: 0, tablewareFee: 0 });
  93. // 当前分类的菜品列表(按当前选中的分类 id 过滤)
  94. const currentFoodList = computed(() => {
  95. const cat = categories.value[currentCategoryIndex.value];
  96. if (!cat) return [];
  97. const categoryId = cat.id ?? cat.categoryId;
  98. return foodList.value.filter((food) => String(food?.categoryId ?? '') === String(categoryId ?? ''));
  99. });
  100. // 购物车列表:只包含数量>0 的菜品,并按菜品 id 去重(同一菜品只算一条,避免金额叠加)
  101. const cartList = computed(() => {
  102. const withQty = foodList.value.filter((food) => (food.quantity || 0) > 0);
  103. const seen = new Set();
  104. return withQty.filter((f) => {
  105. const id = String(f?.id ?? '');
  106. if (seen.has(id)) return false;
  107. seen.add(id);
  108. return true;
  109. });
  110. });
  111. // 用于展示的购物车列表:数量>0 的项展示;餐具(id=-1)始终展示(含数量为 0);若接口未返回餐具则补默认项
  112. const displayCartList = computed(() => {
  113. const apiItems = cartData.value?.items;
  114. let base = [];
  115. if (Array.isArray(apiItems) && apiItems.length > 0) {
  116. base = apiItems.filter((it) => (Number(it?.quantity) || 0) > 0 || Number(it?.cuisineId ?? it?.id) === -1);
  117. } else {
  118. base = cartList.value;
  119. }
  120. const hasTableware = base.some((it) => Number(it?.cuisineId ?? it?.id) === -1);
  121. if (!hasTableware) {
  122. const fee = Number(cartData.value?.tablewareFee) || 0;
  123. return [...base, { cuisineId: -1, cuisineName: '餐具费', quantity: 0, unitPrice: fee || 0, subtotalAmount: 0 }];
  124. }
  125. return base;
  126. });
  127. // 将 tags 统一为 [{ text, type }] 格式(与 FoodCard 一致)
  128. function normalizeTags(raw) {
  129. if (raw == null) return [];
  130. let arr = [];
  131. if (Array.isArray(raw)) arr = raw;
  132. else if (typeof raw === 'string') {
  133. const t = raw.trim();
  134. if (t.startsWith('[')) { try { arr = JSON.parse(t); if (!Array.isArray(arr)) arr = []; } catch { arr = t ? [t] : []; } }
  135. else arr = t ? t.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean) : [];
  136. } else if (raw && typeof raw === 'object') arr = Array.isArray(raw.list) ? raw.list : Array.isArray(raw.items) ? raw.items : [];
  137. return arr.map((item) => {
  138. if (item == null) return { text: '', type: '' };
  139. if (typeof item === 'string') return { text: item, type: '' };
  140. if (typeof item === 'number') return { text: String(item), type: '' };
  141. return { text: item.text ?? item.tagName ?? item.name ?? item.label ?? item.title ?? '', type: item.type ?? item.tagType ?? '' };
  142. }).filter((t) => t.text !== '' && t.text != null);
  143. }
  144. // 购物车展示列表(含标签):从 foodList 补全 cart 项缺失的 tags
  145. const displayCartListWithTags = computed(() => {
  146. return displayCartList.value.map((item) => {
  147. const tags = item?.tags ?? item?.tagList ?? item?.tagNames;
  148. if (tags != null && (Array.isArray(tags) ? tags.length > 0 : true)) return item;
  149. const food = findFoodByCartItem(item);
  150. const fromFood = food?.tags ?? food?.tagList ?? food?.tagNames ?? food?.labels ?? food?.tag;
  151. const normalized = normalizeTags(fromFood);
  152. return normalized.length > 0 ? { ...item, tags: normalized } : item;
  153. });
  154. });
  155. // 行小计:接口项用 subtotalAmount,否则 数量×单价(与 BottomActionBar/CartModal 一致)
  156. const getItemLinePrice = (item) => {
  157. if (item?.subtotalAmount != null) return Number(item.subtotalAmount);
  158. const qty = Number(item?.quantity) || 0;
  159. const unitPrice = Number(item?.unitPrice ?? item?.price ?? item?.salePrice ?? item?.totalPrice ?? 0) || 0;
  160. return qty * unitPrice;
  161. };
  162. // 展示用总数量:优先接口/本地 cartData.totalQuantity,否则从展示列表计算
  163. const displayTotalQuantity = computed(() => {
  164. const apiItems = cartData.value?.items;
  165. if (Array.isArray(apiItems) && apiItems.length > 0 && cartData.value?.totalQuantity != null) {
  166. return Number(cartData.value.totalQuantity);
  167. }
  168. return displayCartList.value.reduce((sum, item) => sum + (Number(item?.quantity) || 0), 0);
  169. });
  170. // 展示用总金额:优先接口/本地 cartData.totalAmount,否则从展示列表按行小计累加
  171. const displayTotalAmount = computed(() => {
  172. const apiItems = cartData.value?.items;
  173. if (Array.isArray(apiItems) && apiItems.length > 0 && cartData.value?.totalAmount != null) {
  174. return Number(cartData.value.totalAmount);
  175. }
  176. return displayCartList.value.reduce((sum, item) => sum + getItemLinePrice(item), 0);
  177. });
  178. // 优惠券列表(由接口 /dining/coupon/storeUsableList 返回后赋值)
  179. const couponList = ref([]);
  180. // 门店可用优惠券列表(选择优惠券弹窗用,与 couponList 同步自同一接口)
  181. const storeUsableCouponList = ref([]);
  182. // 规范化接口优惠券项为弹窗所需格式(对接 getUserCouponList 返回的 data:id/couponId/userCouponId、name、couponType、discountRate、minimumSpendingAmount、expirationTime 等)
  183. // couponType 1=满减券显示 nominalValue 为金额,2=折扣券显示 discountRate 为折扣力度
  184. function normalizeCouponItem(item) {
  185. if (!item || typeof item !== 'object') return null;
  186. const raw = item;
  187. const couponType = Number(raw.couponType) || 0;
  188. const nominalValue = Number(raw.nominalValue ?? raw.amount ?? 0) || 0;
  189. const discountRate = ((Number(raw.discountRate) || 0) / 10) || 0;
  190. const minAmount = Number(raw.minimumSpendingAmount ?? raw.minAmount ?? raw.min_amount ?? 0) || 0;
  191. const isReceived = raw.canReceived === false;
  192. let amountDisplay = nominalValue + '元';
  193. if (couponType === 1) {
  194. amountDisplay = nominalValue + '元';
  195. } else if (couponType === 2 && discountRate > 0) {
  196. amountDisplay = (discountRate % 1 === 0 ? discountRate : discountRate.toFixed(1)) + '折';
  197. }
  198. return {
  199. id: raw.userCouponId ?? raw.id ?? raw.couponId ?? raw.coupon_id ?? '',
  200. amount: nominalValue,
  201. minAmount,
  202. name: raw.name ?? raw.title ?? raw.couponName ?? '',
  203. expireDate: raw.expirationTime ?? raw.endGetDate ?? raw.validDate ?? raw.expireDate ?? raw.endTime ?? '',
  204. isReceived,
  205. couponType,
  206. discountRate,
  207. amountDisplay
  208. };
  209. }
  210. // 可用的优惠券列表(选择优惠券弹窗用,来自 storeUsableCouponList)
  211. const availableCoupons = computed(() => storeUsableCouponList.value);
  212. // 分类 id 统一转字符串,避免 number/string 比较导致误判
  213. const sameCategory = (a, b) => String(a ?? '') === String(b ?? '');
  214. // 将购物车数量同步到菜品列表:用购物车接口的 items 按 cuisineId 匹配 foodList 中的菜品并更新 quantity;items 为空时清空所有菜品数量
  215. const mergeCartIntoFoodList = () => {
  216. const cartItems = pendingCartData ?? cartData.value?.items ?? [];
  217. if (!Array.isArray(cartItems)) return;
  218. if (!foodList.value.length) {
  219. if (cartItems.length > 0) console.log('购物车已缓存,等 foodList 加载后再同步到菜品列表');
  220. return;
  221. }
  222. foodList.value = foodList.value.map((item) => {
  223. const itemId = String(item?.id ?? item?.cuisineId ?? '');
  224. const cartItem = cartItems.find((c) => {
  225. const cId = String(c?.id ?? c?.cuisineId ?? c?.foodId ?? c?.dishId ?? '');
  226. return cId && cId === itemId;
  227. });
  228. const qty = cartItem
  229. ? Number(cartItem.quantity ?? cartItem.num ?? cartItem.count ?? 0) || 0
  230. : 0;
  231. return { ...item, quantity: qty };
  232. });
  233. pendingCartData = null;
  234. console.log('购物车数量已同步到菜品列表', cartItems.length ? '' : '(已清空)');
  235. };
  236. // 从接口返回的 data 层中解析出购物车数组(/store/order/cart/{id} 返回在 items 下)
  237. function parseCartListFromResponse(cartRes) {
  238. if (cartRes == null) return [];
  239. if (Array.isArray(cartRes.items)) return cartRes.items;
  240. // 优先 items(当前接口约定),再兼容 list / data / records 等
  241. const firstLevel =
  242. cartRes.items ??
  243. cartRes.list ??
  244. cartRes.data ??
  245. cartRes.records ??
  246. cartRes.cartList ??
  247. cartRes.cartItems ??
  248. cartRes.cart?.list ??
  249. cartRes.cart?.items ??
  250. cartRes.result?.list ??
  251. cartRes.result?.data ??
  252. cartRes.result?.items;
  253. if (Array.isArray(firstLevel)) return firstLevel;
  254. if (typeof cartRes === 'object') {
  255. const arr = Object.values(cartRes).find((v) => Array.isArray(v));
  256. if (arr) return arr;
  257. }
  258. return [];
  259. }
  260. // 调获取购物车接口并合并到 foodList(需在 foodList 有数据后调用才能返显到购物车)
  261. // 注意:request 层在 code=200 时只返回 res.data.data,故 cartRes 已是后端 data 层
  262. const fetchAndMergeCart = async (tableid) => {
  263. if (!tableid) return;
  264. try {
  265. const cartRes = await GetOrderCart(tableid);
  266. const list = parseCartListFromResponse(cartRes);
  267. pendingCartData = list;
  268. // 按接口格式绑定:data.items / totalAmount / totalQuantity / tablewareFee
  269. cartData.value = {
  270. items: list,
  271. totalAmount: Number(cartRes?.totalAmount) || 0,
  272. totalQuantity: Number(cartRes?.totalQuantity) || 0,
  273. tablewareFee: Number(cartRes?.tablewareFee ?? cartRes?.tablewareAmount ?? 0) || 0
  274. };
  275. mergeCartIntoFoodList();
  276. console.log('购物车接口返回(data 层):', cartRes, '解析条数:', list.length);
  277. } catch (err) {
  278. console.error('获取购物车失败:', err);
  279. throw err;
  280. }
  281. };
  282. // 根据分类 id 拉取菜品并合并到 foodList,切换分类时保留其他分类已选菜品及本分类已选数量
  283. const fetchCuisinesByCategoryId = async (categoryId) => {
  284. if (!categoryId) return;
  285. try {
  286. const res = await GetStoreCuisines({ categoryId });
  287. const list = res?.list ?? res?.data ?? (Array.isArray(res) ? res : []);
  288. const normalized = (Array.isArray(list) ? list : []).map(item => ({
  289. ...item,
  290. quantity: item.quantity ?? 0,
  291. categoryId: item.categoryId ?? categoryId
  292. }));
  293. // 其他分类的菜品原样保留(含已选数量)
  294. const rest = foodList.value.filter(f => !sameCategory(f.categoryId, categoryId));
  295. // 本分类:按菜品 id 从整份列表里取已选数量,id 一致则自动带上数量
  296. const merged = normalized.map((newItem) => {
  297. const existing = foodList.value.find(
  298. (e) => String(e?.id ?? e?.cuisineId ?? '') === String(newItem?.id ?? newItem?.cuisineId ?? '')
  299. );
  300. const quantity = existing ? existing.quantity : (newItem.quantity ?? 0);
  301. return { ...newItem, quantity };
  302. });
  303. foodList.value = [...rest, ...merged];
  304. // 切换分类后,把购物车数量再同步到当前分类的菜品列表
  305. mergeCartIntoFoodList();
  306. } catch (err) {
  307. console.error('获取菜品失败:', err);
  308. }
  309. };
  310. // 选择分类:切换高亮并拉取该分类菜品
  311. const selectCategory = async (index) => {
  312. currentCategoryIndex.value = index;
  313. const cat = categories.value[index];
  314. if (!cat) return;
  315. const categoryId = cat.id ?? cat.categoryId;
  316. await fetchCuisinesByCategoryId(categoryId);
  317. };
  318. // 根据展示项(可能来自接口 cuisineId 或 foodList 的 id)找到 foodList 中的菜品
  319. const findFoodByCartItem = (item) => {
  320. const id = item?.id ?? item?.cuisineId;
  321. if (id == null) return null;
  322. return foodList.value.find((f) => String(f?.id ?? f?.cuisineId ?? '') === String(id));
  323. };
  324. // 同步 cartData:根据 cuisineId 更新 items 中对应项的 quantity、subtotalAmount,并重算 totalAmount、totalQuantity
  325. const syncCartDataFromFoodList = () => {
  326. const items = cartData.value?.items ?? [];
  327. if (!items.length) return;
  328. let totalAmount = 0;
  329. let totalQuantity = 0;
  330. const nextItems = items.map((it) => {
  331. const food = findFoodByCartItem(it);
  332. const qty = food != null ? (food.quantity || 0) : (it.quantity || 0);
  333. const unitPrice = Number(it?.unitPrice ?? it?.price ?? 0) || 0;
  334. const subtotalAmount = qty * unitPrice;
  335. totalAmount += subtotalAmount;
  336. totalQuantity += qty;
  337. return { ...it, quantity: qty, subtotalAmount };
  338. });
  339. cartData.value = { ...cartData.value, items: nextItems, totalAmount, totalQuantity };
  340. };
  341. // 判断是否为餐具(cuisineId 或 id 为 -1),餐具可修改数量(含减至 0)
  342. const isTableware = (item) => {
  343. if (!item) return false;
  344. const id = item.id ?? item.cuisineId;
  345. return Number(id) === -1;
  346. };
  347. // 更新菜品数量:菜品 id 一致则全部同步为同一数量,触发响应式;新增加入购物车时调接口;并同步 cartData
  348. // Update 接口返回 400 时不改页面数量和金额(会回滚本地状态)
  349. const updateFoodQuantity = (food, delta) => {
  350. if (!food) return;
  351. const id = food.id ?? food.cuisineId;
  352. const prevQty = food.quantity || 0;
  353. const nextQty = Math.max(0, prevQty + delta);
  354. const sameId = (item) =>
  355. String(item?.id ?? item?.cuisineId ?? '') === String(id ?? '');
  356. const items = cartData.value?.items ?? [];
  357. const idx = items.findIndex((it) => String(it?.cuisineId ?? it?.id ?? '') === String(id));
  358. const applyQuantity = (qty) => {
  359. foodList.value = foodList.value.map((item) =>
  360. sameId(item) ? { ...item, quantity: qty } : item
  361. );
  362. const currentItems = cartData.value?.items ?? [];
  363. const currentIdx = currentItems.findIndex((it) => String(it?.cuisineId ?? it?.id ?? '') === String(id));
  364. if (currentIdx >= 0) {
  365. const it = currentItems[currentIdx];
  366. const unitPrice = Number(it?.unitPrice ?? it?.price ?? 0) || 0;
  367. const nextItems = currentItems.slice();
  368. nextItems[currentIdx] = { ...it, quantity: qty, subtotalAmount: qty * unitPrice };
  369. const totalAmount = nextItems.reduce((s, i) => s + (Number(i.subtotalAmount) || 0), 0);
  370. const totalQuantity = nextItems.reduce((s, i) => s + (Number(i.quantity) || 0), 0);
  371. cartData.value = { ...cartData.value, items: nextItems, totalAmount, totalQuantity };
  372. } else if (qty > 0) {
  373. const unitPrice = Number(food?.price ?? food?.unitPrice ?? food?.salePrice ?? 0) || 0;
  374. const newItem = {
  375. cuisineId: id,
  376. cuisineName: food?.name ?? food?.cuisineName ?? '',
  377. cuisineImage: food?.image ?? food?.cuisineImage ?? food?.imageUrl ?? '',
  378. quantity: qty,
  379. unitPrice,
  380. subtotalAmount: qty * unitPrice
  381. };
  382. const nextItems = [...currentItems, newItem];
  383. const totalAmount = nextItems.reduce((s, i) => s + (Number(i.subtotalAmount) || 0), 0);
  384. const totalQuantity = nextItems.reduce((s, i) => s + (Number(i.quantity) || 0), 0);
  385. cartData.value = { ...cartData.value, items: nextItems, totalAmount, totalQuantity };
  386. }
  387. };
  388. applyQuantity(nextQty);
  389. if (tableId.value && delta !== 0) {
  390. const needAdd = delta > 0 && (idx < 0 || nextQty === 1);
  391. const isTablewareItem = Number(id) === -1;
  392. if (isTablewareItem && fromNumberOfDiners.value) {
  393. PutOrderCartUpdateTableware({
  394. quantity: nextQty,
  395. tableId: tableId.value
  396. }).catch((err) => {
  397. console.error('更新餐具数量失败:', err);
  398. applyQuantity(prevQty);
  399. });
  400. } else if (isTablewareItem) {
  401. // 非选座页进入:餐具仅本地更新,不调 update-tableware
  402. } else if (needAdd) {
  403. PostOrderCartAdd({
  404. cuisineId: id,
  405. quantity: nextQty,
  406. tableId: tableId.value
  407. }).catch((err) => {
  408. console.error('加入购物车失败:', err);
  409. applyQuantity(prevQty);
  410. });
  411. } else {
  412. PostOrderCartUpdate({
  413. cuisineId: id,
  414. quantity: nextQty,
  415. tableId: tableId.value
  416. }).catch((err) => {
  417. console.error('更新购物车失败:', err);
  418. applyQuantity(prevQty);
  419. });
  420. }
  421. }
  422. };
  423. // 增加数量:支持传入接口项(cuisineId)或菜品项(id),先解析为 foodList 中的 food
  424. const handleIncrease = (item) => {
  425. const food = item?.id != null && item?.cuisineId == null ? item : findFoodByCartItem(item) ?? item;
  426. updateFoodQuantity(food, 1);
  427. };
  428. // 减少数量
  429. const handleDecrease = (item) => {
  430. const food = item?.id != null && item?.cuisineId == null ? item : findFoodByCartItem(item) ?? item;
  431. if (food && (food.quantity || 0) > 0) updateFoodQuantity(food, -1);
  432. };
  433. // 拉取用户优惠券列表(GET /dining/coupon/userOwnedByStore,入参 storeId),列表在 SelectCouponModal 中展示
  434. const fetchUserOwnedCoupons = async () => {
  435. const storeId = uni.getStorageSync('currentStoreId') || '';
  436. try {
  437. const res = await GetUserOwnedCouponList({ storeId });
  438. // 接口返回 { code, data: [...], msg, success },请求层可能只返回 data,故 res 可能为数组
  439. const list = Array.isArray(res) ? res : (res?.data ?? res?.records ?? res?.list ?? []);
  440. const normalized = (Array.isArray(list) ? list : []).map((item) => normalizeCouponItem(item)).filter(Boolean);
  441. storeUsableCouponList.value = normalized;
  442. return true;
  443. } catch (err) {
  444. console.error('获取用户优惠券失败:', err);
  445. uni.showToast({ title: '获取优惠券失败', icon: 'none' });
  446. return false;
  447. }
  448. };
  449. // 打开选择优惠券弹窗。viewOnly=true 仅查看(左下角),false 可选择并默认选中第一张(购物车内)
  450. const openSelectCouponModal = async (viewOnly = false) => {
  451. if (cartModalOpen.value) cartModalOpen.value = false;
  452. if (couponModalOpen.value) couponModalOpen.value = false;
  453. selectCouponViewOnly.value = viewOnly;
  454. const ok = await fetchUserOwnedCoupons();
  455. if (ok) {
  456. if (!viewOnly) {
  457. const list = storeUsableCouponList.value;
  458. if (list && list.length > 0) {
  459. const first = list[0];
  460. const firstId = first.id != null && first.id !== '' ? String(first.id) : null;
  461. selectedCouponId.value = firstId;
  462. discountAmount.value = first.amount ?? 0;
  463. } else {
  464. selectedCouponId.value = null;
  465. discountAmount.value = 0;
  466. }
  467. }
  468. await nextTick();
  469. selectCouponModalOpen.value = true;
  470. }
  471. };
  472. // 优惠券点击(左下角):仅查看,不可选择
  473. const handleCouponClick = () => {
  474. openSelectCouponModal(true);
  475. };
  476. // 选择优惠券点击(购物车内):可选择,默认选中第一张
  477. const handleSelectCouponClick = () => {
  478. openSelectCouponModal(false);
  479. };
  480. // 处理优惠券选择(单选:只保留当前选中的一张)
  481. const handleCouponSelect = ({ coupon, index, selectedId }) => {
  482. selectedCouponId.value = selectedId != null && selectedId !== '' ? String(selectedId) : null;
  483. // 根据选中的优惠券更新优惠金额
  484. if (selectedCouponId.value) {
  485. discountAmount.value = coupon.amount ?? 0;
  486. } else {
  487. discountAmount.value = 0;
  488. }
  489. // 选择后关闭选择优惠券弹窗,打开购物车弹窗
  490. selectCouponModalOpen.value = false;
  491. // 延迟打开购物车弹窗,确保选择优惠券弹窗完全关闭
  492. setTimeout(() => {
  493. cartModalOpen.value = true;
  494. }, 100);
  495. };
  496. // 选择优惠券弹窗关闭
  497. const handleSelectCouponClose = () => {
  498. selectCouponModalOpen.value = false;
  499. };
  500. // 优惠券弹窗关闭
  501. const handleCouponClose = () => {
  502. couponModalOpen.value = false;
  503. };
  504. // 购物车点击
  505. const handleCartClick = () => {
  506. if (displayCartList.value.length === 0) {
  507. uni.showToast({
  508. title: '购物车为空',
  509. icon: 'none'
  510. });
  511. return;
  512. }
  513. // 先关闭其他弹窗
  514. if (couponModalOpen.value) {
  515. couponModalOpen.value = false;
  516. }
  517. if (selectCouponModalOpen.value) {
  518. selectCouponModalOpen.value = false;
  519. }
  520. cartModalOpen.value = true;
  521. };
  522. // 购物车弹窗关闭
  523. const handleCartClose = () => {
  524. cartModalOpen.value = false;
  525. };
  526. // 清空购物车:调用 /store/order/cart/clear,成功后用接口返回的数据更新购物车
  527. const handleCartClear = () => {
  528. const items = cartData.value?.items ?? [];
  529. const dishItems = items.filter((it) => Number(it?.cuisineId ?? it?.id) !== -1);
  530. const applyClearResult = (res) => {
  531. const list = parseCartListFromResponse(res) ?? [];
  532. pendingCartData = list;
  533. const totalAmount = Number(res?.totalAmount) || 0;
  534. const totalQuantity = Number(res?.totalQuantity) || 0;
  535. const tablewareFee = Number(res?.tablewareFee ?? res?.tablewareAmount ?? 0) || list.reduce((s, it) => {
  536. if (Number(it?.cuisineId ?? it?.id) !== -1) return s;
  537. const line = it?.subtotalAmount != null ? Number(it.subtotalAmount) : (Number(it?.quantity) || 0) * (Number(it?.unitPrice ?? it?.price) || 0);
  538. return s + line;
  539. }, 0);
  540. cartData.value = { items: list, totalAmount, totalQuantity, tablewareFee };
  541. mergeCartIntoFoodList();
  542. cartModalOpen.value = false;
  543. uni.showToast({ title: '已清空购物车', icon: 'success' });
  544. };
  545. if (!tableId.value) {
  546. const tablewareItems = items.filter((it) => Number(it?.cuisineId ?? it?.id) === -1);
  547. const utensilFee = tablewareItems.reduce((sum, it) => {
  548. const line = it?.subtotalAmount != null ? Number(it.subtotalAmount) : (Number(it?.quantity) || 0) * (Number(it?.unitPrice ?? it?.price) || 0);
  549. return sum + line;
  550. }, 0);
  551. const utensilQty = tablewareItems.reduce((s, i) => s + (Number(i?.quantity) || 0), 0);
  552. applyClearResult({ items: tablewareItems, totalAmount: utensilFee, totalQuantity: utensilQty, tablewareFee: utensilFee });
  553. return;
  554. }
  555. if (dishItems.length === 0) {
  556. applyClearResult(cartData.value);
  557. return;
  558. }
  559. PostOrderCartClear(tableId.value)
  560. .then(applyClearResult)
  561. .catch((err) => {
  562. console.error('清空购物车失败:', err);
  563. uni.showToast({ title: '清空失败,请重试', icon: 'none' });
  564. });
  565. };
  566. // 从购物车项中计算餐具费:cuisineId/id 为 -1 的项为餐具,其金额合计为餐具费
  567. const getTablewareFeeFromCart = () => {
  568. const items = cartData.value?.items ?? displayCartList.value ?? [];
  569. return (Array.isArray(items) ? items : []).reduce((sum, it) => {
  570. const id = it?.cuisineId ?? it?.id;
  571. if (Number(id) !== -1) return sum;
  572. const line = it?.subtotalAmount != null ? Number(it.subtotalAmount) : (Number(it?.quantity) || 0) * (Number(it?.unitPrice ?? it?.price) || 0);
  573. return sum + line;
  574. }, 0);
  575. };
  576. // 下单点击:先带购物车数据跳转确认订单页,创建订单在确认页点击「确认下单」时再调
  577. const handleOrderClick = () => {
  578. // 仅餐具无菜品时不允许下单
  579. const items = displayCartList.value ?? [];
  580. const hasDish = items.some((it) => Number(it?.cuisineId ?? it?.id) !== -1);
  581. if (!hasDish && items.length > 0) {
  582. uni.showToast({ title: '请至少选择一道菜品', icon: 'none' });
  583. return;
  584. }
  585. if (items.length === 0) {
  586. uni.showToast({ title: '请先选择菜品', icon: 'none' });
  587. return;
  588. }
  589. const fromApi = Number(cartData.value?.tablewareFee) || 0;
  590. const fromItems = getTablewareFeeFromCart();
  591. const utensilFee = fromApi > 0 ? fromApi : fromItems;
  592. const totalAmount = displayTotalAmount.value;
  593. const dishTotal = Math.max(0, Number(totalAmount) - Number(utensilFee));
  594. const cartPayload = {
  595. list: displayCartListWithTags.value,
  596. totalAmount,
  597. dishTotal,
  598. totalQuantity: displayTotalQuantity.value,
  599. tableId: tableId.value,
  600. tableNumber: tableNumber.value,
  601. diners: currentDiners.value,
  602. utensilFee: Number(utensilFee) || 0
  603. };
  604. uni.setStorageSync('placeOrderCart', JSON.stringify(cartPayload));
  605. const query = [];
  606. if (tableId.value) query.push(`tableId=${encodeURIComponent(tableId.value)}`);
  607. if (tableNumber.value) query.push(`tableNumber=${encodeURIComponent(tableNumber.value)}`);
  608. if (currentDiners.value) query.push(`diners=${encodeURIComponent(currentDiners.value)}`);
  609. go(query.length ? `/pages/placeOrder/index?${query.join('&')}` : '/pages/placeOrder/index');
  610. };
  611. onLoad(async (options) => {
  612. // 获取上一页(选择就餐人数页)传来的桌号和就餐人数
  613. const tableid = options.tableid || '';
  614. const diners = options.diners || '';
  615. tableId.value = tableid;
  616. currentDiners.value = diners;
  617. fromNumberOfDiners.value = diners !== '' && diners != null; // 仅从选座页进入时带 diners 参数
  618. if (tableid) uni.setStorageSync('currentTableId', tableid);
  619. if (diners) uni.setStorageSync('currentDiners', diners);
  620. console.log('点餐页接收参数 - 桌号(tableid):', tableid, '就餐人数(diners):', diners);
  621. // 更新餐具数量:仅从选座页进入时调用 update-tableware 接口
  622. if (fromNumberOfDiners.value && tableid) {
  623. PutOrderCartUpdateTableware({
  624. quantity: Number(diners) || 1,
  625. tableId: parseInt(tableid, 10) || 0
  626. }).catch((err) => console.warn('更新餐具数量失败:', err));
  627. }
  628. // 先拉取购物车并缓存,再加载菜品,保证合并时 pendingCartData 已就绪,避免不返显
  629. if (tableid) {
  630. await fetchAndMergeCart(tableid).catch((err) => console.error('获取购物车失败:', err));
  631. }
  632. // 页面加载时创建订单 SSE 连接(仅 SSE 使用 utils/sse 封装,兼容微信小程序)
  633. if (tableid) {
  634. try {
  635. const sseConfig = getOrderSseConfig(tableid);
  636. sseRequestTask = createSSEConnection(sseConfig.url, sseConfig);
  637. sseRequestTask.onMessage((msg) => {
  638. console.log('SSE 收到:', msg);
  639. if (msg.event === 'cart_update' && msg.data) {
  640. try {
  641. const payload = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
  642. const items = payload?.items ?? [];
  643. cartData.value = {
  644. items: Array.isArray(items) ? items : [],
  645. totalAmount: Number(payload?.totalAmount) || 0,
  646. totalQuantity: Number(payload?.totalQuantity) || 0,
  647. tablewareFee: Number(payload?.tablewareFee ?? payload?.tablewareAmount ?? 0) || (cartData.value?.tablewareFee ?? 0)
  648. };
  649. pendingCartData = null;
  650. mergeCartIntoFoodList();
  651. } catch (e) {
  652. console.error('SSE 购物车数据解析失败:', e);
  653. }
  654. }
  655. });
  656. sseRequestTask.onOpen(async () => {
  657. console.log('SSE 连接已建立');
  658. await fetchAndMergeCart(tableid).catch(() => {});
  659. });
  660. sseRequestTask.onError((err) => console.error('SSE 错误:', err));
  661. } catch (e) {
  662. console.error('SSE 连接失败:', e);
  663. }
  664. }
  665. // 调用点餐页接口,入参 dinerCount、tableId
  666. if (tableid || diners) {
  667. try {
  668. const res = await DiningOrderFood({
  669. tableId: tableid,
  670. dinerCount: diners
  671. });
  672. console.log('点餐页接口返回:', res);
  673. const data = res?.data ?? res ?? {};
  674. tableNumber.value = data?.tableNumber ?? data?.tableNo ?? '';
  675. tableNumberFetched.value = true;
  676. storeName.value = data?.storeName ?? data?.storeInfo?.storeName ?? '';
  677. // 餐具费:点餐页接口可能返回,若购物车未带则用此处兜底
  678. const fee = Number(data?.tablewareFee ?? data?.tablewareAmount ?? 0) || 0;
  679. if (fee > 0) cartData.value = { ...cartData.value, tablewareFee: fee };
  680. // 成功后调接口获取菜品种类(storeId 取自点餐页接口返回,若无则需从别处获取)
  681. const storeId = res?.storeId ?? data?.storeId ?? '';
  682. if (storeId) uni.setStorageSync('currentStoreId', storeId);
  683. if (storeId && !storeName.value) {
  684. try {
  685. const storeRes = await GetStoreDetail(storeId);
  686. const storeData = storeRes?.data ?? storeRes;
  687. storeName.value = storeData?.storeInfo?.storeName ?? storeData?.storeName ?? '';
  688. } catch (_) {}
  689. }
  690. if (storeId) {
  691. try {
  692. const categoriesRes = await GetStoreCategories({ storeId });
  693. const list = categoriesRes?.list ?? categoriesRes?.data ?? categoriesRes;
  694. if (Array.isArray(list) && list.length) {
  695. categories.value = list;
  696. // 默认用第一项的分类 id 拉取菜品
  697. const firstCat = list[0];
  698. const firstCategoryId = firstCat.id ?? firstCat.categoryId;
  699. if (firstCategoryId) {
  700. const cuisinesRes = await GetStoreCuisines({ categoryId: firstCategoryId });
  701. const cuisinesList = cuisinesRes?.list ?? cuisinesRes?.data ?? (Array.isArray(cuisinesRes) ? cuisinesRes : []);
  702. foodList.value = (Array.isArray(cuisinesList) ? cuisinesList : []).map(item => ({
  703. ...item,
  704. quantity: item.quantity ?? 0,
  705. categoryId: item.categoryId ?? firstCategoryId
  706. }));
  707. console.log('默认分类菜品:', cuisinesRes);
  708. mergeCartIntoFoodList();
  709. }
  710. }
  711. console.log('菜品种类:', categoriesRes);
  712. } catch (err) {
  713. console.error('获取菜品种类失败:', err);
  714. }
  715. }
  716. } catch (e) {
  717. console.error('点餐页接口失败:', e);
  718. tableNumberFetched.value = true;
  719. }
  720. } else {
  721. tableNumberFetched.value = true;
  722. const storeId = uni.getStorageSync('currentStoreId') || '';
  723. if (storeId && !storeName.value) {
  724. try {
  725. const storeRes = await GetStoreDetail(storeId);
  726. const storeData = storeRes?.data ?? storeRes;
  727. storeName.value = storeData?.storeInfo?.storeName ?? storeData?.storeName ?? '';
  728. } catch (_) {}
  729. }
  730. }
  731. });
  732. // 回到点餐页时重新获取购物车(从接口拉取最新数据并合并到列表)
  733. onShow(() => {
  734. const tableid = tableId.value || uni.getStorageSync('currentTableId') || '';
  735. if (tableid) {
  736. fetchAndMergeCart(tableid).catch((err) => console.warn('重新获取购物车失败:', err));
  737. }
  738. });
  739. onUnload(() => {
  740. if (sseRequestTask && typeof sseRequestTask.abort === 'function') {
  741. sseRequestTask.abort();
  742. }
  743. sseRequestTask = null;
  744. });
  745. </script>
  746. <style lang="scss" scoped>
  747. .page-wrap {
  748. height: 100vh;
  749. overflow: hidden;
  750. display: flex;
  751. flex-direction: column;
  752. background-color: #f7f9fa;
  753. }
  754. .top-fixed {
  755. flex-shrink: 0;
  756. z-index: 100;
  757. background-color: #fff;
  758. padding-bottom: 20rpx;
  759. }
  760. .content {
  761. flex: 1;
  762. min-height: 0;
  763. overflow: hidden;
  764. display: flex;
  765. flex-direction: column;
  766. /* 底部预留:BottomActionBar 高度(100rpx) + 内边距(20rpx) + 安全区 */
  767. padding-bottom: calc(120rpx + constant(safe-area-inset-bottom));
  768. padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
  769. box-sizing: border-box;
  770. }
  771. .top-info {
  772. font-size: 28rpx;
  773. color: #888888;
  774. text-align: center;
  775. width: 100%;
  776. padding: 20rpx 0;
  777. background-color: #fff;
  778. }
  779. .search-box {
  780. width: 90%;
  781. margin-left: 5%;
  782. height: 80rpx;
  783. background-color: #f5f5f5;
  784. border-radius: 40rpx;
  785. margin-top: 20rpx;
  786. display: flex;
  787. align-items: center;
  788. justify-content: center;
  789. padding: 0 30rpx;
  790. box-sizing: border-box;
  791. .search-box-inner {
  792. display: flex;
  793. align-items: center;
  794. justify-content: center;
  795. }
  796. .search-icon {
  797. width: 34rpx;
  798. height: 34rpx;
  799. flex-shrink: 0;
  800. display: block;
  801. }
  802. .search-input {
  803. width: 200rpx;
  804. height: 80rpx;
  805. line-height: 80rpx;
  806. font-size: 28rpx;
  807. color: #333;
  808. background: transparent;
  809. border: none;
  810. text-align: center;
  811. vertical-align: middle;
  812. }
  813. }
  814. .content-box {
  815. flex: 1;
  816. min-height: 0;
  817. overflow: hidden;
  818. display: flex;
  819. margin-top: 20rpx;
  820. }
  821. // 左侧分类列表容器(给 scroll-view 提供固定高度)
  822. .category-list-wrap {
  823. width: 180rpx;
  824. flex-shrink: 0;
  825. min-height: 0;
  826. display: flex;
  827. flex-direction: column;
  828. overflow: hidden;
  829. align-self: stretch;
  830. }
  831. .category-list {
  832. flex: 1;
  833. min-height: 0;
  834. height: 100%;
  835. background-color: #fff;
  836. box-sizing: border-box;
  837. }
  838. .category-item {
  839. height: 100rpx;
  840. display: flex;
  841. align-items: center;
  842. justify-content: center;
  843. font-size: 28rpx;
  844. color: #999;
  845. background-color: #fff;
  846. transition: all 0.3s;
  847. &.active {
  848. background-color: #fff4e6;
  849. color: #333;
  850. font-weight: 600;
  851. }
  852. }
  853. // 右侧菜品列表容器(给 scroll-view 提供固定高度)
  854. .food-list-wrap {
  855. flex: 1;
  856. min-width: 0;
  857. min-height: 0;
  858. display: flex;
  859. flex-direction: column;
  860. overflow: hidden;
  861. margin: 0 20rpx;
  862. }
  863. .food-list {
  864. flex: 1;
  865. min-height: 0;
  866. height: 100%;
  867. }
  868. </style>