shinnosuke
2026-07-31 82141e1b9e46a154418295aa95b34cb842c001c3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
<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 class="coupon-tag">券</text><text 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="detailImages.length > 0 || detailText.length > 0" class="detail-card">
                <text class="card-title">商品详情</text>
                <view v-if="detailImages.length > 0" class="detail-images"><image v-for="(imageUrl, index) in detailImages" :key="index" :src="imageUrl" mode="widthFix" @error="replaceDetailImage(index)"></image></view>
                <text v-if="detailText.length > 0" class="detail-description">{{detailText}}</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 { resolveApiUrl } from '../../services/http.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 productName = ref('【川味盛宴】川菜12人餐')
    const productImage = ref('/static/food-assets/food-1.jpg')
    const fallbackImage = '/static/common/image-fallback.svg'
    const replaceProductImage = () => { productImage.value = fallbackImage }
    const productPrice = ref('138')
    const productOriginalPrice = ref('266')
    const productDiscount = ref('6折')
    const productAvailable = ref(true)
    const productSoldOut = ref(false)
 
    // 后台分别返回图片数组与文字结构,不使用富文本 HTML。
    const detailImages = ref<any[]>([])
    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
        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, (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 sale = data != null && data.salePrice != null ? Number(data.salePrice) : 0
            const original = data != null && data.originalPrice != null ? Number(data.originalPrice) : 0
            if (sale > 0) productPrice.value = '' + sale
            if (original > 0) productOriginalPrice.value = '' + original
            if (sale > 0 && original > sale) 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)
            detailText.value = data != null && data.detailText != null ? data.detailText as string : ''
            detailImages.value = getProductImages(data != null ? data.productImages : null)
            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]
            const content = notice != null && notice[item.type] != null && notice[item.type] != '' ? '' + notice[item.type] : '以商户说明为准'
            next.push({ type: item.type, title: item.title, icon: item.icon, content: content })
        }
        return next
    }
 
    const getProductImages = (value: any): string[] => {
        let images: any = value
        if (typeof images == 'string') {
            try {
                images = JSON.parse(images)
            } catch (_) {
                return []
            }
        }
        if (!(images instanceof Array)) return []
        const result: string[] = []
        for (let index = 0; index < images.length; index++) {
            const image = images[index]
            if (typeof image == 'string' && image.length > 0) result.push(resolveApiUrl(image))
        }
        return result
    }
    const replaceDetailImage = (index: number) => {
        if (index >= 0 && index < detailImages.value.length) detailImages.value[index] = fallbackImage
    }
 
    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-images image { display: block; width: 100%; margin-bottom: 24rpx; border-radius: 12rpx; }
    .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>