移动端浏览器支付 ok

This commit is contained in:
yml2213
2026-06-13 19:17:02 +08:00
parent 9d7184ecde
commit 6ce031160b
4 changed files with 337 additions and 39 deletions
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { formatCent } from '@/shared/utils/money'
import type { PaymentOrder } from '@/features/orders/api/orders'
defineProps<{
show: boolean
payment: PaymentOrder | null
payUrl: string
qrCodeUrl: string
qrGenerating: boolean
checking: boolean
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
refresh: []
}>()
</script>
<template>
<van-popup
:show="show"
position="bottom"
round
class="mobile-pay-popup"
@update:show="emit('update:show', $event)"
>
<header class="mobile-pay-header">
<div>
<span>订单支付</span>
<strong v-if="payment">¥{{ formatCent(payment.amount_cent) }}</strong>
</div>
<button type="button" aria-label="关闭支付弹窗" @click="emit('update:show', false)">
<van-icon name="cross" :size="20" />
</button>
</header>
<div class="mobile-pay-body">
<div v-if="payUrl" class="mobile-pay-qr">
<van-loading v-if="qrGenerating" size="28px" />
<img v-else-if="qrCodeUrl" :src="qrCodeUrl" alt="支付二维码" />
<van-icon v-else name="qr" :size="46" />
</div>
<p class="mobile-pay-title">请使用微信或支付宝扫码支付</p>
<p class="mobile-pay-desc">支付完成后页面会自动刷新也可以手动点击下方按钮确认</p>
<van-button
type="primary"
block
round
:loading="checking"
loading-text="查询中..."
@click="emit('refresh')"
>
我已支付刷新状态
</van-button>
</div>
</van-popup>
</template>
<style scoped>
.mobile-pay-popup {
overflow: hidden;
padding-bottom: calc(18px + env(safe-area-inset-bottom));
background: #fff;
}
.mobile-pay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 12px;
border-bottom: 1px solid #f1f5f9;
}
.mobile-pay-header div {
display: flex;
flex-direction: column;
gap: 3px;
}
.mobile-pay-header span {
color: #111827;
font-size: 16px;
font-weight: 800;
}
.mobile-pay-header strong {
color: #ff5f00;
font-size: 20px;
font-weight: 900;
}
.mobile-pay-header button {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 0;
border-radius: 18px;
background: #f8fafc;
color: #64748b;
}
.mobile-pay-body {
padding: 18px 20px 0;
text-align: center;
}
.mobile-pay-qr {
display: grid;
width: 224px;
height: 224px;
margin: 0 auto 14px;
place-items: center;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
}
.mobile-pay-qr img {
width: 210px;
height: 210px;
}
.mobile-pay-title {
margin: 0;
color: #111827;
font-size: 15px;
font-weight: 800;
}
.mobile-pay-desc {
margin: 8px 0 18px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,150 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import QRCode from 'qrcode'
import { showDialog, showToast } from 'vant'
import { queryOrderPayment, type PaymentOrder } from '@/features/orders/api/orders'
type PaidHandler = () => Promise<void> | void
export function mobilePaymentPayURL(payment?: PaymentOrder | null) {
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
}
function isInAppPaymentBrowser() {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
return /MicroMessenger|AlipayClient/i.test(ua)
}
function isHTTPURL(value: string) {
return /^https?:\/\//i.test(value)
}
export function useMobilePaymentCashier() {
const paymentPopupVisible = ref(false)
const activePayment = ref<PaymentOrder | null>(null)
const paymentQRCodeURL = ref('')
const qrGenerating = ref(false)
const checkingPayment = ref(false)
let paymentPollingTimer: number | undefined
let paidHandler: PaidHandler | undefined
const payURL = computed(() => mobilePaymentPayURL(activePayment.value))
async function openMobilePaymentCashier(payment: PaymentOrder, onPaid?: PaidHandler) {
activePayment.value = payment
paidHandler = onPaid
if (payment.paid) {
await handlePaid()
return
}
const currentPayURL = mobilePaymentPayURL(payment)
if (currentPayURL && isHTTPURL(currentPayURL) && isInAppPaymentBrowser()) {
window.location.href = currentPayURL
return
}
if (currentPayURL && isHTTPURL(currentPayURL)) {
paymentPopupVisible.value = true
await renderPaymentQRCode(payment)
startPaymentPolling()
return
}
await showDialog({
title: '订单支付',
message: currentPayURL || '支付单已创建,请稍后刷新订单状态。',
confirmButtonText: '知道了',
})
}
async function renderPaymentQRCode(payment = activePayment.value) {
const currentPayURL = mobilePaymentPayURL(payment)
paymentQRCodeURL.value = ''
if (!currentPayURL || !isHTTPURL(currentPayURL)) return
qrGenerating.value = true
try {
paymentQRCodeURL.value = await QRCode.toDataURL(currentPayURL, {
width: 220,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111827',
light: '#ffffff',
},
})
} catch {
showToast({ message: '二维码生成失败', icon: 'cross' })
} finally {
qrGenerating.value = false
}
}
function startPaymentPolling() {
stopPaymentPolling()
paymentPollingTimer = window.setInterval(() => {
void refreshPaymentStatus(true)
}, 2500)
}
function stopPaymentPolling() {
if (paymentPollingTimer === undefined) return
window.clearInterval(paymentPollingTimer)
paymentPollingTimer = undefined
}
async function refreshPaymentStatus(silent = false) {
if (!activePayment.value || checkingPayment.value) return
checkingPayment.value = true
const previousPayURL = payURL.value
try {
const payment = await queryOrderPayment(activePayment.value.order_id)
activePayment.value = payment
if (payment.paid) {
await handlePaid()
return
}
if (mobilePaymentPayURL(payment) !== previousPayURL) {
await renderPaymentQRCode(payment)
}
if (!silent) {
showToast({ message: '支付暂未完成', icon: 'info-o' })
}
} catch {
if (!silent) {
showToast({ message: '查询支付状态失败', icon: 'cross' })
}
} finally {
checkingPayment.value = false
}
}
async function handlePaid() {
stopPaymentPolling()
paymentPopupVisible.value = false
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await paidHandler?.()
}
watch(paymentPopupVisible, visible => {
if (!visible) stopPaymentPolling()
})
onBeforeUnmount(stopPaymentPolling)
return {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
openMobilePaymentCashier,
refreshPaymentStatus,
stopPaymentPolling,
}
}
@@ -23,8 +23,9 @@ import {
OrderResourceUsageEditor,
type HandoffRecord,
type Order,
type PaymentOrder,
} from '@/features/orders'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import {
amountYuan,
hydrateCounterFormFromCheckout,
@@ -92,6 +93,17 @@ const showCounterPopup = ref(false)
const showRejectPopup = ref(false)
let autoPayHandled = false
const {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
openMobilePaymentCashier,
refreshPaymentStatus,
} = useMobilePaymentCashier()
const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金'))
@@ -186,7 +198,7 @@ async function handlePay() {
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await loadOrder()
} else {
openPaymentCashier(payment)
await openMobilePaymentCashier(payment, loadOrder)
}
} catch (error) {
showToast({ message: readError(error, '支付失败'), icon: 'cross' })
@@ -195,19 +207,6 @@ async function handlePay() {
}
}
function openPaymentCashier(payment: PaymentOrder) {
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || ''
if (payURL && /^https?:\/\//i.test(payURL)) {
window.location.href = payURL
return
}
showDialog({
title: '订单支付',
message: payURL || '支付单已创建,请稍后刷新订单状态。',
confirmButtonText: '知道了',
})
}
async function handleSubmitHandoff() {
if (!order.value) return
if (!handoffContent.value.trim()) {
@@ -764,6 +763,16 @@ async function copyListingCode() {
<van-empty image="search" description="订单不存在或已被删除" />
</div>
<MobilePaymentCashierPopup
v-model:show="paymentPopupVisible"
:payment="activePayment"
:pay-url="payURL"
:qr-code-url="paymentQRCodeURL"
:qr-generating="qrGenerating"
:checking="checkingPayment"
@refresh="refreshPaymentStatus(false)"
/>
<!-- POPUP: Owner Counter Checkout Adjustments -->
<van-popup v-model:show="showCounterPopup" position="bottom" round class="mobile-popup-form">
<header class="popup-header">
@@ -2,15 +2,12 @@
import { readError } from '@/shared/utils/error'
import { onMounted, ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showDialog, showToast } from 'vant'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import {
fetchOrders,
startOrderPayment,
type Order,
type PaymentOrder,
} from '@/features/orders/api/orders'
import { fetchOrders, startOrderPayment, type Order } from '@/features/orders/api/orders'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import { centToYuan, formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/shared/utils/time'
@@ -23,6 +20,16 @@ const loading = ref(false)
const orders = ref<Order[]>([])
const activeTab = ref('all')
const payingOrderId = ref<number | null>(null)
const {
paymentPopupVisible,
activePayment,
payURL,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
openMobilePaymentCashier,
refreshPaymentStatus,
} = useMobilePaymentCashier()
onMounted(() => {
loadOrders()
@@ -89,7 +96,7 @@ async function handlePay(order: Order) {
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await loadOrders()
} else {
openPaymentCashier(payment)
await openMobilePaymentCashier(payment, loadOrders)
}
} catch (error) {
showToast({ message: readError(error, '支付失败'), icon: 'cross' })
@@ -98,22 +105,6 @@ async function handlePay(order: Order) {
}
}
function openPaymentCashier(payment: PaymentOrder) {
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || ''
if (payURL && /^https?:\/\//i.test(payURL)) {
window.location.href = payURL
return
}
showDialog({
title: '订单支付',
message: payURL || '支付单已创建,请在订单详情页刷新支付状态。',
confirmButtonText: '查看详情',
}).then(() => {
router.push(`/m/orders/${payment.order_id}`)
})
}
function money(value: unknown) {
return formatMoney(Number(value || 0))
}
@@ -267,6 +258,16 @@ async function copyListingCode(order: Order) {
</section>
<!-- 底部导航 -->
<MobilePaymentCashierPopup
v-model:show="paymentPopupVisible"
:payment="activePayment"
:pay-url="payURL"
:qr-code-url="paymentQRCodeURL"
:qr-generating="qrGenerating"
:checking="checkingPayment"
@refresh="refreshPaymentStatus(false)"
/>
<MobileBottomNav />
</main>
</template>