<template>
|
<view class="pending-page">
|
<app-nav title="名导精讲" />
|
|
<scroll-view class="pending-scroll" scroll-y="true">
|
<view class="status-panel">
|
<text class="status-title">待支付</text>
|
<text class="countdown-text">{{ countdownText }}</text>
|
<text class="status-desc">请在倒计时结束前完成支付,超时订单将自动取消</text>
|
</view>
|
|
<view class="detail-card">
|
<view class="order-summary">
|
<view class="order-title-row">
|
<view class="order-title-main">
|
<text class="order-scenic">{{ order.scenic }}</text>
|
<text class="order-service">{{ order.service }}</text>
|
<text class="order-duration">{{ order.duration }}</text>
|
</view>
|
<text>{{ order.meetingPoint }}</text>
|
</view>
|
|
<view class="price-row">
|
<view class="unit-block">
|
<text class="unit-price">¥{{ order.unitPrice }}/人</text>
|
<text class="quantity">x{{ order.quantity }}</text>
|
</view>
|
<text class="total-price">¥{{ order.total }}</text>
|
</view>
|
</view>
|
</view>
|
</scroll-view>
|
|
<view class="bottom-bar">
|
<app-action-button
|
class="bottom-action bottom-action--ghost"
|
variant="ghost"
|
text="取消订单"
|
:height="76"
|
:radius="18"
|
:font-size="26"
|
@tap="handleCancel"
|
/>
|
<app-action-button
|
class="bottom-action bottom-action--primary"
|
text="立即支付"
|
loading-text="支付中..."
|
success-text="支付成功"
|
:height="76"
|
:radius="18"
|
:font-size="26"
|
:loading="paying"
|
:success="paySuccess"
|
@tap="handlePay"
|
/>
|
</view>
|
|
<cancel-order-sheet
|
:visible="cancelSheetVisible"
|
@close="closeCancelSheet"
|
@confirm="confirmCancelOrder"
|
/>
|
</view>
|
</template>
|
|
<script setup>
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
import AppNav from '@/components/app-nav/app-nav.vue'
|
import CancelOrderSheet from '@/components/cancel-order-sheet/cancel-order-sheet.vue'
|
import { cancelTourOrder, getTourOrder, payTourOrder } from '@/services/order'
|
import { useLoginGuard } from '@/utils/auth-guard'
|
import { requestWechatPayment } from '@/utils/payment'
|
|
useLoginGuard('/pages/order-pending-pay/index')
|
|
const ORDER_POLL_INTERVAL = 3000
|
const DEFAULT_PAYMENT_TIMEOUT_SECONDS = 5 * 60
|
const PAID_STATUSES = ['PENDING_ACCEPT', 'PENDING_MEETING', 'IN_SERVICE', 'COMPLETED']
|
const TERMINAL_STATUSES = ['CANCELLED', 'REFUNDED']
|
|
const order = ref({
|
id: '',
|
scenic: '青城山景区',
|
service: '拼团·快讲',
|
duration: '30分钟',
|
date: '今天 14:30',
|
meetingPoint: '青城山正门汇合',
|
unitPrice: 36,
|
quantity: 4,
|
total: '366.00'
|
})
|
const orderStatus = ref('PENDING_PAYMENT')
|
|
const remainingSeconds = ref(DEFAULT_PAYMENT_TIMEOUT_SECONDS)
|
const paymentTimeoutSeconds = ref(DEFAULT_PAYMENT_TIMEOUT_SECONDS)
|
const paymentDeadline = ref(0)
|
const cancelSheetVisible = ref(false)
|
const paying = ref(false)
|
const paySuccess = ref(false)
|
const returnHomeAfterPayment = ref(false)
|
let orderPollTimer = null
|
let orderRefreshing = false
|
let pageActive = false
|
let redirecting = false
|
let loadErrorShown = false
|
const timer = setInterval(() => {
|
updateCountdown()
|
}, 1000)
|
|
const countdownText = computed(() => {
|
const minutes = Math.floor(remainingSeconds.value / 60)
|
const seconds = remainingSeconds.value % 60
|
return `00 : ${formatTime(minutes)} : ${formatTime(seconds)}`
|
})
|
|
onLoad((options = {}) => {
|
const orderId = options.orderId || options.id
|
if (orderId) {
|
order.value.id = orderId
|
}
|
})
|
|
onShow(() => {
|
pageActive = true
|
redirecting = false
|
startOrderPolling()
|
})
|
|
onHide(() => {
|
pageActive = false
|
stopOrderPolling()
|
})
|
|
onUnload(() => {
|
pageActive = false
|
stopOrderPolling()
|
})
|
|
onBeforeUnmount(() => {
|
clearInterval(timer)
|
stopOrderPolling()
|
})
|
|
function formatTime(value) {
|
return String(value).padStart(2, '0')
|
}
|
|
function startOrderPolling() {
|
stopOrderPolling()
|
if (!order.value.id) {
|
uni.showToast({ title: '订单参数缺失', icon: 'none' })
|
return
|
}
|
|
refreshOrderStatus()
|
orderPollTimer = setInterval(refreshOrderStatus, ORDER_POLL_INTERVAL)
|
}
|
|
function stopOrderPolling() {
|
if (orderPollTimer) {
|
clearInterval(orderPollTimer)
|
orderPollTimer = null
|
}
|
}
|
|
async function refreshOrderStatus() {
|
if (!pageActive || !order.value.id || orderRefreshing || redirecting) return
|
|
orderRefreshing = true
|
try {
|
const detail = await getTourOrder(order.value.id)
|
if (!pageActive) return
|
updateOrder(detail)
|
loadErrorShown = false
|
const status = detail && detail.status
|
if (paying.value && PAID_STATUSES.includes(status)) {
|
returnHomeAfterPayment.value = true
|
}
|
redirectForOrderStatus(status)
|
} catch (error) {
|
if (!loadErrorShown) {
|
loadErrorShown = true
|
uni.showToast({ title: error.message || '订单状态更新失败', icon: 'none' })
|
}
|
console.warn('待支付订单状态查询失败', error)
|
} finally {
|
orderRefreshing = false
|
}
|
}
|
|
function updateOrder(detail) {
|
if (!detail) return
|
orderStatus.value = detail.status || orderStatus.value
|
updatePaymentDeadline(detail)
|
order.value = {
|
...order.value,
|
id: detail.id,
|
scenic: detail.scenicName || '',
|
service: detail.packageName || '',
|
duration: getDuration(detail.orderType),
|
date: '',
|
meetingPoint: detail.meetingPointName || '',
|
unitPrice: Number(detail.unitPrice || 0).toFixed(2),
|
quantity: detail.peopleCount || 1,
|
total: Number(detail.amount || 0).toFixed(2)
|
}
|
}
|
|
function updatePaymentDeadline(detail) {
|
const placedAt = parseServerTime(detail.placedAt)
|
const createdAt = parseServerTime(detail.createdAt)
|
const baseTime = placedAt || createdAt
|
if (!baseTime) return
|
|
const configuredMinutes = Number(detail.unpaidCancelMinutes)
|
const timeoutSeconds = (Number.isFinite(configuredMinutes) && configuredMinutes > 0
|
? configuredMinutes * 60
|
: DEFAULT_PAYMENT_TIMEOUT_SECONDS)
|
paymentTimeoutSeconds.value = timeoutSeconds
|
paymentDeadline.value = baseTime + timeoutSeconds * 1000
|
updateCountdown()
|
}
|
|
function updateCountdown() {
|
if (!paymentDeadline.value) return
|
const previous = remainingSeconds.value
|
remainingSeconds.value = Math.max(
|
0,
|
Math.min(
|
paymentTimeoutSeconds.value,
|
Math.ceil((paymentDeadline.value - Date.now()) / 1000)
|
)
|
)
|
if (previous > 0 && remainingSeconds.value === 0) {
|
onCountdownExpired()
|
}
|
}
|
|
function onCountdownExpired() {
|
if (pageActive && order.value.id && !paying.value && !paySuccess.value) {
|
redirectForOrderStatus('CANCELLED')
|
}
|
}
|
|
function parseServerTime(value) {
|
if (!value) return 0
|
if (typeof value === 'number') return value < 100000000000 ? value * 1000 : value
|
const timestamp = Date.parse(String(value).trim().replace(' ', 'T'))
|
return Number.isNaN(timestamp) ? 0 : timestamp
|
}
|
|
function redirectForOrderStatus(status) {
|
const routeMap = {
|
PENDING_ACCEPT: '/pages/order-waiting/index',
|
PENDING_MEETING: '/pages/order-pending-guide/index',
|
IN_SERVICE: '/pages/order-serving/index',
|
COMPLETED: '/pages/order-pending-review/index',
|
CANCELLED: '/pages/order-canceled/index',
|
REFUNDED: '/pages/order-canceled/index'
|
}
|
const target = routeMap[status]
|
if (!target || redirecting) return
|
|
redirecting = true
|
stopOrderPolling()
|
const returnHomeQuery = returnHomeAfterPayment.value ? '&returnHome=1' : ''
|
uni.redirectTo({
|
url: `${target}?orderId=${order.value.id}${returnHomeQuery}`,
|
fail() {
|
redirecting = false
|
if (pageActive) startOrderPolling()
|
}
|
})
|
}
|
|
function getDuration(orderType) {
|
return String(orderType || '').endsWith('PREMIUM') ? '60分钟' : '30分钟'
|
}
|
|
function handleCancel() {
|
cancelSheetVisible.value = true
|
}
|
|
function closeCancelSheet() {
|
cancelSheetVisible.value = false
|
}
|
|
async function confirmCancelOrder() {
|
if (!order.value.id) return
|
|
try {
|
await cancelTourOrder(order.value.id)
|
} catch (error) {
|
return
|
}
|
cancelSheetVisible.value = false
|
uni.redirectTo({
|
url: `/pages/order-canceled/index?id=${order.value.id}`
|
})
|
}
|
|
async function handlePay() {
|
if (!order.value.id || paying.value || paySuccess.value) return
|
|
paying.value = true
|
try {
|
const payment = await payTourOrder(order.value.id)
|
await requestWechatPayment(payment)
|
returnHomeAfterPayment.value = true
|
const confirmed = await waitForPaymentConfirmation(order.value.id)
|
if (!confirmed) {
|
returnHomeAfterPayment.value = false
|
paying.value = false
|
uni.showToast({
|
title: '支付结果确认中,请稍后刷新',
|
icon: 'none'
|
})
|
return
|
}
|
|
paying.value = false
|
paySuccess.value = true
|
redirectForOrderStatus(orderStatus.value)
|
} catch (error) {
|
paying.value = false
|
paySuccess.value = false
|
returnHomeAfterPayment.value = false
|
await refreshOrderStatus()
|
const message = error && (error.errMsg || error.message)
|
if (message && message.includes('cancel')) {
|
uni.showToast({ title: '已取消支付', icon: 'none' })
|
} else if (message) {
|
uni.showToast({ title: message, icon: 'none' })
|
}
|
}
|
}
|
|
async function waitForPaymentConfirmation(orderId) {
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
const current = await getTourOrder(orderId)
|
if (current) {
|
updateOrder(current)
|
if (PAID_STATUSES.includes(current.status)) {
|
returnHomeAfterPayment.value = true
|
return true
|
}
|
}
|
if (current && TERMINAL_STATUSES.includes(current.status)) {
|
throw new Error('订单已取消,无法确认支付')
|
}
|
await new Promise((resolve) => setTimeout(resolve, 800))
|
}
|
return false
|
}
|
</script>
|
|
<style lang="scss" scoped>
|
.pending-page {
|
height: 100vh;
|
display: flex;
|
flex-direction: column;
|
overflow: hidden;
|
background: #f4f6fa;
|
}
|
|
.pending-scroll {
|
flex: 1;
|
min-height: 0;
|
}
|
|
.status-panel {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
padding: 34rpx 28rpx 38rpx;
|
background: #ffffff;
|
box-shadow: 0 4rpx 14rpx rgba(17, 24, 39, 0.04);
|
}
|
|
.status-title {
|
color: #111827;
|
font-size: 34rpx;
|
font-weight: 850;
|
line-height: 1;
|
}
|
|
.countdown-text {
|
margin-top: 34rpx;
|
color: #111827;
|
font-size: 32rpx;
|
font-weight: 900;
|
line-height: 1;
|
letter-spacing: 0;
|
}
|
|
.status-desc {
|
margin-top: 20rpx;
|
color: #8a93a2;
|
font-size: 23rpx;
|
font-weight: 650;
|
line-height: 1;
|
}
|
|
.detail-card {
|
margin: 24rpx 28rpx 0;
|
padding: 30rpx;
|
border-radius: 28rpx;
|
background: #ffffff;
|
box-shadow: 0 6rpx 18rpx rgba(17, 24, 39, 0.04);
|
}
|
|
.order-title-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 24rpx;
|
}
|
|
.order-title-main {
|
display: flex;
|
align-items: center;
|
flex: 1;
|
min-width: 0;
|
}
|
|
.order-scenic,
|
.order-service {
|
color: #111827;
|
font-size: 27rpx;
|
font-weight: 900;
|
line-height: 1;
|
white-space: nowrap;
|
}
|
|
.order-service {
|
margin-left: 10rpx;
|
}
|
|
.order-duration {
|
margin-left: 12rpx;
|
color: #8a93a2;
|
font-size: 24rpx;
|
font-weight: 750;
|
line-height: 1;
|
}
|
|
.order-title-row > text {
|
flex: 0 1 auto;
|
max-width: 220rpx;
|
color: #8a93a2;
|
font-size: 23rpx;
|
font-weight: 750;
|
line-height: 1;
|
overflow: hidden;
|
text-align: right;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.price-row {
|
display: flex;
|
align-items: flex-end;
|
justify-content: space-between;
|
margin-top: 44rpx;
|
}
|
|
.unit-block {
|
display: flex;
|
align-items: baseline;
|
gap: 34rpx;
|
}
|
|
.unit-price {
|
color: #8a93a2;
|
font-size: 31rpx;
|
font-weight: 850;
|
line-height: 1;
|
}
|
|
.quantity {
|
color: #8a93a2;
|
font-size: 22rpx;
|
font-weight: 800;
|
line-height: 1;
|
}
|
|
.total-price {
|
color: #111827;
|
font-size: 34rpx;
|
font-weight: 900;
|
line-height: 1;
|
}
|
|
.bottom-bar {
|
position: fixed;
|
left: 0;
|
right: 0;
|
bottom: 0;
|
z-index: 30;
|
display: flex;
|
align-items: center;
|
height: calc(112rpx + env(safe-area-inset-bottom));
|
padding: 0 28rpx env(safe-area-inset-bottom);
|
border-top: 2rpx solid #edf1f6;
|
background: rgba(255, 255, 255, 0.98);
|
box-sizing: border-box;
|
}
|
|
.bottom-action {
|
flex: 1;
|
}
|
|
.bottom-action--ghost {
|
color: #111827;
|
}
|
|
.bottom-action--primary {
|
margin-left: 22rpx;
|
}
|
|
</style>
|