| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533 |
- <template>
- <view class="safe-page detail-page">
- <scroll-view scroll-y class="detail-scroll">
- <swiper v-if="images.length" class="swiper" circular indicator-dots>
- <swiper-item v-for="img in images" :key="img">
- <image class="hero-img" :src="img" mode="aspectFill" />
- </swiper-item>
- </swiper>
- <view v-else class="empty-img">订餐</view>
- <view v-if="item" class="card main-card">
- <view class="title-row">
- <view class="name">{{ item.itemName }}</view>
- <view class="price">¥{{ formatMoney(finalSalePrice) }}</view>
- </view>
- <view v-if="item.tags?.length" class="tags">
- <text v-for="tag in item.tags" :key="tag" class="tag">{{ tag }}</text>
- </view>
- <view :class="['stock', soldOut && 'danger']">{{ stockText }}</view>
- </view>
- <view v-if="item" class="card info-card" @click="goServiceAreaSelect">
- <view class="section-title">当前服务区域</view>
- <view class="area-row">
- <view>
- <view class="area-name">{{ currentServiceArea?.name || item.serviceArea?.name || item.restaurant?.name || '未选择服务区域' }}</view>
- <view class="muted">{{ currentServiceArea?.floorLabel || currentServiceArea?.locationLabel || item.serviceArea?.locationLabel || item.serviceArea?.address || item.restaurant?.locationLabel || item.restaurant?.address || '暂无位置信息' }}</view>
- <view v-if="currentServiceArea?.phone || item.serviceArea?.phone || item.restaurant?.phone" class="muted">电话:{{ currentServiceArea?.phone || item.serviceArea?.phone || item.restaurant?.phone }}</view>
- </view>
- <view class="switch-link">切换</view>
- </view>
- </view>
- <view v-if="item" class="card info-card">
- <view class="section-title">取餐信息</view>
- <view class="info-line">取货方式:自提</view>
- <view class="info-line">取货日期:{{ item.pickupDate || item.menuDate || '以订单为准' }}</view>
- <view class="info-line">取餐点:{{ item.pickupLocation || currentServiceArea?.pickupLocation || '以现场通知为准' }}</view>
- </view>
- <view v-if="item" class="card info-card">
- <view class="section-title">下单倒计时</view>
- <view :class="['countdown', hasDeadline && countdown <= 0 && 'danger']">{{ countdownText }}</view>
- </view>
- <view v-if="item?.specs?.length" class="card info-card">
- <view class="section-title">菜品规格</view>
- <view class="specs">
- <view v-for="spec in item.specs" :key="spec.id || spec.name" :class="['spec', isSpecSelected(spec) && 'active']" @click="selectSpec(spec)">
- <view>{{ spec.name }}</view>
- <view v-if="spec.description" class="muted">{{ spec.description }}</view>
- <view class="muted">{{ specStockText(spec) }}</view>
- </view>
- </view>
- </view>
- <view v-if="item" class="card info-card">
- <view class="section-title">购买数量</view>
- <view class="stepper-row">
- <view class="stepper">
- <view :class="['step-btn', quantity <= 1 && 'step-disabled']" @click="changeQuantity(-1)">-</view>
- <text class="step-num">{{ quantity }}</text>
- <view :class="['step-btn', quantity >= maxQuantity && 'step-disabled']" @click="changeQuantity(1)">+</view>
- </view>
- <view v-if="maxQuantityText" class="muted step-tip">{{ maxQuantityText }}</view>
- </view>
- </view>
- <view v-if="item?.descriptionHtml" class="card info-card">
- <view class="section-title">菜品描述</view>
- <rich-text :nodes="item.descriptionHtml" />
- </view>
- <view class="detail-bottom-spacer" />
- </scroll-view>
- <view class="bottom-bar">
- <button :class="['buy-btn', (!canBuy || loading) && 'disabled']" :disabled="!canBuy || loading" @click="buyNow">{{ buttonText }}</button>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { computed, onUnmounted, ref } from 'vue'
- import { onLoad } from '@dcloudio/uni-app'
- import { fetchMealMenuItemDetail } from '@/api/modules/meal'
- import { fetchCompanyMealMenuItemDetail, trialCompanyMealOrder } from '@/api/modules/companyMeal'
- import { useCompanyStore } from '@/store/modules/company'
- import { useMealStore } from '@/store/modules/meal'
- import type { FoodMealMenuItemDetail, FoodMealMenuItemSpec } from '@/types/meal'
- import { formatMoney } from '@/utils/money'
- import { requireLoginNavigate } from '@/utils/auth'
- type StockSnapshot = {
- stockQuantity?: number | null
- soldQuantity?: number | null
- remainingQuantity?: number | null
- soldOut?: boolean
- limitQuantityPerUser?: number | null
- }
- type SpecWithStock = FoodMealMenuItemSpec & StockSnapshot
- const company = useCompanyStore()
- const meal = useMealStore()
- const item = ref<FoodMealMenuItemDetail | null>(null)
- const loading = ref(false)
- const menuItemId = ref('')
- const mode = ref<'personal' | 'company'>('personal')
- const companyId = ref('')
- const countdown = ref(0)
- const selectedSpec = ref<SpecWithStock | null>(null)
- const quantity = ref(1)
- let timer: ReturnType<typeof setInterval> | null = null
- let countdownRefreshed = false
- const currentServiceArea = computed(() => item.value?.serviceArea || meal.currentServiceArea || item.value?.restaurant)
- const detailServiceAreaId = computed(() => item.value?.serviceAreaId || item.value?.serviceArea?.id || item.value?.restaurantId || item.value?.restaurant?.id)
- const serviceAreaChanged = computed(() => {
- return Boolean(meal.currentServiceArea?.id && detailServiceAreaId.value && meal.currentServiceArea.id !== detailServiceAreaId.value)
- })
- const images = computed(() => item.value?.images?.length ? item.value.images : (item.value?.itemImage ? [item.value.itemImage] : []))
- const hasSpecs = computed(() => Boolean(item.value?.specs?.length))
- const finalSalePrice = computed(() => selectedSpec.value?.salePrice ?? item.value?.salePrice ?? 0)
- const currentStockSource = computed<StockSnapshot | null>(() => selectedSpec.value || item.value)
- const currentLimitQuantityPerUser = computed(() => selectedSpec.value?.limitQuantityPerUser ?? item.value?.limitQuantityPerUser ?? null)
- const currentRemainingQuantity = computed(() => currentStockSource.value ? remainingQuantity(currentStockSource.value) : null)
- const soldOut = computed(() => {
- if (selectedSpec.value?.soldOut === true) return true
- if (!selectedSpec.value && item.value?.soldOut === true) return true
- return currentRemainingQuantity.value != null && currentRemainingQuantity.value <= 0
- })
- const hasDeadline = computed(() => item.value?.deadlineRemainSeconds != null)
- const maxQuantity = computed(() => {
- const stock = currentRemainingQuantity.value == null ? 999 : currentRemainingQuantity.value
- const limit = currentLimitQuantityPerUser.value == null ? 999 : currentLimitQuantityPerUser.value
- return Math.max(Math.min(stock, limit), 1)
- })
- const maxQuantityText = computed(() => {
- const remain = currentRemainingQuantity.value
- const limit = currentLimitQuantityPerUser.value
- if (remain != null && limit != null) return `本单最多可选 ${Math.min(remain, limit)} 份`
- if (remain != null) return `本单最多可选 ${remain} 份`
- if (limit != null) return `每人限购 ${limit} 份`
- return ''
- })
- const canBuy = computed(() => Boolean(item.value && !soldOut.value && item.value.orderable !== false && (!hasDeadline.value || countdown.value > 0)))
- const stockText = computed(() => {
- const remain = currentRemainingQuantity.value
- const stock = remain == null ? '库存充足' : (remain <= 0 ? '仅剩 0,已售罄' : `仅剩 ${remain} 份`)
- if (currentLimitQuantityPerUser.value != null) return `${stock},每人限购 ${currentLimitQuantityPerUser.value} 份`
- return stock
- })
- const countdownText = computed(() => {
- if (item.value?.orderable === false) return item.value.notOrderableReason || '已停止接单'
- if (hasDeadline.value && countdown.value <= 0) return '已截单/停止接单'
- if (!hasDeadline.value) return '随时可订'
- const h = Math.floor(countdown.value / 3600).toString().padStart(2, '0')
- const m = Math.floor((countdown.value % 3600) / 60).toString().padStart(2, '0')
- const s = Math.floor(countdown.value % 60).toString().padStart(2, '0')
- return `${h}:${m}:${s}`
- })
- const buttonText = computed(() => {
- if (soldOut.value) return '已售罄'
- if (item.value?.orderable === false) return '已停止接单'
- if (hasDeadline.value && countdown.value <= 0) return '已截单'
- return '立即购买'
- })
- onLoad(async query => {
- menuItemId.value = String(query?.menuItemId || '')
- mode.value = query?.mode === 'company' ? 'company' : 'personal'
- companyId.value = String(query?.companyId || '')
- await loadDetail()
- })
- onUnmounted(() => stopTimer())
- async function loadDetail() {
- if (!menuItemId.value) return
- loading.value = true
- try {
- const currentCompanyId = companyId.value || company.currentCompany?.companyId
- const res = mode.value === 'company' && currentCompanyId
- ? await fetchCompanyMealMenuItemDetail(currentCompanyId, menuItemId.value)
- : await fetchMealMenuItemDetail(menuItemId.value)
- const detail = res.data
- item.value = detail
- selectedSpec.value = detail.specs?.find(spec => !specSoldOut(spec)) || detail.specs?.[0] || null
- quantity.value = 1
- countdown.value = Math.max(Number(detail.deadlineRemainSeconds || 0), 0)
- countdownRefreshed = false
- startTimer()
- } finally {
- loading.value = false
- }
- }
- function startTimer() {
- stopTimer()
- if (!item.value || !hasDeadline.value || countdown.value <= 0) return
- timer = setInterval(async () => {
- countdown.value = Math.max(countdown.value - 1, 0)
- if (countdown.value <= 0) {
- stopTimer()
- if (!countdownRefreshed) {
- countdownRefreshed = true
- await refreshDetailOnce()
- }
- }
- }, 1000)
- }
- function stopTimer() {
- if (timer) clearInterval(timer)
- timer = null
- }
- async function refreshDetailOnce() {
- if (!menuItemId.value) return
- const currentCompanyId = companyId.value || company.currentCompany?.companyId
- const res = mode.value === 'company' && currentCompanyId
- ? await fetchCompanyMealMenuItemDetail(currentCompanyId, menuItemId.value)
- : await fetchMealMenuItemDetail(menuItemId.value)
- const detail = res.data
- item.value = detail
- selectedSpec.value = detail.specs?.find(spec => selectedSpec.value?.id ? spec.id === selectedSpec.value.id : spec.name === selectedSpec.value?.name) as SpecWithStock || detail.specs?.find(spec => !specSoldOut(spec)) || detail.specs?.[0] || null
- countdown.value = Math.max(Number(detail.deadlineRemainSeconds || 0), 0)
- }
- function modeQuery() {
- const params = [`mode=${mode.value}`]
- const currentCompanyId = companyId.value || company.currentCompany?.companyId
- if (mode.value === 'company' && currentCompanyId) params.push(`companyId=${encodeURIComponent(currentCompanyId)}`)
- return params.join('&')
- }
- function goServiceAreaSelect() {
- uni.navigateTo({ url: `/package-views/pages/meal/restaurant-select?from=detail&${modeQuery()}` })
- }
- function remainingQuantity(source: { stockQuantity?: number | null; soldQuantity?: number | null; remainingQuantity?: number | null }) {
- if (source.remainingQuantity != null) return source.remainingQuantity
- if (source.stockQuantity == null) return null
- return Math.max(source.stockQuantity - (source.soldQuantity || 0), 0)
- }
- function specSoldOut(spec: FoodMealMenuItemSpec) {
- const snapshot = spec as SpecWithStock
- const remain = remainingQuantity(snapshot)
- return snapshot.soldOut === true || (remain != null && remain <= 0)
- }
- function specStockText(spec: FoodMealMenuItemSpec) {
- const snapshot = spec as SpecWithStock
- const remain = remainingQuantity(snapshot)
- const stock = remain == null ? '库存充足' : (remain <= 0 ? '仅剩 0,已售罄' : `仅剩 ${remain} 份`)
- if (snapshot.limitQuantityPerUser != null) return `${stock},每人限购 ${snapshot.limitQuantityPerUser} 份`
- return stock
- }
- function selectSpec(spec: FoodMealMenuItemSpec) {
- selectedSpec.value = spec as SpecWithStock
- quantity.value = 1
- }
- function changeQuantity(delta: number) {
- if (delta < 0 && quantity.value <= 1) return
- if (delta > 0 && quantity.value >= maxQuantity.value) return
- const next = Math.max(1, Math.min(quantity.value + delta, maxQuantity.value))
- if (next === quantity.value && delta > 0) {
- uni.showToast({ title: '已达可选上限', icon: 'none' })
- }
- quantity.value = next
- }
- function isSpecSelected(spec: FoodMealMenuItemSpec) {
- if (!selectedSpec.value) return false
- if (selectedSpec.value.id || spec.id) return selectedSpec.value.id === spec.id
- return selectedSpec.value.name === spec.name
- }
- async function buyNow() {
- if (!item.value || !canBuy.value) return
- if (serviceAreaChanged.value) {
- uni.showModal({
- title: '服务区域已切换',
- content: '请返回菜单重新选择当前服务区域的菜品',
- confirmText: '返回菜单',
- success: res => {
- if (res.confirm) uni.redirectTo({ url: `/package-views/pages/meal/menu?${modeQuery()}` })
- }
- })
- return
- }
- if (!requireLoginNavigate(`/package-views/pages/meal/detail?menuItemId=${encodeURIComponent(menuItemId.value)}&${modeQuery()}`)) return
- if (hasSpecs.value && !selectedSpec.value?.id) {
- uni.showToast({ title: '请选择规格', icon: 'none' })
- return
- }
- const currentCompanyId = companyId.value || company.currentCompany?.companyId
- if (mode.value === 'company' && !currentCompanyId) {
- uni.showToast({ title: '请选择企业', icon: 'none' })
- return
- }
- const serviceArea = item.value.serviceArea || item.value.restaurant || currentServiceArea.value
- const stockSnapshot = selectedSpec.value || item.value
- const checkoutItem = {
- menuItemId: item.value.id,
- specId: selectedSpec.value?.id,
- specName: selectedSpec.value?.name,
- specDescription: selectedSpec.value?.description,
- quantity: quantity.value,
- itemName: item.value.itemName,
- salePrice: finalSalePrice.value,
- totalAmount: finalSalePrice.value * quantity.value,
- stockQuantity: stockSnapshot.stockQuantity,
- soldQuantity: stockSnapshot.soldQuantity,
- remainingQuantity: remainingQuantity(stockSnapshot),
- limitQuantityPerUser: stockSnapshot.limitQuantityPerUser
- }
- let trial
- if (mode.value === 'company' && currentCompanyId) {
- const trialRes = await trialCompanyMealOrder({
- companyId: currentCompanyId,
- serviceAreaId: detailServiceAreaId.value || serviceArea?.id,
- menuId: item.value.menuId,
- mealDate: item.value.menuDate || '',
- mealType: item.value.mealType || '',
- items: [{ menuItemId: item.value.id, specId: selectedSpec.value?.id, quantity: quantity.value }]
- })
- trial = {
- originalAmount: trialRes.data.originalAmount,
- discountAmount: trialRes.data.discountAmount,
- companyPayAmount: trialRes.data.companyPayAmount,
- employeePayAmount: trialRes.data.employeePayAmount
- }
- }
- uni.setStorageSync('meal_checkout_payload', {
- mode: mode.value,
- ...(mode.value === 'company' ? { companyId: currentCompanyId, companyName: company.currentCompany?.companyName, trial } : {}),
- menuId: item.value.menuId,
- serviceAreaId: detailServiceAreaId.value || serviceArea?.id,
- serviceAreaName: item.value.serviceAreaName || serviceArea?.name,
- serviceAreaPhone: serviceArea?.phone,
- serviceArea,
- mealDate: item.value.menuDate,
- mealType: item.value.mealType,
- pickupType: 'pickup',
- pickupDate: item.value.pickupDate || item.value.menuDate,
- pickupLocation: item.value.pickupLocation || item.value.serviceArea?.pickupLocation || item.value.restaurant?.pickupLocation || serviceArea?.pickupLocation,
- deadlineTime: item.value.deadlineTime,
- deadlineRemainSeconds: countdown.value,
- items: [checkoutItem],
- totalAmount: finalSalePrice.value * quantity.value
- })
- uni.navigateTo({ url: '/package-views/pages/meal/checkout' })
- }
- </script>
- <style scoped lang="scss">
- .detail-page {
- height: 100vh;
- padding: 0;
- overflow: hidden;
- }
- .detail-scroll {
- height: calc(100vh - 148rpx - env(safe-area-inset-bottom));
- padding: 24rpx 24rpx 0;
- box-sizing: border-box;
- }
- .detail-bottom-spacer {
- height: 40rpx;
- }
- .swiper,
- .hero-img,
- .empty-img {
- width: 100%;
- height: 430rpx;
- border-radius: 28rpx;
- overflow: hidden;
- }
- .empty-img {
- display: flex;
- align-items: center;
- justify-content: center;
- color: #00838f;
- background: #e0f7fa;
- font-size: 42rpx;
- font-weight: 700;
- }
- .main-card,
- .info-card {
- margin-top: 22rpx;
- }
- .title-row,
- .area-row {
- display: flex;
- justify-content: space-between;
- gap: 20rpx;
- }
- .name {
- flex: 1;
- font-size: 38rpx;
- font-weight: 700;
- }
- .price {
- color: #ef4444;
- font-size: 38rpx;
- font-weight: 800;
- }
- .tags {
- margin-top: 18rpx;
- display: flex;
- gap: 10rpx;
- flex-wrap: wrap;
- }
- .tag {
- padding: 5rpx 14rpx;
- border-radius: 999rpx;
- background: #fff7ed;
- color: #f97316;
- font-size: 22rpx;
- }
- .stock,
- .countdown {
- margin-top: 18rpx;
- font-size: 30rpx;
- font-weight: 700;
- color: #16a34a;
- }
- .danger {
- color: #ef4444;
- }
- .section-title {
- font-size: 30rpx;
- font-weight: 700;
- margin-bottom: 16rpx;
- }
- .area-name {
- font-weight: 700;
- font-size: 30rpx;
- }
- .switch-link {
- color: #f97316;
- font-weight: 700;
- }
- .info-line {
- margin-top: 10rpx;
- color: #334155;
- }
- .specs {
- display: flex;
- flex-direction: column;
- gap: 14rpx;
- }
- .spec {
- padding: 18rpx;
- border-radius: 18rpx;
- border: 2rpx solid #e2e8f0;
- }
- .spec.disabled {
- opacity: 0.58;
- }
- .spec-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16rpx;
- font-weight: 700;
- }
- .spec-price {
- color: #ef4444;
- }
- .spec.active {
- border-color: #00bcd4;
- background: #e0f7fa;
- }
- .stepper-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20rpx;
- }
- .stepper {
- display: flex;
- align-items: center;
- gap: 24rpx;
- }
- .step-btn {
- width: 56rpx;
- height: 56rpx;
- line-height: 52rpx;
- padding: 0;
- border-radius: 50%;
- border: 2rpx solid #e2e8f0;
- background: #f8fafc;
- font-size: 32rpx;
- color: #334155;
- }
- .step-btn[disabled],
- .step-disabled {
- opacity: 0.4;
- }
- .step-num {
- min-width: 48rpx;
- text-align: center;
- font-size: 32rpx;
- font-weight: 700;
- }
- .step-tip {
- font-size: 24rpx;
- }
- .bottom-bar {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
- background: #fff;
- box-shadow: 0 -8rpx 30rpx rgba(15, 23, 42, 0.08);
- }
- .buy-btn {
- height: 88rpx;
- line-height: 88rpx;
- border-radius: 999rpx;
- background: #00bcd4;
- color: #fff;
- font-weight: 700;
- }
- .buy-btn.disabled {
- background: #cbd5e1;
- }
- </style>
|