初始版本
This commit is contained in:
606
app/pages/console/recharge.vue
Normal file
606
app/pages/console/recharge.vue
Normal file
@@ -0,0 +1,606 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user