Files
client-web/src/modules/finance/views/FinanceAccountView.vue
T
2026-09-19 17:30:27 +08:00

329 lines
18 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, 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>