优化移动端收款账号管理

This commit is contained in:
yml2213
2026-06-21 12:57:53 +08:00
parent efdbb4066c
commit a0145e6b18
3 changed files with 281 additions and 73 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Upload, Delete, Loading } from '@element-plus/icons-vue'
import alipayReceiveQrExample from '@/assets/alipay-receive-qr-example.png'
import wechatReceiveQrExample from '@/assets/wechat-receive-qr-example.png'
import {
@@ -33,6 +34,39 @@ const form = ref<CreatePaymentAccountRequest>({
bank_branch: '',
certificate_urls: [],
})
const receiveQrTip = computed(() => {
if (form.value.account_type === 'alipay') {
return {
title: '支付宝收款二维码上传提醒',
lines: [
'请在支付宝收钱码页面保存收钱码,上传保存后的二维码图片。',
'不要上传收钱码截图、聊天截图或带页面背景的整屏截图。',
],
image: alipayReceiveQrExample,
alt: '支付宝收款二维码示例',
label: '支付宝收款二维码',
hint: '请上传保存后的支付宝收款二维码图片(可选,最多3张)',
}
}
if (form.value.account_type === 'wechat') {
return {
title: '微信收款二维码上传提醒',
lines: [
'请在微信收款码页面点击保存收款码,上传保存后的二维码图片。',
'不要上传收款码截图、聊天截图或带页面背景的整屏截图。',
],
image: wechatReceiveQrExample,
alt: '微信收款二维码示例',
label: '微信收款二维码',
hint: '请上传保存后的微信收款二维码图片(可选,最多3张)',
}
}
return null
})
const certificateLabel = computed(() => receiveQrTip.value?.label || '凭证截图')
const certificateHint = computed(
() => receiveQrTip.value?.hint || '可上传收款码截图等凭证(可选,最多3张)'
)
watch(
() => props.modelValue,
@@ -196,21 +230,16 @@ function removeImage(index: number) {
</el-form-item>
</template>
<el-form-item :label="form.account_type === 'wechat' ? '收款二维码' : '凭证截图'">
<div v-if="form.account_type === 'wechat'" class="wechat-qr-tip">
<el-form-item :label="certificateLabel">
<div v-if="receiveQrTip" class="receive-qr-tip">
<div>
<strong>微信收款二维码上传提醒</strong>
<p>请在微信收款码页面点击保存收款码上传保存后的二维码图片</p>
<p>不要上传收款码截图聊天截图或带页面背景的整屏截图</p>
<strong>{{ receiveQrTip.title }}</strong>
<p v-for="line in receiveQrTip.lines" :key="line">{{ line }}</p>
</div>
<img :src="wechatReceiveQrExample" alt="微信收款二维码示例" />
<img :src="receiveQrTip.image" :alt="receiveQrTip.alt" />
</div>
<div style="font-size: 12px; color: #999; margin-bottom: 8px">
{{
form.account_type === 'wechat'
? '请上传保存后的微信收款二维码图片(可选,最多3张)'
: '可上传收款码截图等凭证(可选,最多3张)'
}}
{{ certificateHint }}
</div>
<div class="certificate-upload">
<div v-for="(url, index) in form.certificate_urls" :key="url" class="certificate-item">
@@ -284,7 +313,7 @@ function removeImage(index: number) {
gap: 12px;
}
.wechat-qr-tip {
.receive-qr-tip {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 12px;
@@ -298,20 +327,20 @@ function removeImage(index: number) {
color: #92400e;
}
.wechat-qr-tip strong {
.receive-qr-tip strong {
display: block;
margin-bottom: 6px;
color: #c2410c;
font-size: 13px;
}
.wechat-qr-tip p {
.receive-qr-tip p {
margin: 0;
font-size: 12px;
line-height: 1.6;
}
.wechat-qr-tip img {
.receive-qr-tip img {
display: block;
width: 128px;
border-radius: 8px;
@@ -7,17 +7,21 @@ import {
cancelWithdrawal,
createPaymentAccount,
createWithdrawal,
deletePaymentAccount,
fetchPaymentAccounts,
fetchWithdrawals,
setDefaultPaymentAccount,
type AccountType,
type PaymentAccount,
type WithdrawalRequest,
updatePaymentAccount,
} 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'
import alipayReceiveQrExample from '@/assets/alipay-receive-qr-example.png'
import wechatReceiveQrExample from '@/assets/wechat-receive-qr-example.png'
const router = useRouter()
@@ -25,11 +29,13 @@ const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const addingAccount = ref(false)
const managingAccountId = ref(0)
const uploadingCertificate = ref(false)
const showAccountPopup = ref(false)
const account = ref<WalletAccount | null>(null)
const paymentAccounts = ref<PaymentAccount[]>([])
const withdrawals = ref<WithdrawalRequest[]>([])
const editingAccount = ref<PaymentAccount | null>(null)
const MIN_AMOUNT = 10
const MAX_AMOUNT = 5000
@@ -51,6 +57,35 @@ const accountForm = reactive({
const selectedAccount = computed(() =>
paymentAccounts.value.find(item => item.id === withdrawForm.payment_account_id)
)
const canAddMore = computed(() => paymentAccounts.value.length < 5)
const accountPopupTitle = computed(() => (editingAccount.value ? '管理收款账号' : '添加收款账号'))
const receiveQrTip = computed(() => {
if (accountForm.account_type === 'alipay') {
return {
title: '支付宝收款二维码上传提醒',
lines: [
'请在支付宝收钱码页面保存收钱码,上传保存后的二维码图片。',
'不要上传收钱码截图、聊天截图或带页面背景的整屏截图。',
],
image: alipayReceiveQrExample,
alt: '支付宝收款二维码示例',
label: '支付宝收款二维码',
}
}
if (accountForm.account_type === 'wechat') {
return {
title: '微信收款二维码上传提醒',
lines: [
'请在微信收款码页面点击保存收款码,上传保存后的二维码图片。',
'不要上传收款码截图、聊天截图或带页面背景的整屏截图。',
],
image: wechatReceiveQrExample,
alt: '微信收款二维码示例',
label: '微信收款二维码',
}
}
return null
})
const amountValue = computed(() => Number(withdrawForm.amount || 0))
const amountCent = computed(() => yuanToCent(amountValue.value))
@@ -144,6 +179,7 @@ async function handleCancel(item: WithdrawalRequest) {
}
function openAccountPopup() {
editingAccount.value = null
Object.assign(accountForm, {
account_type: 'alipay' as AccountType,
account_name: '',
@@ -155,12 +191,25 @@ function openAccountPopup() {
showAccountPopup.value = true
}
async function handleAddAccount() {
function openEditAccountPopup(item: PaymentAccount) {
editingAccount.value = item
Object.assign(accountForm, {
account_type: item.account_type,
account_name: item.account_name,
account_no: item.account_no,
bank_name: item.bank_name || '',
bank_branch: item.bank_branch || '',
certificate_urls: [...(item.certificate_urls || [])],
})
showAccountPopup.value = true
}
async function handleSaveAccount() {
if (!accountForm.account_name.trim()) {
showToast({ message: '请输入账户名', icon: 'warning-o' })
return
}
if (!accountForm.account_no.trim()) {
if (!editingAccount.value && !accountForm.account_no.trim()) {
showToast({
message: accountForm.account_type === 'bank' ? '请输入银行卡号' : '请输入账号',
icon: 'warning-o',
@@ -174,6 +223,16 @@ async function handleAddAccount() {
addingAccount.value = true
try {
if (editingAccount.value) {
await updatePaymentAccount(editingAccount.value.id, {
bank_branch: accountForm.account_type === 'bank' ? accountForm.bank_branch.trim() : '',
certificate_urls: accountForm.certificate_urls,
})
showToast({ message: '收款账号已更新', icon: 'passed' })
showAccountPopup.value = false
editingAccount.value = null
await loadData()
} else {
const created = await createPaymentAccount({
account_type: accountForm.account_type,
account_name: accountForm.account_name.trim(),
@@ -184,15 +243,65 @@ async function handleAddAccount() {
})
showToast({ message: '收款账号已添加', icon: 'passed' })
showAccountPopup.value = false
editingAccount.value = null
await loadData()
withdrawForm.payment_account_id = created.id
}
} catch (error) {
showToast({ message: readError(error, '添加失败'), icon: 'cross' })
showToast({
message: readError(error, editingAccount.value ? '更新失败' : '添加失败'),
icon: 'cross',
})
} finally {
addingAccount.value = false
}
}
async function handleSetDefaultAccount(item: PaymentAccount) {
if (item.is_default || managingAccountId.value) return
managingAccountId.value = item.id
try {
await setDefaultPaymentAccount(item.id)
showToast({ message: '已设为默认账号', icon: 'passed' })
withdrawForm.payment_account_id = item.id
await loadData()
} catch (error) {
showToast({ message: readError(error, '设置失败'), icon: 'cross' })
} finally {
managingAccountId.value = 0
}
}
async function handleDeleteAccount(item: PaymentAccount) {
if (managingAccountId.value) return
try {
await showDialog({
title: '删除收款账号',
message: `确定删除 ${accountTypeLabel(item.account_type)} · ${item.account_name} 吗?删除后不会影响已提交的提现记录。`,
confirmButtonText: '确认删除',
cancelButtonText: '取消',
showCancelButton: true,
})
} catch {
return
}
managingAccountId.value = item.id
try {
await deletePaymentAccount(item.id)
showToast({ message: '收款账号已删除', icon: 'passed' })
if (editingAccount.value?.id === item.id) {
showAccountPopup.value = false
editingAccount.value = null
}
await loadData()
} catch (error) {
showToast({ message: readError(error, '删除失败'), icon: 'cross' })
} finally {
managingAccountId.value = 0
}
}
async function handleCertificateUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -293,9 +402,9 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
<section class="withdraw-card">
<div class="section-title">
<h2>申请提现</h2>
<button type="button" @click="openAccountPopup">
<button type="button" :disabled="!canAddMore" @click="openAccountPopup">
<van-icon name="plus" :size="14" />
添加账号
{{ canAddMore ? '添加账号' : '已达上限' }}
</button>
</div>
@@ -311,14 +420,13 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
<template v-else>
<div class="account-list">
<button
<article
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)"
>
<button type="button" class="account-select" @click="selectAccount(item.id)">
<span class="account-icon">
<van-icon :name="accountTypeIcon(item.account_type)" :size="18" />
</span>
@@ -332,6 +440,26 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
:size="18"
/>
</button>
<div class="account-actions">
<button
v-if="!item.is_default"
type="button"
:disabled="managingAccountId === item.id"
@click="handleSetDefaultAccount(item)"
>
设默认
</button>
<button type="button" @click="openEditAccountPopup(item)">管理</button>
<button
type="button"
class="danger"
:disabled="managingAccountId === item.id"
@click="handleDeleteAccount(item)"
>
删除
</button>
</div>
</article>
</div>
<van-field
@@ -419,21 +547,22 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
class="account-popup"
>
<div class="popup-body">
<h2>添加收款账号</h2>
<h2>{{ accountPopupTitle }}</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 name="alipay" :disabled="!!editingAccount">支付宝</van-radio>
<van-radio name="wechat" :disabled="!!editingAccount">微信</van-radio>
<van-radio name="bank" :disabled="!!editingAccount">银行卡</van-radio>
</van-radio-group>
<van-field
v-model.trim="accountForm.account_name"
label="账户名"
placeholder="必须与实名认证姓名一致"
:disabled="!!editingAccount"
required
/>
<van-field
@@ -446,6 +575,7 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
? '微信号'
: '银行卡号'
"
:disabled="!!editingAccount"
required
/>
<template v-if="accountForm.account_type === 'bank'">
@@ -453,6 +583,7 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
v-model.trim="accountForm.bank_name"
label="银行名称"
placeholder="如:中国工商银行"
:disabled="!!editingAccount"
required
/>
<van-field v-model.trim="accountForm.bank_branch" label="开户支行" placeholder="可选" />
@@ -462,20 +593,17 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
账户名必须与实名认证姓名一致提现将转账到该收款账号请确认账号信息准确
</div>
<div v-if="accountForm.account_type === 'wechat'" class="wechat-qr-tip">
<div v-if="receiveQrTip" class="receive-qr-tip">
<div>
<strong>微信收款二维码上传提醒</strong>
<p>请在微信收款码页面点击保存收款码上传保存后的二维码图片</p>
<p>不要上传收款码截图聊天截图或带页面背景的整屏截图</p>
<strong>{{ receiveQrTip.title }}</strong>
<p v-for="line in receiveQrTip.lines" :key="line">{{ line }}</p>
</div>
<img :src="wechatReceiveQrExample" alt="微信收款二维码示例" />
<img :src="receiveQrTip.image" :alt="receiveQrTip.alt" />
</div>
<section class="certificate-section">
<div class="certificate-title">
<span>{{
accountForm.account_type === 'wechat' ? '微信收款二维码' : '收款码/凭证截图'
}}</span>
<span>{{ receiveQrTip?.label || '收款码/凭证截图' }}</span>
<small>最多3张</small>
</div>
<div class="certificate-grid">
@@ -508,10 +636,22 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
round
type="primary"
:loading="addingAccount"
loading-text="添加中..."
@click="handleAddAccount"
:loading-text="editingAccount ? '保存中...' : '添加中...'"
@click="handleSaveAccount"
>
保存账号
{{ editingAccount ? '保存变更' : '保存账号' }}
</van-button>
<van-button
v-if="editingAccount"
block
round
plain
type="danger"
class="delete-account-btn"
:loading="managingAccountId === editingAccount.id"
@click="handleDeleteAccount(editingAccount)"
>
删除此账号
</van-button>
</div>
</van-popup>
@@ -616,6 +756,10 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
font-size: 13px;
}
.section-title button:disabled {
color: #98a2b3;
}
.section-title span {
color: #98a2b3;
font-size: 12px;
@@ -647,16 +791,11 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
.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 {
@@ -664,6 +803,19 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
background: #fff7ed;
}
.account-select {
display: grid;
grid-template-columns: 34px 1fr auto 20px;
align-items: center;
gap: 8px;
width: 100%;
min-height: 44px;
padding: 0;
border: 0;
background: transparent;
text-align: left;
}
.account-icon {
display: inline-flex;
align-items: center;
@@ -697,6 +849,29 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
white-space: nowrap;
}
.account-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #f2f4f7;
}
.account-actions button {
border: 0;
background: transparent;
color: #ff6a00;
font-size: 12px;
}
.account-actions button.danger {
color: #b42318;
}
.account-actions button:disabled {
color: #98a2b3;
}
:deep(.van-cell) {
padding-right: 0;
padding-left: 0;
@@ -800,6 +975,10 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
color: #101828;
}
.delete-account-btn {
margin-top: 10px;
}
.type-tabs {
margin-bottom: 12px;
}
@@ -819,7 +998,7 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
line-height: 1.6;
}
.wechat-qr-tip {
.receive-qr-tip {
display: grid;
grid-template-columns: minmax(0, 1fr) 108px;
gap: 10px;
@@ -832,20 +1011,20 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
color: #92400e;
}
.wechat-qr-tip strong {
.receive-qr-tip strong {
display: block;
margin-bottom: 5px;
color: #c2410c;
font-size: 13px;
}
.wechat-qr-tip p {
.receive-qr-tip p {
margin: 0;
font-size: 12px;
line-height: 1.55;
}
.wechat-qr-tip img {
.receive-qr-tip img {
display: block;
width: 108px;
border: 1px solid #fde6c8;