index.vue 13 KB

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