初始版本

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,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>