index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. <template>
  2. <!-- 菜品详情:图片从状态栏开始,返回按钮悬浮其上 -->
  3. <view class="content">
  4. <swiper class="swiper" :circular="true" :autoplay="true" :interval="3000" v-if="swiperImages.length">
  5. <swiper-item v-for="(img, i) in swiperImages" :key="i">
  6. <image :src="getFileUrl(img)" mode="aspectFill" class="food-image"></image>
  7. </swiper-item>
  8. </swiper>
  9. <view class="swiper swiper-placeholder" v-else>
  10. <image :src="placeholderImage" mode="aspectFill" class="food-image"></image>
  11. </view>
  12. <!-- 返回按钮悬浮在图片上方 -->
  13. <view class="back-bar">
  14. <view class="back-bar-status" :style="{ height: statusBarHeight + 'px' }"></view>
  15. <view class="back-bar-inner" :style="backBarInnerStyle">
  16. <view class="back-btn" hover-class="hover-active" @click="onBack">
  17. <text class="back-arrow">‹</text>
  18. </view>
  19. </view>
  20. </view>
  21. <view class="food-info">
  22. <view class="food-price">
  23. <text class="price-symbol">¥</text>
  24. <text class="price-main">{{ priceMain }}</text>
  25. <text class="price-decimal">.{{ priceDecimal }}</text>
  26. </view>
  27. <view class="food-sales">
  28. <image :src="getFileUrl('img/icon/star.png')" mode="aspectFill" class="star-icon"></image>
  29. <text class="sales-text">月售:{{ detail.monthlySales ?? 0 }}</text>
  30. </view>
  31. </view>
  32. <view class="food-desc">
  33. <view class="food-desc-title">{{ detail.name || detail.cuisineName || '—' }}</view>
  34. <view class="food-desc-tags" v-if="detailTags.length">
  35. <view v-for="(tag, ti) in detailTags" :key="ti" class="food-desc-tag" :class="tag.type || 'normal'">{{ tag.text }}</view>
  36. </view>
  37. <view class="food-desc-content">{{ detail.description || detail.desc || '暂无描述' }}</view>
  38. </view>
  39. <view class="food-desc">
  40. <view class="food-desc-title-intro">菜品介绍</view>
  41. <view class="food-desc-content">{{ detail.detailContent || '暂无介绍' }}</view>
  42. </view>
  43. <!-- 底部按钮 -->
  44. <view class="bottom-btn">
  45. <!-- 左侧:数量选择器 -->
  46. <view class="quantity-selector">
  47. <view class="action-btn minus" :class="{ disabled: quantity === 0 }" @click="handleDecrease"
  48. hover-class="hover-active">
  49. <image :src="getFileUrl('img/icon/reduce1.png')" mode="aspectFit" class="action-icon" v-show="quantity == 0">
  50. </image>
  51. <image :src="getFileUrl('img/icon/reduce2.png')" mode="aspectFit" class="action-icon" v-show="quantity != 0">
  52. </image>
  53. </view>
  54. <view class="quantity">{{ quantity || 0 }}</view>
  55. <view class="action-btn plus" @click="handleIncrease" hover-class="hover-active">
  56. <image :src="getFileUrl('img/icon/add2.png')" mode="widthFix" class="action-icon" v-show="quantity < 99">
  57. </image>
  58. <image :src="getFileUrl('img/icon/add1.png')" mode="widthFix" class="action-icon" v-show="quantity >= 99">
  59. </image>
  60. </view>
  61. </view>
  62. <!-- 右侧:加入购物车按钮 -->
  63. <view class="add-to-cart-btn" @click="handleAddToCart" hover-class="hover-active">
  64. 加入购物车
  65. </view>
  66. </view>
  67. </view>
  68. </template>
  69. <script setup>
  70. import { ref, computed } from 'vue';
  71. import { onLoad } from "@dcloudio/uni-app";
  72. import { getFileUrl } from "@/utils/file.js";
  73. import { GetCuisineDetail, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate } from "@/api/dining.js";
  74. // 胶囊信息:返回按钮与小程序胶囊同一行
  75. let menuButtonInfo = {};
  76. // #ifdef MP-WEIXIN
  77. try {
  78. menuButtonInfo = uni.getMenuButtonBoundingClientRect() || {};
  79. } catch (_) {}
  80. // #endif
  81. const systemInfo = uni.getWindowInfo();
  82. const statusBarHeight = systemInfo.statusBarHeight ?? 20;
  83. const menuTop = menuButtonInfo.top ?? statusBarHeight;
  84. const menuRight = menuButtonInfo.right ?? (systemInfo.windowWidth ?? 375) - 87;
  85. const menuHeight = menuButtonInfo.height ?? 32;
  86. const gap = menuButtonInfo.top != null ? Math.max(0, menuTop - statusBarHeight) : 4;
  87. const padRight = Math.max(0, (systemInfo.windowWidth ?? 375) - menuRight);
  88. const backBarInnerStyle = computed(() => ({
  89. height: menuHeight + gap * 2 + 'px',
  90. paddingRight: padRight + 'px'
  91. }));
  92. const onBack = () => {
  93. uni.navigateBack();
  94. };
  95. const quantity = ref(1);
  96. const loading = ref(false);
  97. const cuisineId = ref('');
  98. const tableId = ref('');
  99. /** 进入详情页时该菜品在购物车中的数量,用于与列表页一致的 Add/Update 逻辑 */
  100. const initialCartQuantity = ref(0);
  101. // 接口返回的菜品详情(兼容多种字段名)
  102. const detail = ref({});
  103. const placeholderImage = computed(() => getFileUrl('img/icon/star.png') || '');
  104. // 轮播图列表:接口可能返回 image/cuisineImage/images(逗号分隔或数组)
  105. const swiperImages = computed(() => {
  106. const d = detail.value;
  107. const raw = d?.images ?? d?.cuisineImage ?? d?.image ?? d?.imageUrl ?? '';
  108. if (Array.isArray(raw)) return raw.filter(Boolean);
  109. if (typeof raw === 'string' && raw) return raw.split(',').map((s) => s.trim()).filter(Boolean);
  110. return [];
  111. });
  112. const priceMain = computed(() => {
  113. const p = detail.value?.totalPrice ?? detail.value?.unitPrice ?? detail.value?.price ?? detail.value?.salePrice ?? 0;
  114. const num = Number(p);
  115. return Number.isNaN(num) ? '0' : String(Math.floor(num));
  116. });
  117. const priceDecimal = computed(() => {
  118. const p = detail.value?.totalPrice ?? detail.value?.unitPrice ?? detail.value?.price ?? detail.value?.salePrice ?? 0;
  119. const num = Number(p);
  120. if (Number.isNaN(num)) return '00';
  121. const dec = Math.round((num - Math.floor(num)) * 100);
  122. return String(dec).padStart(2, '0');
  123. });
  124. // 标签:接口可能返回 tags 数组、JSON 字符串或逗号/顿号分隔字符串,解析为 [{ text, type }] 数组
  125. const detailTags = computed(() => {
  126. const t = detail.value?.tags;
  127. if (Array.isArray(t)) {
  128. return t.map((item) => {
  129. if (item == null) return { text: '', type: 'normal' };
  130. if (typeof item === 'string') return { text: item, type: 'normal' };
  131. return { text: item?.text ?? item?.tagName ?? item?.name ?? '', type: item?.type ?? 'normal' };
  132. }).filter((x) => x.text !== '' && x.text != null);
  133. }
  134. if (t && typeof t === 'string') {
  135. const s = t.trim();
  136. if (!s) return [];
  137. // 尝试 JSON 解析(如 "[{\"text\":\"招牌\",\"type\":\"signature\"}]")
  138. if (s.startsWith('[')) {
  139. try {
  140. const arr = JSON.parse(s);
  141. if (Array.isArray(arr)) {
  142. return arr.map((item) => {
  143. if (item == null) return { text: '', type: 'normal' };
  144. if (typeof item === 'string') return { text: item, type: 'normal' };
  145. return { text: item?.text ?? item?.tagName ?? item?.name ?? '', type: item?.type ?? 'normal' };
  146. }).filter((x) => x.text !== '' && x.text != null);
  147. }
  148. } catch (_) {}
  149. }
  150. // 按逗号、顿号、空格等拆成数组
  151. return s.split(/[,,、\s]+/).map((part) => ({ text: part.trim(), type: 'normal' })).filter((x) => x.text !== '');
  152. }
  153. return [];
  154. });
  155. // 加减号只改本地数量,不调接口;只有点击「加入购物车」才调接口
  156. const handleIncrease = () => {
  157. if (quantity.value >= 99) return;
  158. quantity.value++;
  159. };
  160. const handleDecrease = () => {
  161. if (quantity.value > 0) quantity.value--;
  162. };
  163. // 与列表页一致:购物车无该商品或数量从 0 到 1 用 Add,否则用 Update
  164. const handleAddToCart = async () => {
  165. const id = cuisineId.value;
  166. const tid = tableId.value;
  167. if (!id) {
  168. uni.showToast({ title: '缺少菜品信息', icon: 'none' });
  169. return;
  170. }
  171. if (!tid) {
  172. uni.showToast({ title: '请先选择桌号', icon: 'none' });
  173. return;
  174. }
  175. const targetQty = quantity.value || 0;
  176. if (targetQty <= 0) {
  177. uni.showToast({ title: '请选择数量', icon: 'none' });
  178. return;
  179. }
  180. if (loading.value) return;
  181. loading.value = true;
  182. try {
  183. const needAdd = initialCartQuantity.value === 0 && targetQty >= 1;
  184. if (needAdd) {
  185. await PostOrderCartAdd({
  186. cuisineId: id,
  187. quantity: targetQty,
  188. tableId: tid
  189. });
  190. } else {
  191. await PostOrderCartUpdate({
  192. cuisineId: id,
  193. quantity: targetQty,
  194. tableId: tid
  195. });
  196. }
  197. uni.showToast({ title: '已加入购物车', icon: 'none' });
  198. setTimeout(() => uni.navigateBack(), 500);
  199. } catch (e) {
  200. // 与列表页一致:接口失败(如 code 400)时不保留本次修改,数量回滚为进入页时的购物车数量
  201. quantity.value = initialCartQuantity.value;
  202. uni.showToast({ title: e?.message || '加入失败', icon: 'none' });
  203. } finally {
  204. loading.value = false;
  205. }
  206. };
  207. onLoad(async (options) => {
  208. const id = options?.cuisineId ?? options?.id ?? '';
  209. const tid = options?.tableId ?? options?.tableid ?? uni.getStorageSync('currentTableId') ?? '';
  210. cuisineId.value = id;
  211. tableId.value = tid;
  212. // 从列表页带过来的购物车数量,用于底部数量展示和 Add/Update 判断
  213. const fromQuery = Math.max(0, Number(options?.quantity) || 0);
  214. quantity.value = fromQuery || 1;
  215. initialCartQuantity.value = fromQuery;
  216. if (!id) {
  217. uni.showToast({ title: '缺少菜品ID', icon: 'none' });
  218. return;
  219. }
  220. try {
  221. const res = await GetCuisineDetail(id, tid);
  222. const data = res?.data ?? res;
  223. detail.value = data || {};
  224. } catch (e) {
  225. uni.showToast({ title: e?.message || '加载失败', icon: 'none' });
  226. }
  227. // 有桌号时拉取购物车,以接口数量为准覆盖展示和 initialCartQuantity(与列表同步)
  228. if (tid) {
  229. try {
  230. const cartRes = await GetOrderCart(tid);
  231. const list = cartRes?.items ?? cartRes?.list ?? cartRes?.data?.items ?? [];
  232. const arr = Array.isArray(list) ? list : [];
  233. const cartItem = arr.find(
  234. (it) => String(it?.cuisineId ?? it?.id ?? '') === String(id)
  235. );
  236. const cartQty = cartItem ? (Number(cartItem.quantity) || 0) : 0;
  237. quantity.value = cartQty > 0 ? cartQty : quantity.value;
  238. initialCartQuantity.value = cartQty;
  239. } catch (_) {}
  240. }
  241. });
  242. </script>
  243. <style lang="scss" scoped>
  244. .content {
  245. position: relative;
  246. }
  247. .back-bar {
  248. position: absolute;
  249. top: 0;
  250. left: 0;
  251. right: 0;
  252. z-index: 101;
  253. }
  254. .back-bar-status {
  255. width: 100%;
  256. }
  257. .back-bar-inner {
  258. padding: 0 24rpx;
  259. display: flex;
  260. align-items: center;
  261. box-sizing: border-box;
  262. }
  263. .back-btn {
  264. width: 100rpx;
  265. height: 100rpx;
  266. display: flex;
  267. align-items: center;
  268. justify-content: center;
  269. &.hover-active {
  270. opacity: 0.8;
  271. }
  272. }
  273. .back-arrow {
  274. font-size: 80rpx;
  275. color: #000000;
  276. font-weight: 300;
  277. line-height: 1;
  278. margin-left: -4rpx;
  279. }
  280. .swiper {
  281. width: 100%;
  282. height: 510rpx;
  283. }
  284. .food-image {
  285. width: 100%;
  286. height: 510rpx;
  287. }
  288. .food-info {
  289. width: 100%;
  290. height: 100rpx;
  291. background: linear-gradient(90deg, #1E1E1E 0%, #464646 100%);
  292. display: flex;
  293. align-items: center;
  294. justify-content: space-between;
  295. color: #fff;
  296. box-sizing: border-box;
  297. padding: 0 30rpx;
  298. .food-price {
  299. display: flex;
  300. align-items: baseline;
  301. line-height: 1;
  302. .price-symbol {
  303. font-size: 24rpx;
  304. font-weight: bold;
  305. margin-right: 10rpx;
  306. }
  307. .price-main {
  308. font-size: 44rpx;
  309. font-weight: bold;
  310. }
  311. .price-decimal {
  312. font-size: 24rpx;
  313. font-weight: bold;
  314. }
  315. }
  316. .food-sales {
  317. font-size: 22rpx;
  318. color: #fff;
  319. display: flex;
  320. align-items: center;
  321. gap: 4rpx;
  322. }
  323. .star-icon {
  324. width: 26rpx;
  325. height: 26rpx;
  326. }
  327. }
  328. .food-desc {
  329. width: 100%;
  330. padding: 30rpx;
  331. box-sizing: border-box;
  332. background-color: #fff;
  333. margin-top: 20rpx;
  334. .food-desc-title {
  335. font-weight: bold;
  336. font-size: 38rpx;
  337. color: #151515;
  338. }
  339. .food-desc-tags {
  340. display: flex;
  341. gap: 10rpx;
  342. margin-top: 10rpx;
  343. }
  344. .food-desc-tag {
  345. padding: 4rpx 12rpx;
  346. border-radius: 4rpx;
  347. font-size: 20rpx;
  348. &.signature {
  349. background: linear-gradient(90deg, #FCB13F 0%, #FC793D 100%);
  350. color: #fff;
  351. }
  352. &.spicy {
  353. background: #2E2E2E;
  354. color: #fff;
  355. }
  356. &.normal {
  357. background: #f0f0f0;
  358. color: #666;
  359. }
  360. }
  361. .food-desc-content {
  362. font-size: 27rpx;
  363. color: #666666;
  364. line-height: 38rpx;
  365. margin-top: 22rpx;
  366. }
  367. .food-desc-title-intro {
  368. font-weight: bold;
  369. font-size: 27rpx;
  370. color: #151515;
  371. }
  372. }
  373. .bottom-btn {
  374. position: fixed;
  375. left: 0;
  376. right: 0;
  377. bottom: 0;
  378. z-index: 999;
  379. display: flex;
  380. align-items: center;
  381. justify-content: space-between;
  382. padding: 20rpx 30rpx;
  383. padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
  384. box-sizing: border-box;
  385. background-color: #fff;
  386. box-shadow: 0rpx -11rpx 46rpx 0rpx rgba(0, 0, 0, 0.05);
  387. }
  388. .quantity-selector {
  389. display: flex;
  390. align-items: center;
  391. justify-content: space-between;
  392. width: 214rpx;
  393. height: 58rpx;
  394. background: #F8F8F8;
  395. border-radius: 56rpx;
  396. box-sizing: border-box;
  397. padding: 0 3rpx;
  398. }
  399. .action-btn {
  400. width: 52rpx;
  401. height: 52rpx;
  402. border-radius: 50%;
  403. display: flex;
  404. align-items: center;
  405. justify-content: center;
  406. font-size: 32rpx;
  407. font-weight: 600;
  408. transition: all 0.3s;
  409. background-color: #fff;
  410. .action-icon {
  411. width: 24rpx;
  412. height: 24rpx;
  413. }
  414. &.disabled {
  415. opacity: 0.5;
  416. }
  417. }
  418. .quantity {
  419. font-size: 30rpx;
  420. color: #000;
  421. min-width: 40rpx;
  422. text-align: center;
  423. }
  424. .add-to-cart-btn {
  425. width: 266rpx;
  426. height: 80rpx;
  427. margin-left: 20rpx;
  428. background: linear-gradient(90deg, #FF6B35 0%, #F7931E 100%);
  429. border-radius: 40rpx;
  430. display: flex;
  431. align-items: center;
  432. justify-content: center;
  433. color: #fff;
  434. font-size: 32rpx;
  435. font-weight: bold;
  436. transition: all 0.3s;
  437. &.hover-active {
  438. opacity: 0.8;
  439. }
  440. }
  441. </style>