cashier/pages/pay/checkout.vue

589 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 { isCheckoutClosing, returnToHostMiniProgram, resetCheckoutClosing } from '@/utils/checkout-return';
import { isPaymentCancelled, requestWxPayment } from '@/utils/wxpay';
const POLL_INTERVAL_MS = 1000;
const POLL_MAX_ATTEMPTS = 120;
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: 'mp-weixin',
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 || {};
const params = {
timeStamp: String(payload.timestamp || payload.timeStamp || ''),
nonceStr: payload.nonce_str || payload.nonceStr || '',
package: payload.package || '',
signType: payload.sign_type || payload.signType || 'RSA',
paySign: payload.pay_sign || payload.paySign || ''
};
if (!params.paySign) {
throw new Error('支付参数不完整');
}
try {
await requestWxPayment(params);
} 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 = error?.message || '支付失败';
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>