607 lines
16 KiB
Vue
607 lines
16 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<a-page-header title="充值余额" sub-title="选择套餐或自定义金额,完成支付后余额自动到账" />
|
||
|
||
<a-spin :spinning="pageLoading" tip="加载中...">
|
||
<a-row :gutter="[24, 24]">
|
||
<!-- 左侧:充值操作区 -->
|
||
<a-col :xs="24" :lg="14">
|
||
<!-- 当前余额 -->
|
||
<a-card :bordered="false" class="card balance-card">
|
||
<div class="balance-row">
|
||
<div>
|
||
<div class="balance-label">当前余额</div>
|
||
<div class="balance-value">¥ {{ currentBalance }}</div>
|
||
</div>
|
||
<WalletOutlined class="balance-icon" />
|
||
</div>
|
||
</a-card>
|
||
|
||
<!-- 套餐选择 -->
|
||
<a-card :bordered="false" class="card mt-4" title="选择充值金额">
|
||
<div class="presets-grid">
|
||
<div
|
||
v-for="p in presets"
|
||
:key="p.value"
|
||
class="preset-item"
|
||
:class="{ active: selectedPreset === p.value }"
|
||
@click="selectPreset(p.value)"
|
||
>
|
||
<div class="preset-amount">¥ {{ p.label }}</div>
|
||
<div v-if="p.gift" class="preset-gift">赠 {{ p.gift }} 积分</div>
|
||
</div>
|
||
<!-- 自定义 -->
|
||
<div
|
||
class="preset-item custom"
|
||
:class="{ active: selectedPreset === 'custom' }"
|
||
@click="selectPreset('custom')"
|
||
>
|
||
<div class="preset-amount">自定义</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自定义金额输入 -->
|
||
<div v-if="selectedPreset === 'custom'" class="custom-input-wrap">
|
||
<a-input-number
|
||
v-model:value="customAmount"
|
||
:min="1"
|
||
:max="99999"
|
||
:precision="2"
|
||
placeholder="请输入充值金额(元)"
|
||
style="width: 100%"
|
||
size="large"
|
||
>
|
||
<template #prefix>¥</template>
|
||
</a-input-number>
|
||
</div>
|
||
|
||
<a-divider />
|
||
|
||
<!-- 支付方式 -->
|
||
<div class="pay-method-label">支付方式</div>
|
||
<div class="pay-methods">
|
||
<div
|
||
class="pay-method-item"
|
||
:class="{ active: payMethod === 'wechat_native' }"
|
||
@click="payMethod = 'wechat_native'"
|
||
>
|
||
<WechatOutlined class="pay-icon-fallback" />
|
||
<span>微信支付</span>
|
||
</div>
|
||
<div
|
||
class="pay-method-item"
|
||
:class="{ active: payMethod === 'alipay' }"
|
||
@click="payMethod = 'alipay'"
|
||
>
|
||
<AlipayOutlined class="pay-icon-fallback alipay" />
|
||
<span>支付宝</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pay-summary">
|
||
实付金额:<span class="pay-price">¥ {{ finalAmount }}</span>
|
||
</div>
|
||
|
||
<a-button
|
||
type="primary"
|
||
size="large"
|
||
block
|
||
:loading="paying"
|
||
:disabled="!finalAmount || finalAmount <= 0"
|
||
@click="handlePay"
|
||
>
|
||
立即充值
|
||
</a-button>
|
||
</a-card>
|
||
</a-col>
|
||
|
||
<!-- 右侧:充值记录 -->
|
||
<a-col :xs="24" :lg="10">
|
||
<a-card :bordered="false" class="card" title="充值记录">
|
||
<template #extra>
|
||
<a-button type="link" size="small" @click="loadLogs">刷新</a-button>
|
||
</template>
|
||
<a-spin :spinning="logsLoading">
|
||
<div v-if="!logs.length" class="empty-logs">
|
||
<a-empty description="暂无充值记录" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||
</div>
|
||
<div v-else class="log-list">
|
||
<div v-for="log in logs" :key="log.logId" class="log-item">
|
||
<div class="log-left">
|
||
<div class="log-desc">{{ log.describe || '余额充值' }}</div>
|
||
<div class="log-time">{{ log.createTime }}</div>
|
||
</div>
|
||
<div class="log-amount">
|
||
+ ¥ {{ formatMoney(log.money) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 分页 -->
|
||
<a-pagination
|
||
v-if="logTotal > pageSize"
|
||
v-model:current="logPage"
|
||
:total="logTotal"
|
||
:page-size="pageSize"
|
||
size="small"
|
||
style="margin-top: 12px; text-align: right"
|
||
@change="loadLogs"
|
||
/>
|
||
</a-spin>
|
||
</a-card>
|
||
</a-col>
|
||
</a-row>
|
||
</a-spin>
|
||
|
||
<!-- 支付二维码弹窗 -->
|
||
<a-modal
|
||
v-model:open="qrModalOpen"
|
||
title="扫码支付"
|
||
:footer="null"
|
||
:width="320"
|
||
centered
|
||
@cancel="cancelPay"
|
||
>
|
||
<div class="qr-modal-body">
|
||
<div class="qr-amount">¥ {{ finalAmount }}</div>
|
||
<div v-if="qrCodeUrl" class="qr-box">
|
||
<img :src="qrCodeUrl" alt="支付二维码" class="qr-img" />
|
||
</div>
|
||
<a-spin v-else tip="生成二维码中..." />
|
||
<div class="qr-hint">
|
||
<component :is="payMethod === 'wechat_native' ? WechatOutlined : AlipayOutlined" />
|
||
{{ payMethod === 'wechat_native' ? '请使用微信扫码完成支付' : '请使用支付宝扫码完成支付' }}
|
||
</div>
|
||
<div class="qr-status">
|
||
<a-spin v-if="polling" size="small" />
|
||
<span v-if="polling" style="margin-left: 8px; color: #8c8c8c">等待支付中...</span>
|
||
<a-tag v-if="paySuccess" color="success">支付成功</a-tag>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 支付宝跳转提示弹窗 -->
|
||
<a-modal
|
||
v-model:open="alipayModalOpen"
|
||
title="支付宝支付"
|
||
ok-text="我已完成支付"
|
||
cancel-text="取消支付"
|
||
:confirm-loading="polling"
|
||
@ok="checkAlipayResult"
|
||
@cancel="cancelPay"
|
||
>
|
||
<a-result
|
||
status="info"
|
||
title="即将跳转到支付宝"
|
||
sub-title="请在支付宝页面完成支付,完成后点击「我已完成支付」"
|
||
/>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||
import { message, Empty } from 'ant-design-vue'
|
||
|
||
definePageMeta({ layout: 'console', ssr: false })
|
||
import { WalletOutlined, WechatOutlined, AlipayOutlined } from '@ant-design/icons-vue'
|
||
import { getUserInfo } from '@/api/layout'
|
||
import { pageUserBalanceLog } from '@/api/user/balance-log'
|
||
import type { UserBalanceLog } from '@/api/user/balance-log/model'
|
||
import { createRechargeOrder } from '@/api/user/recharge/order'
|
||
import { createWechatNativePay, createAlipayPay, queryPayStatus, queryRechargeStatus } from '@/api/payment'
|
||
|
||
useHead({ title: '充值余额 - 控制台' })
|
||
|
||
// ===== 套餐 =====
|
||
const presets = [
|
||
{ value: 10, label: '10', gift: 10 },
|
||
{ value: 30, label: '30', gift: 50 },
|
||
{ value: 50, label: '50', gift: 100 },
|
||
{ value: 100, label: '100', gift: 200 },
|
||
{ value: 200, label: '200', gift: 500 },
|
||
{ value: 500, label: '500', gift: 1500 },
|
||
]
|
||
|
||
const selectedPreset = ref<number | 'custom'>(30)
|
||
const customAmount = ref<number | null>(null)
|
||
const payMethod = ref<'wechat_native' | 'alipay'>('wechat_native')
|
||
|
||
const finalAmount = computed(() => {
|
||
if (selectedPreset.value === 'custom') return customAmount.value ?? 0
|
||
return selectedPreset.value as number
|
||
})
|
||
|
||
function selectPreset(val: number | 'custom') {
|
||
selectedPreset.value = val
|
||
if (val !== 'custom') customAmount.value = null
|
||
}
|
||
|
||
// ===== 当前余额 =====
|
||
const pageLoading = ref(false)
|
||
const currentBalance = ref('0.00')
|
||
|
||
async function loadBalance() {
|
||
try {
|
||
const u = await getUserInfo()
|
||
currentBalance.value = ((u?.balance ?? 0) / 100).toFixed(2)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// ===== 充值记录 =====
|
||
const logsLoading = ref(false)
|
||
const logs = ref<UserBalanceLog[]>([])
|
||
const logPage = ref(1)
|
||
const logTotal = ref(0)
|
||
const pageSize = 10
|
||
|
||
async function loadLogs() {
|
||
logsLoading.value = true
|
||
try {
|
||
const res = await pageUserBalanceLog({ page: logPage.value, limit: pageSize })
|
||
logs.value = res?.list ?? []
|
||
logTotal.value = res?.total ?? 0
|
||
} catch { /* ignore */ } finally {
|
||
logsLoading.value = false
|
||
}
|
||
}
|
||
|
||
function formatMoney(val: string | undefined) {
|
||
const n = parseFloat(val ?? '0')
|
||
return isNaN(n) ? '0.00' : n.toFixed(2)
|
||
}
|
||
|
||
// ===== 支付流程 =====
|
||
const paying = ref(false)
|
||
const qrModalOpen = ref(false)
|
||
const alipayModalOpen = ref(false)
|
||
const qrCodeUrl = ref('')
|
||
const currentOrderNo = ref('')
|
||
const polling = ref(false)
|
||
const paySuccess = ref(false)
|
||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
async function handlePay() {
|
||
const amount = finalAmount.value
|
||
if (!amount || amount <= 0) {
|
||
message.warning('请选择或输入充值金额')
|
||
return
|
||
}
|
||
|
||
paying.value = true
|
||
try {
|
||
// 1. 创建充值订单
|
||
const rechargeResult = await createRechargeOrder({
|
||
money: String(amount),
|
||
describe: `余额充值 ¥${amount}`,
|
||
})
|
||
currentOrderNo.value = rechargeResult.orderNo
|
||
log.info('创建充值订单成功:', rechargeResult)
|
||
|
||
// 2. 发起支付
|
||
if (payMethod.value === 'wechat_native') {
|
||
const result = await createWechatNativePay({
|
||
orderNo: currentOrderNo.value,
|
||
subject: `充值 ¥${amount}`,
|
||
body: '账户余额充值',
|
||
totalAmount: Math.round(amount * 100),
|
||
})
|
||
// 生成二维码图片
|
||
const codeUrl = result.codeUrl || result.qrcode || ''
|
||
if (codeUrl) {
|
||
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(codeUrl)}`
|
||
}
|
||
qrModalOpen.value = true
|
||
startPolling()
|
||
} else {
|
||
// 支付宝
|
||
const result = await createAlipayPay({
|
||
orderNo: currentOrderNo.value,
|
||
subject: `充值 ¥${amount}`,
|
||
body: '账户余额充值',
|
||
totalAmount: Math.round(amount * 100),
|
||
returnUrl: window.location.href,
|
||
})
|
||
const payUrl = result.paymentUrl || result.payUrl || ''
|
||
if (payUrl) {
|
||
window.open(payUrl, '_blank')
|
||
}
|
||
alipayModalOpen.value = true
|
||
}
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '创建订单失败,请重试')
|
||
} finally {
|
||
paying.value = false
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
polling.value = true
|
||
let times = 0
|
||
pollTimer = setInterval(async () => {
|
||
times++
|
||
if (times > 60) { // 最多轮询 2 分钟
|
||
stopPolling()
|
||
message.warning('支付超时,请刷新页面检查余额')
|
||
return
|
||
}
|
||
try {
|
||
// 优先查询充值状态
|
||
const status = await queryRechargeStatus(currentOrderNo.value)
|
||
if (status.paid || status.payStatus === 20) {
|
||
stopPolling()
|
||
paySuccess.value = true
|
||
message.success('充值成功!余额已到账')
|
||
await loadBalance()
|
||
await loadLogs()
|
||
setTimeout(() => {
|
||
qrModalOpen.value = false
|
||
alipayModalOpen.value = false
|
||
paySuccess.value = false
|
||
}, 2000)
|
||
return
|
||
}
|
||
|
||
// 备用:查询支付状态
|
||
const payStatus = await queryPayStatus(currentOrderNo.value)
|
||
if (payStatus.paid || payStatus.payStatus === 1) {
|
||
stopPolling()
|
||
paySuccess.value = true
|
||
message.success('充值成功!余额已到账')
|
||
await loadBalance()
|
||
await loadLogs()
|
||
setTimeout(() => {
|
||
qrModalOpen.value = false
|
||
alipayModalOpen.value = false
|
||
paySuccess.value = false
|
||
}, 2000)
|
||
}
|
||
} catch { /* 轮询失败静默处理 */ }
|
||
}, 2000)
|
||
}
|
||
|
||
function stopPolling() {
|
||
polling.value = false
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
}
|
||
|
||
function cancelPay() {
|
||
stopPolling()
|
||
qrModalOpen.value = false
|
||
alipayModalOpen.value = false
|
||
qrCodeUrl.value = ''
|
||
paySuccess.value = false
|
||
}
|
||
|
||
async function checkAlipayResult() {
|
||
polling.value = true
|
||
try {
|
||
const status = await queryPayStatus(currentOrderNo.value)
|
||
if (status.paid || status.payStatus === 1) {
|
||
message.success('充值成功!余额已到账')
|
||
alipayModalOpen.value = false
|
||
await loadBalance()
|
||
await loadLogs()
|
||
} else {
|
||
message.warning('暂未检测到支付,请稍后刷新余额')
|
||
}
|
||
} catch {
|
||
message.error('查询支付状态失败')
|
||
} finally {
|
||
polling.value = false
|
||
}
|
||
}
|
||
|
||
onBeforeUnmount(stopPolling)
|
||
|
||
// 初始化
|
||
pageLoading.value = true
|
||
Promise.all([loadBalance(), loadLogs()]).finally(() => {
|
||
pageLoading.value = false
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card {
|
||
border-radius: 12px;
|
||
}
|
||
|
||
/* 余额卡片 */
|
||
.balance-card {
|
||
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
|
||
color: white;
|
||
}
|
||
.balance-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.balance-label {
|
||
font-size: 14px;
|
||
opacity: 0.85;
|
||
margin-bottom: 6px;
|
||
}
|
||
.balance-value {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
}
|
||
.balance-icon {
|
||
font-size: 40px;
|
||
opacity: 0.3;
|
||
}
|
||
:deep(.balance-card .ant-card-body) {
|
||
color: white;
|
||
}
|
||
|
||
/* 套餐格 */
|
||
.presets-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.preset-item {
|
||
border: 2px solid #f0f0f0;
|
||
border-radius: 10px;
|
||
padding: 14px 8px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.preset-item:hover {
|
||
border-color: #91d5ff;
|
||
}
|
||
.preset-item.active {
|
||
border-color: #1890ff;
|
||
background: #e6f7ff;
|
||
}
|
||
.preset-amount {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #262626;
|
||
}
|
||
.preset-gift {
|
||
font-size: 11px;
|
||
color: #fa8c16;
|
||
margin-top: 4px;
|
||
}
|
||
.preset-item.custom .preset-amount {
|
||
font-size: 15px;
|
||
color: #595959;
|
||
}
|
||
|
||
.custom-input-wrap {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* 支付方式 */
|
||
.pay-method-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: rgba(0,0,0,0.65);
|
||
margin-bottom: 10px;
|
||
}
|
||
.pay-methods {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.pay-method-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 20px;
|
||
border: 2px solid #f0f0f0;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-size: 14px;
|
||
}
|
||
.pay-method-item:hover {
|
||
border-color: #91d5ff;
|
||
}
|
||
.pay-method-item.active {
|
||
border-color: #1890ff;
|
||
background: #e6f7ff;
|
||
}
|
||
.pay-icon {
|
||
width: 24px;
|
||
height: 24px;
|
||
object-fit: contain;
|
||
}
|
||
.pay-icon-fallback {
|
||
font-size: 22px;
|
||
color: #07c160;
|
||
}
|
||
.pay-icon-fallback.alipay {
|
||
color: #1677ff;
|
||
}
|
||
|
||
.pay-summary {
|
||
font-size: 14px;
|
||
color: rgba(0,0,0,0.45);
|
||
margin-bottom: 16px;
|
||
}
|
||
.pay-price {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: #f5222d;
|
||
}
|
||
|
||
/* 充值记录 */
|
||
.empty-logs {
|
||
padding: 24px 0;
|
||
}
|
||
.log-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
.log-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
}
|
||
.log-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.log-desc {
|
||
font-size: 14px;
|
||
color: rgba(0,0,0,0.85);
|
||
}
|
||
.log-time {
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.35);
|
||
margin-top: 2px;
|
||
}
|
||
.log-amount {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #52c41a;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 二维码弹窗 */
|
||
.qr-modal-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 8px 0 16px;
|
||
}
|
||
.qr-amount {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #f5222d;
|
||
}
|
||
.qr-box {
|
||
width: 200px;
|
||
height: 200px;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
.qr-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
.qr-hint {
|
||
font-size: 13px;
|
||
color: rgba(0,0,0,0.55);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.qr-status {
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 24px;
|
||
}
|
||
</style>
|