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

607 lines
16 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="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>