手机端增加提现功能

This commit is contained in:
yml2213
2026-06-12 21:57:49 +08:00
parent f1ae72cc7a
commit c726659866
5 changed files with 899 additions and 5 deletions
+2
View File
@@ -77,6 +77,8 @@ declare module 'vue' {
VanNoticeBar: typeof import('vant/es')['NoticeBar'] VanNoticeBar: typeof import('vant/es')['NoticeBar']
VanPopup: typeof import('vant/es')['Popup'] VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh'] VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanRadio: typeof import('vant/es')['Radio']
VanRadioGroup: typeof import('vant/es')['RadioGroup']
VanSearch: typeof import('vant/es')['Search'] VanSearch: typeof import('vant/es')['Search']
VanStepper: typeof import('vant/es')['Stepper'] VanStepper: typeof import('vant/es')['Stepper']
VanSwipe: typeof import('vant/es')['Swipe'] VanSwipe: typeof import('vant/es')['Swipe']
@@ -136,10 +136,7 @@ async function openPostRentalNotice() {
} }
function handleWithdraw() { function handleWithdraw() {
showDialog({ router.push('/m/wallet/withdrawal')
title: '提现提示',
message: '为了您的资金安全,提现请前往电脑端网页版进行操作。',
})
} }
function goOrders(tabKey: string) { function goOrders(tabKey: string) {
@@ -364,7 +361,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="icon-wrap purple"><van-icon name="shop-o" :size="22" /></div> <div class="icon-wrap purple"><van-icon name="shop-o" :size="22" /></div>
<span>我的商品</span> <span>我的商品</span>
</div> </div>
<div class="grid-item" @click="openLedgers"> <div class="grid-item" @click="router.push('/m/wallet/withdrawal')">
<div class="icon-wrap teal"><van-icon name="balance-o" :size="22" /></div> <div class="icon-wrap teal"><van-icon name="balance-o" :size="22" /></div>
<span>提现/账单</span> <span>提现/账单</span>
</div> </div>
@@ -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: 100vh;
padding: 12px 14px 28px;
background: #fff7ed;
}
.page-header {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
height: 44px;
margin-bottom: 12px;
}
.page-header h1 {
margin: 0;
text-align: center;
font-size: 17px;
color: #101828;
}
.back-btn,
.refresh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
border-radius: 18px;
background: #fff;
color: #1f2937;
}
.refresh-btn {
justify-self: end;
}
.balance-panel {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 8px;
background: linear-gradient(135deg, #ff8a1f 0%, #ff5a00 100%);
color: #fff;
box-shadow: 0 14px 28px rgba(255, 106, 0, 0.22);
}
.balance-panel span,
.balance-panel small {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
}
.balance-panel strong {
font-size: 31px;
line-height: 1.1;
}
.withdraw-card,
.records-card {
margin-top: 12px;
padding: 14px;
border-radius: 8px;
background: #fff;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title h2 {
margin: 0;
font-size: 16px;
color: #101828;
}
.section-title button {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: #ff6a00;
font-size: 13px;
}
.section-title span {
color: #98a2b3;
font-size: 12px;
}
.center-loading {
display: flex;
justify-content: center;
padding: 28px 0;
}
.empty-state {
display: grid;
justify-items: center;
gap: 10px;
padding: 24px 0;
color: #98a2b3;
}
.empty-state p {
margin: 0;
}
.account-list {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.account-option {
display: grid;
grid-template-columns: 34px 1fr auto 20px;
align-items: center;
gap: 8px;
width: 100%;
min-height: 64px;
padding: 10px;
border: 1px solid #eaecf0;
border-radius: 8px;
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: 34px;
height: 34px;
border-radius: 8px;
background: #fff3e8;
color: #ff6a00;
}
.account-main {
display: grid;
gap: 3px;
min-width: 0;
}
.account-main strong {
overflow: hidden;
color: #101828;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-main small {
overflow: hidden;
color: #667085;
font-size: 12px;
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: 10px;
margin: 10px 0;
color: #667085;
font-size: 12px;
}
.amount-meta button {
border: 0;
background: transparent;
color: #ff6a00;
}
.amount-preview {
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.amount-preview strong {
color: #ff6a00;
font-size: 18px;
}
.submit-btn {
margin-top: 14px;
}
.record-list {
display: grid;
gap: 10px;
}
.record-item {
padding: 12px;
border: 1px solid #eaecf0;
border-radius: 8px;
background: #fff;
}
.record-top,
.record-meta {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.record-top strong {
display: block;
color: #101828;
font-size: 18px;
}
.record-top small,
.record-meta {
color: #98a2b3;
font-size: 12px;
}
.record-meta {
margin-top: 8px;
}
.record-remark {
margin: 10px 0 0;
color: #b42318;
font-size: 12px;
}
.cancel-btn {
margin-top: 10px;
padding: 6px 10px;
border: 1px solid #fecdca;
border-radius: 999px;
background: #fff;
color: #b42318;
font-size: 12px;
}
.account-popup {
max-height: 86vh;
}
.popup-body {
padding: 20px 16px 24px;
}
.popup-body h2 {
margin: 0 0 16px;
font-size: 17px;
color: #101828;
}
.type-tabs {
margin-bottom: 12px;
}
.type-tabs :deep(.van-radio__label) {
color: #1f2937;
font-size: 15px;
}
.account-tip {
margin: 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #fffbeb;
color: #92400e;
font-size: 12px;
line-height: 1.6;
}
.certificate-section {
margin: 14px 0;
}
.certificate-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
color: #1f2937;
font-size: 14px;
font-weight: 600;
}
.certificate-title small {
color: #98a2b3;
font-size: 12px;
font-weight: 400;
}
.certificate-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.certificate-item,
.certificate-upload {
position: relative;
overflow: hidden;
aspect-ratio: 1;
border-radius: 8px;
background: #fff7ed;
}
.certificate-item {
border: 1px solid #fed7aa;
}
.certificate-item img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.certificate-remove {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
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: 6px;
border: 1px dashed #ff9f43;
color: #ff6a00;
font-size: 12px;
}
.certificate-upload input {
display: none;
}
.certificate-upload span {
line-height: 1;
}
</style>
+3
View File
@@ -41,6 +41,8 @@ export function getMobilePath(path: string): string {
} }
// PC-only views that don't have mobile counterparts go to mobile profile as fallback // PC-only views that don't have mobile counterparts go to mobile profile as fallback
if (path === '/wallet/withdrawal') return '/m/wallet/withdrawal'
if (path === '/wallet' || path === '/notifications' || path.startsWith('/seller')) { if (path === '/wallet' || path === '/notifications' || path.startsWith('/seller')) {
return '/m/profile' return '/m/profile'
} }
@@ -68,6 +70,7 @@ export function getPcPath(path: string): string {
if (subPath === '/login') return '/login' if (subPath === '/login') return '/login'
if (subPath === '/register') return '/login' // PC uses /login for both auth actions if (subPath === '/register') return '/login' // PC uses /login for both auth actions
if (subPath === '/realname') return '/realname' if (subPath === '/realname') return '/realname'
if (subPath === '/wallet/withdrawal') return '/wallet/withdrawal'
if (subPath === '/seller/listings/create') return '/seller/listings/create' if (subPath === '/seller/listings/create') return '/seller/listings/create'
if (subPath.startsWith('/seller/listings/') && subPath.endsWith('/edit')) return subPath if (subPath.startsWith('/seller/listings/') && subPath.endsWith('/edit')) return subPath
+6
View File
@@ -67,6 +67,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
component: () => import('@/features/auth/views/MobileProfileView.vue'), component: () => import('@/features/auth/views/MobileProfileView.vue'),
meta: { layout: 'blank', requiresAuth: true }, meta: { layout: 'blank', requiresAuth: true },
}, },
{
path: '/m/wallet/withdrawal',
name: 'mobile-withdrawal',
component: () => import('@/features/wallet/views/MobileWithdrawalView.vue'),
meta: { layout: 'blank', requiresAuth: true },
},
{ {
path: '/m/orders', path: '/m/orders',
name: 'mobile-orders', name: 'mobile-orders',