index.vue 30 KB

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