329 lines
18 KiB
Vue
329 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { CircleDollarSign, RefreshCcw, Upload } from '@lucide/vue'
|
||
import { ElMessage, type UploadFile, type UploadFiles, type UploadInstance } from 'element-plus'
|
||
import { ApiError } from '@/api/http'
|
||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||
import { useAuthStore } from '@/modules/auth/public'
|
||
import {
|
||
createRechargeRequest,
|
||
getAccountLedger,
|
||
getFinanceAccount,
|
||
getRechargeRequests,
|
||
type AccountChangeType,
|
||
type AccountLedgerEntry,
|
||
type FinanceAccount,
|
||
type RechargeRequest,
|
||
type RechargeStatus,
|
||
} from '../api/account'
|
||
|
||
const auth = useAuthStore()
|
||
const account = ref<FinanceAccount | null>(null)
|
||
const ledger = ref<AccountLedgerEntry[]>([])
|
||
const rechargeRequests = ref<RechargeRequest[]>([])
|
||
const pageLoading = ref(false)
|
||
const pageError = ref('')
|
||
const qrCodeLoadError = ref(false)
|
||
const ledgerPage = ref(1)
|
||
const ledgerTotal = ref(0)
|
||
const ledgerPageSize = ref(10)
|
||
const rechargePage = ref(1)
|
||
const rechargeTotal = ref(0)
|
||
const rechargePageSize = ref(10)
|
||
const rechargeVisible = ref(false)
|
||
const submitting = ref(false)
|
||
const submitError = ref('')
|
||
const voucherFile = ref<File | null>(null)
|
||
const voucherUpload = ref<UploadInstance>()
|
||
let pendingIdempotencyKey = ''
|
||
|
||
const form = reactive({ amount: '1000' })
|
||
const quickAmounts = ['1000', '5000', '20000', '50000']
|
||
const amountMode = ref<'preset' | 'custom'>('preset')
|
||
const canRecharge = computed(() => auth.hasPermission('merchant:finance:recharge:create'))
|
||
const channelReady = computed(() => !qrCodeLoadError.value)
|
||
const amountValid = computed(() => {
|
||
const value = Number(form.amount)
|
||
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
|
||
})
|
||
const canSubmit = computed(() => amountValid.value && channelReady.value && Boolean(voucherFile.value) && !submitting.value)
|
||
|
||
const changeLabels: Record<AccountChangeType, string> = {
|
||
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
|
||
}
|
||
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
|
||
const ledgerColumns: DataTableColumn[] = [
|
||
{ label: '业务单号', prop: 'businessNo', minWidth: 180 },
|
||
{ label: '变动类型', slotName: 'changeType', width: 100 },
|
||
{ label: '变动金额', slotName: 'amount', width: 120, align: 'right' },
|
||
{ label: '实际余额', slotName: 'actualBalance', width: 120, align: 'right' },
|
||
{ label: '冻结金额', slotName: 'frozenBalance', width: 120, align: 'right' },
|
||
{ label: '可用余额', slotName: 'availableBalance', width: 120, align: 'right' },
|
||
{ label: '操作人', slotName: 'operatorName', width: 120 },
|
||
{ label: '备注', slotName: 'remark', minWidth: 180 },
|
||
{ label: '发生时间', slotName: 'createdAt', width: 160 },
|
||
]
|
||
const rechargeColumns: DataTableColumn[] = [
|
||
{ label: '充值申请单号', prop: 'requestNo', minWidth: 180 },
|
||
{ label: '金额', slotName: 'amount', width: 120, align: 'right' },
|
||
{ label: '付款截图', prop: 'voucherFileName', minWidth: 150 },
|
||
{ label: '状态', slotName: 'status', width: 100 },
|
||
{ label: '提交人', slotName: 'submittedByName', width: 120 },
|
||
{ label: '审核备注', slotName: 'reviewRemark', minWidth: 180 },
|
||
{ label: '提交时间', slotName: 'createdAt', width: 160 },
|
||
{ label: '审核时间', slotName: 'reviewedAt', width: 160 },
|
||
]
|
||
|
||
function messageOf(error: unknown, fallback: string) {
|
||
return error instanceof ApiError ? error.message : fallback
|
||
}
|
||
|
||
function formatAmount(value: string | null | undefined) {
|
||
const number = Number(value ?? '0')
|
||
return Number.isFinite(number)
|
||
? number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
: '0.00'
|
||
}
|
||
|
||
function formatAccountAmount(value: string | null | undefined) {
|
||
return value == null ? '—' : formatAmount(value)
|
||
}
|
||
|
||
function formatDateTime(value: string | null | undefined) {
|
||
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
|
||
}
|
||
|
||
function statusType(status: RechargeStatus) {
|
||
return ({ PENDING: 'warning', APPROVED: 'success', REJECTED: 'danger' })[status] as 'warning' | 'success' | 'danger'
|
||
}
|
||
|
||
function amountClass(amount: string) {
|
||
return Number(amount) >= 0 ? 'amount-positive' : 'amount-negative'
|
||
}
|
||
|
||
async function loadAccountData() {
|
||
pageLoading.value = true
|
||
pageError.value = ''
|
||
qrCodeLoadError.value = false
|
||
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, ledgerPageSize.value), getRechargeRequests(rechargePage.value, rechargePageSize.value)] as const
|
||
try {
|
||
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
|
||
account.value = accountResult
|
||
ledger.value = ledgerResult.records
|
||
ledgerTotal.value = ledgerResult.total
|
||
rechargeRequests.value = rechargeResult.records
|
||
rechargeTotal.value = rechargeResult.total
|
||
} catch (error) {
|
||
account.value = null
|
||
ledger.value = []
|
||
rechargeRequests.value = []
|
||
ledgerTotal.value = 0
|
||
rechargeTotal.value = 0
|
||
pageError.value = messageOf(error, '资金账户加载失败,请稍后重试')
|
||
} finally {
|
||
pageLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function changeLedgerPage(nextPage: number) {
|
||
try {
|
||
const result = await getAccountLedger(nextPage, ledgerPageSize.value)
|
||
ledger.value = result.records
|
||
ledgerTotal.value = result.total
|
||
} catch (error) {
|
||
ElMessage.error(messageOf(error, '资金流水加载失败,请重试'))
|
||
}
|
||
}
|
||
|
||
async function changeRechargePage(nextPage: number) {
|
||
try {
|
||
const result = await getRechargeRequests(nextPage, rechargePageSize.value)
|
||
rechargeRequests.value = result.records
|
||
rechargeTotal.value = result.total
|
||
} catch (error) {
|
||
ElMessage.error(messageOf(error, '充值申请加载失败,请重试'))
|
||
}
|
||
}
|
||
|
||
function openRecharge() {
|
||
submitError.value = ''
|
||
qrCodeLoadError.value = false
|
||
amountMode.value = 'preset'
|
||
form.amount = '1000'
|
||
rechargeVisible.value = true
|
||
}
|
||
|
||
function handleQrCodeError() {
|
||
qrCodeLoadError.value = true
|
||
}
|
||
|
||
function chooseAmount(amount: string) {
|
||
amountMode.value = 'preset'
|
||
form.amount = amount
|
||
}
|
||
|
||
function chooseCustomAmount() {
|
||
amountMode.value = 'custom'
|
||
if (quickAmounts.includes(form.amount)) form.amount = ''
|
||
}
|
||
|
||
function handleVoucherChange(uploadFile: UploadFile, _uploadFiles: UploadFiles) {
|
||
const file = uploadFile.raw
|
||
if (!file) return
|
||
const extensionAllowed = /\.(?:jpe?g|png)$/i.test(file.name)
|
||
const mimeAllowed = ['image/jpeg', 'image/png'].includes(file.type)
|
||
if (!extensionAllowed || !mimeAllowed) {
|
||
voucherFile.value = null
|
||
ElMessage.error('付款截图仅支持 jpg、jpeg 或 png 图片')
|
||
return
|
||
}
|
||
voucherFile.value = file
|
||
}
|
||
|
||
function handleVoucherRemove() {
|
||
voucherFile.value = null
|
||
}
|
||
|
||
function normalizedAmount() {
|
||
return Number(form.amount).toFixed(2)
|
||
}
|
||
|
||
async function submitRecharge() {
|
||
if (submitting.value) return
|
||
submitError.value = ''
|
||
if (!amountValid.value) {
|
||
submitError.value = '请输入大于 0 且最多两位小数的充值金额'
|
||
return
|
||
}
|
||
if (!channelReady.value) {
|
||
submitError.value = '收款二维码尚未配置,暂不能提交充值申请'
|
||
return
|
||
}
|
||
if (!voucherFile.value) {
|
||
submitError.value = '请上传付款截图'
|
||
return
|
||
}
|
||
if (!pendingIdempotencyKey) {
|
||
pendingIdempotencyKey = globalThis.crypto?.randomUUID?.() ?? `recharge-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
await createRechargeRequest(normalizedAmount(), voucherFile.value, pendingIdempotencyKey)
|
||
pendingIdempotencyKey = ''
|
||
rechargeVisible.value = false
|
||
voucherFile.value = null
|
||
voucherUpload.value?.clearFiles()
|
||
ElMessage.success('充值申请已提交,等待平台审核')
|
||
await loadAccountData()
|
||
} catch (error) {
|
||
submitError.value = messageOf(error, '充值申请提交失败,请使用当前内容重试')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
watch([() => form.amount, voucherFile], () => {
|
||
if (!submitting.value) pendingIdempotencyKey = ''
|
||
submitError.value = ''
|
||
})
|
||
|
||
watch([ledgerPage, ledgerPageSize], () => { void changeLedgerPage(ledgerPage.value) })
|
||
watch([rechargePage, rechargePageSize], () => { void changeRechargePage(rechargePage.value) })
|
||
|
||
onMounted(() => { void loadAccountData() })
|
||
</script>
|
||
|
||
<template>
|
||
<div v-loading="pageLoading" class="finance-account-page" :aria-busy="pageLoading">
|
||
<div v-if="pageError" class="account-state account-state--error" role="alert">
|
||
<p>{{ pageError }}</p><el-button @click="loadAccountData"><RefreshCcw :size="15" />重新加载</el-button>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<section class="balance-summary" aria-label="账户余额">
|
||
<article><div class="balance-title"><span>实际余额</span><el-button v-if="canRecharge" size="small" type="primary" @click="openRecharge"><CircleDollarSign :size="15" />账户充值</el-button></div><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
|
||
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
|
||
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
|
||
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>{{ account?.creditExpiresOn ? `${account.creditExpiresOn} ${account.creditActive ? '到期' : '已到期'}` : '未设置有效期' }}</small></article>
|
||
<article><span>已用授信</span><strong>¥ {{ formatAccountAmount(account?.creditUsed) }}</strong><small>已结算使用的授信</small></article>
|
||
<article><span>可用授信</span><strong>¥ {{ formatAccountAmount(account?.availableCredit) }}</strong><small>扣除已用和冻结后的授信</small></article>
|
||
</section>
|
||
|
||
<section class="account-card">
|
||
<div class="section-heading"><div><h3>账户流水</h3><p>每次资金变动均生成唯一业务单号。</p></div></div>
|
||
<DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :total="ledgerTotal" :page-sizes="[10, 20, 50]" row-key="businessNo" empty-text="暂无资金流水">
|
||
<template #changeType="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template>
|
||
<template #amount="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template>
|
||
<template #actualBalance="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template>
|
||
<template #frozenBalance="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template>
|
||
<template #availableBalance="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template>
|
||
<template #operatorName="{ row }">{{ row.operatorName || '—' }}</template>
|
||
<template #remark="{ row }">{{ row.remark || '—' }}</template>
|
||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||
</DataTable>
|
||
</section>
|
||
|
||
<section class="account-card">
|
||
<div class="section-heading"><div><h3>充值申请</h3><p>付款后上传截图,平台审核通过后资金入账。</p></div></div>
|
||
<DataTable v-model:page="rechargePage" v-model:page-size="rechargePageSize" :data="rechargeRequests" :columns="rechargeColumns" :total="rechargeTotal" :page-sizes="[10, 20, 50]" row-key="requestNo" empty-text="暂无充值申请">
|
||
<template #amount="{ row }">¥ {{ formatAmount(row.amount) }}</template>
|
||
<template #status="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template>
|
||
<template #submittedByName="{ row }">{{ row.submittedByName || '—' }}</template>
|
||
<template #reviewRemark="{ row }">{{ row.reviewRemark || '—' }}</template>
|
||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
|
||
</DataTable>
|
||
</section>
|
||
</template>
|
||
|
||
<el-dialog v-model="rechargeVisible" title="账户充值" width="760px" :close-on-click-modal="false" :close-on-press-escape="!submitting" :show-close="!submitting" class="recharge-dialog">
|
||
<div class="recharge-form">
|
||
<label class="field-label" for="recharge-amount">充值金额</label>
|
||
<el-input v-if="amountMode === 'custom'" id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
|
||
<div class="quick-amounts" aria-label="快捷充值金额">
|
||
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: amountMode === 'preset' && form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount).toLocaleString() }}</button>
|
||
<button type="button" :class="{ active: amountMode === 'custom' }" @click="chooseCustomAmount">自定义</button>
|
||
</div>
|
||
|
||
<div v-if="channelReady" class="qr-panel">
|
||
<img src="/customer-recharge-qr.png" alt="平台收款二维码" @error="handleQrCodeError">
|
||
<div><strong>扫码付款</strong><p>请扫描平台收款二维码,付款后上传截图。</p></div>
|
||
</div>
|
||
<div v-else-if="qrCodeLoadError" class="channel-warning" role="alert">平台收款二维码资源不可用,请联系管理员更新二维码</div>
|
||
<div v-else class="channel-warning" role="status">收款二维码尚未配置,暂不能提交充值申请。</div>
|
||
|
||
<div class="voucher-upload">
|
||
<span class="field-label">付款截图</span>
|
||
<el-upload ref="voucherUpload" :auto-upload="false" :limit="1" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :on-change="handleVoucherChange" :on-remove="handleVoucherRemove">
|
||
<el-button><Upload :size="16" />选择付款截图</el-button>
|
||
<template #tip><div class="el-upload__tip">仅支持 jpg、jpeg、png 图片;提交后进入平台审核。</div></template>
|
||
</el-upload>
|
||
</div>
|
||
<p v-if="submitError" class="submit-error" role="alert">{{ submitError }}</p>
|
||
</div>
|
||
<template #footer><div class="dialog-actions"><el-button :disabled="submitting" @click="rechargeVisible = false">取消</el-button><el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="submitRecharge">确认充值</el-button></div></template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.finance-account-page { display: flex; min-height: 420px; flex-direction: column; gap: 16px; }
|
||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||
.section-heading h3 { margin: 0; color: var(--navy); font-size: 16px; }
|
||
.section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
|
||
.balance-summary { display: grid; overflow: hidden; grid-template-columns: repeat(6, minmax(0, 1fr)); border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
|
||
.balance-summary article { display: flex; min-width: 0; min-height: 104px; flex-direction: column; padding: 14px 16px; border-right: 1px solid var(--client-border); }
|
||
.balance-summary article:last-child { border-right: 0; }
|
||
.balance-summary span { color: var(--muted); font-size: 12px; }.balance-summary strong { margin-top: 10px; overflow: hidden; color: var(--navy); font-size: 22px; line-height: 1.1; text-overflow: ellipsis; white-space: nowrap; }.balance-summary small { margin-top: auto; overflow: hidden; color: #8a98a8; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.balance-title { display: flex; min-height: 28px; align-items: center; justify-content: space-between; gap: 8px; }.balance-title .el-button { flex: 0 0 auto; height: 28px; padding: 0 9px; }.balance-title .el-button :deep(span) { display: inline-flex; align-items: center; gap: 4px; color: inherit; }
|
||
.balance-summary .balance-available { background: #f3faf8; }.balance-summary .balance-available strong { color: #058b78; }
|
||
.account-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }.section-heading { padding: 14px 16px; border-bottom: 1px solid var(--client-border); }
|
||
.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
|
||
.account-state { display: flex; min-height: 240px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border-radius: 8px; }.account-state--error { border: 1px solid #f5c2bd; background: #fff8f7; color: #b42318; }.account-state p { margin: 0; }
|
||
.recharge-form { display: flex; flex-direction: column; gap: 16px; }.field-label { color: #4e6075; font-size: 14px; font-weight: 650; }
|
||
.quick-amounts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }.quick-amounts button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
|
||
.qr-panel { display: grid; grid-template-columns: 210px minmax(0, 1fr); align-items: center; gap: 24px; padding: 20px; border: 1px solid #dbe3ed; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel strong { color: var(--navy); font-size: 17px; }.qr-panel p { margin: 8px 0 0; color: #62758d; }
|
||
.channel-warning { padding: 24px; border: 1px solid #f0d39b; border-radius: 10px; background: #fff9ed; color: #8b5b12; text-align: center; }.voucher-upload { display: grid; gap: 10px; }.voucher-upload :deep(.el-button) { display: inline-flex; align-items: center; gap: 6px; }.submit-error { margin: 0; color: #b42318; font-size: 13px; }.dialog-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }.dialog-actions .el-button { min-height: 46px; margin: 0; }
|
||
@media (max-width: 1200px) { .balance-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }.balance-summary article { border-bottom: 1px solid var(--client-border); }.balance-summary article:nth-child(3n) { border-right: 0; }.balance-summary article:nth-last-child(-n + 3) { border-bottom: 0; } }
|
||
</style>
|