@@ -0,0 +1,308 @@
< 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 { useAuthStore } from '@/modules/auth/public'
import {
createRechargeRequest ,
getAccountLedger ,
getFinanceAccount ,
getRechargeChannels ,
getRechargeRequests ,
type AccountChangeType ,
type AccountLedgerEntry ,
type FinanceAccount ,
type RechargeChannel ,
type RechargePaymentMethod ,
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 channels = ref < RechargeChannel [ ] > ( [ ] )
const pageLoading = ref ( false )
const pageError = ref ( '' )
const channelsError = ref ( '' )
const ledgerPage = ref ( 1 )
const ledgerTotal = ref ( 0 )
const rechargePage = ref ( 1 )
const rechargeTotal = ref ( 0 )
const pageSize = 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 : '100.00' , paymentMethod : 'WECHAT' as RechargePaymentMethod } )
const quickAmounts = [ '50.00' , '100.00' , '200.00' , '500.00' ]
const canRecharge = computed ( ( ) => auth . hasPermission ( 'merchant:finance:recharge:create' ) )
const selectedChannel = computed ( ( ) => channels . value . find ( ( item ) => item . paymentMethod === form . paymentMethod ) )
const channelReady = computed ( ( ) => Boolean ( selectedChannel . value ? . enabled && selectedChannel . value . qrCodeUrl ) )
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 : '消费' ,
}
const statusLabels : Record < RechargeStatus , string > = { PENDING : '待审核' , APPROVED : '已通过' , REJECTED : '已驳回' }
const methodLabels : Record < RechargePaymentMethod , string > = { WECHAT : '微信支付' , ALIPAY : '支付宝' }
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 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 = ''
channelsError . value = ''
const requests = [ getFinanceAccount ( ) , getAccountLedger ( ledgerPage . value , pageSize ) , getRechargeRequests ( rechargePage . value , pageSize ) ] 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
}
if ( canRecharge . value ) {
try {
channels . value = await getRechargeChannels ( )
} catch ( error ) {
channels . value = [ ]
channelsError . value = messageOf ( error , '充值渠道加载失败,请稍后重试' )
}
}
}
async function changeLedgerPage ( nextPage : number ) {
ledgerPage . value = nextPage
try {
const result = await getAccountLedger ( nextPage , pageSize )
ledger . value = result . records
ledgerTotal . value = result . total
} catch ( error ) {
ElMessage . error ( messageOf ( error , '资金流水加载失败,请重试' ) )
}
}
async function changeRechargePage ( nextPage : number ) {
rechargePage . value = nextPage
try {
const result = await getRechargeRequests ( nextPage , pageSize )
rechargeRequests . value = result . records
rechargeTotal . value = result . total
} catch ( error ) {
ElMessage . error ( messageOf ( error , '充值申请加载失败,请重试' ) )
}
}
function openRecharge ( ) {
submitError . value = ''
rechargeVisible . value = true
}
function chooseAmount ( amount : string ) {
form . amount = 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 = ` ${ methodLabels [ form . paymentMethod ] } 收款二维码尚未配置,暂不能提交充值申请 `
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 ( ) , form . paymentMethod , 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 , ( ) => form . paymentMethod , voucherFile ] , ( ) => {
if ( ! submitting . value ) pendingIdempotencyKey = ''
submitError . value = ''
} )
onMounted ( ( ) => { void loadAccountData ( ) } )
< / script >
< template >
< div v-loading = "pageLoading" class="finance-account-page" :aria-busy="pageLoading" >
< div class = "account-heading" >
< div > < h2 > 资金账户 < / h2 > < p > 查看本企业 CNY 资金余额 、 账户流水和充值申请状态 。 < / p > < / div >
< el-button v-if = "canRecharge" type="primary" @click="openRecharge"><CircleDollarSign :size="17" / > 账户充值 < / el-button >
< / div >
< 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-grid" aria -label = " 账户余额 " >
< article > < span > 实际余额 < / span > < strong > ¥ { { formatAmount ( account ? . actualBalance ) } } < / strong > < small > 当前账户实际资金 < / small > < / article >
< article > < span > 冻结金额 < / span > < strong > ¥ { { formatAmount ( account ? . frozenBalance ) } } < / strong > < small > 暂不可使用的资金 < / small > < / article >
< article class = "balance-available" > < span > 可用余额 < / span > < strong > ¥ { { formatAmount ( account ? . availableBalance ) } } < / strong > < small > 可用于后续业务支付 < / small > < / article >
< / section >
< section class = "account-card" >
< div class = "section-heading" > < div > < h3 > 账户流水 < / h3 > < p > 每次资金变动均生成唯一业务单号 。 < / p > < / div > < / div >
< el-table :data = "ledger" row -key = " businessNo " empty -text = " 暂无资金流水 " >
< el-table-column prop = "businessNo" label = "业务单号" min -width = " 190 " / >
< el-table-column label = "变动类型" width = "110" > < template # default = "{ row }" > { { changeLabels [ row . changeType as AccountChangeType ] } } < / template > < / el-table-column >
< el-table-column label = "变动金额" width = "145" align = "right" > < template # default = "{ row }" > < strong :class = "amountClass(row.amount)" > { { Number ( row . amount ) >= 0 ? '+' : '' } } ¥ { { formatAmount ( row . amount ) } } < / strong > < / template > < / el-table-column >
< el-table-column label = "实际余额" width = "145" align = "right" > < template # default = "{ row }" > ¥ { { formatAmount ( row . actualBalanceAfter ) } } < / template > < / el-table-column >
< el-table-column label = "冻结金额" width = "145" align = "right" > < template # default = "{ row }" > ¥ { { formatAmount ( row . frozenBalanceAfter ) } } < / template > < / el-table-column >
< el-table-column label = "可用余额" width = "145" align = "right" > < template # default = "{ row }" > ¥ { { formatAmount ( row . availableBalanceAfter ) } } < / template > < / el-table-column >
< el-table-column prop = "operatorName" label = "操作人" min -width = " 130 " > < template # default = "{ row }" > { { row . operatorName || '—' } } < / template > < / el-table-column >
< el-table-column prop = "remark" label = "备注" min -width = " 180 " > < template # default = "{ row }" > { { row . remark || '—' } } < / template > < / el-table-column >
< el-table-column label = "发生时间" width = "170" > < template # default = "{ row }" > { { formatDateTime ( row . createdAt ) } } < / template > < / el-table-column >
< / el-table >
< div v-if = "ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" / > < / div >
< / section >
< section class = "account-card" >
< div class = "section-heading" > < div > < h3 > 充值申请 < / h3 > < p > 付款后上传截图 , 平台审核通过后资金入账 。 < / p > < / div > < / div >
< el-table :data = "rechargeRequests" row -key = " requestNo " empty -text = " 暂无充值申请 " >
< el-table-column prop = "requestNo" label = "充值申请单号" min -width = " 190 " / >
< el-table-column label = "金额" width = "145" align = "right" > < template # default = "{ row }" > ¥ { { formatAmount ( row . amount ) } } < / template > < / el-table-column >
< el-table-column label = "付款方式" width = "120" > < template # default = "{ row }" > { { methodLabels [ row . paymentMethod as RechargePaymentMethod ] } } < / template > < / el-table-column >
< el-table-column prop = "voucherFileName" label = "付款截图" min -width = " 170 " / >
< el-table-column label = "状态" width = "110" > < template # default = "{ row }" > < el-tag : type = "statusType(row.status as RechargeStatus)" > { { statusLabels [ row . status as RechargeStatus ] } } < / el-tag > < / template > < / el-table-column >
< el-table-column prop = "submittedByName" label = "提交人" min -width = " 120 " > < template # default = "{ row }" > { { row . submittedByName || '—' } } < / template > < / el-table-column >
< el-table-column prop = "reviewRemark" label = "审核备注" min -width = " 180 " > < template # default = "{ row }" > { { row . reviewRemark || '—' } } < / template > < / el-table-column >
< el-table-column label = "提交时间" width = "170" > < template # default = "{ row }" > { { formatDateTime ( row . createdAt ) } } < / template > < / el-table-column >
< el-table-column label = "审核时间" width = "170" > < template # default = "{ row }" > { { formatDateTime ( row . reviewedAt ) } } < / template > < / el-table-column >
< / el-table >
< div v-if = "rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" / > < / div >
< / 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 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: form.amount === amount }" @click="chooseAmount(amount)" > ¥ {{ Number ( amount ) }} < / button >
< / div >
< div class = "payment-methods" aria -label = " 付款方式 " >
< button v-for = "method in (['WECHAT', 'ALIPAY'] as RechargePaymentMethod[])" :key="method" type="button" :class="[`payment-${method.toLowerCase()}`, { active: form.paymentMethod === method }]" @click="form.paymentMethod = method" > < span > { { method === 'WECHAT' ? '微' : '支' } } < / span > { { methodLabels [ method ] } } < / button >
< / div >
< div v-if = "channelsError" class="channel-warning" role="alert" > {{ channelsError }} < / div >
< div v-else-if = "channelReady" class="qr-panel" >
< img : src = "selectedChannel?.qrCodeUrl || ''" :alt = "`${methodLabels[form.paymentMethod]}收款二维码`" >
< p > 请使用 { { methodLabels [ form . paymentMethod ] } } 扫码完成付款 < / p >
< / div >
< div v-else class = "channel-warning" role = "status" > { { methodLabels [ form . paymentMethod ] } } 收款二维码尚未配置 , 暂不能提交充值申请 。 < / 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 : 420 px ; flex - direction : column ; gap : 16 px ; }
. account - heading , . section - heading , . pagination - row { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; }
. account - heading h2 , . section - heading h3 { margin : 0 ; color : var ( -- navy ) ; } . account - heading h2 { font - size : 22 px ; } . section - heading h3 { font - size : 16 px ; }
. account - heading p , . section - heading p { margin : 5 px 0 0 ; color : var ( -- muted ) ; font - size : 13 px ; } . account - heading . el - button { display : inline - flex ; align - items : center ; gap : 6 px ; }
. balance - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 16 px ; }
. balance - grid article { display : flex ; min - height : 132 px ; flex - direction : column ; padding : 22 px 24 px ; border : 1 px solid var ( -- border ) ; border - radius : 10 px ; background : # fff ; }
. balance - grid span { color : var ( -- muted ) ; font - size : 13 px ; } . balance - grid strong { margin - top : 14 px ; color : var ( -- navy ) ; font - size : 28 px ; line - height : 1 ; } . balance - grid small { margin - top : auto ; color : # 8 a98a8 ; } . balance - grid . balance - available { border - color : # b9e1db ; background : # f3faf8 ; } . balance - grid . balance - available strong { color : # 058 b78 ; }
. account - card { min - width : 0 ; padding : 18 px ; border : 1 px solid var ( -- border ) ; border - radius : 8 px ; background : # fff ; } . section - heading { margin - bottom : 14 px ; }
. pagination - row { padding - top : 14 px ; color : var ( -- muted ) ; font - size : 12 px ; } . amount - positive { color : # 078 a72 ; } . amount - negative { color : # b42318 ; }
. account - state { display : flex ; min - height : 240 px ; align - items : center ; justify - content : center ; flex - direction : column ; gap : 12 px ; border - radius : 8 px ; } . account - state -- error { border : 1 px solid # f5c2bd ; background : # fff8f7 ; color : # b42318 ; } . account - state p { margin : 0 ; }
. recharge - form { display : flex ; flex - direction : column ; gap : 16 px ; } . field - label { color : # 4 e6075 ; font - size : 14 px ; font - weight : 650 ; }
. quick - amounts , . payment - methods { display : grid ; grid - template - columns : repeat ( 4 , 1 fr ) ; gap : 16 px ; } . quick - amounts button , . payment - methods button { min - height : 54 px ; border : 1 px solid # dbe3ed ; border - radius : 10 px ; background : # f8fafc ; color : # 506077 ; cursor : pointer ; font : inherit ; } . quick - amounts button . active , . payment - methods button . active { border : 2 px solid # 1671 c9 ; background : # eef8f7 ; color : # 1467 b8 ; font - weight : 700 ; }
. payment - methods { grid - template - columns : repeat ( 2 , 1 fr ) ; } . payment - methods button { display : flex ; align - items : center ; justify - content : center ; gap : 10 px ; font - size : 17 px ; font - weight : 650 ; } . payment - methods span { display : grid ; width : 26 px ; height : 26 px ; place - items : center ; border - radius : 7 px ; background : # 1685 ef ; color : # fff ; font - size : 14 px ; } . payment - wechat span { background : # 09 ae42 ; }
. qr - panel { display : flex ; align - items : center ; flex - direction : column ; gap : 14 px ; padding : 22 px ; border : 1 px dashed # bed0e4 ; border - radius : 12 px ; background : # f8fafc ; } . qr - panel img { width : 210 px ; height : 210 px ; border : 1 px solid # dae3ed ; border - radius : 10 px ; background : # fff ; object - fit : contain ; } . qr - panel p { margin : 0 ; color : # 62758 d ; }
. channel - warning { padding : 24 px ; border : 1 px solid # f0d39b ; border - radius : 10 px ; background : # fff9ed ; color : # 8 b5b12 ; text - align : center ; } . voucher - upload { display : grid ; gap : 10 px ; } . voucher - upload : deep ( . el - button ) { display : inline - flex ; align - items : center ; gap : 6 px ; } . submit - error { margin : 0 ; color : # b42318 ; font - size : 13 px ; } . dialog - actions { display : grid ; grid - template - columns : repeat ( 2 , 1 fr ) ; gap : 16 px ; } . dialog - actions . el - button { min - height : 46 px ; margin : 0 ; }
< / style >