409 lines
10 KiB
Vue
409 lines
10 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view v-if="loading" class="loading">
|
||
<text class="loading-text">加载中...</text>
|
||
</view>
|
||
<template v-else>
|
||
<view class="intro-section">
|
||
<rich-text :nodes="introduction" />
|
||
</view>
|
||
</template>
|
||
|
||
<!-- 底部固定购买区域 -->
|
||
<view class="bottom-bar">
|
||
<view class="price-row">
|
||
<text class="price-label">价格:</text>
|
||
<text class="price-symbol">¥</text>
|
||
<text class="price-value">{{ buyPrice }}</text>
|
||
<text v-if="showPrice" class="price-original">¥{{ showPrice }}</text>
|
||
</view>
|
||
<view class="btn-buy" :class="{ 'btn-buy--disabled': paying }" @tap="handleBuy">
|
||
<text class="btn-buy__text">{{ paying ? '支付中...' : '立即购买' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 购卡须知弹窗:内容为富文本 -->
|
||
<view v-if="showNotice" class="modal-mask" @tap="showNotice = false">
|
||
<view class="modal-panel" @tap.stop>
|
||
<text class="modal-title">购卡须知</text>
|
||
<scroll-view scroll-y class="modal-scroll">
|
||
<rich-text class="modal-html" :nodes="noticeHtml" />
|
||
</scroll-view>
|
||
<view class="modal-divider" />
|
||
<view class="modal-footer">
|
||
<view class="modal-foot-btn" @tap="showNotice = false">
|
||
<text class="modal-foot-text">取消</text>
|
||
</view>
|
||
<view class="modal-foot-split" />
|
||
<view class="modal-foot-btn" @tap="confirmBuy">
|
||
<text class="modal-foot-text modal-foot-text--red">确认购买</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||
import { post, getToken } from '../../../api/request'
|
||
import { BASE_URL } from '../../../config'
|
||
import { useUserStore } from '../../../store/user'
|
||
import { createOrder, prepay } from '../api/pingxiang-order'
|
||
import type { BannerItem } from '../../../api/types'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
const goodsId = ref('')
|
||
const cardModleId = ref('')
|
||
const mainPic = ref('')
|
||
const introduction = ref('')
|
||
const buyPrice = ref('0')
|
||
const showPrice = ref('')
|
||
const skuId = ref('')
|
||
const loading = ref(true)
|
||
const paying = ref(false)
|
||
const shareTitle = ref('爱上萍乡旅游年卡')
|
||
const showNotice = ref(false)
|
||
const noticeContent = ref('')
|
||
|
||
/** 富文本 HTML 清洗:给所有 img 加 max-width:100%,防止超出容器边界 */
|
||
function fixRichHtml(html: string): string {
|
||
if (!html) return ''
|
||
const style = 'max-width:100%;height:auto;display:block;'
|
||
return html.replace(/<img(?![^>]*max-width)[^>]*>/gi, (tag: string) => {
|
||
if (/style="[^"]*"/i.test(tag)) {
|
||
return tag.replace(/style="([^"]*)"/i, (_: string, s: string) => `style="${s};${style}"`)
|
||
}
|
||
return tag.replace(/<img/i, `<img style="${style}"`)
|
||
})
|
||
}
|
||
|
||
const noticeHtml = computed(() => fixRichHtml(noticeContent.value))
|
||
|
||
/** 获取购卡须知富文本(type = CARD_PURCHASE_NOTICE) */
|
||
async function fetchPurchaseNotice() {
|
||
try {
|
||
const res = await post<BannerItem[]>('/mp-api/mpContent/queryList', {
|
||
channel: 'pingxiang',
|
||
type: 'CARD_PURCHASE_NOTICE',
|
||
})
|
||
const list = res || []
|
||
const item = list.find((i) => i.type === 'CARD_PURCHASE_NOTICE') || list[0]
|
||
noticeContent.value = item?.content || ''
|
||
} catch {
|
||
noticeContent.value = ''
|
||
}
|
||
}
|
||
|
||
onLoad((query) => {
|
||
goodsId.value = (query?.goodsId as string) || '821926819630853'
|
||
})
|
||
|
||
function sanitizeHtml(html: string): string {
|
||
return html.replace(/<img[^>]*>/g, (match) => {
|
||
return match.replace(/style="[^"]*"/g, 'style="width:100%"')
|
||
})
|
||
}
|
||
|
||
interface CardModelDetail {
|
||
cardModel: any
|
||
name: string
|
||
goods: { id: string; mainPic: string }
|
||
goodsInfo: { introduction: string }
|
||
sku: Array<{ id: string; buyPrice: number; showPrice: number }>
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const data = await post<CardModelDetail>(`${BASE_URL}/open-api/xgh/cardModelDetail?goodsId=${goodsId.value}`, {})
|
||
cardModleId.value = data.cardModel?.id || ''
|
||
mainPic.value = data.goods?.mainPic || ''
|
||
shareTitle.value = data.name || data.cardModel?.name || '爱上萍乡旅游年卡'
|
||
introduction.value = sanitizeHtml(data.goodsInfo?.introduction || '')
|
||
const sku = data.sku?.[0]
|
||
skuId.value = sku?.id || ''
|
||
const price = sku?.buyPrice || 0
|
||
buyPrice.value = (price / 100).toFixed(2)
|
||
const showPriceValue = sku?.showPrice || 0
|
||
showPrice.value = showPriceValue ? (showPriceValue / 100).toFixed(2) : ''
|
||
} catch {
|
||
// 静默失败
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
onShareAppMessage(() => ({
|
||
title: shareTitle.value,
|
||
path: `/packages/user/pages/buy?goodsId=${goodsId.value}`,
|
||
imageUrl: mainPic.value,
|
||
}))
|
||
|
||
onShareTimeline(() => ({
|
||
title: shareTitle.value,
|
||
query: `goodsId=${goodsId.value}`,
|
||
imageUrl: mainPic.value,
|
||
}))
|
||
|
||
async function handleBuy() {
|
||
if (!userStore.isLogin) {
|
||
uni.navigateTo({ url: '/pages/login/login' })
|
||
return
|
||
}
|
||
if (paying.value) return
|
||
showNotice.value = true
|
||
fetchPurchaseNotice()
|
||
}
|
||
|
||
async function confirmBuy() {
|
||
if (paying.value) return
|
||
paying.value = true
|
||
showNotice.value = false
|
||
|
||
try {
|
||
const mobile = (uni.getStorageSync('phone') as string) || ''
|
||
const orderRes = await createOrder({
|
||
cardModleId: cardModleId.value,
|
||
buyNum: 1,
|
||
skuId: skuId.value,
|
||
mobile,
|
||
couponId: '',
|
||
})
|
||
const orderId = orderRes.orderId
|
||
const outTradeNo = orderRes.outTradeNo
|
||
|
||
const openId = getToken()
|
||
if (!openId) {
|
||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const payParams = await prepay({
|
||
openId,
|
||
orderNo: orderRes.outTradeNo,
|
||
description: mobile,
|
||
amount: Math.round(Number(buyPrice.value) * 100),
|
||
})
|
||
|
||
if (!payParams?.timeStamp || !payParams?.nonce || !payParams?.pack || !payParams?.paySign) {
|
||
uni.showToast({ title: '支付参数异常,请重试', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
uni.requestPayment({
|
||
provider: 'wxpay',
|
||
appId: payParams.appId,
|
||
timeStamp: payParams.timeStamp,
|
||
nonceStr: payParams.nonce,
|
||
package: payParams.pack,
|
||
signType: payParams.signType,
|
||
paySign: payParams.paySign,
|
||
success: () => resolve(),
|
||
fail: (err: any) => reject(err),
|
||
})
|
||
})
|
||
|
||
uni.showToast({ title: '支付成功', icon: 'success' })
|
||
setTimeout(() => {
|
||
uni.redirectTo({ url: `/packages/user/pages/order/detail?orderId=${outTradeNo}` })
|
||
}, 1500)
|
||
} catch (err: any) {
|
||
uni.showToast({ title: err?.errMsg?.includes('cancel') ? '已取消支付' : '支付失败,请重试', icon: 'none' })
|
||
setTimeout(() => {
|
||
uni.redirectTo({ url: `/packages/user/pages/order/list?outTradeNo=${outTradeNo}` })
|
||
}, 1500)
|
||
} finally {
|
||
paying.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
$bar-height: 120rpx;
|
||
|
||
.page {
|
||
min-height: 100vh;
|
||
background: $color-card;
|
||
padding-top: 20rpx;
|
||
padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom));
|
||
font-family: $font-family-body;
|
||
}
|
||
|
||
/* ===== 加载状态 ===== */
|
||
.loading {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 80rpx 0;
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: $font-size-sm;
|
||
color: $uv-tips-color;
|
||
}
|
||
|
||
/* ===== 富文本介绍 ===== */
|
||
.intro-section {
|
||
width: 100%;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
/* ===== 底部固定购买区域 ===== */
|
||
.bottom-bar {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
box-sizing: border-box;
|
||
min-height: $bar-height;
|
||
background: $color-card;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 20rpx 32rpx;
|
||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
|
||
z-index: 100;
|
||
}
|
||
|
||
.price-label {
|
||
font-family: $font-family-body;
|
||
font-size: 26rpx;
|
||
color: $uv-tips-color;
|
||
}
|
||
|
||
.price-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.price-symbol {
|
||
font-family: $font-family-body;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: $color-price;
|
||
}
|
||
|
||
.price-value {
|
||
font-family: $font-family-body;
|
||
font-size: 38rpx;
|
||
font-weight: 700;
|
||
color: $color-price;
|
||
line-height: 1;
|
||
}
|
||
|
||
.price-original {
|
||
font-family: $font-family-body;
|
||
font-size: 22rpx;
|
||
color: $uv-placeholder-color;
|
||
text-decoration: line-through;
|
||
margin-left: 12rpx;
|
||
}
|
||
|
||
.btn-buy {
|
||
width: auto;
|
||
min-width: auto;
|
||
height: 80rpx;
|
||
padding: 0 56rpx;
|
||
border-radius: 40rpx;
|
||
background: $uv-primary;
|
||
border: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 4rpx 16rpx rgba(60, 156, 255, 0.3);
|
||
transition: all 0.2s ease;
|
||
|
||
&:active {
|
||
transform: scale(0.97);
|
||
background: $uv-primary;
|
||
}
|
||
|
||
&--disabled {
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
|
||
.btn-buy__text {
|
||
font-family: $font-family-body;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #ffffff;
|
||
letter-spacing: 2rpx;
|
||
|
||
&:active {
|
||
color: #ffffff;
|
||
}
|
||
}
|
||
|
||
/* ===== 购卡须知弹窗 ===== */
|
||
.modal-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: $z-modal;
|
||
background: rgba(120, 120, 120, 0.4);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.modal-panel {
|
||
width: 662rpx;
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
padding: 24rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.modal-title {
|
||
display: block;
|
||
text-align: center;
|
||
font-family: $font-family-display;
|
||
font-size: $font-size-lg;
|
||
font-weight: $font-weight-bold;
|
||
color: $uv-main-color;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
.modal-scroll {
|
||
height: 700rpx;
|
||
}
|
||
.modal-html {
|
||
width: 100%;
|
||
display: block;
|
||
font-size: $font-size-sm;
|
||
color: $uv-content-color;
|
||
line-height: 1.7;
|
||
word-break: break-all;
|
||
}
|
||
.modal-divider {
|
||
height: 1rpx;
|
||
background: $uv-border-color;
|
||
margin-top: 24rpx;
|
||
}
|
||
.modal-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.modal-foot-btn {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.modal-foot-split {
|
||
width: 1rpx;
|
||
height: 48rpx;
|
||
background: $uv-border-color;
|
||
}
|
||
.modal-foot-text {
|
||
font-size: $font-size-lg;
|
||
color: $uv-main-color;
|
||
&--red {
|
||
color: #ff383c;
|
||
}
|
||
}
|
||
</style>
|