Files
jczxw-pc/app/components/payment/PaymentModal.vue
2026-04-23 16:30:57 +08:00

571 lines
18 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-modal
:open="visible"
:title="title"
:width="payMethod === 'native' ? 480 : 420"
:footer="footerContent"
:mask-closable="false"
:destroy-on-close="true"
@cancel="handleClose"
>
<!-- 订单信息 -->
<div class="mb-5">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="订单号">
<span class="font-mono text-sm">{{ orderNo }}</span>
</a-descriptions-item>
<a-descriptions-item label="订单金额">
<span class="text-xl font-bold text-orange-500">¥{{ payPrice }}</span>
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 支付方式选择 -->
<div v-if="!started" class="space-y-3">
<div class="text-sm text-gray-500 mb-2">请选择支付方式</div>
<!-- 微信支付 -->
<div
v-if="availableMethods.wechat"
class="pay-method-item"
:class="{ active: selectedMethod === 'wechat' }"
@click="selectedMethod = 'wechat'"
>
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="#07C160">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.11.24-.245 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.87a5.755 5.755 0 0 0-.407-.012zm-1.155 2.26c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.857 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>
<div class="flex-1 ml-3">
<div class="font-medium">微信支付</div>
<div class="text-xs text-gray-400">推荐</div>
</div>
<a-radio :checked="selectedMethod === 'wechat'" />
</div>
<!-- 支付宝 -->
<div
v-if="availableMethods.alipay"
class="pay-method-item"
:class="{ active: selectedMethod === 'alipay' }"
@click="selectedMethod = 'alipay'"
>
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="#1677FF">
<path d="M21.97 11.1c-1.23-.79-2.75-1.04-4.33-.86-1.09-1.65-2.78-2.59-4.72-2.59-2.91 0-5.27 2.27-5.27 5.06 0 2.07 1.25 3.89 3.1 4.78V13.2c-.42-.11-.86-.17-1.32-.17-2.56 0-4.64 2.03-4.64 4.53 0 2.51 2.08 4.53 4.64 4.53.32 0 .63-.03.93-.08v2.58c-.31.04-.63.06-.95.06-3.45 0-6.25-2.74-6.25-6.1 0-3.36 2.8-6.1 6.25-6.1 1.01 0 1.95.24 2.78.67-.05-.32-.08-.65-.08-.99 0-2.59 2.15-4.69 4.79-4.69 1.52 0 2.88.68 3.78 1.75.19-.03.38-.05.58-.05 1.94 0 3.51 1.57 3.51 3.51 0 1.06-.48 2-1.24 2.64 1.1.4 1.83 1.47 1.83 2.67 0 1.73-1.49 3.13-3.32 3.13-.65 0-1.26-.18-1.78-.5.06.22.1.45.1.69 0 2.07-1.7 3.75-3.79 3.75-.45 0-.89-.07-1.3-.22v2.49c.45.13.92.2 1.4.2 2.97 0 5.38-2.36 5.38-5.26 0-2.22-1.32-4.15-3.22-4.93zm-8.16 2.09c-.46 0-.84-.37-.84-.83s.38-.84.84-.84.84.38.84.84-.38.83-.84.83zm5.14-.83c0-.46.38-.84.84-.84s.84.38.84.84-.38.83-.84.83-.84-.37-.84-.83zm-.87 2.49l-.69-.55c-.34-.27-.55-.67-.55-1.1 0-.74.61-1.34 1.36-1.34.46 0 .88.24 1.12.62l.77.62c.01-.35-.28-.67-.64-.8v-.06h-.09c-.54 0-.98-.44-.98-.98s.44-.98.98-.98.98.44.98.98v.03h.05l.15.12c.57.45.91 1.14.91 1.87 0 1.21-.89 2.23-2.05 2.48l-.33-.91h-.01zm-2.24-1.38h.08c.34 0 .61-.27.61-.61s-.27-.61-.61-.61h-.08v1.22zm.61 2.16c-.14.04-.29.06-.45.06h-.16v-1.16h.25c.26 0 .47.1.47.36 0 .38-.06.67-.11.74zm-.11-3.52c.18 0 .32-.14.32-.32s-.14-.32-.32-.32-.32.14-.32.32.14.32.32.32zm0 1.86c-.18 0-.32.14-.32.32s.14.32.32.32.32-.14.32-.32-.14-.32-.32-.32z"/>
</svg>
<div class="flex-1 ml-3">
<div class="font-medium">支付宝</div>
<div class="text-xs text-gray-400">安全便捷</div>
</div>
<a-radio :checked="selectedMethod === 'alipay'" />
</div>
<!-- 余额支付 -->
<div
v-if="availableMethods.balance && userBalance !== undefined"
class="pay-method-item"
:class="{ active: selectedMethod === 'balance' }"
@click="selectedMethod = 'balance'"
>
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-yellow-100 text-yellow-600">
<WalletOutlined />
</div>
<div class="flex-1 ml-3">
<div class="font-medium">余额支付</div>
<div class="text-xs text-gray-400">可用余额¥{{ userBalance }}</div>
</div>
<a-radio :checked="selectedMethod === 'balance'" />
</div>
</div>
<!-- Native 二维码支付 -->
<div v-else-if="payMethod === 'native' && !paid" class="text-center">
<div v-if="loading" class="py-10">
<a-spin size="large" />
<div class="mt-3 text-gray-500">正在获取支付二维码...</div>
</div>
<div v-else-if="codeUrl" class="py-4">
<div class="text-sm text-gray-500 mb-4">请使用微信/支付宝扫描下方二维码完成支付</div>
<div class="flex justify-center bg-white p-4 rounded-lg">
<a-qrcode :value="codeUrl" :size="200" />
</div>
<div class="mt-4 text-sm text-gray-500">
<template v-if="countdown > 0">
二维码 {{ countdown }} 秒后过期请尽快支付
</template>
<a-button v-else type="link" size="small" @click="rebuildQrcode">
二维码已过期点击重新获取
</a-button>
</div>
</div>
<div v-else-if="errorMsg" class="py-10 text-center">
<a-result status="error" title="获取二维码失败" :sub-title="errorMsg">
<template #extra>
<a-button type="primary" @click="rebuildQrcode">重试</a-button>
</template>
</a-result>
</div>
</div>
<!-- 等待支付状态 -->
<div v-else-if="!paid" class="text-center py-6">
<a-spin size="large" />
<div class="mt-4">
<div class="text-base font-medium">等待支付中...</div>
<div class="text-sm text-gray-500 mt-1">
{{ selectedMethod === 'balance' ? '正在处理余额支付...' : '请在支付页面完成付款' }}
</div>
</div>
</div>
<!-- 支付成功 -->
<div v-else class="text-center py-6">
<a-result status="success" title="支付成功" sub-title="您的订单已支付成功页面即将跳转...">
<template #extra>
<a-button type="primary" @click="handleSuccess">查看订单</a-button>
<a-button @click="handleClose">继续逛逛</a-button>
</template>
</a-result>
</div>
<!-- 错误提示 -->
<div v-if="errorMsg && !paid" class="mt-4">
<a-alert :message="errorMsg" type="error" show-icon />
</div>
</a-modal>
<!-- 支付宝跳转提示 -->
<a-modal
v-model:open="alipayRedirectVisible"
title="正在跳转支付宝"
:footer="null"
centered
:mask-closable="false"
>
<div class="text-center py-6">
<a-spin size="large" />
<div class="mt-4 text-base">正在跳转到支付宝支付页面</div>
<div class="text-sm text-gray-500 mt-2">
如果支付页面没有弹出<a :href="alipayUrl" target="_blank">点击这里</a>继续支付
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref, computed, watch, h } from 'vue'
import { message } from 'ant-design-vue'
import { WalletOutlined } from '@ant-design/icons-vue'
import {
detectPayEnvironment,
isWechatBrowser,
isAlipayBrowser,
createWechatJsapiPay,
createWechatH5Pay,
createWechatNativePay,
createAlipayPay,
queryPayStatus
} from '@/api/payment'
import type { PayResult } from '@/api/payment'
/** Props */
const props = defineProps<{
visible: boolean
orderNo: string
orderId?: number
payPrice: string | number
/** 可用支付方式,默认全部 */
availableMethods?: {
wechat?: boolean
alipay?: boolean
balance?: boolean
native?: boolean
}
/** 用户余额(余额支付时) */
userBalance?: number
/** 标题 */
title?: string
}>()
/** Emits */
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success', result: PayResult): void
(e: 'close'): void
}>()
/** 状态 */
const selectedMethod = ref<'wechat' | 'alipay' | 'balance'>('wechat')
const started = ref(false)
const loading = ref(false)
const paid = ref(false)
const errorMsg = ref('')
const codeUrl = ref('')
const countdown = ref(0)
const alipayRedirectVisible = ref(false)
const alipayUrl = ref('')
// 轮询定时器
let pollTimer: ReturnType<typeof setTimeout> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
/** 默认可用支付方式 */
const defaultMethods = { wechat: true, alipay: true, balance: true, native: true }
const methods = computed(() => ({ ...defaultMethods, ...props.availableMethods }))
/** 实际使用的支付方式 */
const payMethod = computed(() => {
if (!started.value) return 'select'
const env = detectPayEnvironment()
// 微信浏览器内 → JSAPI
if (selectedMethod.value === 'wechat' && isWechatBrowser()) {
return 'wechat_jsapi'
}
// 支付宝浏览器内 → 支付宝 WAP
if (selectedMethod.value === 'alipay' && isAlipayBrowser()) {
return 'alipay_wap'
}
// 桌面端 / 其他情况 → Native 或 H5
if (env === 'desktop') {
return 'native'
}
return 'h5'
})
/** 底部按钮 */
const footerContent = computed(() => {
if (paid.value || started.value) return null
return [
h('a-button', { onClick: handleClose }, '取消'),
h('a-button', {
type: 'primary',
loading: loading.value,
onClick: startPay
}, `确认支付 ¥${props.payPrice}`)
]
})
/** 关闭弹窗 */
function handleClose() {
stopPoll()
emit('update:visible', false)
emit('close')
}
/** 支付成功 */
function handleSuccess() {
stopPoll()
emit('success', { orderNo: props.orderNo })
handleClose()
}
/** 开始支付 */
async function startPay() {
errorMsg.value = ''
loading.value = true
try {
const params = {
orderNo: props.orderNo,
subject: `订单支付-${props.orderNo}`,
body: 'WebSoft 产品订单',
totalAmount: Math.round(Number(props.payPrice) * 100) // 转为分
}
let result: PayResult
switch (payMethod.value) {
case 'wechat_jsapi':
result = await createWechatJsapiPay({
...params,
openId: await getWxOpenId()
})
await handleWechatJsapiResult(result)
break
case 'native':
started.value = true
result = await createWechatNativePay(params)
handleNativeResult(result)
break
case 'h5':
if (selectedMethod.value === 'wechat') {
result = await createWechatH5Pay({ ...params, returnUrl: window.location.href })
handleH5Result(result, 'wechat')
} else {
result = await createAlipayPay({ ...params, returnUrl: window.location.href })
handleH5Result(result, 'alipay')
}
break
case 'alipay_wap':
result = await createAlipayPay({ ...params, returnUrl: window.location.href })
handleH5Result(result, 'alipay')
break
case 'balance':
await handleBalancePay()
break
default:
throw new Error('不支持的支付方式')
}
} catch (err: unknown) {
const e = err as Error
errorMsg.value = e.message || '发起支付失败'
message.error(errorMsg.value)
} finally {
loading.value = false
}
}
/** 处理微信 JSAPI 支付结果 */
async function handleWechatJsapiResult(result: PayResult) {
// 如果返回的是跳转链接H5支付链接直接跳转
if (result.mwebUrl) {
window.location.href = result.mwebUrl
return
}
// JSAPI 调起支付
if (result.prepayId) {
const appId = result.codeUrl || '' // 实际应该从后端获取
const timestamp = String(Math.floor(Date.now() / 1000))
const nonceStr = Math.random().toString(36).slice(2)
const pkg = `prepay_id=${result.prepayId}`
const signType = 'MD5'
const paySign = '' // 后端需要返回签名
try {
// 动态加载微信 JSSDK
await loadWechatJSSDK()
// @ts-ignore
window.WeixinJSBridge?.invoke('getBrandWCPayRequest', {
appId,
timeStamp: timestamp,
nonceStr,
package: pkg,
signType,
paySign
}, (res: { err_msg: string }) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
paid.value = true
startPoll()
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
errorMsg.value = '用户取消支付'
} else {
errorMsg.value = res.err_msg
}
})
} catch {
// 如果 JSSDK 不可用,降级到 H5 支付
const h5Result = await createWechatH5Pay({
orderNo: props.orderNo,
subject: `订单支付-${props.orderNo}`,
totalAmount: Math.round(Number(props.payPrice) * 100)
})
if (h5Result.mwebUrl) {
window.location.href = h5Result.mwebUrl
} else {
throw new Error('JSAPI 支付失败')
}
}
}
}
/** 处理 Native 支付结果 */
function handleNativeResult(result: PayResult) {
loading.value = false
if (result.codeUrl) {
codeUrl.value = result.codeUrl
// 二维码有效期 2 分钟
countdown.value = 120
startCountdown()
startPoll()
} else if (result.qrcode) {
codeUrl.value = result.qrcode
countdown.value = 120
startCountdown()
startPoll()
} else {
errorMsg.value = '获取支付二维码失败'
}
}
/** 处理 H5 支付结果 */
function handleH5Result(result: PayResult, type: 'wechat' | 'alipay') {
if (type === 'alipay' && (result.paymentUrl || result.payUrl)) {
const url = result.paymentUrl || result.payUrl
if (result.paymentUrl?.includes('https://openapi.alipay.com')) {
// PC 场景:显示二维码
alipayRedirectVisible.value = true
alipayUrl.value = url
} else if (result.paymentUrl?.includes('https://m.alipay.com')) {
// H5 场景:直接跳转
window.location.href = url
} else {
// 未知格式,直接跳转
window.location.href = url
}
started.value = true
startPoll()
} else if (type === 'wechat' && result.mwebUrl) {
window.location.href = result.mwebUrl
}
}
/** 余额支付 */
async function handleBalancePay() {
started.value = true
loading.value = false
// 余额支付直接查询状态
try {
const status = await queryPayStatus(props.orderNo)
if (status.paid || status.payStatus === 1) {
paid.value = true
} else {
startPoll()
}
} catch {
// 轮询查询
startPoll()
}
}
/** 轮询支付状态 */
async function startPoll() {
stopPoll()
pollTimer = setTimeout(async () => {
try {
const status = await queryPayStatus(props.orderNo)
if (status.paid || status.payStatus === 1) {
paid.value = true
stopPoll()
setTimeout(() => handleSuccess(), 1500)
} else {
startPoll()
}
} catch {
startPoll()
}
}, 2000)
}
/** 停止轮询 */
function stopPoll() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
/** 二维码倒计时 */
function startCountdown() {
countdownTimer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
} else {
stopPoll()
}
}, 1000)
}
/** 重新获取二维码 */
async function rebuildQrcode() {
loading.value = true
errorMsg.value = ''
codeUrl.value = ''
try {
const params = {
orderNo: props.orderNo,
subject: `订单支付-${props.orderNo}`,
totalAmount: Math.round(Number(props.payPrice) * 100)
}
const result = await createWechatNativePay(params)
handleNativeResult(result)
} catch (err: unknown) {
const e = err as Error
errorMsg.value = e.message || '获取二维码失败'
} finally {
loading.value = false
}
}
/** 获取微信 OpenId */
async function getWxOpenId(): Promise<string> {
// 从 localStorage 或 Vuex 获取已存储的 openId
const stored = localStorage.getItem('wx_openid')
if (stored) return stored
// 如果没有,需要先通过 OAuth 获取
// 这里可以引导用户授权,或从后端获取
throw new Error('请先在微信中打开,或联系客服获取支付方式')
}
/** 动态加载微信 JSSDK */
async function loadWechatJSSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 如果已加载
if (window.WeixinJSBridge || window.jWeixin) {
resolve()
return
}
// 加载 JSSDK
const script = document.createElement('script')
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
script.onload = () => resolve()
script.onerror = () => reject(new Error('微信 JSSDK 加载失败'))
document.head.appendChild(script)
})
}
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (!val) {
stopPoll()
started.value = false
paid.value = false
errorMsg.value = ''
codeUrl.value = ''
}
})
</script>
<style scoped>
.pay-method-item {
display: flex;
align-items: center;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.pay-method-item:hover {
border-color: #d1d5db;
}
.pay-method-item.active {
border-color: #1890ff;
background: #f0f9ff;
}
</style>