<template>
|
<view class="page">
|
<view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''">
|
<view class="custom-nav-bg"></view>
|
<view class="safe-space" :style="'height:' + navTop + 'px'"></view>
|
<view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
|
<view class="back-button" @tap="goBack"><image src="/static/food-assets/arrow-left-white.svg"></image></view>
|
<text class="nav-title">{{productName}}</text>
|
<view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
|
</view>
|
</view>
|
|
<scroll-view class="content" scroll-y @scroll="handleScroll">
|
<view class="hero">
|
<image class="hero-image" :src="productImage" mode="aspectFill" @error="replaceProductImage"></image>
|
<view class="hero-shade"></view>
|
</view>
|
<view class="product-summary">
|
<view class="price-line"><text class="discount-label">折扣价</text><text class="price-symbol">¥</text><text class="price">{{productPrice}}</text><text class="old-price">¥{{productOriginalPrice}}</text><view class="summary-tags"><text v-if="productHasCoupon" class="coupon-tag">券</text><text v-if="productDiscount != ''" class="discount-tag">{{productDiscount}}</text></view></view>
|
<text class="product-name">{{productName}}</text>
|
<text v-if="!productAvailable" class="product-status">商品已下架</text>
|
<text v-else-if="productSoldOut" class="product-status">已售罄</text>
|
</view>
|
|
<view v-if="detailText.length > 0" class="detail-card">
|
<text class="card-title">商品详情</text>
|
<rich-text class="detail-description" :nodes="detailText"></rich-text>
|
</view>
|
|
<view class="notice-card">
|
<text class="card-title">购买须知</text>
|
<view class="notice-item" v-for="item in purchaseNotices" :key="item.type">
|
<image :src="item.icon"></image>
|
<view class="notice-copy"><text class="notice-title">{{item.title}}</text><text class="notice-content">{{item.content}}</text></view>
|
</view>
|
</view>
|
<view class="content-bottom"></view>
|
</scroll-view>
|
|
<view class="action-bar">
|
<view class="merchant-entry" @tap="goMerchant"><image src="/static/product-assets/store.svg"></image><text>商家</text></view>
|
<view class="buy-now" :class="(!productAvailable || productSoldOut) ? 'is-disabled' : ''" @tap="buyNow"><text>{{!productAvailable ? '商品已下架' : (productSoldOut ? '已售罄' : '立即购买')}}</text></view>
|
</view>
|
</view>
|
</template>
|
|
<script setup lang="uts">
|
import { isApiConfigured } from '../../config/api.uts'
|
import { getProductDetail } from '../../services/portal.uts'
|
import { sanitizeRichText } from '../../services/business.uts'
|
import { resolveApiUrl } from '../../services/http.uts'
|
import { getSession } from '../../services/session.uts'
|
import { safeBack } from '../../utils/navigation.uts'
|
|
const navTop = ref(28)
|
const navHeight = ref(32)
|
const navWidth = ref(88)
|
const navRight = ref(10)
|
const isSolidNav = ref(false)
|
const merchantId = ref('')
|
const productId = ref('')
|
const talentId = ref('')
|
const productName = ref('【川味盛宴】川菜12人餐')
|
const productImage = ref('')
|
const fallbackImage = ''
|
const replaceProductImage = () => { productImage.value = '' }
|
const productPrice = ref('138')
|
const productOriginalPrice = ref('266')
|
const productDiscount = ref('6折')
|
const productHasCoupon = ref(false)
|
const productAvailable = ref(true)
|
const productSoldOut = ref(false)
|
|
// Sanitize and render backend-configured product details as rich text.
|
const detailText = ref('')
|
|
// 购买须知固定只有四项,后台只返回四项各自的内容文本。
|
const defaultPurchaseNotices = [
|
{ type: 'validity', title: '有效期', content: '购买之后30天有效', icon: '/static/product-assets/notice-valid.svg' },
|
{ type: 'usage', title: '使用方式', content: '仅限堂食,到店出示人才码后完成核销', icon: '/static/product-assets/notice-use.svg' },
|
{ type: 'availableTime', title: '可用时间', content: '营业时间内可用', icon: '/static/product-assets/notice-time.svg' },
|
{ type: 'otherRules', title: '其他规则', content: '· 本单发票由商家提供,详情请咨询商家\n· 如部分菜品因时令或售罄无法提供,请联系商家协商处理', icon: '/static/product-assets/notice-rule.svg' }
|
]
|
const purchaseNotices = ref<any[]>(defaultPurchaseNotices)
|
|
const loadProduct = (id: string) => {
|
if (id == 'food-set-002') { productName.value = '【川香四溢】川菜4人餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '118'; productOriginalPrice.value = '198'; productDiscount.value = '6折'; return }
|
if (id == 'food-set-003') { productName.value = '【欢聚时光】招牌菜6人餐'; productImage.value = '/static/food-assets/food-1.jpg'; productPrice.value = '168'; productOriginalPrice.value = '248'; productDiscount.value = '6.8折'; return }
|
if (id == 'food-set-004') { productName.value = '【暖心小聚】特色菜双人餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '88'; productOriginalPrice.value = '126'; productDiscount.value = '7折'; return }
|
if (id == 'food-set-005') { productName.value = '【家庭聚餐】经典川菜8人餐'; productImage.value = '/static/food-assets/food-1.jpg'; productPrice.value = '238'; productOriginalPrice.value = '366'; productDiscount.value = '6.5折'; return }
|
if (id == 'food-set-006') { productName.value = '【人气必点】招牌菜4人餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '128'; productOriginalPrice.value = '188'; productDiscount.value = '6.8折'; return }
|
if (id == 'food-set-007') { productName.value = '【周末欢聚】特色菜10人餐'; productImage.value = '/static/food-assets/food-1.jpg'; productPrice.value = '298'; productOriginalPrice.value = '498'; productDiscount.value = '6折'; return }
|
if (id == 'food-set-008') { productName.value = '【一人食】暖心工作餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '39'; productOriginalPrice.value = '49'; productDiscount.value = '8折'; return }
|
}
|
|
onLoad((options: any) => {
|
// #ifdef MP-WEIXIN
|
const capsule = uni.getMenuButtonBoundingClientRect()
|
const systemInfo = uni.getSystemInfoSync()
|
navTop.value = capsule.top
|
navHeight.value = capsule.height
|
navWidth.value = capsule.width
|
navRight.value = systemInfo.windowWidth - capsule.right
|
// #endif
|
if (options != null && options['merchantId'] != null) merchantId.value = options['merchantId'] as string
|
if (options != null && options['productId'] != null) productId.value = options['productId'] as string
|
const session = getSession()
|
if (session != null && session.talentId != null) talentId.value = '' + session.talentId
|
loadProduct(productId.value)
|
if (options != null && options['name'] != null) productName.value = decodeURIComponent(options['name'] as string)
|
if (options != null && options['image'] != null) productImage.value = decodeURIComponent(options['image'] as string)
|
if (options != null && options['price'] != null) productPrice.value = options['price'] as string
|
if (options != null && options['originalPrice'] != null) productOriginalPrice.value = options['originalPrice'] as string
|
if (options != null && options['discount'] != null) productDiscount.value = decodeURIComponent(options['discount'] as string)
|
loadRemoteProduct()
|
})
|
const loadRemoteProduct = () => {
|
if (!isApiConfigured() || !/^\d+$/.test(productId.value)) return
|
getProductDetail(productId.value, talentId.value, (data: any) => {
|
productName.value = data != null && data.productName != null ? data.productName as string : productName.value
|
productImage.value = data != null && data.coverUrl != null && data.coverUrl != '' ? resolveApiUrl(data.coverUrl as string) : productImage.value
|
const hasSale = data != null && data.salePrice != null
|
const hasOriginal = data != null && data.originalPrice != null
|
const sale = hasSale ? Number(data.salePrice) : 0
|
const original = hasOriginal ? Number(data.originalPrice) : 0
|
if (hasSale && !isNaN(sale)) productPrice.value = sale.toFixed(2).replace(/\.00$/, '')
|
if (hasOriginal && !isNaN(original) && original > 0) productOriginalPrice.value = original.toFixed(2).replace(/\.00$/, '')
|
productHasCoupon.value = data != null && (data.couponAvailable === true || Number(data.couponAvailable) == 1)
|
if (data != null && data.discountText != null) productDiscount.value = data.discountText as string
|
else if (original > 0 && sale < original) productDiscount.value = '' + Math.round(sale / original * 100) / 10 + '折'
|
productAvailable.value = data == null || data.available !== false
|
productSoldOut.value = data != null && (data.soldOut === true || Number(data.stock) == 0)
|
const rawDetailText = data != null && data.detailText != null ? data.detailText as string : ''
|
detailText.value = sanitizeRichText(rawDetailText)
|
purchaseNotices.value = buildPurchaseNotices(data != null ? data.purchaseNotice : null)
|
}, () => {})
|
}
|
const buildPurchaseNotices = (value: any): any[] => {
|
let notice: any = value
|
if (typeof notice == 'string') {
|
try {
|
notice = JSON.parse(notice)
|
} catch (_) {
|
notice = null
|
}
|
}
|
const next: any[] = []
|
for (let index = 0; index < defaultPurchaseNotices.length; index++) {
|
const item = defaultPurchaseNotices[index]
|
let configured: any = null
|
if (notice != null) {
|
// The admin group-buy form stores these three fields under its own
|
// form names. Keep the old keys as a fallback for existing products.
|
if (item.type == 'validity') configured = notice.validPeriod != null ? notice.validPeriod : notice.validity
|
else if (item.type == 'usage') configured = notice.useMethod != null ? notice.useMethod : notice.usage
|
else if (item.type == 'otherRules') configured = notice.otherRule != null ? notice.otherRule : notice.otherRules
|
else configured = notice.availableTime
|
}
|
const content = configured != null && ('' + configured).trim() != '' ? '' + configured : '以商户说明为准'
|
next.push({ type: item.type, title: item.title, icon: item.icon, content: content })
|
}
|
return next
|
}
|
|
const goBack = () => safeBack('/pages/benefits/benefits')
|
const handleScroll = (event: any) => {
|
const shouldShow = event.detail.scrollTop > 64
|
if (isSolidNav.value != shouldShow) isSolidNav.value = shouldShow
|
}
|
const goMerchant = () => safeBack('/pages/merchant/merchant?merchantId=' + encodeURIComponent(merchantId.value))
|
const buyNow = () => {
|
if (!productAvailable.value) {
|
uni.showToast({ title: '商品已下架', icon: 'none' })
|
return
|
}
|
if (productSoldOut.value) {
|
uni.showToast({ title: '商品已售罄', icon: 'none' })
|
return
|
}
|
const orderId = 'order-draft-' + merchantId.value + '-' + productId.value
|
uni.navigateTo({
|
url: '/pages/payment-confirm/payment-confirm?merchantId=' + encodeURIComponent(merchantId.value)
|
+ '&productId=' + encodeURIComponent(productId.value)
|
+ '&orderId=' + encodeURIComponent(orderId)
|
+ '&name=' + encodeURIComponent(productName.value)
|
+ '&image=' + encodeURIComponent(productImage.value)
|
+ '&price=' + encodeURIComponent(productPrice.value)
|
+ '&originalPrice=' + encodeURIComponent(productOriginalPrice.value)
|
+ '&discount=' + encodeURIComponent(productDiscount.value)
|
})
|
}
|
</script>
|
|
<style>
|
.page { width: 100%; height: 100vh; background-color: #F6F6F6; }
|
.content { height: calc(100% - 122rpx); height: calc(100% - 122rpx - env(safe-area-inset-bottom)); background-color: #F6F6F6; }
|
.hero { height: 425rpx; position: relative; overflow: hidden; background-color: #44382D; }
|
.hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 425rpx; }
|
.hero-shade { position: absolute; left: 0; right: 0; top: 0; height: 180rpx; background-image: linear-gradient(180deg, rgba(0,0,0,.52) 0%, rgba(0,0,0,0) 100%); }
|
.custom-nav { position: fixed; z-index: 20; left: 0; right: 0; top: 0; padding-bottom: 20rpx; }
|
.custom-nav-bg { position: absolute; z-index: 0; left: 0; right: 0; top: 0; bottom: 0; opacity: 0; background-image: linear-gradient(180deg, #F26A38 0%, #EE542B 100%); transition: opacity 280ms ease-out; }
|
.custom-nav.is-solid .custom-nav-bg { opacity: 1; }
|
.safe-space { height: 28px; position: relative; z-index: 2; }
|
.nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 24rpx; padding-right: 10px; position: relative; z-index: 2; flex-direction: row; align-items: center; justify-content: space-between; }
|
.back-button { display: flex; width: 54rpx; height: 54rpx; position: relative; z-index: 2; border-radius: 50%; background-color: rgba(45,45,45,.52); transition: background-color 280ms ease-out; justify-content: center; align-items: center; }
|
.custom-nav.is-solid .back-button { background-color: rgba(45,45,45,0); }
|
.back-button image { width: 34rpx; height: 34rpx; }
|
.nav-title { position: absolute; z-index: 1; left: 105rpx; right: 105rpx; opacity: 0; color: #FFFFFF; font-size: 29rpx; font-weight: 600; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity 280ms ease-out; }
|
.custom-nav.is-solid .nav-title { opacity: 1; }
|
.capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
|
.product-summary { position: relative; z-index: 3; margin-top: -18rpx; padding: 18rpx 39rpx 28rpx; border-top-left-radius: 26rpx; border-top-right-radius: 26rpx; background-color: #FFFFFF; }
|
.price-line { display: flex; height: 65rpx; flex-direction: row; align-items: center; }
|
.discount-label { margin-right: 17rpx; color: #FF7B22; font-size: 25rpx; }
|
.price-symbol { color: #FF4C2B; font-size: 30rpx; }
|
.price { margin-left: 3rpx; color: #FF4C2B; font-size: 47rpx; font-weight: 700; }
|
.old-price { margin-left: 14rpx; color: #A9A9A9; font-size: 24rpx; text-decoration: line-through; }
|
.summary-tags { display: flex; margin-left: auto; flex-direction: row; align-items: center; }
|
.coupon-tag { padding: 6rpx 8rpx; border-radius: 5rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 24rpx; }
|
.discount-tag { margin-left: 10rpx; padding: 6rpx 9rpx; border-radius: 5rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 24rpx; }
|
.product-name { display: block; margin-top: 15rpx; color: #292929; font-size: 31rpx; font-weight: 700; }
|
.product-status { display: block; margin-top: 11rpx; color: #E45D3C; font-size: 24rpx; }
|
.detail-card, .notice-card { margin: 16rpx 25rpx 0; padding: 27rpx 27rpx 30rpx; border-radius: 15rpx; background-color: #FFFFFF; }
|
.card-title { display: block; margin-bottom: 23rpx; color: #55575B; font-size: 30rpx; font-weight: 700; }
|
.detail-description { display: block; color: #626870; font-size: 25rpx; line-height: 43rpx; white-space: pre-wrap; }
|
.notice-item { display: flex; min-height: 105rpx; padding: 19rpx 0; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #EEEEEE; flex-direction: row; align-items: flex-start; }
|
.notice-item:last-child { border-bottom-width: 0; }
|
.notice-item > image { width: 28rpx; height: 28rpx; margin-top: 3rpx; margin-right: 12rpx; flex-shrink: 0; }
|
.notice-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
|
.notice-title { color: #45474B; font-size: 26rpx; font-weight: 700; }
|
.notice-content { margin-top: 8rpx; color: #8A8D92; font-size: 23rpx; line-height: 38rpx; white-space: pre-wrap; }
|
.content-bottom { height: 36rpx; }
|
.action-bar { display: flex; height: 122rpx; height: calc(122rpx + env(safe-area-inset-bottom)); box-sizing: border-box; padding: 13rpx 34rpx; padding-bottom: calc(13rpx + env(safe-area-inset-bottom)); background-color: #FFFFFF; box-shadow: 0 -5rpx 15rpx rgba(0,0,0,.05); flex-direction: row; align-items: center; }
|
.merchant-entry { display: flex; width: 150rpx; height: 82rpx; flex-direction: column; justify-content: center; align-items: center; }
|
.merchant-entry image { width: 38rpx; height: 38rpx; }
|
.merchant-entry text { margin-top: 4rpx; color: #747D89; font-size: 21rpx; }
|
.buy-now { display: flex; flex: 1; height: 76rpx; margin-left: 22rpx; border-radius: 39rpx; background-image: linear-gradient(90deg, #FF4D32 0%, #FF8A20 100%); justify-content: center; align-items: center; }
|
.buy-now.is-disabled { background-color: #A8ADB5; background-image: none; }
|
.buy-now text { color: #FFFFFF; font-size: 29rpx; font-weight: 600; }
|
</style>
|