| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- <template>
- <view class="hotel-customer-theme pay-page">
- <!-- 加载中 -->
- <view v-if="loading" class="pay-loading">
- <text>加载中...</text>
- </view>
- <!-- 金额展示 -->
- <view v-else class="pay-hero">
- <text class="pay-label">支付金额</text>
- <view class="pay-amount">
- <text class="pay-yen">¥</text>
- <text class="pay-num">{{ amount }}</text>
- </view>
- <text class="pay-desc">客房送餐服务</text>
- </view>
- <!-- 倒计时 -->
- <view v-if="!loading && remainSeconds > 0" class="pay-countdown">
- <text class="pc-label">请在</text>
- <text class="pc-time">{{ countdownText }}</text>
- <text class="pc-label">内完成支付,超时订单将自动取消</text>
- </view>
- <view v-else-if="!loading && remainSeconds <= 0" class="pay-countdown pay-countdown--expired">
- <text class="pc-label">订单已超时,请重新下单</text>
- </view>
- <!-- 订单信息 -->
- <view v-if="!loading && orderId" class="pay-info-card">
- <view class="pi-line">
- <text class="pi-label">订单编号</text>
- <text class="pi-value">{{ orderNo }}</text>
- </view>
- <view class="pi-line">
- <text class="pi-label">送至</text>
- <text class="pi-value">{{ roomNo }}</text>
- </view>
- </view>
- <!-- 支付按钮 -->
- <view
- v-if="!loading && remainSeconds > 0 && !paying && !paySuccess"
- class="pay-btn"
- @click="doPay"
- >立即支付 ¥{{ amount }}</view>
- <view v-else-if="!loading && paying" class="pay-btn pay-btn--disabled">支付中...</view>
- <view v-else-if="!loading && paySuccess" class="pay-btn pay-btn--success">支付成功 ✓</view>
- <view v-else-if="!loading" class="pay-btn pay-btn--disabled">订单已超时</view>
- <!-- 返回修改 -->
- <view v-if="!loading && !paying && !paySuccess" class="pay-back" @click="goBack">← 返回修改</view>
- </view>
- </template>
- <script setup>
- import { ref, computed, onMounted, onUnmounted } from 'vue'
- import { useHotelOrderStore } from '@/store'
- import api from '@/api'
- const orderStore = useHotelOrderStore()
- const orderId = ref('')
- const orderNo = ref('')
- const roomNo = ref('')
- const amount = ref('0.00')
- const paying = ref(false)
- const paySuccess = ref(false)
- const remainSeconds = ref(900) // 15分钟 = 900秒
- const loading = ref(true)
- let countdownTimer = null
- let pollTimer = null
- // 支付环境自动识别(paySource 用于 doPay 判断支付渠道)
- const paySource = ref('MOCK')
- onMounted(() => {
- const pages = getCurrentPages()
- const currentPage = pages[pages.length - 1]
- const options = currentPage?.$page?.options || currentPage?.options || {}
- orderId.value = options.orderId || ''
- const tenantId = orderStore.tenantId
- // 从后端查询订单详情,获取真实金额和订单信息
- if (orderId.value && tenantId) {
- api.hotelOrderDetail(orderId.value, tenantId).then(function(res) {
- var data = res?.data || res
- if (data) {
- orderNo.value = data.orderNo || ''
- roomNo.value = data.roomNo || orderStore.roomInfo?.roomNo || ''
- amount.value = data.totalAmount ? String(data.totalAmount) : '0.00'
- // 如果订单已支付成功(从支付宝返回后重新进入),直接跳转
- if (data.payStatus === 1) {
- loading.value = false
- paySuccess.value = true
- stopCountdown()
- uni.showToast({ title: '支付成功', icon: 'success' })
- setTimeout(function() {
- uni.redirectTo({
- url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
- })
- }, 1200)
- return
- }
- // 根据后端返回的支付截止时间计算剩余秒数
- if (data.payExpireTime) {
- var expireTime = new Date(data.payExpireTime).getTime()
- var now = Date.now()
- var diff = Math.floor((expireTime - now) / 1000)
- remainSeconds.value = diff > 0 ? diff : 0
- }
- }
- loading.value = false
- startCountdown()
- // 订单未支付时,自动启动轮询检测支付状态(用于从支付宝返回后的场景)
- startPayPolling()
- }).catch(function() {
- loading.value = false
- uni.showToast({ title: '加载订单信息失败', icon: 'none' })
- })
- } else {
- loading.value = false
- }
- // 自动识别支付环境
- // #ifdef MP-WEIXIN
- paySource.value = 'WECHAT'
- // #endif
- // #ifdef MP-ALIPAY
- paySource.value = 'ALIPAY'
- // #endif
- // #ifdef H5
- const ua = navigator.userAgent.toLowerCase()
- if (ua.includes('micromessenger')) {
- paySource.value = 'WECHAT'
- } else {
- paySource.value = 'ALIPAY'
- }
- // #endif
- startCountdown()
- })
- onUnmounted(() => {
- stopCountdown()
- stopPolling()
- })
- /** 启动倒计时 */
- function startCountdown() {
- stopCountdown()
- countdownTimer = setInterval(() => {
- if (remainSeconds.value > 0) {
- remainSeconds.value--
- } else {
- stopCountdown()
- }
- }, 1000)
- }
- function stopCountdown() {
- if (countdownTimer) {
- clearInterval(countdownTimer)
- countdownTimer = null
- }
- }
- function stopPolling() {
- if (pollTimer) {
- clearTimeout(pollTimer)
- pollTimer = null
- }
- }
- /** 倒计时格式化 */
- const countdownText = computed(() => {
- const m = Math.floor(remainSeconds.value / 60)
- const s = remainSeconds.value % 60
- return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
- })
- /** 执行支付 */
- async function doPay() {
- if (paying.value || paySuccess.value) return
- if (remainSeconds.value <= 0) {
- uni.showToast({ title: '订单已超时', icon: 'none' })
- return
- }
- paying.value = true
- try {
- var tenantId = orderStore.tenantId
- // 先调用后端创建支付流水,获取 tradeNo
- var createRes = await api.hotelPayCreate(orderId.value, tenantId, paySource.value)
- var createData = createRes?.data || createRes
- if (!createData) {
- paying.value = false
- uni.showToast({ title: '创建支付失败', icon: 'none' })
- return
- }
- // 根据支付渠道拉起支付
- if (paySource.value === 'ALIPAY' && createData.payForm) {
- // 支付宝 H5 手机网站支付:后端返回 payForm(HTML 表单),渲染后自动提交跳转支付宝收银台
- // #ifdef H5
- var payContainer = document.getElementById('alipay-pay-form')
- if (!payContainer) {
- payContainer = document.createElement('div')
- payContainer.id = 'alipay-pay-form'
- payContainer.style.display = 'none'
- document.body.appendChild(payContainer)
- }
- payContainer.innerHTML = createData.payForm
- var form = payContainer.querySelector('form')
- if (form) {
- form.submit()
- } else {
- paying.value = false
- uni.showToast({ title: '支付表单异常', icon: 'none' })
- }
- // 提交后页面会跳转到支付宝收银台,用户支付完成后通过 return-url 返回
- // 同时启动轮询,用户返回后自动检测支付状态
- startPayPolling()
- // #endif
- // #ifdef MP-ALIPAY
- // 支付宝小程序:调用 my.tradePay 拉起收银台
- my.tradePay({
- tradeNO: createData.tradeNo,
- success: function(res) {
- startPayPolling()
- },
- fail: function(err) {
- paying.value = false
- uni.showToast({ title: '支付取消或失败', icon: 'none' })
- }
- })
- // #endif
- } else if (paySource.value === 'ALIPAY' && createData.tradeNo) {
- // 支付宝小程序环境:有 tradeNo 无 payForm,用 my.tradePay
- // #ifdef MP-ALIPAY
- my.tradePay({
- tradeNO: createData.tradeNo,
- success: function(res) {
- startPayPolling()
- },
- fail: function(err) {
- paying.value = false
- uni.showToast({ title: '支付取消或失败', icon: 'none' })
- }
- })
- // #endif
- // #ifdef H5
- // H5 环境不应只有 tradeNo,启动轮询兜底
- startPayPolling()
- // #endif
- } else {
- // MOCK 模式:直接调用模拟支付(开发调试用)
- var mockRes = await api.hotelPayMockSuccess(orderId.value, tenantId)
- var mockData = mockRes?.data || mockRes
- if (mockData && mockData.payStatus === 1) {
- paySuccess.value = true
- stopCountdown()
- uni.showToast({ title: '支付成功', icon: 'success' })
- setTimeout(function() {
- uni.redirectTo({
- url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
- })
- }, 1200)
- } else {
- paying.value = false
- uni.showToast({ title: '支付失败,请重试', icon: 'none' })
- }
- }
- } catch (err) {
- paying.value = false
- uni.showToast({ title: err?.message || '支付失败', icon: 'none' })
- }
- }
- /** 支付后轮询后端查询真实支付状态 */
- function startPayPolling() {
- stopPolling()
- var pollCount = 0
- var maxPoll = 60 // 最多轮询60次,每次2秒,共120秒
- // 延迟3秒后开始第一次轮询,给支付宝异步回调留出处理时间
- pollTimer = setTimeout(function poll() {
- pollCount++
- api.hotelPayStatus(orderId.value, orderStore.tenantId).then(function(res) {
- var data = res?.data || res
- if (data && data.payStatus === 1) {
- // 支付成功
- stopPolling()
- paySuccess.value = true
- paying.value = false
- stopCountdown()
- uni.showToast({ title: '支付成功', icon: 'success' })
- setTimeout(function() {
- uni.redirectTo({
- url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
- })
- }, 1200)
- } else if (pollCount >= maxPoll) {
- // 轮询超时,主动查询支付宝侧订单状态(底机制)
- stopPolling()
- paying.value = false
- queryAlipayTradeStatus()
- } else {
- // 未支付,继续轮询
- pollTimer = setTimeout(poll, 2000)
- }
- }).catch(function() {
- // 静默失败,继续轮询
- if (pollCount < maxPoll) {
- pollTimer = setTimeout(poll, 2000)
- } else {
- // 轮询超时且请求失败,也尝试主动查询支付宝
- stopPolling()
- paying.value = false
- queryAlipayTradeStatus()
- }
- })
- }, pollCount === 0 ? 3000 : 2000)
- }
- /** 轮询超时后,主动查询支付宝侧订单状态(底机制) */
- function queryAlipayTradeStatus() {
- uni.showLoading({ title: '正在确认支付结果...' })
- api.hotelPayAlipayQuery(orderStore.tenantId, orderId.value).then(function(res) {
- uni.hideLoading()
- var data = res?.data || res
- if (data && data.payStatus === 1) {
- // 支付宝侧已支付,补偿成功
- paySuccess.value = true
- paying.value = false
- stopCountdown()
- uni.showToast({ title: '支付成功', icon: 'success' })
- setTimeout(function() {
- uni.redirectTo({
- url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
- })
- }, 1200)
- } else {
- // 支付宝侧也未支付
- uni.showModal({
- title: '支付结果未知',
- content: '暂时无法确认支付状态,请稍后在订单列表中查看。如已扣款请联系客服。',
- showCancel: false
- })
- }
- }).catch(function() {
- uni.hideLoading()
- uni.showModal({
- title: '查询失败',
- content: '网络异常,请稍后在订单列表中查看支付结果。',
- showCancel: false
- })
- })
- }
- function goBack() {
- // 购物车已清空,返回菜单页重新点餐
- uni.redirectTo({ url: '/pages/hotel/customer/menu' })
- }
- </script>
- <style lang="scss" scoped>
- .pay-page {
- min-height: 100vh;
- background: var(--h-bg, #F7F7F7);
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 0 28rpx;
- }
- /* 加载中 */
- .pay-loading {
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 400rpx;
- color: var(--h-txt-light, #999);
- font-size: 28rpx;
- }
- /* 金额区 */
- .pay-hero {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 60rpx 0 32rpx;
- }
- .pay-label {
- font-size: 26rpx;
- color: var(--h-txt-sub, #666);
- margin-bottom: 16rpx;
- }
- .pay-amount {
- display: flex;
- align-items: baseline;
- margin-bottom: 12rpx;
- }
- .pay-yen {
- font-size: 48rpx;
- font-weight: 700;
- color: var(--h-pri, #2D5016);
- }
- .pay-num {
- font-size: 88rpx;
- font-weight: 800;
- color: var(--h-pri, #2D5016);
- line-height: 1;
- }
- .pay-desc {
- font-size: 24rpx;
- color: var(--h-txt-light, #999);
- }
- /* 倒计时 */
- .pay-countdown {
- display: flex;
- align-items: center;
- gap: 8rpx;
- padding: 16rpx 32rpx;
- background: #FFF7ED;
- border-radius: 40rpx;
- margin-bottom: 24rpx;
- border: 1rpx solid #FED7AA;
- }
- .pay-countdown--expired {
- background: #FEF2F2;
- border-color: #FECACA;
- }
- .pc-label {
- font-size: 22rpx;
- color: var(--h-txt-sub, #666);
- }
- .pc-time {
- font-size: 28rpx;
- font-weight: 700;
- color: var(--h-warn, #D97706);
- font-variant-numeric: tabular-nums;
- }
- /* 订单信息 */
- .pay-info-card {
- width: 100%;
- background: #fff;
- border-radius: var(--h-rad, 20rpx);
- padding: 24rpx 28rpx;
- margin-bottom: 32rpx;
- box-shadow: var(--h-shd, 0 2rpx 8rpx rgba(0, 0, 0, 0.04));
- }
- .pi-line {
- display: flex;
- justify-content: space-between;
- padding: 8rpx 0;
- font-size: 26rpx;
- }
- .pi-label {
- color: var(--h-txt-light, #999);
- }
- .pi-value {
- color: var(--h-txt, #1A1A1A);
- font-weight: 500;
- }
- /* 支付按钮 */
- .pay-btn {
- width: 100%;
- box-sizing: border-box;
- padding: 28rpx;
- border-radius: 48rpx;
- background: #07C160;
- color: #fff;
- font-size: 32rpx;
- font-weight: 700;
- text-align: center;
- box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
- margin-top: 8rpx;
- }
- .pay-btn:active {
- opacity: 0.85;
- }
- .pay-btn--disabled {
- opacity: 0.6;
- background: #9CA3AF;
- box-shadow: none;
- }
- .pay-btn--success {
- background: #16A34A;
- box-shadow: 0 8rpx 24rpx rgba(22, 163, 74, 0.3);
- }
- .pay-back {
- margin-top: 32rpx;
- font-size: 24rpx;
- color: var(--h-txt-light, #999);
- padding: 16rpx;
- }
- </style>
|