Files
pingxiangcard/src/packages/user/pages/buy.vue
T

409 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>