582 lines
15 KiB
Vue
582 lines
15 KiB
Vue
<template>
|
||
<div class="pay-page">
|
||
<div class="pay-container">
|
||
<!-- 加载中 -->
|
||
<div v-if="loading" class="pay-loading">
|
||
<a-spin size="large" />
|
||
<p class="mt-4 text-gray-400">加载订单信息...</p>
|
||
</div>
|
||
|
||
<!-- 订单不存在 -->
|
||
<div v-else-if="!subscription" class="pay-empty">
|
||
<div class="text-6xl mb-4">🔍</div>
|
||
<h2 class="text-xl font-semibold text-gray-700 mb-2">订单不存在</h2>
|
||
<p class="text-gray-400 mb-6">该订阅可能已取消或链接有误</p>
|
||
<a-button type="primary" @click="navigateTo('/console/apps?tab=purchased')">
|
||
返回应用中心
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 已支付成功 -->
|
||
<div v-else-if="paySuccess" class="pay-success">
|
||
<div class="success-icon">
|
||
<CheckCircleFilled class="success-check" />
|
||
</div>
|
||
<h2 class="text-2xl font-bold text-gray-800 mb-2">支付成功</h2>
|
||
<p class="text-gray-500 mb-6">
|
||
{{ subscription.productName || '应用' }} 已激活,快去使用吧!
|
||
</p>
|
||
<a-space>
|
||
<a-button type="primary" size="large" @click="navigateTo('/console/apps?tab=purchased')">
|
||
查看我的应用
|
||
</a-button>
|
||
<a-button size="large" @click="navigateTo('/market')">
|
||
继续浏览
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<!-- 待支付 -->
|
||
<div v-else class="pay-content">
|
||
<!-- 订单信息 -->
|
||
<div class="pay-card">
|
||
<div class="pay-card-header">
|
||
<h3 class="text-lg font-semibold text-gray-800">订单详情</h3>
|
||
<a-tag :color="subscription.status === 'pending' ? 'warning' : 'default'">
|
||
{{ subscription.status === 'pending' ? '待支付' : subscription.status }}
|
||
</a-tag>
|
||
</div>
|
||
|
||
<div class="pay-info">
|
||
<div class="pay-info-row">
|
||
<span class="pay-label">应用名称</span>
|
||
<span class="pay-value font-medium">{{ subscription.productName || '未知应用' }}</span>
|
||
</div>
|
||
<div class="pay-info-row">
|
||
<span class="pay-label">订阅编号</span>
|
||
<span class="pay-value font-mono text-sm">{{ subscription.subscriptionNo }}</span>
|
||
</div>
|
||
<div class="pay-info-row">
|
||
<span class="pay-label">价格类型</span>
|
||
<span class="pay-value">{{ priceTypeText(subscription.priceType) }}</span>
|
||
</div>
|
||
<div v-if="subscription.subscriptionPeriod" class="pay-info-row">
|
||
<span class="pay-label">订阅周期</span>
|
||
<span class="pay-value">{{ subscription.subscriptionPeriod === 'year' ? '按年' : '按月' }}</span>
|
||
</div>
|
||
|
||
<a-divider class="!my-3" />
|
||
|
||
<div class="pay-info-row pay-amount-row">
|
||
<span class="pay-label">支付金额</span>
|
||
<span class="pay-amount">
|
||
<span v-if="subscription.payPrice === 0" class="text-green-500 text-2xl font-bold">免费</span>
|
||
<span v-else class="text-amber-500 text-2xl font-bold">
|
||
¥{{ subscription.payPrice }}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pay-countdown" v-if="countdown > 0">
|
||
<ClockCircleOutlined class="text-amber-500 mr-1" />
|
||
<span>请在 <span class="text-amber-500 font-semibold">{{ formatCountdown(countdown) }}</span> 内完成支付</span>
|
||
</div>
|
||
<div class="pay-countdown expired" v-else>
|
||
订单已超时,请重新下单
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 支付区域 -->
|
||
<div class="pay-card" v-if="subscription.payPrice > 0 && countdown > 0">
|
||
<h3 class="text-lg font-semibold text-gray-800 mb-4">选择支付方式</h3>
|
||
|
||
<!-- 支付方式选择 -->
|
||
<div class="pay-methods">
|
||
<div
|
||
v-for="method in payMethods"
|
||
:key="method.key"
|
||
class="pay-method"
|
||
:class="{
|
||
active: selectedMethod === method.key,
|
||
disabled: method.key === 'balance' && !hasEnoughBalance
|
||
}"
|
||
@click="handleSelectMethod(method.key)"
|
||
>
|
||
<component :is="method.icon" class="pay-method-icon" />
|
||
<span>{{ method.label }}</span>
|
||
<span v-if="method.key === 'balance'" class="balance-hint">
|
||
({{ userBalance.toFixed(2) }}元)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 余额不足提示 -->
|
||
<div v-if="selectedMethod === 'balance' && !hasEnoughBalance" class="balance-warning">
|
||
<WarningOutlined class="mr-1" />
|
||
余额不足,当前余额 {{ userBalance.toFixed(2) }} 元,还需 {{ (subscription.payPrice - userBalance).toFixed(2) }} 元
|
||
</div>
|
||
|
||
<!-- 支付按钮 -->
|
||
<a-button
|
||
type="primary"
|
||
size="large"
|
||
block
|
||
class="pay-btn"
|
||
:loading="payLoading"
|
||
:disabled="selectedMethod === 'balance' && !hasEnoughBalance"
|
||
@click="handlePay"
|
||
>
|
||
确认支付 ¥{{ subscription.payPrice }}
|
||
</a-button>
|
||
|
||
<!-- 二维码区域(Native 支付时展示) -->
|
||
<div v-if="qrcodeValue" class="pay-qrcode">
|
||
<div class="qrcode-wrapper">
|
||
<a-qrcode :value="qrcodeValue" :size="200" />
|
||
</div>
|
||
<p class="qrcode-tip">
|
||
请使用微信扫码支付
|
||
</p>
|
||
<p v-if="testMode" class="qrcode-test-mode">
|
||
<a-tag color="orange">测试模式</a-tag>
|
||
实际支付请使用真实微信商户
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 免费应用自动激活 -->
|
||
<div class="pay-card" v-if="subscription.payPrice === 0 || subscription.payPrice == null">
|
||
<a-result status="info" title="免费应用" sub-description="点击确认即可激活使用">
|
||
<template #extra>
|
||
<a-button type="primary" size="large" :loading="activateLoading" @click="handleFreeActivate">
|
||
确认激活
|
||
</a-button>
|
||
</template>
|
||
</a-result>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import {
|
||
CheckCircleFilled,
|
||
ClockCircleOutlined,
|
||
WechatOutlined,
|
||
AlipayCircleOutlined,
|
||
WalletOutlined,
|
||
WarningOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import { checkPayStatus, paySubscription, getUserBalance } from '@/api/app/subscription'
|
||
import type { AppSubscription } from '@/api/app/subscription/model'
|
||
import { APP_API_URL } from '@/config/setting'
|
||
|
||
definePageMeta({ layout: 'console' })
|
||
|
||
const route = useRoute()
|
||
const subscriptionNo = computed(() => route.params.subscriptionNo as string)
|
||
|
||
// 状态
|
||
const loading = ref(true)
|
||
const subscription = ref<AppSubscription | null>(null)
|
||
const paySuccess = ref(false)
|
||
const payLoading = ref(false)
|
||
const activateLoading = ref(false)
|
||
const selectedMethod = ref<'wechat' | 'alipay' | 'balance'>('wechat')
|
||
const qrcodeValue = ref('')
|
||
const testMode = ref(false)
|
||
const countdown = ref(1800) // 30分钟倒计时
|
||
const userBalance = ref(0) // 用户余额
|
||
|
||
// 计算余额是否充足
|
||
const hasEnoughBalance = computed(() => {
|
||
if (!subscription.value) return false
|
||
return userBalance.value >= subscription.value.payPrice
|
||
})
|
||
|
||
// 支付方式
|
||
const payMethods = [
|
||
{ key: 'wechat', label: '微信支付', icon: WechatOutlined },
|
||
{ key: 'alipay', label: '支付宝', icon: AlipayCircleOutlined },
|
||
{ key: 'balance', label: '余额支付', icon: WalletOutlined },
|
||
]
|
||
|
||
// 轮询定时器
|
||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
// 工具函数
|
||
function priceTypeText(type?: string) {
|
||
const map: Record<string, string> = { free: '免费', one_time: '一次性购买', subscription: '订阅' }
|
||
return map[type || ''] || type || '-'
|
||
}
|
||
|
||
function formatCountdown(seconds: number): string {
|
||
const m = Math.floor(seconds / 60)
|
||
const s = seconds % 60
|
||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||
}
|
||
|
||
// 加载订单(checkPayStatus 已包含完整订单详情,无需再调 getSubscription)
|
||
async function fetchSubscription() {
|
||
loading.value = true
|
||
try {
|
||
const data = await checkPayStatus(subscriptionNo.value)
|
||
|
||
if (data.status === 'active' || data.payStatus === 1) {
|
||
paySuccess.value = true
|
||
stopPolling()
|
||
return
|
||
}
|
||
|
||
// 将返回数据映射为 subscription(checkPayStatus 已包含所有详情字段)
|
||
subscription.value = {
|
||
id: data.id,
|
||
subscriptionNo: data.subscriptionNo ?? subscriptionNo.value,
|
||
productId: data.productId,
|
||
productName: data.productName,
|
||
productLogo: data.productLogo,
|
||
priceType: data.priceType,
|
||
payPrice: data.payPrice ?? 0,
|
||
subscriptionPeriod: data.subscriptionPeriod,
|
||
status: data.status,
|
||
payStatus: data.payStatus,
|
||
} as AppSubscription
|
||
|
||
// 加载用户余额
|
||
fetchUserBalance()
|
||
} catch {
|
||
subscription.value = null
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 获取用户余额
|
||
async function fetchUserBalance() {
|
||
try {
|
||
const data = await getUserBalance()
|
||
userBalance.value = data.balance ?? 0
|
||
} catch {
|
||
userBalance.value = 0
|
||
}
|
||
}
|
||
|
||
// 轮询支付状态
|
||
function startPolling() {
|
||
stopPolling()
|
||
pollTimer = setInterval(async () => {
|
||
try {
|
||
const data = await checkPayStatus(subscriptionNo.value)
|
||
if (data.paid || data.status === 'active') {
|
||
paySuccess.value = true
|
||
stopPolling()
|
||
}
|
||
} catch {
|
||
// 静默处理
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
if (countdownTimer) {
|
||
clearInterval(countdownTimer)
|
||
countdownTimer = null
|
||
}
|
||
}
|
||
|
||
// 倒计时
|
||
function startCountdown() {
|
||
countdownTimer = setInterval(() => {
|
||
if (countdown.value > 0) {
|
||
countdown.value--
|
||
} else {
|
||
stopPolling()
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
// 发起支付
|
||
async function handlePay() {
|
||
if (!subscription.value?.id) return
|
||
payLoading.value = true
|
||
try {
|
||
const method = selectedMethod.value === 'balance' ? 'balance' : 'wechat'
|
||
const result = await paySubscription(subscription.value.id, method)
|
||
|
||
// 余额支付直接成功
|
||
if (selectedMethod.value === 'balance') {
|
||
const payData = result as any
|
||
if (payData.paid) {
|
||
userBalance.value = payData.balance ?? userBalance.value - (subscription.value?.payPrice ?? 0)
|
||
paySuccess.value = true
|
||
stopPolling()
|
||
message.success('支付成功!')
|
||
return
|
||
}
|
||
}
|
||
|
||
// 微信支付返回 codeUrl
|
||
if (result && typeof result === 'object') {
|
||
const payData = result as any
|
||
if (payData.codeUrl || payData.qrcode) {
|
||
qrcodeValue.value = payData.codeUrl || payData.qrcode
|
||
testMode.value = payData._testMode === true
|
||
startPolling()
|
||
} else if (payData.paymentUrl || payData.payUrl || payData.mwebUrl) {
|
||
const url = payData.paymentUrl || payData.payUrl || payData.mwebUrl
|
||
window.open(url, '_blank')
|
||
startPolling()
|
||
} else {
|
||
message.info('支付请求已发送')
|
||
startPolling()
|
||
}
|
||
} else {
|
||
message.info('支付请求已发送')
|
||
startPolling()
|
||
}
|
||
} catch (error: any) {
|
||
message.error(error.message || '发起支付失败')
|
||
} finally {
|
||
payLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 选择支付方式
|
||
function handleSelectMethod(key: string) {
|
||
if (key === 'balance' && !hasEnoughBalance.value) {
|
||
return // 余额不足不可选
|
||
}
|
||
selectedMethod.value = key as 'wechat' | 'alipay' | 'balance'
|
||
// 切换到余额支付时清空二维码
|
||
if (key !== 'wechat') {
|
||
qrcodeValue.value = ''
|
||
stopPolling()
|
||
}
|
||
}
|
||
|
||
// 免费激活
|
||
async function handleFreeActivate() {
|
||
activateLoading.value = true
|
||
try {
|
||
await paySubscription(subscription.value!.id!)
|
||
paySuccess.value = true
|
||
} catch (error: any) {
|
||
message.error(error.message || '激活失败')
|
||
} finally {
|
||
activateLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchSubscription()
|
||
startCountdown()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopPolling()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pay-page {
|
||
min-height: calc(100vh - 120px);
|
||
background: #f5f7fa;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px 20px;
|
||
}
|
||
|
||
.pay-container {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
}
|
||
|
||
.pay-loading,
|
||
.pay-empty,
|
||
.pay-success {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.success-icon {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.success-check {
|
||
font-size: 64px;
|
||
color: #52c41a;
|
||
}
|
||
|
||
.pay-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.pay-card {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.pay-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.pay-card-header h3 {
|
||
margin: 0;
|
||
}
|
||
|
||
.pay-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.pay-info-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.pay-label {
|
||
color: #9ca3af;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.pay-value {
|
||
color: #374151;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.pay-amount-row {
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.pay-amount {
|
||
font-size: 24px;
|
||
}
|
||
|
||
.pay-countdown {
|
||
margin-top: 16px;
|
||
padding: 10px 16px;
|
||
background: #fffbeb;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
color: #666;
|
||
text-align: center;
|
||
}
|
||
|
||
.pay-countdown.expired {
|
||
background: #fef2f2;
|
||
color: #ef4444;
|
||
}
|
||
|
||
.pay-methods {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.pay-method {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 16px 12px;
|
||
border: 2px solid #f0f0f0;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-size: 13px;
|
||
color: #666;
|
||
}
|
||
|
||
.pay-method:hover {
|
||
border-color: #d9d9d9;
|
||
}
|
||
|
||
.pay-method.active {
|
||
border-color: #4f46e5;
|
||
color: #4f46e5;
|
||
background: #f5f3ff;
|
||
}
|
||
|
||
.pay-method.disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.pay-method .balance-hint {
|
||
font-size: 11px;
|
||
color: #9ca3af;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.balance-warning {
|
||
margin-top: 12px;
|
||
padding: 10px 12px;
|
||
background: #fef2f2;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
color: #ef4444;
|
||
}
|
||
|
||
.pay-method-icon {
|
||
font-size: 28px;
|
||
}
|
||
|
||
.pay-btn {
|
||
height: 48px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.pay-qrcode {
|
||
margin-top: 24px;
|
||
text-align: center;
|
||
}
|
||
|
||
.qrcode-wrapper {
|
||
margin: 0 auto 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.qrcode-tip {
|
||
font-size: 13px;
|
||
color: #9ca3af;
|
||
margin: 0;
|
||
}
|
||
|
||
.qrcode-test-mode {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #f97316;
|
||
}
|
||
|
||
.mt-4 {
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|