579 lines
12 KiB
Vue
579 lines
12 KiB
Vue
<template>
|
|
<view class="checkout-page">
|
|
<view v-if="loading" class="checkout-loading">
|
|
<text class="ri-loader-4-line loading-spinner"></text>
|
|
<text class="loading-text">正在加载订单...</text>
|
|
</view>
|
|
|
|
<view v-else-if="loadError" class="checkout-error">
|
|
<text class="error-text">{{ loadError }}</text>
|
|
</view>
|
|
|
|
<view v-else class="checkout-sheet">
|
|
<view v-if="returning" class="returning-mask">
|
|
<text class="ri-loader-4-line loading-spinner"></text>
|
|
<text class="returning-text">支付成功,正在返回...</text>
|
|
</view>
|
|
|
|
<view class="countdown-pill">
|
|
<view class="countdown-pill-inner">
|
|
<text class="countdown-label">支付剩余时间</text>
|
|
<text class="countdown-value">{{ countdownText }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="amount-block">
|
|
<text class="amount-currency">¥</text>
|
|
<text class="amount-value">{{ amountText }}</text>
|
|
</view>
|
|
<view class="merchant-name">{{ subjectText }}</view>
|
|
|
|
<view class="info-card">
|
|
<view class="info-row">
|
|
<text class="info-label">备注信息</text>
|
|
<input
|
|
v-model="remark"
|
|
class="info-input"
|
|
placeholder="点击填写订单备注"
|
|
placeholder-class="info-placeholder"
|
|
:disabled="paying || expired || returning"
|
|
/>
|
|
</view>
|
|
<view class="info-row">
|
|
<text class="info-label">订单信息</text>
|
|
<text class="info-value">{{ bodyText }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="pay-method-card">
|
|
<view class="pay-method-title">支付方式</view>
|
|
<view class="pay-method-item">
|
|
<view class="pay-method-left">
|
|
<view class="wx-icon">
|
|
<text class="ri-wechat-pay-fill"></text>
|
|
</view>
|
|
<text class="pay-method-name">微信支付</text>
|
|
</view>
|
|
<text class="ri-checkbox-circle-fill pay-method-check"></text>
|
|
</view>
|
|
</view>
|
|
|
|
<button
|
|
class="pay-button"
|
|
hover-class="pay-button-hover"
|
|
:loading="paying"
|
|
:disabled="expired || returning || !tradeNo"
|
|
@tap="handlePay"
|
|
>
|
|
确认支付 ¥{{ amountText }}
|
|
</button>
|
|
|
|
<view class="provider-footer">卓享汇收银台 提供服务</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue';
|
|
import { onLoad, onUnload } from '@dcloudio/uni-app';
|
|
import { cancelTrade, cashTradeInfo, cashTradePay, queryTrade } from '@/api/cashier';
|
|
import { resolveLoginPlatform } from '@/api/auth';
|
|
import { isCheckoutClosing, returnToHostMiniProgram, resetCheckoutClosing } from '@/utils/checkout-return';
|
|
import { formatPaymentError, isPaymentCancelled, requestPlatformPayment } from '@/utils/wxpay';
|
|
|
|
const POLL_INTERVAL_MS = 1000;
|
|
const POLL_MAX_ATTEMPTS = 240;
|
|
|
|
const tradeNo = ref('');
|
|
const subjectText = ref('');
|
|
const bodyText = ref('');
|
|
const amountText = ref('0.00');
|
|
const remark = ref('');
|
|
const loading = ref(true);
|
|
const loadError = ref('');
|
|
const paying = ref(false);
|
|
const returning = ref(false);
|
|
const remainSeconds = ref(0);
|
|
|
|
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
|
let hasPayAttempted = false;
|
|
let paymentSucceeded = false;
|
|
let timeoutHandled = false;
|
|
|
|
const expired = computed(() => remainSeconds.value <= 0);
|
|
const countdownText = computed(() => {
|
|
const minutes = String(Math.floor(remainSeconds.value / 60)).padStart(2, '0');
|
|
const seconds = String(remainSeconds.value % 60).padStart(2, '0');
|
|
return `${minutes}:${seconds}`;
|
|
});
|
|
|
|
const clearCountdown = () => {
|
|
if (countdownTimer) {
|
|
clearInterval(countdownTimer);
|
|
countdownTimer = null;
|
|
}
|
|
};
|
|
|
|
const parseDateTime = (value?: string) => {
|
|
if (!value) return NaN;
|
|
return new Date(value.replace(/-/g, '/')).getTime();
|
|
};
|
|
|
|
const resolveExpireAt = (trade: Record<string, any> = {}) => {
|
|
const timeoutSeconds = Number(trade.timeout || 0);
|
|
const createAt = parseDateTime(trade.create_time);
|
|
if (Number.isFinite(createAt) && timeoutSeconds > 0) {
|
|
return createAt + timeoutSeconds * 1000;
|
|
}
|
|
if (timeoutSeconds > 0) {
|
|
return Date.now() + timeoutSeconds * 1000;
|
|
}
|
|
return Date.now() + 5 * 60 * 1000;
|
|
};
|
|
|
|
const startCountdown = (expireAt: number) => {
|
|
clearCountdown();
|
|
const tick = () => {
|
|
const next = Math.max(0, Math.floor((expireAt - Date.now()) / 1000));
|
|
remainSeconds.value = next;
|
|
if (next <= 0) {
|
|
clearCountdown();
|
|
handleTimeout();
|
|
}
|
|
};
|
|
tick();
|
|
countdownTimer = setInterval(tick, 1000);
|
|
};
|
|
|
|
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
|
|
const isTradeNotPayable = (message = '') => {
|
|
const text = String(message);
|
|
return text.includes('订单状态') || text.includes('不允许付款') || text.includes('已取消') || text.includes('已关闭');
|
|
};
|
|
|
|
const finishCancelledAndReturn = async () => {
|
|
if (isCheckoutClosing() || !tradeNo.value) return;
|
|
|
|
try {
|
|
await cancelTrade(tradeNo.value);
|
|
} catch (error) {
|
|
console.log('cancel trade failed', error);
|
|
}
|
|
|
|
await backToHost('cancelled', 0);
|
|
};
|
|
|
|
const pollTradePaid = async () => {
|
|
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
|
|
try {
|
|
const response = await queryTrade(tradeNo.value);
|
|
if (Number(response?.code) === 200 && Number(response?.data?.pay_status) === 1) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.log('trade query failed', error);
|
|
}
|
|
|
|
if (attempt < POLL_MAX_ATTEMPTS - 1) {
|
|
await sleep(POLL_INTERVAL_MS);
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const backToHost = async (status: 'success' | 'cancelled' | 'timeout', payStatus?: number) => {
|
|
if (isCheckoutClosing() || !tradeNo.value) return false;
|
|
|
|
const returned = await returnToHostMiniProgram({
|
|
status,
|
|
trade_no: tradeNo.value,
|
|
pay_status: payStatus
|
|
});
|
|
|
|
if (!returned) {
|
|
resetCheckoutClosing();
|
|
}
|
|
|
|
return returned;
|
|
};
|
|
|
|
const handleTimeout = async () => {
|
|
if (timeoutHandled || paymentSucceeded || isCheckoutClosing()) return;
|
|
timeoutHandled = true;
|
|
clearCountdown();
|
|
|
|
try {
|
|
await cancelTrade(tradeNo.value);
|
|
} catch (error) {
|
|
console.log('timeout cancel failed', error);
|
|
}
|
|
|
|
await backToHost('timeout', 0);
|
|
};
|
|
|
|
const loadOrderInfo = async () => {
|
|
loading.value = true;
|
|
loadError.value = '';
|
|
|
|
try {
|
|
const response = await cashTradeInfo(tradeNo.value);
|
|
if (Number(response?.code) !== 200 || !response?.data) {
|
|
throw new Error(response?.msg || '获取订单信息失败');
|
|
}
|
|
|
|
const payload = response.data || {};
|
|
const trade = payload.trade || {};
|
|
if (Number(trade.pay_status) === 1) {
|
|
paymentSucceeded = true;
|
|
await backToHost('success', 1);
|
|
return;
|
|
}
|
|
|
|
subjectText.value = payload.subject || '';
|
|
bodyText.value = payload.body || '';
|
|
amountText.value = Number(trade.real_money || 0).toFixed(2);
|
|
startCountdown(resolveExpireAt(trade));
|
|
} catch (error: any) {
|
|
loadError.value = error?.message || '获取订单信息失败';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handlePay = async () => {
|
|
if (paying.value || expired.value || returning.value || !tradeNo.value) return;
|
|
|
|
paying.value = true;
|
|
hasPayAttempted = true;
|
|
|
|
try {
|
|
const response = await cashTradePay({
|
|
trade_no: tradeNo.value,
|
|
platform: resolveLoginPlatform(),
|
|
remark: remark.value.trim()
|
|
});
|
|
|
|
if (Number(response?.code) !== 200 || !response?.data) {
|
|
const msg = response?.msg || '获取支付参数失败';
|
|
if (isTradeNotPayable(msg)) {
|
|
await finishCancelledAndReturn();
|
|
return;
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
|
|
const payload = response.data || {};
|
|
|
|
try {
|
|
await requestPlatformPayment(payload);
|
|
} catch (error) {
|
|
if (isPaymentCancelled(error)) {
|
|
await finishCancelledAndReturn();
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
returning.value = true;
|
|
const paid = await pollTradePaid();
|
|
if (paid) {
|
|
paymentSucceeded = true;
|
|
await backToHost('success', 1);
|
|
return;
|
|
}
|
|
|
|
await backToHost('success', 0);
|
|
} catch (error: any) {
|
|
const message = formatPaymentError(error);
|
|
if (isTradeNotPayable(message)) {
|
|
await finishCancelledAndReturn();
|
|
return;
|
|
}
|
|
uni.showToast({
|
|
title: message,
|
|
icon: 'none'
|
|
});
|
|
} finally {
|
|
paying.value = false;
|
|
returning.value = false;
|
|
}
|
|
};
|
|
|
|
const decodeQuery = (value?: string) => {
|
|
if (!value) return '';
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch (error) {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
onLoad((options: any) => {
|
|
const nextTradeNo = decodeQuery(options?.trade_no || options?.tradeNo).trim();
|
|
if (!nextTradeNo) {
|
|
loadError.value = '缺少交易单号';
|
|
loading.value = false;
|
|
return;
|
|
}
|
|
|
|
tradeNo.value = nextTradeNo;
|
|
loadOrderInfo();
|
|
});
|
|
|
|
onUnload(() => {
|
|
clearCountdown();
|
|
|
|
if (isCheckoutClosing() || paymentSucceeded || !tradeNo.value) return;
|
|
|
|
const currentTradeNo = tradeNo.value;
|
|
cancelTrade(currentTradeNo).catch(() => {});
|
|
returnToHostMiniProgram({
|
|
status: 'cancelled',
|
|
trade_no: currentTradeNo,
|
|
pay_status: 0
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.checkout-page {
|
|
box-sizing: border-box;
|
|
min-height: 100vh;
|
|
padding: 24rpx 0 calc(32rpx + env(safe-area-inset-bottom));
|
|
background: #f3f4f6;
|
|
}
|
|
|
|
.checkout-loading,
|
|
.checkout-error {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 60vh;
|
|
gap: 16rpx;
|
|
}
|
|
|
|
.loading-spinner {
|
|
color: #1f5bd8;
|
|
font-size: 56rpx;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.loading-text,
|
|
.error-text,
|
|
.returning-text {
|
|
color: #6b7280;
|
|
font-size: 28rpx;
|
|
}
|
|
|
|
.error-text {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.checkout-sheet {
|
|
position: relative;
|
|
padding: 16rpx 32rpx 0;
|
|
}
|
|
|
|
.returning-mask {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 20;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16rpx;
|
|
background: rgba(255, 255, 255, 0.92);
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.countdown-pill {
|
|
display: flex;
|
|
justify-content: center;
|
|
margin-bottom: 40rpx;
|
|
}
|
|
|
|
.countdown-pill-inner {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 12rpx;
|
|
padding: 10rpx 24rpx;
|
|
border-radius: 999rpx;
|
|
background: #ffffff;
|
|
box-shadow: 0 4rpx 16rpx rgba(17, 24, 39, 0.06);
|
|
}
|
|
|
|
.countdown-label,
|
|
.countdown-value {
|
|
color: #6b7280;
|
|
font-size: 24rpx;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.countdown-value {
|
|
color: #111827;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.amount-block {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: center;
|
|
}
|
|
|
|
.amount-currency {
|
|
margin-right: 8rpx;
|
|
color: #111827;
|
|
font-size: 44rpx;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
}
|
|
|
|
.amount-value {
|
|
color: #111827;
|
|
font-size: 72rpx;
|
|
font-weight: 800;
|
|
line-height: 1;
|
|
}
|
|
|
|
.merchant-name {
|
|
margin-top: 20rpx;
|
|
text-align: center;
|
|
color: #6b7280;
|
|
font-size: 28rpx;
|
|
}
|
|
|
|
.info-card,
|
|
.pay-method-card {
|
|
margin-top: 40rpx;
|
|
border-radius: 16rpx;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.info-card {
|
|
padding: 4rpx 0;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 24rpx;
|
|
padding: 28rpx;
|
|
border-bottom: 1rpx solid #f0f2f5;
|
|
}
|
|
|
|
.info-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.info-label {
|
|
flex-shrink: 0;
|
|
color: #6b7280;
|
|
font-size: 28rpx;
|
|
}
|
|
|
|
.info-input,
|
|
.info-value {
|
|
flex: 1;
|
|
color: #111827;
|
|
font-size: 28rpx;
|
|
text-align: right;
|
|
}
|
|
|
|
.info-placeholder {
|
|
color: #c2c7d0;
|
|
}
|
|
|
|
.pay-method-card {
|
|
padding: 28rpx;
|
|
}
|
|
|
|
.pay-method-title {
|
|
margin-bottom: 20rpx;
|
|
color: #6b7280;
|
|
font-size: 28rpx;
|
|
}
|
|
|
|
.pay-method-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.pay-method-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16rpx;
|
|
}
|
|
|
|
.wx-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 48rpx;
|
|
height: 48rpx;
|
|
border-radius: 50%;
|
|
background: #07c160;
|
|
color: #ffffff;
|
|
font-size: 36rpx;
|
|
line-height: 1;
|
|
}
|
|
|
|
.pay-method-name {
|
|
color: #111827;
|
|
font-size: 30rpx;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.pay-method-check {
|
|
color: #07c160;
|
|
font-size: 36rpx;
|
|
line-height: 1;
|
|
}
|
|
|
|
.pay-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 92rpx;
|
|
margin-top: 48rpx;
|
|
padding: 0;
|
|
border-radius: 12rpx;
|
|
background: #1f5bd8;
|
|
color: #ffffff;
|
|
font-size: 32rpx;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.pay-button::after {
|
|
border: none;
|
|
}
|
|
|
|
.pay-button-hover {
|
|
background: #174dc1;
|
|
}
|
|
|
|
.pay-button[disabled] {
|
|
background: #c8d3ee;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.provider-footer {
|
|
margin-top: 28rpx;
|
|
text-align: center;
|
|
color: #9ca3af;
|
|
font-size: 24rpx;
|
|
}
|
|
</style>
|