Files
hfb_sys/frontend/src/features/wallet/views/MobileWithdrawalView.vue
T
yml2213 e024319860 feat(distribution): 分销站点消息中心与钱包提现多租户独立分支隔离
- 新增 000070 数据库迁移:支持 chat_conversations、wallet_accounts、wallet_ledger、withdrawal_requests 按 distribution_site_id 隔离
- 消息中心:会话列表、未读数统计、站点专属客服及订单群聊与主站实现双向完全隔离
- 资金账户与提现:独立按 (user_id, distribution_site_id) 建账,分销订单自动结算至对应分站钱包,提现与流水相互独立
- 前台界面:Axios 自动透传 X-Distribution-Site-Code,钱包、提现与消息页面呈现分站专属标牌与独立说明
2026-09-07 18:11:27 +08:00

678 lines
22 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 } from 'vue'
import { useRouter } from 'vue-router'
import { showDialog, showToast } from 'vant'
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'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const router = useRouter()
const distributionStore = useDistributionSiteStore()
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
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 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))
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 siteCode = distributionStore.isDistribution ? distributionStore.siteCode : undefined
const [balanceData, accountsData, withdrawalsData] = await Promise.all([
fetchWalletBalance(siteCode),
fetchPaymentAccounts(1, 100),
fetchWithdrawals(1, 20, siteCode),
])
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,
},
distributionStore.isDistribution ? distributionStore.siteCode : undefined
)
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() {
editingAccount.value = null
Object.assign(accountForm, {
account_type: 'alipay' as AccountType,
account_name: '',
account_no: '',
bank_name: '',
bank_branch: '',
certificate_urls: [] as string[],
})
showAccountPopup.value = true
}
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 (!editingAccount.value && !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 {
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(),
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
editingAccount.value = null
await loadData()
withdrawForm.payment_account_id = created.id
}
} catch (error) {
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]
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')
accountForm.certificate_urls.push(uploaded.url)
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>
{{ distributionStore.isDistribution ? `${distributionStore.siteName} · 提现` : '提现' }}
</h1>
<button class="refresh-btn" type="button" @click="loadData">
<van-icon name="replay" :size="18" />
</button>
</header>
<section class="balance-panel">
<div v-if="distributionStore.isDistribution" class="site-account-tip">
{{ distributionStore.siteName }}独立账户
</div>
<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" :disabled="!canAddMore" @click="openAccountPopup">
<van-icon name="plus" :size="14" />
{{ canAddMore ? '添加账号' : '已达上限' }}
</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">
<article
v-for="item in paymentAccounts"
:key="item.id"
class="account-option"
:class="{ active: item.id === withdrawForm.payment_account_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>
<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 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
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>{{ accountPopupTitle }}</h2>
<van-radio-group
v-model="accountForm.account_type"
direction="horizontal"
class="type-tabs"
>
<van-radio name="alipay" :disabled="!!editingAccount">
<span class="type-label">
<span>支付宝</span>
<span v-if="!editingAccount" class="recommend-badge">推荐</span>
</span>
</van-radio>
<van-radio name="wechat" :disabled="!!editingAccount">微信</van-radio>
<van-radio name="bank" :disabled="!!editingAccount">银行卡</van-radio>
</van-radio-group>
<p v-if="!editingAccount" class="type-tip">推荐优先添加支付宝,微信和银行卡可按需选择。</p>
<van-field
v-model.trim="accountForm.account_name"
label="账户名"
placeholder="必须与实名认证姓名一致"
:disabled="!!editingAccount"
required
/>
<van-field
v-model.trim="accountForm.account_no"
:label="accountForm.account_type === 'bank' ? '银行卡号' : '账号'"
:placeholder="
accountForm.account_type === 'alipay'
? '支付宝账号'
: accountForm.account_type === 'wechat'
? '微信号'
: '银行卡号'
"
:disabled="!!editingAccount"
required
/>
<template v-if="accountForm.account_type === 'bank'">
<van-field
v-model.trim="accountForm.bank_name"
label="银行名称"
placeholder="如:中国工商银行"
:disabled="!!editingAccount"
required
/>
<van-field v-model.trim="accountForm.bank_branch" label="开户支行" placeholder="可选" />
</template>
<div class="account-tip">
账户名必须与实名认证姓名一致;提现将转账到该收款账号,请确认账号信息准确。
</div>
<div v-if="receiveQrTip" class="receive-qr-tip">
<div>
<strong>{{ receiveQrTip.title }}</strong>
<p v-for="line in receiveQrTip.lines" :key="line">{{ line }}</p>
</div>
<img :src="receiveQrTip.image" :alt="receiveQrTip.alt" />
</div>
<section class="certificate-section">
<div class="certificate-title">
<span>{{ receiveQrTip?.label || '收款码/凭证截图' }}</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="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>
</main>
</template>
<style scoped src="./MobileWithdrawalView.css"></style>