初始版本

This commit is contained in:
2026-04-23 16:30:57 +08:00
commit 0d0683a6e6
538 changed files with 113042 additions and 0 deletions

View File

@@ -0,0 +1,581 @@
<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>