Files
jczxw-pc/app/pages/console/pay/[subscriptionNo].vue
2026-04-23 16:30:57 +08:00

582 lines
15 KiB
Vue
Raw 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>
<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
}
// 将返回数据映射为 subscriptioncheckPayStatus 已包含所有详情字段)
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>