| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- <template>
- <!-- 菜品详情:图片从状态栏开始,返回按钮悬浮其上 -->
- <view class="content">
- <swiper class="swiper" :circular="true" :autoplay="true" :interval="3000" v-if="swiperImages.length">
- <swiper-item v-for="(img, i) in swiperImages" :key="i">
- <image :src="getFileUrl(img)" mode="aspectFill" class="food-image"></image>
- </swiper-item>
- </swiper>
- <view class="swiper swiper-placeholder" v-else>
- <image :src="placeholderImage" mode="aspectFill" class="food-image"></image>
- </view>
- <!-- 返回按钮悬浮在图片上方 -->
- <view class="back-bar">
- <view class="back-bar-status" :style="{ height: statusBarHeight + 'px' }"></view>
- <view class="back-bar-inner" :style="backBarInnerStyle">
- <view class="back-btn" hover-class="hover-active" @click="onBack">
- <text class="back-arrow">‹</text>
- </view>
- </view>
- </view>
- <view class="food-info">
- <view class="food-price">
- <text class="price-symbol">¥</text>
- <text class="price-main">{{ priceMain }}</text>
- <text class="price-decimal">.{{ priceDecimal }}</text>
- </view>
- <view class="food-sales">
- <image :src="getFileUrl('img/icon/star.png')" mode="aspectFill" class="star-icon"></image>
- <text class="sales-text">月售:{{ detail.monthlySales ?? 0 }}</text>
- </view>
- </view>
- <view class="food-desc">
- <view class="food-desc-title">{{ detail.name || detail.cuisineName || '—' }}</view>
- <view class="food-desc-tags" v-if="detailTags.length">
- <view v-for="(tag, ti) in detailTags" :key="ti" class="food-desc-tag" :class="tag.type || 'normal'">{{ tag.text }}</view>
- </view>
- <view class="food-desc-content">{{ detail.description || detail.desc || '暂无描述' }}</view>
- </view>
- <view class="food-desc">
- <view class="food-desc-title-intro">菜品介绍</view>
- <view class="food-desc-content">{{ detail.detailContent || '暂无介绍' }}</view>
- </view>
- <!-- 底部按钮 -->
- <view class="bottom-btn">
- <!-- 左侧:数量选择器 -->
- <view class="quantity-selector">
- <view class="action-btn minus" :class="{ disabled: quantity === 0 }" @click="handleDecrease"
- hover-class="hover-active">
- <image :src="getFileUrl('img/icon/reduce1.png')" mode="aspectFit" class="action-icon" v-show="quantity == 0">
- </image>
- <image :src="getFileUrl('img/icon/reduce2.png')" mode="aspectFit" class="action-icon" v-show="quantity != 0">
- </image>
- </view>
- <view class="quantity">{{ quantity || 0 }}</view>
- <view class="action-btn plus" @click="handleIncrease" hover-class="hover-active">
- <image :src="getFileUrl('img/icon/add2.png')" mode="widthFix" class="action-icon" v-show="quantity < 99">
- </image>
- <image :src="getFileUrl('img/icon/add1.png')" mode="widthFix" class="action-icon" v-show="quantity >= 99">
- </image>
- </view>
- </view>
- <!-- 右侧:加入购物车按钮 -->
- <view class="add-to-cart-btn" @click="handleAddToCart" hover-class="hover-active">
- 加入购物车
- </view>
- </view>
- </view>
- </template>
- <script setup>
- import { ref, computed } from 'vue';
- import { onLoad } from "@dcloudio/uni-app";
- import { getFileUrl } from "@/utils/file.js";
- import { GetCuisineDetail, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate } from "@/api/dining.js";
- // 胶囊信息:返回按钮与小程序胶囊同一行
- let menuButtonInfo = {};
- // #ifdef MP-WEIXIN
- try {
- menuButtonInfo = uni.getMenuButtonBoundingClientRect() || {};
- } catch (_) {}
- // #endif
- const systemInfo = uni.getWindowInfo();
- const statusBarHeight = systemInfo.statusBarHeight ?? 20;
- const menuTop = menuButtonInfo.top ?? statusBarHeight;
- const menuRight = menuButtonInfo.right ?? (systemInfo.windowWidth ?? 375) - 87;
- const menuHeight = menuButtonInfo.height ?? 32;
- const gap = menuButtonInfo.top != null ? Math.max(0, menuTop - statusBarHeight) : 4;
- const padRight = Math.max(0, (systemInfo.windowWidth ?? 375) - menuRight);
- const backBarInnerStyle = computed(() => ({
- height: menuHeight + gap * 2 + 'px',
- paddingRight: padRight + 'px'
- }));
- const onBack = () => {
- uni.navigateBack();
- };
- const quantity = ref(1);
- const loading = ref(false);
- const cuisineId = ref('');
- const tableId = ref('');
- /** 进入详情页时该菜品在购物车中的数量,用于与列表页一致的 Add/Update 逻辑 */
- const initialCartQuantity = ref(0);
- // 接口返回的菜品详情(兼容多种字段名)
- const detail = ref({});
- const placeholderImage = computed(() => getFileUrl('img/icon/star.png') || '');
- // 轮播图列表:接口可能返回 image/cuisineImage/images(逗号分隔或数组)
- const swiperImages = computed(() => {
- const d = detail.value;
- const raw = d?.images ?? d?.cuisineImage ?? d?.image ?? d?.imageUrl ?? '';
- if (Array.isArray(raw)) return raw.filter(Boolean);
- if (typeof raw === 'string' && raw) return raw.split(',').map((s) => s.trim()).filter(Boolean);
- return [];
- });
- const priceMain = computed(() => {
- const p = detail.value?.totalPrice ?? detail.value?.unitPrice ?? detail.value?.price ?? detail.value?.salePrice ?? 0;
- const num = Number(p);
- return Number.isNaN(num) ? '0' : String(Math.floor(num));
- });
- const priceDecimal = computed(() => {
- const p = detail.value?.totalPrice ?? detail.value?.unitPrice ?? detail.value?.price ?? detail.value?.salePrice ?? 0;
- const num = Number(p);
- if (Number.isNaN(num)) return '00';
- const dec = Math.round((num - Math.floor(num)) * 100);
- return String(dec).padStart(2, '0');
- });
- // 标签:接口可能返回 tags 数组、JSON 字符串或逗号/顿号分隔字符串,解析为 [{ text, type }] 数组
- const detailTags = computed(() => {
- const t = detail.value?.tags;
- if (Array.isArray(t)) {
- return t.map((item) => {
- if (item == null) return { text: '', type: 'normal' };
- if (typeof item === 'string') return { text: item, type: 'normal' };
- return { text: item?.text ?? item?.tagName ?? item?.name ?? '', type: item?.type ?? 'normal' };
- }).filter((x) => x.text !== '' && x.text != null);
- }
- if (t && typeof t === 'string') {
- const s = t.trim();
- if (!s) return [];
- // 尝试 JSON 解析(如 "[{\"text\":\"招牌\",\"type\":\"signature\"}]")
- if (s.startsWith('[')) {
- try {
- const arr = JSON.parse(s);
- if (Array.isArray(arr)) {
- return arr.map((item) => {
- if (item == null) return { text: '', type: 'normal' };
- if (typeof item === 'string') return { text: item, type: 'normal' };
- return { text: item?.text ?? item?.tagName ?? item?.name ?? '', type: item?.type ?? 'normal' };
- }).filter((x) => x.text !== '' && x.text != null);
- }
- } catch (_) {}
- }
- // 按逗号、顿号、空格等拆成数组
- return s.split(/[,,、\s]+/).map((part) => ({ text: part.trim(), type: 'normal' })).filter((x) => x.text !== '');
- }
- return [];
- });
- // 加减号只改本地数量,不调接口;只有点击「加入购物车」才调接口
- const handleIncrease = () => {
- if (quantity.value >= 99) return;
- quantity.value++;
- };
- const handleDecrease = () => {
- if (quantity.value > 0) quantity.value--;
- };
- // 与列表页一致:购物车无该商品或数量从 0 到 1 用 Add,否则用 Update
- const handleAddToCart = async () => {
- const id = cuisineId.value;
- const tid = tableId.value;
- if (!id) {
- uni.showToast({ title: '缺少菜品信息', icon: 'none' });
- return;
- }
- if (!tid) {
- uni.showToast({ title: '请先选择桌号', icon: 'none' });
- return;
- }
- const targetQty = quantity.value || 0;
- if (targetQty <= 0) {
- uni.showToast({ title: '请选择数量', icon: 'none' });
- return;
- }
- if (loading.value) return;
- loading.value = true;
- try {
- const needAdd = initialCartQuantity.value === 0 && targetQty >= 1;
- if (needAdd) {
- await PostOrderCartAdd({
- cuisineId: id,
- quantity: targetQty,
- tableId: tid
- });
- } else {
- await PostOrderCartUpdate({
- cuisineId: id,
- quantity: targetQty,
- tableId: tid
- });
- }
- uni.showToast({ title: '已加入购物车', icon: 'none' });
- setTimeout(() => uni.navigateBack(), 500);
- } catch (e) {
- // 与列表页一致:接口失败(如 code 400)时不保留本次修改,数量回滚为进入页时的购物车数量
- quantity.value = initialCartQuantity.value;
- uni.showToast({ title: e?.message || '加入失败', icon: 'none' });
- } finally {
- loading.value = false;
- }
- };
- onLoad(async (options) => {
- const id = options?.cuisineId ?? options?.id ?? '';
- const tid = options?.tableId ?? options?.tableid ?? uni.getStorageSync('currentTableId') ?? '';
- cuisineId.value = id;
- tableId.value = tid;
- // 从列表页带过来的购物车数量,用于底部数量展示和 Add/Update 判断
- const fromQuery = Math.max(0, Number(options?.quantity) || 0);
- quantity.value = fromQuery || 1;
- initialCartQuantity.value = fromQuery;
- if (!id) {
- uni.showToast({ title: '缺少菜品ID', icon: 'none' });
- return;
- }
- try {
- const res = await GetCuisineDetail(id, tid);
- const data = res?.data ?? res;
- detail.value = data || {};
- } catch (e) {
- uni.showToast({ title: e?.message || '加载失败', icon: 'none' });
- }
- // 有桌号时拉取购物车,以接口数量为准覆盖展示和 initialCartQuantity(与列表同步)
- if (tid) {
- try {
- const cartRes = await GetOrderCart(tid);
- const list = cartRes?.items ?? cartRes?.list ?? cartRes?.data?.items ?? [];
- const arr = Array.isArray(list) ? list : [];
- const cartItem = arr.find(
- (it) => String(it?.cuisineId ?? it?.id ?? '') === String(id)
- );
- const cartQty = cartItem ? (Number(cartItem.quantity) || 0) : 0;
- quantity.value = cartQty > 0 ? cartQty : quantity.value;
- initialCartQuantity.value = cartQty;
- } catch (_) {}
- }
- });
- </script>
- <style lang="scss" scoped>
- .content {
- position: relative;
- }
- .back-bar {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- z-index: 101;
- }
- .back-bar-status {
- width: 100%;
- }
- .back-bar-inner {
- padding: 0 24rpx;
- display: flex;
- align-items: center;
- box-sizing: border-box;
- }
- .back-btn {
- width: 100rpx;
- height: 100rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- &.hover-active {
- opacity: 0.8;
- }
- }
- .back-arrow {
- font-size: 80rpx;
- color: #000000;
- font-weight: 300;
- line-height: 1;
- margin-left: -4rpx;
- }
- .swiper {
- width: 100%;
- height: 510rpx;
- }
- .food-image {
- width: 100%;
- height: 510rpx;
- }
- .food-info {
- width: 100%;
- height: 100rpx;
- background: linear-gradient(90deg, #1E1E1E 0%, #464646 100%);
- display: flex;
- align-items: center;
- justify-content: space-between;
- color: #fff;
- box-sizing: border-box;
- padding: 0 30rpx;
- .food-price {
- display: flex;
- align-items: baseline;
- line-height: 1;
- .price-symbol {
- font-size: 24rpx;
- font-weight: bold;
- margin-right: 10rpx;
- }
- .price-main {
- font-size: 44rpx;
- font-weight: bold;
- }
- .price-decimal {
- font-size: 24rpx;
- font-weight: bold;
- }
- }
- .food-sales {
- font-size: 22rpx;
- color: #fff;
- display: flex;
- align-items: center;
- gap: 4rpx;
- }
- .star-icon {
- width: 26rpx;
- height: 26rpx;
- }
- }
- .food-desc {
- width: 100%;
- padding: 30rpx;
- box-sizing: border-box;
- background-color: #fff;
- margin-top: 20rpx;
- .food-desc-title {
- font-weight: bold;
- font-size: 38rpx;
- color: #151515;
- }
- .food-desc-tags {
- display: flex;
- gap: 10rpx;
- margin-top: 10rpx;
- }
- .food-desc-tag {
- padding: 4rpx 12rpx;
- border-radius: 4rpx;
- font-size: 20rpx;
- &.signature {
- background: linear-gradient(90deg, #FCB13F 0%, #FC793D 100%);
- color: #fff;
- }
- &.spicy {
- background: #2E2E2E;
- color: #fff;
- }
- &.normal {
- background: #f0f0f0;
- color: #666;
- }
- }
- .food-desc-content {
- font-size: 27rpx;
- color: #666666;
- line-height: 38rpx;
- margin-top: 22rpx;
- }
- .food-desc-title-intro {
- font-weight: bold;
- font-size: 27rpx;
- color: #151515;
- }
- }
- .bottom-btn {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 999;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 20rpx 30rpx;
- padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
- box-sizing: border-box;
- background-color: #fff;
- box-shadow: 0rpx -11rpx 46rpx 0rpx rgba(0, 0, 0, 0.05);
- }
- .quantity-selector {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 214rpx;
- height: 58rpx;
- background: #F8F8F8;
- border-radius: 56rpx;
- box-sizing: border-box;
- padding: 0 3rpx;
- }
- .action-btn {
- width: 52rpx;
- height: 52rpx;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 32rpx;
- font-weight: 600;
- transition: all 0.3s;
- background-color: #fff;
- .action-icon {
- width: 24rpx;
- height: 24rpx;
- }
- &.disabled {
- opacity: 0.5;
- }
- }
- .quantity {
- font-size: 30rpx;
- color: #000;
- min-width: 40rpx;
- text-align: center;
- }
- .add-to-cart-btn {
- width: 266rpx;
- height: 80rpx;
- margin-left: 20rpx;
- background: linear-gradient(90deg, #FF6B35 0%, #F7931E 100%);
- border-radius: 40rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #fff;
- font-size: 32rpx;
- font-weight: bold;
- transition: all 0.3s;
- &.hover-active {
- opacity: 0.8;
- }
- }
- </style>
|