@@ -0,0 +1,886 @@
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref } from 'vue'
import { useRouter } from 'vue-router'
import { showDialog , showToast } from 'vant'
import {
cancelWithdrawal ,
createPaymentAccount ,
createWithdrawal ,
fetchPaymentAccounts ,
fetchWithdrawals ,
type AccountType ,
type PaymentAccount ,
type WithdrawalRequest ,
} from '@/features/wallet/api/withdrawal'
import { fetchWalletBalance , type WalletAccount } from '@/features/wallet/api/wallet'
import { uploadFile } from '@/shared/api/files'
import { readError } from '@/shared/utils/error'
import { formatCent , formatMoney , yuanToCent } from '@/shared/utils/money'
import { formatDateMinute } from '@/shared/utils/time'
const router = useRouter ( )
const loading = ref ( false )
const submitting = ref ( false )
const addingAccount = ref ( false )
const uploadingCertificate = ref ( false )
const showAccountPopup = ref ( false )
const account = ref < WalletAccount | null > ( null )
const paymentAccounts = ref < PaymentAccount [ ] > ( [ ] )
const withdrawals = ref < WithdrawalRequest [ ] > ( [ ] )
const MIN _AMOUNT = 10
const MAX _AMOUNT = 5000
const withdrawForm = reactive ( {
payment _account _id : 0 ,
amount : '' as number | '' ,
} )
const accountForm = reactive ( {
account _type : 'alipay' as AccountType ,
account _name : '' ,
account _no : '' ,
bank _name : '' ,
bank _branch : '' ,
certificate _urls : [ ] as string [ ] ,
} )
const selectedAccount = computed ( ( ) =>
paymentAccounts . value . find ( item => item . id === withdrawForm . payment _account _id )
)
const amountValue = computed ( ( ) => Number ( withdrawForm . amount || 0 ) )
const amountCent = computed ( ( ) => yuanToCent ( amountValue . value ) )
const availableBalanceCent = computed ( ( ) => account . value ? . available _balance _cent || 0 )
const canWithdraw = computed (
( ) =>
withdrawForm . payment _account _id > 0 &&
amountValue . value >= MIN _AMOUNT &&
amountValue . value <= MAX _AMOUNT &&
amountCent . value <= availableBalanceCent . value &&
! submitting . value
)
onMounted ( loadData )
async function loadData ( ) {
loading . value = true
try {
const [ balanceData , accountsData , withdrawalsData ] = await Promise . all ( [
fetchWalletBalance ( ) ,
fetchPaymentAccounts ( 1 , 100 ) ,
fetchWithdrawals ( 1 , 20 ) ,
] )
account . value = balanceData
paymentAccounts . value = accountsData . items
withdrawals . value = withdrawalsData . items
const currentStillExists = paymentAccounts . value . some (
item => item . id === withdrawForm . payment _account _id
)
if ( ! currentStillExists ) {
const defaultAccount = paymentAccounts . value . find ( item => item . is _default )
withdrawForm . payment _account _id = defaultAccount ? . id || paymentAccounts . value [ 0 ] ? . id || 0
}
} catch ( error ) {
showToast ( { message : readError ( error , '加载失败' ) , icon : 'cross' } )
} finally {
loading . value = false
}
}
async function handleSubmit ( ) {
if ( ! canWithdraw . value || ! selectedAccount . value ) return
try {
await showDialog ( {
title : '确认提现' ,
message : ` 提现 ¥ ${ formatMoney ( amountValue . value ) } 到 ${ selectedAccount . value . account _name } ( ${ selectedAccount . value . account _no } )? ` ,
confirmButtonText : '确认提交' ,
cancelButtonText : '再看看' ,
showCancelButton : true ,
} )
} catch {
return
}
submitting . value = true
try {
await createWithdrawal ( {
payment _account _id : withdrawForm . payment _account _id ,
amount _cent : amountCent . value ,
} )
showToast ( { message : '提现申请已提交' , icon : 'passed' } )
withdrawForm . amount = ''
await loadData ( )
} catch ( error ) {
showToast ( { message : readError ( error , '提现申请失败' ) , icon : 'cross' } )
} finally {
submitting . value = false
}
}
async function handleCancel ( item : WithdrawalRequest ) {
try {
await showDialog ( {
title : '取消提现' ,
message : '确定要取消此提现申请吗?' ,
confirmButtonText : '确认取消' ,
cancelButtonText : '返回' ,
showCancelButton : true ,
} )
} catch {
return
}
try {
await cancelWithdrawal ( item . id )
showToast ( { message : '已取消提现' , icon : 'passed' } )
await loadData ( )
} catch ( error ) {
showToast ( { message : readError ( error , '取消失败' ) , icon : 'cross' } )
}
}
function openAccountPopup ( ) {
Object . assign ( accountForm , {
account _type : 'alipay' as AccountType ,
account _name : '' ,
account _no : '' ,
bank _name : '' ,
bank _branch : '' ,
certificate _urls : [ ] as string [ ] ,
} )
showAccountPopup . value = true
}
async function handleAddAccount ( ) {
if ( ! accountForm . account _name . trim ( ) ) {
showToast ( { message : '请输入账户名' , icon : 'warning-o' } )
return
}
if ( ! accountForm . account _no . trim ( ) ) {
showToast ( { message : accountForm . account _type === 'bank' ? '请输入银行卡号' : '请输入账号' , icon : 'warning-o' } )
return
}
if ( accountForm . account _type === 'bank' && ! accountForm . bank _name . trim ( ) ) {
showToast ( { message : '请输入银行名称' , icon : 'warning-o' } )
return
}
addingAccount . value = true
try {
const created = await createPaymentAccount ( {
account _type : accountForm . account _type ,
account _name : accountForm . account _name . trim ( ) ,
account _no : accountForm . account _no . trim ( ) ,
bank _name : accountForm . account _type === 'bank' ? accountForm . bank _name . trim ( ) : '' ,
bank _branch : accountForm . account _type === 'bank' ? accountForm . bank _branch . trim ( ) : '' ,
certificate _urls : accountForm . certificate _urls ,
} )
showToast ( { message : '收款账号已添加' , icon : 'passed' } )
showAccountPopup . value = false
await loadData ( )
withdrawForm . payment _account _id = created . id
} catch ( error ) {
showToast ( { message : readError ( error , '添加失败' ) , icon : 'cross' } )
} finally {
addingAccount . value = false
}
}
async function handleCertificateUpload ( event : Event ) {
const input = event . target as HTMLInputElement
const file = input . files ? . [ 0 ]
input . value = ''
if ( ! file ) return
if ( ! file . type . startsWith ( 'image/' ) ) {
showToast ( { message : '只能上传图片文件' , icon : 'warning-o' } )
return
}
if ( file . size > 5 * 1024 * 1024 ) {
showToast ( { message : '图片大小不能超过5MB' , icon : 'warning-o' } )
return
}
if ( accountForm . certificate _urls . length >= 3 ) {
showToast ( { message : '最多上传3张图片' , icon : 'warning-o' } )
return
}
uploadingCertificate . value = true
try {
const uploaded = await uploadFile ( file , 'payment-cert' )
const publicURL = uploaded . url . replace ( '/api/files/object' , '/api/public/files/object' )
accountForm . certificate _urls . push ( publicURL )
showToast ( { message : '图片已上传' , icon : 'passed' } )
} catch ( error ) {
showToast ( { message : readError ( error , '上传失败' ) , icon : 'cross' } )
} finally {
uploadingCertificate . value = false
}
}
function removeCertificate ( index : number ) {
accountForm . certificate _urls . splice ( index , 1 )
}
function selectAccount ( id : number ) {
withdrawForm . payment _account _id = id
}
function accountTypeLabel ( type : string ) {
const labels : Record < string , string > = {
alipay : '支付宝' ,
wechat : '微信' ,
bank : '银行卡' ,
}
return labels [ type ] || type
}
function accountTypeIcon ( type : string ) {
const icons : Record < string , string > = {
alipay : 'gold-coin-o' ,
wechat : 'chat-o' ,
bank : 'credit-pay' ,
}
return icons [ type ] || 'balance-o'
}
function statusLabel ( status : string ) {
const labels : Record < string , string > = {
pending : '待审核' ,
processing : '处理中' ,
completed : '已完成' ,
rejected : '已拒绝' ,
cancelled : '已取消' ,
}
return labels [ status ] || status
}
function statusType ( status : string ) : 'primary' | 'success' | 'danger' | 'warning' | undefined {
const types : Record < string , ' primary ' | ' success ' | ' danger ' | ' warning ' > = {
pending : 'warning' ,
processing : 'primary' ,
completed : 'success' ,
rejected : 'danger' ,
}
return types [ status ]
}
< / script >
< template >
< main class = "mobile-withdrawal" >
< header class = "page-header" >
< button class = "back-btn" type = "button" @click ="router.back()" >
< van -icon name = "arrow-left" :size = "20" / >
< / button >
< h1 > 提现 < / h1 >
< button class = "refresh-btn" type = "button" @click ="loadData" >
< van -icon name = "replay" :size = "18" / >
< / button >
< / header >
< section class = "balance-panel" >
< span > 可用余额 < / span >
< strong > ¥ { { formatCent ( availableBalanceCent ) } } < / strong >
< small > 冻结余额 ¥ { { formatCent ( account ? . frozen _balance _cent ) } } < / small >
< / section >
< section class = "withdraw-card" >
< div class = "section-title" >
< h2 > 申请提现 < / h2 >
< button type = "button" @click ="openAccountPopup" >
< van -icon name = "plus" :size = "14" / >
添加账号
< / button >
< / div >
< van-loading v-if = "loading && !paymentAccounts.length" class="center-loading" / >
< div v-else-if = "!paymentAccounts.length" class="empty-state" >
< van -icon name = "card" :size = "32" / >
< p > 还没有收款账号 < / p >
< van-button type = "primary" round size = "small" @click ="openAccountPopup" >
添加收款账号
< / van -button >
< / div >
< template v-else >
< div class = "account-list" >
< button
v-for = "item in paymentAccounts"
:key = "item.id"
type = "button"
class = "account-option"
: class = "{ active: item.id === withdrawForm.payment_account_id }"
@click ="selectAccount(item.id)"
>
< span class = "account-icon" >
< van-icon :name = "accountTypeIcon(item.account_type)" :size = "18" / >
< / span >
< span class = "account-main" >
< strong > { { accountTypeLabel ( item . account _type ) } } · { { item . account _name } } < / strong >
< small > { { item . account _no } } < / small >
< / span >
< van-tag v-if = "item.is_default" type="warning" plain > 默认 < / van-tag >
< van-icon
: name = "item.id === withdrawForm.payment_account_id ? 'checked' : 'circle'"
:size = "18"
/ >
< / button >
< / div >
< van-field
v -model .number = " withdrawForm.amount "
type = "number"
label = "提现金额"
placeholder = "请输入提现金额"
input -align = " right "
>
< template # left -icon > ¥ < / template >
< / van-field >
< div class = "amount-meta" >
< span > 单笔限额 ¥ { { MIN _AMOUNT } } - ¥ { { MAX _AMOUNT } } < / span >
< button type = "button" @click ="withdrawForm.amount = availableBalanceCent / 100" >
全部提现
< / button >
< / div >
< div v-if = "amountValue > 0" class="amount-preview" >
< span > 预计到账 < / span >
< strong > ¥ { { formatMoney ( amountValue ) } } < / strong >
< / div >
< van-notice-bar
v-if = "amountValue > 0 && amountCent > availableBalanceCent"
color = "#b91c1c"
background = "#fef2f2"
text = "余额不足,请调整提现金额"
/ >
< van-button
block
round
type = "primary"
class = "submit-btn"
:loading = "submitting"
:disabled = "!canWithdraw"
loading -text = " 提交中... "
@click ="handleSubmit"
>
提交申请
< / van-button >
< / template >
< / section >
< section class = "records-card" >
< div class = "section-title" >
< h2 > 提现记录 < / h2 >
< span > { { withdrawals . length } } 条 < / span >
< / div >
< van-empty v-if = "!withdrawals.length && !loading" description="暂无提现记录" / >
< div v-else class = "record-list" >
< article v-for = "item in withdrawals" :key="item.id" class="record-item" >
< div class = "record-top" >
< div >
< strong > ¥ { { formatCent ( item . amount _cent ) } } < / strong >
< small > { { item . withdraw _no } } < / small >
< / div >
< van-tag :type = "statusType(item.status)" > { { statusLabel ( item . status ) } } < / van-tag >
< / div >
< div class = "record-meta" >
< span > { { accountTypeLabel ( item . account _type ) } } · { { item . account _no } } < / span >
< span > { { formatDateMinute ( item . created _at ) } } < / span >
< / div >
< p v-if = "item.review_remark" class="record-remark" > {{ item.review_remark }} < / p >
< button
v-if = "item.status === 'pending'"
type = "button"
class = "cancel-btn"
@click ="handleCancel(item)"
>
取消申请
< / button >
< / article >
< / div >
< / section >
< van-popup
v -model :show = "showAccountPopup"
round
position = "bottom"
closeable
class = "account-popup"
>
< div class = "popup-body" >
< h2 > 添加收款账号 < / h2 >
< van-radio-group v-model = "accountForm.account_type" direction="horizontal" class="type-tabs" >
< van -radio name = "alipay" > 支付宝 < / van-radio >
< van-radio name = "wechat" > 微信 < / van-radio >
< van-radio name = "bank" > 银行卡 < / van-radio >
< / van-radio-group >
< van-field
v -model .trim = " accountForm.account_name "
label = "账户名"
placeholder = "必须与实名认证姓名一致"
required
/ >
< van-field
v -model .trim = " accountForm.account_no "
: label = "accountForm.account_type === 'bank' ? '银行卡号' : '账号'"
: placeholder = "
accountForm.account_type === 'alipay'
? '支付宝账号'
: accountForm.account_type === 'wechat'
? '微信号'
: '银行卡号'
"
required
/ >
< template v-if = "accountForm.account_type === 'bank'" >
< van -field
v -model .trim = " accountForm.bank_name "
label = "银行名称"
placeholder = "如:中国工商银行"
required
/ >
< van-field
v -model .trim = " accountForm.bank_branch "
label = "开户支行"
placeholder = "可选"
/ >
< / template >
< div class = "account-tip" >
账户名必须与实名认证姓名一致 ; 提现将转账到该收款账号 , 请确认账号信息准确 。
< / div >
< section class = "certificate-section" >
< div class = "certificate-title" >
< span > 收款码 / 凭证截图 < / span >
< small > 最多3张 < / small >
< / div >
< div class = "certificate-grid" >
< div
v-for = "(url, index) in accountForm.certificate_urls"
:key = "url"
class = "certificate-item"
>
< img :src = "url" alt = "收款凭证" / >
< button type = "button" class = "certificate-remove" @click ="removeCertificate(index)" >
< van -icon name = "cross" :size = "13" / >
< / button >
< / div >
< label v-if = "accountForm.certificate_urls.length < 3" class="certificate-upload" >
< input
type = "file"
accept = "image/*"
:disabled = "uploadingCertificate"
@change ="handleCertificateUpload"
/ >
< van-loading v-if = "uploadingCertificate" size="18" color="#ff6a00" / >
< van-icon v-else name = "photograph" :size = "22" / >
< span > { { uploadingCertificate ? '上传中' : '上传图片' } } < / span >
< / label >
< / div >
< / section >
< van-button
block
round
type = "primary"
:loading = "addingAccount"
loading -text = " 添加中... "
@click ="handleAddAccount"
>
保存账号
< / van-button >
< / div >
< / van-popup >
< / main >
< / template >
< style scoped >
. mobile - withdrawal {
-- van - primary - color : # ff6a00 ;
-- van - button - primary - background : # ff6a00 ;
-- van - button - primary - border - color : # ff6a00 ;
-- van - radio - checked - icon - color : # ff6a00 ;
-- van - checkbox - checked - icon - color : # ff6a00 ;
-- van - tag - warning - color : # ff6a00 ;
min - height : 100 vh ;
padding : 12 px 14 px 28 px ;
background : # fff7ed ;
}
. page - header {
display : grid ;
grid - template - columns : 40 px 1 fr 40 px ;
align - items : center ;
height : 44 px ;
margin - bottom : 12 px ;
}
. page - header h1 {
margin : 0 ;
text - align : center ;
font - size : 17 px ;
color : # 101828 ;
}
. back - btn ,
. refresh - btn {
display : inline - flex ;
align - items : center ;
justify - content : center ;
width : 36 px ;
height : 36 px ;
border : 0 ;
border - radius : 18 px ;
background : # fff ;
color : # 1 f2937 ;
}
. refresh - btn {
justify - self : end ;
}
. balance - panel {
display : grid ;
gap : 6 px ;
padding : 18 px ;
border - radius : 8 px ;
background : linear - gradient ( 135 deg , # ff8a1f 0 % , # ff5a00 100 % ) ;
color : # fff ;
box - shadow : 0 14 px 28 px rgba ( 255 , 106 , 0 , 0.22 ) ;
}
. balance - panel span ,
. balance - panel small {
color : rgba ( 255 , 255 , 255 , 0.72 ) ;
font - size : 12 px ;
}
. balance - panel strong {
font - size : 31 px ;
line - height : 1.1 ;
}
. withdraw - card ,
. records - card {
margin - top : 12 px ;
padding : 14 px ;
border - radius : 8 px ;
background : # fff ;
}
. section - title {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
margin - bottom : 12 px ;
}
. section - title h2 {
margin : 0 ;
font - size : 16 px ;
color : # 101828 ;
}
. section - title button {
display : inline - flex ;
align - items : center ;
gap : 4 px ;
border : 0 ;
background : transparent ;
color : # ff6a00 ;
font - size : 13 px ;
}
. section - title span {
color : # 98 a2b3 ;
font - size : 12 px ;
}
. center - loading {
display : flex ;
justify - content : center ;
padding : 28 px 0 ;
}
. empty - state {
display : grid ;
justify - items : center ;
gap : 10 px ;
padding : 24 px 0 ;
color : # 98 a2b3 ;
}
. empty - state p {
margin : 0 ;
}
. account - list {
display : grid ;
gap : 8 px ;
margin - bottom : 12 px ;
}
. account - option {
display : grid ;
grid - template - columns : 34 px 1 fr auto 20 px ;
align - items : center ;
gap : 8 px ;
width : 100 % ;
min - height : 64 px ;
padding : 10 px ;
border : 1 px solid # eaecf0 ;
border - radius : 8 px ;
background : # fff ;
text - align : left ;
}
. account - option . active {
border - color : # ff6a00 ;
background : # fff7ed ;
}
. account - icon {
display : inline - flex ;
align - items : center ;
justify - content : center ;
width : 34 px ;
height : 34 px ;
border - radius : 8 px ;
background : # fff3e8 ;
color : # ff6a00 ;
}
. account - main {
display : grid ;
gap : 3 px ;
min - width : 0 ;
}
. account - main strong {
overflow : hidden ;
color : # 101828 ;
font - size : 14 px ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. account - main small {
overflow : hidden ;
color : # 667085 ;
font - size : 12 px ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
: deep ( . van - cell ) {
padding - right : 0 ;
padding - left : 0 ;
}
. amount - meta ,
. amount - preview {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 10 px ;
margin : 10 px 0 ;
color : # 667085 ;
font - size : 12 px ;
}
. amount - meta button {
border : 0 ;
background : transparent ;
color : # ff6a00 ;
}
. amount - preview {
padding : 10 px 12 px ;
border - radius : 8 px ;
background : # f8fafc ;
}
. amount - preview strong {
color : # ff6a00 ;
font - size : 18 px ;
}
. submit - btn {
margin - top : 14 px ;
}
. record - list {
display : grid ;
gap : 10 px ;
}
. record - item {
padding : 12 px ;
border : 1 px solid # eaecf0 ;
border - radius : 8 px ;
background : # fff ;
}
. record - top ,
. record - meta {
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
gap : 10 px ;
}
. record - top strong {
display : block ;
color : # 101828 ;
font - size : 18 px ;
}
. record - top small ,
. record - meta {
color : # 98 a2b3 ;
font - size : 12 px ;
}
. record - meta {
margin - top : 8 px ;
}
. record - remark {
margin : 10 px 0 0 ;
color : # b42318 ;
font - size : 12 px ;
}
. cancel - btn {
margin - top : 10 px ;
padding : 6 px 10 px ;
border : 1 px solid # fecdca ;
border - radius : 999 px ;
background : # fff ;
color : # b42318 ;
font - size : 12 px ;
}
. account - popup {
max - height : 86 vh ;
}
. popup - body {
padding : 20 px 16 px 24 px ;
}
. popup - body h2 {
margin : 0 0 16 px ;
font - size : 17 px ;
color : # 101828 ;
}
. type - tabs {
margin - bottom : 12 px ;
}
. type - tabs : deep ( . van - radio _ _label ) {
color : # 1 f2937 ;
font - size : 15 px ;
}
. account - tip {
margin : 12 px 0 ;
padding : 10 px 12 px ;
border - radius : 8 px ;
background : # fffbeb ;
color : # 92400 e ;
font - size : 12 px ;
line - height : 1.6 ;
}
. certificate - section {
margin : 14 px 0 ;
}
. certificate - title {
display : flex ;
align - items : center ;
justify - content : space - between ;
margin - bottom : 10 px ;
color : # 1 f2937 ;
font - size : 14 px ;
font - weight : 600 ;
}
. certificate - title small {
color : # 98 a2b3 ;
font - size : 12 px ;
font - weight : 400 ;
}
. certificate - grid {
display : grid ;
grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ;
gap : 10 px ;
}
. certificate - item ,
. certificate - upload {
position : relative ;
overflow : hidden ;
aspect - ratio : 1 ;
border - radius : 8 px ;
background : # fff7ed ;
}
. certificate - item {
border : 1 px solid # fed7aa ;
}
. certificate - item img {
display : block ;
width : 100 % ;
height : 100 % ;
object - fit : cover ;
}
. certificate - remove {
position : absolute ;
top : 6 px ;
right : 6 px ;
display : inline - flex ;
align - items : center ;
justify - content : center ;
width : 22 px ;
height : 22 px ;
border : 0 ;
border - radius : 50 % ;
background : rgba ( 15 , 23 , 42 , 0.72 ) ;
color : # fff ;
}
. certificate - upload {
display : flex ;
flex - direction : column ;
align - items : center ;
justify - content : center ;
gap : 6 px ;
border : 1 px dashed # ff9f43 ;
color : # ff6a00 ;
font - size : 12 px ;
}
. certificate - upload input {
display : none ;
}
. certificate - upload span {
line - height : 1 ;
}
< / style >