feat(wallet): 补齐钱包提现与卖家资金页面

This commit is contained in:
yml2213
2026-10-30 16:41:34 +08:00
parent a39a504b81
commit c0c9f161ae
23 changed files with 3348 additions and 0 deletions
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Dialog,
ErrorBlock,
Form,
Input,
List,
NavBar,
Selector,
Skeleton,
Tag,
Toast,
} from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import {
cancelWithdrawal,
createWithdrawal,
fetchPaymentAccounts,
fetchWalletBalance,
fetchWithdrawals,
} from '@/features/wallet/api'
import { paymentAccountTypeLabel, withdrawalStatusMeta } from '@/features/wallet/labels'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney } from '@/shared/utils/format'
interface WithdrawFormValues {
amount: string
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
export function MobileWithdrawalPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [form] = Form.useForm<WithdrawFormValues>()
const [accountId, setAccountId] = useState<number>()
const [submitting, setSubmitting] = useState(false)
const user = useSessionStore(state => state.user)
const balanceQuery = useQuery({
queryKey: ['wallet-balance'],
queryFn: fetchWalletBalance,
enabled: Boolean(user),
})
const accountsQuery = useQuery({
queryKey: ['payment-accounts', 1],
queryFn: () => fetchPaymentAccounts(1, 50),
enabled: Boolean(user),
})
const withdrawalsQuery = useQuery({
queryKey: ['withdrawals', 1, 20],
queryFn: () => fetchWithdrawals(1, 20),
enabled: Boolean(user),
})
const available = balanceQuery.data?.available_balance_cent ?? 0
const accounts = useMemo(() => accountsQuery.data?.items ?? [], [accountsQuery.data])
const withdrawals = withdrawalsQuery.data?.items ?? []
useEffect(() => {
if (accountId) return
const preferred = accounts.find(item => item.is_default) ?? accounts[0]
if (preferred) setAccountId(preferred.id)
}, [accounts, accountId])
const submit = async () => {
const values = await form.validateFields()
if (!accountId) {
Toast.show({ content: '请先选择收款账户' })
return
}
const amountCent = Math.round(Number(values.amount) * 100)
if (!Number.isFinite(amountCent) || amountCent <= 0) {
Toast.show({ content: '请输入正确的提现金额' })
return
}
if (amountCent > available) {
Toast.show({ content: '提现金额不能超过可用余额' })
return
}
setSubmitting(true)
try {
await createWithdrawal({ payment_account_id: accountId, amount_cent: amountCent })
Toast.show({ icon: 'success', content: '提现申请已提交' })
form.setFieldValue('amount', '')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-ledger'] }),
])
} catch (error) {
Toast.show({ content: errorText(error) })
} finally {
setSubmitting(false)
}
}
const cancel = async (id: number, withdrawNo: string) => {
const confirmed = await Dialog.confirm({
content: `确认取消提现 ${withdrawNo}?取消后金额退回可用余额。`,
confirmText: '取消提现',
cancelText: '返回',
})
if (!confirmed) return
try {
await cancelWithdrawal(id)
Toast.show({ icon: 'success', content: '提现已取消' })
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
])
} catch (error) {
Toast.show({ content: errorText(error) })
}
}
return (
<div className="mobile-wallet">
<NavBar onBack={() => navigate('/m/profile')}>提现</NavBar>
<Card className="mobile-wallet__balance">
<div className="mobile-wallet__label">可用余额</div>
{balanceQuery.isPending ? (
<Skeleton.Title animated />
) : (
<div className="mobile-wallet__amount">{formatMoney(available)}</div>
)}
<Button
size="mini"
color="primary"
fill="outline"
onClick={() => navigate('/m/profile')}
>
返回我的
</Button>
</Card>
{accounts.length === 0 && !accountsQuery.isPending ? (
<ErrorBlock
status="empty"
title="还没有收款账户"
description="请先在电脑端「我的钱包 · 收款账户」添加账户后再提现。"
/>
) : (
<>
<Card title="收款账户">
{accountsQuery.isPending ? (
<Skeleton.Paragraph lineCount={2} animated />
) : (
<Selector
value={accountId ? [String(accountId)] : []}
onChange={values => setAccountId(values[0] ? Number(values[0]) : undefined)}
options={accounts.map(account => ({
label: `${paymentAccountTypeLabel(account.account_type)} · ${account.account_name}`,
description: account.account_no,
value: String(account.id),
}))}
/>
)}
</Card>
<Card title="提现金额">
<Form form={form} layout="horizontal">
<Form.Item
name="amount"
label="金额(元)"
rules={[{ required: true, message: '请输入提现金额' }]}
>
<Input type="number" placeholder="0.00" inputMode="decimal" />
</Form.Item>
</Form>
<div className="mobile-wallet__hint">
可提现 {formatMoney(available)},当前手续费 0%,到账金额以审核结果为准。
</div>
<Button
block
color="primary"
loading={submitting}
onClick={() => void submit()}
style={{ marginTop: 12 }}
>
提交提现申请
</Button>
</Card>
</>
)}
<Card title="提现记录">
{withdrawalsQuery.isPending ? (
<Skeleton.Paragraph lineCount={4} animated />
) : withdrawals.length === 0 ? (
<ErrorBlock status="empty" title="还没有提现记录" />
) : (
<List>
{withdrawals.map(item => {
const meta = withdrawalStatusMeta(item.status)
return (
<List.Item
key={item.id}
description={`${formatDateTime(item.created_at)} · 手续费 ${formatMoney(item.fee_cent)}`}
extra={
<Tag color={meta.color === 'default' ? 'default' : meta.color}>
{meta.label}
</Tag>
}
clickable={item.status === 'pending'}
onClick={() => {
if (item.status === 'pending') void cancel(item.id, item.withdraw_no)
}}
>
<div>{formatMoney(item.amount_cent)}</div>
<div className="mobile-wallet__sub">
{item.withdraw_no} · 到账 {formatMoney(item.actual_amount_cent)}
</div>
</List.Item>
)
})}
</List>
)}
</Card>
</div>
)
}