Files
hfb_sys/frontend/src/features/wallet/views/WalletView.vue
T

981 lines
22 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.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
CircleCheck,
Coin,
Loading,
Lock,
Money,
Refresh,
Tickets,
Wallet as WalletIcon,
} from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import {
fetchWalletBalance,
fetchWalletLedger,
queryWalletRechargePayment,
startWalletRechargePayment,
type WalletAccount,
type WalletLedger,
} from '@/features/wallet'
import type { PaymentOrder } from '@/features/orders'
import { balanceTypeLabel, ledgerDirectionLabel, walletStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const account = ref<WalletAccount | null>(null)
const ledger = ref<WalletLedger[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const isDev = import.meta.env.DEV
const quickRechargeAmounts = [0.01, 1, 10, 100]
const devRechargeAmount = ref(0.01)
const devRecharging = ref(false)
const rechargeDialogVisible = ref(false)
const activeRechargePayment = ref<PaymentOrder | null>(null)
const rechargeQRCodeURL = ref('')
const rechargeQRGenerating = ref(false)
const checkingRecharge = ref(false)
let rechargePollingTimer: number | null = null
const walletMetrics = computed(() => {
if (!account.value) {
return []
}
return [
{
label: '可用余额',
value: formatMoney(account.value.available_balance),
hint: '卖家结算收入累计到此账户',
icon: WalletIcon,
tone: 'available',
},
{
label: '冻结余额',
value: formatMoney(account.value.frozen_balance),
hint: '当前暂无冻结资金使用',
icon: Lock,
tone: 'frozen',
},
{
label: '账户状态',
value: walletStatusLabel(account.value.status),
hint: account.value.status === 'active' ? '钱包可正常使用' : '请联系客服处理',
icon: CircleCheck,
tone: account.value.status === 'active' ? 'status' : 'warning',
},
]
})
onMounted(loadWallet)
onBeforeUnmount(stopRechargePolling)
watch(rechargeDialogVisible, visible => {
if (!visible) {
stopRechargePolling()
}
})
async function loadWallet() {
loading.value = true
try {
const [balance, result] = await Promise.all([
fetchWalletBalance(),
fetchWalletLedger(currentPage.value, currentPageSize.value),
])
account.value = balance
ledger.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadWallet()
}
function loadLedgerPage() {
loadWallet()
}
function handleWithdraw() {
router.push('/wallet/withdrawal')
}
function selectDevRechargeAmount(amount: number) {
devRechargeAmount.value = amount
}
function rechargePayURL(payment = activeRechargePayment.value) {
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
}
async function renderRechargeQRCode(payment = activeRechargePayment.value) {
const payURL = rechargePayURL(payment)
rechargeQRCodeURL.value = ''
if (!payURL) return
rechargeQRGenerating.value = true
try {
rechargeQRCodeURL.value = await QRCode.toDataURL(payURL, {
width: 220,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111827',
light: '#ffffff',
},
})
} catch {
ElMessage.error('二维码生成失败')
} finally {
rechargeQRGenerating.value = false
}
}
async function handleDevRecharge() {
if (!isDev) return
if (devRechargeAmount.value <= 0) {
ElMessage.warning('请输入充值金额')
return
}
devRecharging.value = true
try {
const payment = await startWalletRechargePayment(devRechargeAmount.value)
activeRechargePayment.value = payment
rechargeDialogVisible.value = true
await renderRechargeQRCode(payment)
if (payment.paid) {
ElMessage.success('充值成功')
rechargeDialogVisible.value = false
await loadWallet()
} else {
startRechargePolling()
ElMessage.success('测试支付单已创建')
}
} catch (error) {
ElMessage.error(readError(error, '创建测试充值失败'))
} finally {
devRecharging.value = false
}
}
async function checkDevRechargeStatus(options: { silent?: boolean } = {}) {
if (!activeRechargePayment.value || checkingRecharge.value) return
const silent = options.silent === true
checkingRecharge.value = true
try {
const payment = await queryWalletRechargePayment(activeRechargePayment.value.id)
activeRechargePayment.value = payment
await renderRechargeQRCode(payment)
if (payment.paid) {
stopRechargePolling()
ElMessage.success('充值成功')
rechargeDialogVisible.value = false
await loadWallet()
} else {
if (!silent) {
ElMessage.info('支付暂未完成')
}
}
} catch (error) {
if (!silent) {
ElMessage.error(readError(error, '查询支付状态失败'))
}
} finally {
checkingRecharge.value = false
}
}
function startRechargePolling() {
stopRechargePolling()
rechargePollingTimer = window.setInterval(() => {
void checkDevRechargeStatus({ silent: true })
}, 2000)
}
function stopRechargePolling() {
if (!rechargePollingTimer) return
window.clearInterval(rechargePollingTimer)
rechargePollingTimer = null
}
function openRechargePayURL() {
const payURL = rechargePayURL()
if (!payURL) return
window.open(payURL, '_blank', 'noopener,noreferrer')
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
function formatMoney(value: number) {
return ${(Math.round(Number(value || 0) * 10) / 10).toFixed(1)}`
}
function walletBizTypeLabel(type: string) {
const map: Record<string, string> = {
dev_recharge: '测试充值',
channel_recharge: '渠道充值',
order_pay: '订单支付',
order_lock: '订单冻结',
channel_order_lock: '支付冻结',
order_cancel: '取消解冻',
order_cancel_refund: '取消退款',
admin_order_close: '客服关闭解冻',
admin_order_close_refund: '客服关闭退款',
order_settle: '订单结算',
owner_income: '号主收入',
deposit_compensation: '押金赔付',
rent_refund: '租金退款',
deposit_release: '押金释放',
arbitration_release_frozen: '仲裁解冻',
arbitration_renter_refund: '仲裁退款',
arbitration_owner_income: '仲裁收入',
cancel_refund: '取消退款',
checkout_refund: '结账退款',
channel_deposit_refund: '押金退还',
withdraw_apply: '申请提现',
}
return map[type] || type || '-'
}
function directionTone(direction: string) {
const map: Record<string, string> = {
in: 'success',
out: 'danger',
freeze: 'warning',
unfreeze: 'info',
}
return map[direction] || 'info'
}
function amountPrefix(direction: string) {
if (direction === 'in' || direction === 'unfreeze') return '+'
if (direction === 'out' || direction === 'freeze') return '-'
return ''
}
</script>
<template>
<section class="page wallet-page" v-loading="loading">
<div class="wallet-hero">
<div class="page-header">
<p class="eyebrow">我的钱包</p>
<h1>资金账户</h1>
<p>查看卖家结算收入可提现余额和每一笔资金变化</p>
</div>
<div class="wallet-hero-action">
<span>当前可用</span>
<strong>{{ account ? formatMoney(account.available_balance) : '¥0.00' }}</strong>
<el-button class="withdraw-button" :icon="Money" type="primary" @click="handleWithdraw">
申请提现
</el-button>
</div>
</div>
<div v-if="account" class="wallet-metric-grid">
<div
v-for="item in walletMetrics"
:key="item.label"
class="wallet-metric-card"
:class="`is-${item.tone}`"
>
<div class="metric-icon">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.hint }}</small>
</div>
</div>
</div>
<div class="wallet-workspace">
<section v-if="isDev" class="recharge-panel">
<div class="panel-title">
<div class="panel-title-icon is-orange">
<el-icon><Coin /></el-icon>
</div>
<div>
<h2>测试充值</h2>
<p>开发环境可见用于快速验证当前支付渠道配置</p>
</div>
</div>
<div class="quick-amounts">
<button
v-for="amount in quickRechargeAmounts"
:key="amount"
type="button"
:class="{ active: devRechargeAmount === amount }"
@click="selectDevRechargeAmount(amount)"
>
{{ formatMoney(amount) }}
</button>
</div>
<div class="recharge-action-row">
<el-input-number v-model="devRechargeAmount" :min="0.01" :precision="2" :step="1" />
<el-button
type="primary"
:icon="Coin"
:loading="devRecharging"
@click="handleDevRecharge"
>
创建测试支付
</el-button>
</div>
</section>
<section class="ledger-summary-card">
<div class="panel-title">
<div class="panel-title-icon is-blue">
<el-icon><Tickets /></el-icon>
</div>
<div>
<h2>资金流水</h2>
<p> {{ total }} 条记录最近变动优先展示</p>
</div>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadWallet">刷新</el-button>
</section>
</div>
<section class="wallet-ledger-table" role="table" aria-label="资金流水">
<div class="ledger-grid ledger-header" role="row">
<span role="columnheader">流水号</span>
<span role="columnheader">业务</span>
<span role="columnheader">方向</span>
<span class="align-right" role="columnheader">金额</span>
<span role="columnheader">余额类型</span>
<span class="align-right" role="columnheader">变化后余额</span>
<span role="columnheader">备注</span>
<span role="columnheader">时间</span>
</div>
<div v-if="ledger.length === 0" class="ledger-empty">暂无资金流水</div>
<div v-else class="ledger-body">
<div v-for="row in ledger" :key="row.id" class="ledger-grid ledger-row" role="row">
<span class="ledger-cell ledger-no" :title="row.ledger_no">{{ row.ledger_no }}</span>
<span class="ledger-cell">
<el-tag effect="plain" class="biz-tag">{{ walletBizTypeLabel(row.biz_type) }}</el-tag>
</span>
<span class="ledger-cell">
<el-tag :type="directionTone(row.direction)" effect="light" round>
{{ ledgerDirectionLabel(row.direction) }}
</el-tag>
</span>
<span class="ledger-cell align-right amount-cell" :class="`is-${row.direction}`">
{{ amountPrefix(row.direction) }}{{ formatMoney(row.amount) }}
</span>
<span class="ledger-cell muted-cell">{{ balanceTypeLabel(row.balance_type) }}</span>
<span class="ledger-cell align-right">{{ formatMoney(row.balance_after) }}</span>
<span class="ledger-cell" :title="row.remark">{{ row.remark || '-' }}</span>
<span class="ledger-cell">{{ formatDateTime(row.created_at) }}</span>
</div>
</div>
</section>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLedgerPage"
@size-change="handleSizeChange"
/>
</div>
<el-dialog v-model="rechargeDialogVisible" title="测试充值支付" width="520px">
<div class="recharge-cashier">
<div class="cashier-amount">
<span>支付金额</span>
<strong>{{
activeRechargePayment
? formatMoney(activeRechargePayment.amount_cent / 100)
: formatMoney(devRechargeAmount)
}}</strong>
</div>
<div v-if="rechargePayURL()" class="cashier-qr">
<div class="qr-box">
<el-icon v-if="rechargeQRGenerating" class="qr-loading"><Loading /></el-icon>
<img v-else-if="rechargeQRCodeURL" :src="rechargeQRCodeURL" alt="测试充值支付二维码" />
<span v-else>支付链接已生成</span>
</div>
<el-button text type="primary" @click="openRechargePayURL">打开支付链接</el-button>
</div>
<el-alert
v-else
title="支付单已创建,但渠道未返回可展示的支付链接"
type="info"
:closable="false"
show-icon
/>
<div class="cashier-meta" v-if="activeRechargePayment">
<span>支付单号{{ activeRechargePayment.payment_no }}</span>
<span>渠道{{ activeRechargePayment.provider }}</span>
<span>状态{{ activeRechargePayment.status }}</span>
</div>
</div>
<template #footer>
<el-button @click="rechargeDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="checkingRecharge" @click="checkDevRechargeStatus"
>查询支付状态</el-button
>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.wallet-page {
display: grid;
gap: 18px;
}
.wallet-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: linear-gradient(135deg, rgba(255, 122, 0, 0.08), rgba(15, 118, 110, 0.06)), #ffffff;
box-shadow: 0 14px 36px rgba(17, 24, 39, 0.06);
}
.wallet-hero :deep(.page-header) {
max-width: 780px;
}
.wallet-hero-action {
min-width: 220px;
padding: 16px 18px;
border: 1px solid rgba(255, 122, 0, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
text-align: right;
}
.wallet-hero-action span {
display: block;
color: #6b7280;
font-size: 13px;
}
.wallet-hero-action strong {
display: block;
margin-top: 6px;
color: #111a44;
font-size: 28px;
line-height: 1.15;
}
.withdraw-button {
width: 100%;
margin-top: 14px;
}
.withdraw-tag {
margin-left: 8px;
vertical-align: middle;
}
.wallet-metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.wallet-metric-card {
display: flex;
align-items: center;
gap: 16px;
min-height: 120px;
padding: 20px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.metric-icon {
display: grid;
flex: 0 0 46px;
width: 46px;
height: 46px;
place-items: center;
border-radius: 8px;
color: #ffffff;
font-size: 22px;
}
.wallet-metric-card.is-available .metric-icon {
background: #ff6b00;
}
.wallet-metric-card.is-frozen .metric-icon {
background: #3b82f6;
}
.wallet-metric-card.is-status .metric-icon {
background: #0f766e;
}
.wallet-metric-card.is-warning .metric-icon {
background: #d97706;
}
.wallet-metric-card span,
.wallet-metric-card small {
display: block;
color: #64748b;
}
.wallet-metric-card span {
font-size: 13px;
font-weight: 600;
}
.wallet-metric-card strong {
display: block;
margin-top: 8px;
color: #111a44;
font-size: 26px;
line-height: 1.1;
}
.wallet-metric-card small {
margin-top: 8px;
font-size: 12px;
}
.wallet-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.recharge-panel,
.ledger-summary-card {
display: grid;
gap: 18px;
padding: 20px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.ledger-summary-card {
align-content: space-between;
}
.ledger-summary-card :deep(.el-button) {
justify-self: start;
min-width: 118px;
}
.panel-title {
display: flex;
align-items: center;
gap: 12px;
}
.panel-title-icon {
display: grid;
flex: 0 0 40px;
width: 40px;
height: 40px;
place-items: center;
border-radius: 8px;
background: #fff4ec;
color: #ff6b00;
font-size: 20px;
}
.panel-title-icon.is-blue {
background: #eff6ff;
color: #2563eb;
}
.panel-title-icon.is-orange {
background: #fff4ec;
color: #ff6b00;
}
.panel-title h2 {
margin: 0;
color: #111827;
font-size: 17px;
}
.panel-title p {
margin: 5px 0 0;
color: #64748b;
font-size: 13px;
}
.quick-amounts {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.quick-amounts button {
min-width: 92px;
height: 36px;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-weight: 600;
cursor: pointer;
}
.quick-amounts button.active,
.quick-amounts button:hover {
border-color: #ff8a3d;
background: #fff4ec;
color: #ea580c;
}
.recharge-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.recharge-cashier {
display: grid;
gap: 18px;
}
.cashier-amount {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #f8fafc;
}
.cashier-amount span,
.cashier-meta {
color: #64748b;
font-size: 13px;
}
.cashier-amount strong {
color: #111a44;
font-size: 24px;
}
.cashier-qr {
display: grid;
justify-items: center;
gap: 10px;
}
.qr-box {
display: grid;
width: 236px;
height: 236px;
place-items: center;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
}
.qr-box img {
width: 220px;
height: 220px;
}
.qr-loading {
color: #64748b;
font-size: 28px;
animation: spin 0.9s linear infinite;
}
.cashier-meta {
display: grid;
gap: 6px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.wallet-ledger-table {
overflow-x: auto;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.ledger-grid {
display: grid;
grid-template-columns:
minmax(200px, 2fr)
minmax(100px, 0.8fr)
minmax(80px, 0.6fr)
minmax(100px, 0.8fr)
minmax(100px, 0.8fr)
minmax(120px, 0.9fr)
minmax(140px, 1.2fr)
minmax(170px, 1.1fr);
align-items: center;
column-gap: clamp(10px, 1vw, 20px);
padding: 0 clamp(16px, 1.5vw, 24px);
}
.ledger-header {
min-height: 48px;
border-bottom: 1px solid #e6eaf2;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.ledger-header span {
text-align: center;
}
.ledger-header span.align-right {
text-align: right;
}
.ledger-row {
min-height: 54px;
border-bottom: 1px solid #edf1f6;
color: #334155;
font-size: 14px;
transition: background 0.15s ease;
}
.ledger-row:last-child {
border-bottom: 0;
}
.ledger-row:hover {
background: #f8fafc;
}
.ledger-cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.ledger-cell.align-right {
text-align: right;
}
.ledger-no {
color: #475569;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 12px;
letter-spacing: 0.02em;
text-align: center;
}
.align-right {
text-align: right;
}
.ledger-empty {
display: grid;
min-height: 80px;
place-items: center;
border-top: 1px solid #edf1f6;
color: #94a3b8;
font-size: 14px;
}
.biz-tag {
max-width: 96px;
height: 24px;
line-height: 22px;
}
.amount-cell {
font-weight: 700;
}
.amount-cell.is-in,
.amount-cell.is-unfreeze {
color: #047857;
}
.amount-cell.is-out,
.amount-cell.is-freeze {
color: #dc2626;
}
.muted-cell {
color: #64748b;
}
.pay-dialog-body {
display: grid;
gap: 14px;
}
.pay-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f7f9fc;
color: #64748b;
}
.pay-summary strong {
color: #111a44;
font-size: 22px;
}
.pay-qr-panel {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
align-items: center;
gap: 20px;
padding: 18px;
border-radius: 8px;
background: #f8fafc;
}
.pay-qr-box {
width: 240px;
height: 240px;
display: grid;
place-items: center;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
}
.pay-qr-box img {
width: 220px;
height: 220px;
display: block;
}
.pay-scan-copy {
display: grid;
gap: 8px;
color: #334155;
}
.pay-scan-copy strong {
color: #111a44;
font-size: 18px;
}
.pay-scan-copy span {
color: #64748b;
line-height: 1.7;
}
.pay-hint {
margin: 0;
color: #64748b;
}
.pay-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
:global(.wallet-pay-dialog) {
position: relative;
z-index: 4001;
}
@media (max-width: 720px) {
.wallet-hero {
display: grid;
padding: 20px;
}
.wallet-hero-action {
min-width: 0;
text-align: left;
}
.wallet-metric-grid,
.wallet-workspace {
grid-template-columns: 1fr;
}
.wallet-metric-card {
min-height: auto;
}
.recharge-action-row :deep(.el-input-number) {
width: 100%;
}
.recharge-action-row :deep(.el-button) {
width: 100%;
}
.pay-qr-panel {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}
.ledger-grid {
grid-template-columns:
minmax(160px, 1.5fr)
minmax(80px, 0.8fr)
minmax(64px, 0.6fr)
minmax(80px, 0.8fr)
minmax(80px, 0.8fr)
minmax(100px, 0.9fr)
minmax(120px, 1fr)
minmax(140px, 1fr);
column-gap: 8px;
padding: 0 12px;
font-size: 13px;
}
.ledger-header {
min-height: 40px;
font-size: 12px;
}
.ledger-row {
min-height: 48px;
font-size: 13px;
}
}
</style>