feat(wallet): 补齐钱包提现与卖家资金页面
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
type UploadFile,
|
||||
} from 'antd'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
StarFilled,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { uploadFile } from '@/shared/api/files'
|
||||
import {
|
||||
createPaymentAccount,
|
||||
deletePaymentAccount,
|
||||
fetchPaymentAccounts,
|
||||
setDefaultPaymentAccount,
|
||||
updatePaymentAccount,
|
||||
} from '@/features/wallet/api'
|
||||
import { paymentAccountTypeLabel } from '@/features/wallet/labels'
|
||||
import type {
|
||||
CreatePaymentAccountPayload,
|
||||
PaymentAccount,
|
||||
PaymentAccountType,
|
||||
} from '@/features/wallet/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatText } from '@/shared/utils/format'
|
||||
|
||||
interface AccountFormValues {
|
||||
account_type: PaymentAccountType
|
||||
account_name: string
|
||||
account_no: string
|
||||
bank_name?: string
|
||||
bank_branch?: 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 PaymentAccountsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<AccountFormValues>()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<PaymentAccount>()
|
||||
const [certificates, setCertificates] = useState<string[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const user = useSessionStore(state => state.user)
|
||||
const accountType = Form.useWatch('account_type', form)
|
||||
|
||||
const accountsQuery = useQuery({
|
||||
queryKey: ['payment-accounts', 1],
|
||||
queryFn: () => fetchPaymentAccounts(1, 50),
|
||||
enabled: Boolean(user),
|
||||
})
|
||||
|
||||
const accounts = accountsQuery.data?.items ?? []
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['payment-accounts'] })
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(undefined)
|
||||
setCertificates([])
|
||||
form.setFieldsValue({
|
||||
account_type: 'alipay',
|
||||
account_name: '',
|
||||
account_no: '',
|
||||
bank_name: '',
|
||||
bank_branch: '',
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (account: PaymentAccount) => {
|
||||
setEditing(account)
|
||||
setCertificates(account.certificate_urls ?? [])
|
||||
form.setFieldsValue({
|
||||
account_type: account.account_type,
|
||||
account_name: account.account_name,
|
||||
account_no: account.account_no,
|
||||
bank_name: account.bank_name,
|
||||
bank_branch: account.bank_branch,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updatePaymentAccount(editing.id, {
|
||||
bank_branch: values.bank_branch?.trim(),
|
||||
certificate_urls: certificates,
|
||||
})
|
||||
message.success('收款账户已更新')
|
||||
} else {
|
||||
const payload: CreatePaymentAccountPayload = {
|
||||
account_type: values.account_type,
|
||||
account_name: values.account_name.trim(),
|
||||
account_no: values.account_no.trim(),
|
||||
bank_name: values.bank_name?.trim(),
|
||||
bank_branch: values.bank_branch?.trim(),
|
||||
certificate_urls: certificates,
|
||||
}
|
||||
await createPaymentAccount(payload)
|
||||
message.success('收款账户已添加')
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (account: PaymentAccount) => {
|
||||
modal.confirm({
|
||||
title: `确认删除收款账户「${account.account_name}」?`,
|
||||
content: '删除后该账户不再出现在提现选择列表中,已提交的提现不受影响。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deletePaymentAccount(account.id)
|
||||
message.success('收款账户已删除')
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const setDefault = async (account: PaymentAccount) => {
|
||||
try {
|
||||
await setDefaultPaymentAccount(account.id)
|
||||
message.success('已设为默认收款账户')
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file, 'payment-cert')
|
||||
setCertificates(current => [...current, uploaded.url])
|
||||
message.success('收款凭证已上传')
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const fileList: UploadFile[] = certificates.map((url, index) => ({
|
||||
uid: `${index}`,
|
||||
name: `收款凭证 ${index + 1}`,
|
||||
status: 'done',
|
||||
url,
|
||||
thumbUrl: url,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="wallet-page">
|
||||
<section className="wallet-hero wallet-hero--compact">
|
||||
<div>
|
||||
<Typography.Text className="wallet-eyebrow">JIEYOU · 资金</Typography.Text>
|
||||
<Typography.Title level={1} className="brand-title wallet-title">
|
||||
收款账户
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="wallet-desc">
|
||||
提现前需维护至少一个收款账户;默认账户会在提现表单中自动选中。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="wallet-hero__action">
|
||||
<span>可用账户</span>
|
||||
<strong>{accounts.length}</strong>
|
||||
<Space size={8} wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加账户
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/wallet')}>返回钱包</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card
|
||||
className="wallet-ledger-card"
|
||||
title={`收款账户(${accounts.length})`}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={accountsQuery.isFetching}
|
||||
onClick={() => void accountsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{accounts.length === 0 ? (
|
||||
<Empty description="还没有收款账户,请先添加后再申请提现" />
|
||||
) : (
|
||||
<div className="pay-account-grid">
|
||||
{accounts.map(account => (
|
||||
<div key={account.id} className="pay-account-card">
|
||||
<div className="pay-account-card__head">
|
||||
<Space size={6} wrap>
|
||||
<Tag bordered={false} color="geekblue">
|
||||
{paymentAccountTypeLabel(account.account_type)}
|
||||
</Tag>
|
||||
{account.is_default ? (
|
||||
<Tag bordered={false} color="green">
|
||||
默认
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<span className="wallet-mono">{account.account_no}</span>
|
||||
</div>
|
||||
<strong>{account.account_name}</strong>
|
||||
{account.bank_name ? (
|
||||
<span className="pay-account-card__bank">
|
||||
{account.bank_name}
|
||||
{account.bank_branch ? ` · ${account.bank_branch}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="pay-account-card__time">
|
||||
添加于 {formatDateTime(account.created_at)}
|
||||
</span>
|
||||
{account.certificate_urls?.length ? (
|
||||
<span className="pay-account-card__cert">
|
||||
凭证 {account.certificate_urls.length} 张
|
||||
</span>
|
||||
) : null}
|
||||
<Space size={4} wrap style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={account.is_default ? <StarFilled /> : <StarOutlined />}
|
||||
disabled={account.is_default}
|
||||
onClick={() => void setDefault(account)}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(account)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => remove(account)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editing ? `编辑收款账户 · ${editing.account_name}` : '添加收款账户'}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void save()}
|
||||
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
|
||||
maskClosable={!submitting}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="account_type"
|
||||
label="收款方式"
|
||||
rules={[{ required: true, message: '请选择收款方式' }]}
|
||||
>
|
||||
<Select
|
||||
disabled={Boolean(editing)}
|
||||
options={[
|
||||
{ value: 'alipay', label: '支付宝' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'bank', label: '银行卡' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="account_name"
|
||||
label="收款人"
|
||||
rules={[{ required: true, message: '请输入收款人姓名' }]}
|
||||
>
|
||||
<Input placeholder="需与实名信息一致" disabled={Boolean(editing)} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="account_no"
|
||||
label="收款账号"
|
||||
rules={[{ required: true, message: '请输入收款账号' }]}
|
||||
>
|
||||
<Input placeholder="支付宝/微信账号或银行卡号" disabled={Boolean(editing)} />
|
||||
</Form.Item>
|
||||
{accountType === 'bank' ? (
|
||||
<>
|
||||
<Form.Item name="bank_name" label="开户行">
|
||||
<Input placeholder="例如:招商银行" disabled={Boolean(editing)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bank_branch" label="开户支行">
|
||||
<Input placeholder="例如:深圳分行科技园支行" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
<Form.Item label="收款凭证(可选)" extra="支持上传收款码截图,便于财务核对。">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={fileList}
|
||||
accept="image/*"
|
||||
beforeUpload={file => {
|
||||
void handleUpload(file as File)
|
||||
return false
|
||||
}}
|
||||
onRemove={file => {
|
||||
const index = Number(file.uid)
|
||||
setCertificates(current => current.filter((_, i) => i !== index))
|
||||
}}
|
||||
>
|
||||
{certificates.length >= 5 || uploading ? null : (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 6 }}>上传</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
|
||||
当前共 {accounts.length} 个账户,其中默认账户 {accounts.filter(item => item.is_default).length} 个
|
||||
{formatText('')}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Space, Table, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api'
|
||||
import {
|
||||
ledgerBalanceTypeLabel,
|
||||
walletBizTypeLabel,
|
||||
walletStatusLabel,
|
||||
} from '@/features/wallet/labels'
|
||||
import type { WalletLedger } from '@/features/wallet/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function directionTag(value?: string | null) {
|
||||
if (value === 'income') {
|
||||
return (
|
||||
<Tag color="green" icon={<ArrowUpOutlined />} bordered={false}>
|
||||
入账
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="volcano" icon={<ArrowDownOutlined />} bordered={false}>
|
||||
出账
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WalletLedger> = [
|
||||
{
|
||||
title: '流水号',
|
||||
dataIndex: 'ledger_no',
|
||||
width: 210,
|
||||
render: value => <span className="wallet-mono">{formatText(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '业务',
|
||||
dataIndex: 'biz_type',
|
||||
width: 140,
|
||||
render: value => <Tag bordered={false}>{walletBizTypeLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'direction',
|
||||
width: 100,
|
||||
render: value => directionTag(value),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cent',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (value: number, row) => (
|
||||
<strong className={row.direction === 'income' ? 'wallet-amount-up' : 'wallet-amount-down'}>
|
||||
{row.direction === 'income' ? '+' : '-'}
|
||||
{formatMoney(Math.abs(value))}
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '余额类型',
|
||||
dataIndex: 'balance_type',
|
||||
width: 120,
|
||||
render: value => (
|
||||
<Typography.Text type="secondary">{ledgerBalanceTypeLabel(value)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变化后余额',
|
||||
dataIndex: 'balance_after_cent',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: value => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: value => formatText(value),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
]
|
||||
|
||||
export function WalletPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
||||
|
||||
const balanceQuery = useQuery({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: fetchWalletBalance,
|
||||
enabled: Boolean(user),
|
||||
})
|
||||
const ledgerQuery = useQuery({
|
||||
queryKey: ['wallet-ledger', page, pageSize],
|
||||
queryFn: () => fetchWalletLedger(page, pageSize),
|
||||
enabled: Boolean(user),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const account = balanceQuery.data
|
||||
const ledger = ledgerQuery.data
|
||||
|
||||
return (
|
||||
<div className="wallet-page">
|
||||
<section className="wallet-hero">
|
||||
<div>
|
||||
<Typography.Text className="wallet-eyebrow">JIEYOU · 资金</Typography.Text>
|
||||
<Typography.Title level={1} className="brand-title wallet-title">
|
||||
资金账户
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="wallet-desc">
|
||||
查看卖家结算收入、冻结押金与每一笔资金变化;提现需先维护收款账户。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="wallet-hero__action">
|
||||
<span>当前可用</span>
|
||||
<strong>{formatMoney(account?.available_balance_cent ?? 0)}</strong>
|
||||
<Space size={8} wrap>
|
||||
<Button type="primary" onClick={() => navigate('/wallet/withdrawal')}>
|
||||
申请提现
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/wallet/payment-accounts')}>收款账户</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="wallet-metrics">
|
||||
<div className="wallet-metric">
|
||||
<span>可用余额</span>
|
||||
<strong>{formatMoney(account?.available_balance_cent ?? 0)}</strong>
|
||||
<small>可随时申请提现</small>
|
||||
</div>
|
||||
<div className="wallet-metric">
|
||||
<span>冻结余额</span>
|
||||
<strong>{formatMoney(account?.frozen_balance_cent ?? 0)}</strong>
|
||||
<small>提现处理中或订单押金暂扣</small>
|
||||
</div>
|
||||
<div className="wallet-metric">
|
||||
<span>账户状态</span>
|
||||
<strong>{walletStatusLabel(account?.status)}</strong>
|
||||
<small>异常时请联系客服核实</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{balanceQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="资金账户加载失败"
|
||||
description={(balanceQuery.error as Error)?.message}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
className="wallet-ledger-card"
|
||||
title={
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
资金流水
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
共 {ledger?.total ?? 0} 条记录,最近变动优先展示。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ledgerQuery.isFetching}
|
||||
onClick={() => {
|
||||
void ledgerQuery.refetch()
|
||||
void balanceQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table<WalletLedger>
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
columns={columns}
|
||||
dataSource={ledger?.items ?? []}
|
||||
loading={ledgerQuery.isFetching}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{ emptyText: '暂无资金流水' }}
|
||||
pagination={{
|
||||
current: ledger?.page ?? page,
|
||||
pageSize: ledger?.page_size ?? pageSize,
|
||||
total: ledger?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
cancelWithdrawal,
|
||||
createWithdrawal,
|
||||
fetchPaymentAccounts,
|
||||
fetchWalletBalance,
|
||||
fetchWithdrawals,
|
||||
} from '@/features/wallet/api'
|
||||
import {
|
||||
paymentAccountTypeLabel,
|
||||
withdrawalStatusMeta,
|
||||
} from '@/features/wallet/labels'
|
||||
import type { PaymentAccount, WithdrawalRecord } from '@/features/wallet/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
interface WithdrawFormValues {
|
||||
payment_account_id: number
|
||||
amount_yuan: number
|
||||
}
|
||||
|
||||
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 : '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WithdrawalRecord> = [
|
||||
{
|
||||
title: '提现单号',
|
||||
dataIndex: 'withdraw_no',
|
||||
width: 200,
|
||||
render: value => <span className="wallet-mono">{formatText(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '提现金额',
|
||||
dataIndex: 'amount_cent',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: value => <strong>{formatMoney(value)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '手续费 / 到账',
|
||||
key: 'fee',
|
||||
width: 170,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="wallet-stack">
|
||||
<span>{formatMoney(row.actual_amount_cent)}</span>
|
||||
<span className="wallet-muted">手续费 {formatMoney(row.fee_cent)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '收款方式',
|
||||
key: 'account',
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<div className="wallet-stack">
|
||||
<span>{paymentAccountTypeLabel(row.account_type)}</span>
|
||||
<span className="wallet-muted">
|
||||
{formatText(row.account_name)} · {formatText(row.account_no)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: value => {
|
||||
const meta = withdrawalStatusMeta(value)
|
||||
return (
|
||||
<Tag bordered={false} color={meta.color}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'review_remark',
|
||||
ellipsis: true,
|
||||
render: value => formatText(value),
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
]
|
||||
|
||||
export function WithdrawalPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<WithdrawFormValues>()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const user = useSessionStore(state => state.user)
|
||||
const amountYuan = Form.useWatch('amount_yuan', form)
|
||||
|
||||
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', page, pageSize],
|
||||
queryFn: () => fetchWithdrawals(page, pageSize),
|
||||
enabled: Boolean(user),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const available = balanceQuery.data?.available_balance_cent ?? 0
|
||||
const accounts: PaymentAccount[] = accountsQuery.data?.items ?? []
|
||||
const withdrawals = withdrawalsQuery.data
|
||||
|
||||
const accountOptions = useMemo(
|
||||
() =>
|
||||
accounts.map(account => ({
|
||||
value: account.id,
|
||||
label: `${paymentAccountTypeLabel(account.account_type)} · ${account.account_name} (${account.account_no})`,
|
||||
})),
|
||||
[accounts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (form.getFieldValue('payment_account_id')) return
|
||||
const preferred = accounts.find(item => item.is_default) ?? accounts[0]
|
||||
if (preferred) form.setFieldValue('payment_account_id', preferred.id)
|
||||
}, [accounts, form])
|
||||
|
||||
const submit = async () => {
|
||||
const values = await form.validateFields()
|
||||
const amountCent = Math.round(Number(values.amount_yuan) * 100)
|
||||
if (amountCent <= 0) {
|
||||
message.warning('提现金额必须大于 0')
|
||||
return
|
||||
}
|
||||
if (amountCent > available) {
|
||||
message.warning('提现金额不能超过可用余额')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createWithdrawal({
|
||||
payment_account_id: values.payment_account_id,
|
||||
amount_cent: amountCent,
|
||||
})
|
||||
message.success('提现申请已提交,等待审核')
|
||||
form.setFieldValue('amount_yuan', undefined)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-ledger'] }),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = (record: WithdrawalRecord) => {
|
||||
modal.confirm({
|
||||
title: `确认取消提现「${record.withdraw_no}」?`,
|
||||
content: '取消后冻结金额会退回可用余额,需重新提交提现申请。',
|
||||
okText: '取消提现',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '返回',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await cancelWithdrawal(record.id)
|
||||
message.success('提现已取消')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const rows: WithdrawalRecord[] = withdrawals?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="wallet-page">
|
||||
<section className="wallet-hero wallet-hero--compact">
|
||||
<div>
|
||||
<Typography.Text className="wallet-eyebrow">JIEYOU · 资金</Typography.Text>
|
||||
<Typography.Title level={1} className="brand-title wallet-title">
|
||||
申请提现
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="wallet-desc">
|
||||
提现申请提交后进入人工审核,审核通过后由财务打款到所选收款账户。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="wallet-hero__action">
|
||||
<span>当前可用</span>
|
||||
<strong>{formatMoney(available)}</strong>
|
||||
<Space size={8} wrap>
|
||||
<Button onClick={() => navigate('/wallet')}>资金流水</Button>
|
||||
<Button onClick={() => navigate('/wallet/payment-accounts')}>收款账户</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="还没有收款账户"
|
||||
description="请先添加收款账户,再提交提现申请。"
|
||||
action={
|
||||
<Button size="small" onClick={() => navigate('/wallet/payment-accounts')}>
|
||||
去添加
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card className="wallet-ledger-card" title="提现信息">
|
||||
<Form form={form} layout="vertical" requiredMark={false} style={{ maxWidth: 520 }}>
|
||||
<Form.Item
|
||||
name="payment_account_id"
|
||||
label="收款账户"
|
||||
rules={[{ required: true, message: '请选择收款账户' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择收款账户"
|
||||
options={accountOptions}
|
||||
disabled={accounts.length === 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount_yuan"
|
||||
label="提现金额(元)"
|
||||
rules={[{ required: true, message: '请输入提现金额' }]}
|
||||
extra={`可提现 ${formatMoney(available)};当前手续费为 0%,到账金额以审核结果为准。`}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
max={available / 100}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
prefix="¥"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Paragraph type="secondary">
|
||||
预计到账 {formatMoney(Math.round(Number(amountYuan ?? 0) * 100))}
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={accounts.length === 0}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
提交提现申请
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="wallet-ledger-card"
|
||||
title="提现记录"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={withdrawalsQuery.isFetching}
|
||||
onClick={() => void withdrawalsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table<WithdrawalRecord>
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
columns={[
|
||||
...columns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
fixed: 'right',
|
||||
render: (_, row) =>
|
||||
row.status === 'pending' ? (
|
||||
<Button type="link" size="small" danger onClick={() => cancel(row)}>
|
||||
取消提现
|
||||
</Button>
|
||||
) : (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={rows}
|
||||
loading={withdrawalsQuery.isFetching}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{ emptyText: '还没有提现记录' }}
|
||||
pagination={{
|
||||
current: withdrawals?.page ?? page,
|
||||
pageSize: withdrawals?.page_size ?? pageSize,
|
||||
total: withdrawals?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { apiClient, unwrapData } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/api/types'
|
||||
import type {
|
||||
CreatePaymentAccountPayload,
|
||||
CreateWithdrawalPayload,
|
||||
PaymentAccount,
|
||||
PaymentAccountPage,
|
||||
UpdatePaymentAccountPayload,
|
||||
WalletAccount,
|
||||
WalletLedger,
|
||||
WalletLedgerPage,
|
||||
WithdrawalPage,
|
||||
WithdrawalRecord,
|
||||
} from '@/features/wallet/types'
|
||||
|
||||
export const WALLET_PAGE_SIZE = 20
|
||||
|
||||
export function fetchWalletBalance() {
|
||||
return unwrapData<WalletAccount>(apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance'))
|
||||
}
|
||||
|
||||
export function fetchWalletLedger(page = 1, pageSize = WALLET_PAGE_SIZE) {
|
||||
return unwrapData<Partial<WalletLedgerPage>>(
|
||||
apiClient.get<ApiResponse<Partial<WalletLedgerPage>>>('/wallet/ledger', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
).then(data => ({
|
||||
items: (data.items ?? []) as WalletLedger[],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? page,
|
||||
page_size: data.page_size ?? pageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
export function fetchPaymentAccounts(page = 1, pageSize = WALLET_PAGE_SIZE) {
|
||||
return unwrapData<Partial<PaymentAccountPage>>(
|
||||
apiClient.get<ApiResponse<Partial<PaymentAccountPage>>>('/payment-accounts', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
).then(data => ({
|
||||
items: (data.items ?? []) as PaymentAccount[],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? page,
|
||||
page_size: data.page_size ?? pageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createPaymentAccount(payload: CreatePaymentAccountPayload) {
|
||||
return unwrapData<PaymentAccount>(
|
||||
apiClient.post<ApiResponse<PaymentAccount>>('/payment-accounts', payload)
|
||||
)
|
||||
}
|
||||
|
||||
export function updatePaymentAccount(id: number, payload: UpdatePaymentAccountPayload) {
|
||||
return unwrapData<PaymentAccount>(
|
||||
apiClient.put<ApiResponse<PaymentAccount>>(`/payment-accounts/${id}`, payload)
|
||||
)
|
||||
}
|
||||
|
||||
export function deletePaymentAccount(id: number) {
|
||||
return unwrapData<{ deleted: boolean }>(
|
||||
apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/payment-accounts/${id}`)
|
||||
)
|
||||
}
|
||||
|
||||
export function setDefaultPaymentAccount(id: number) {
|
||||
return unwrapData<{ updated: boolean }>(
|
||||
apiClient.post<ApiResponse<{ updated: boolean }>>(`/payment-accounts/${id}/set-default`)
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchWithdrawals(page = 1, pageSize = WALLET_PAGE_SIZE) {
|
||||
return unwrapData<Partial<WithdrawalPage>>(
|
||||
apiClient.get<ApiResponse<Partial<WithdrawalPage>>>('/withdrawals', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
).then(data => ({
|
||||
items: (data.items ?? []) as WithdrawalRecord[],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? page,
|
||||
page_size: data.page_size ?? pageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createWithdrawal(payload: CreateWithdrawalPayload) {
|
||||
return unwrapData<WithdrawalRecord>(
|
||||
apiClient.post<ApiResponse<WithdrawalRecord>>('/withdrawals', payload)
|
||||
)
|
||||
}
|
||||
|
||||
export function cancelWithdrawal(id: number) {
|
||||
return unwrapData<{ cancelled: boolean }>(
|
||||
apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/withdrawals/${id}/cancel`)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PaymentAccountType } from '@/features/wallet/types'
|
||||
|
||||
const bizTypeLabels: Record<string, string> = {
|
||||
order: '订单结算',
|
||||
order_pay: '订单支付',
|
||||
cancel_refund: '取消退款',
|
||||
checkout_refund: '结账退款',
|
||||
admin_refund: '后台退款',
|
||||
arbitration_owner_income: '仲裁号主收入',
|
||||
arbitration_release_frozen: '仲裁解冻',
|
||||
dispute: '申诉',
|
||||
withdraw_apply: '提现申请',
|
||||
withdraw_freeze: '提现冻结',
|
||||
withdraw_complete: '提现到账',
|
||||
withdraw_reject: '提现驳回',
|
||||
withdraw_cancel: '提现取消',
|
||||
invite_rebate: '邀请返利',
|
||||
main_recharge: '充值',
|
||||
admin_pickup: '线下提号',
|
||||
admin_pickup_settle: '线下提号结算',
|
||||
admin_pickup_adjustment: '线下提号调整',
|
||||
distribution_settlement: '分销结算',
|
||||
distribution_reversal: '分销冲正',
|
||||
site_income: '站点收入',
|
||||
site_withdraw: '站点提现',
|
||||
mohong_order: '摸红订单',
|
||||
}
|
||||
|
||||
export function walletBizTypeLabel(value?: string | null) {
|
||||
if (!value) return '其他'
|
||||
return bizTypeLabels[value] ?? value
|
||||
}
|
||||
|
||||
export function ledgerDirectionLabel(value?: string | null) {
|
||||
if (value === 'income') return '入账'
|
||||
if (value === 'expense') return '出账'
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
export function ledgerBalanceTypeLabel(value?: string | null) {
|
||||
if (value === 'available') return '可用余额'
|
||||
if (value === 'frozen') return '冻结余额'
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
const accountTypeLabels: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信',
|
||||
bank: '银行卡',
|
||||
}
|
||||
|
||||
export function paymentAccountTypeLabel(value?: PaymentAccountType | string | null) {
|
||||
if (!value) return '—'
|
||||
return accountTypeLabels[value] ?? value
|
||||
}
|
||||
|
||||
const withdrawalStatusLabels: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待审核', color: 'gold' },
|
||||
processing: { label: '打款中', color: 'blue' },
|
||||
completed: { label: '已到账', color: 'green' },
|
||||
rejected: { label: '已驳回', color: 'red' },
|
||||
cancelled: { label: '已取消', color: 'default' },
|
||||
}
|
||||
|
||||
export function withdrawalStatusMeta(value?: string | null) {
|
||||
if (!value) return { label: '未知', color: 'default' }
|
||||
return withdrawalStatusLabels[value] ?? { label: value, color: 'default' }
|
||||
}
|
||||
|
||||
export function walletStatusLabel(value?: string | null) {
|
||||
if (value === 'active') return '正常'
|
||||
if (value === 'frozen') return '已冻结'
|
||||
if (value === 'closed') return '已关闭'
|
||||
return value || '—'
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export type WalletStatus = 'active' | 'frozen' | 'closed' | string
|
||||
|
||||
export type LedgerDirection = 'income' | 'expense' | string
|
||||
|
||||
export type LedgerBalanceType = 'available' | 'frozen' | string
|
||||
|
||||
export interface WalletAccount {
|
||||
user_id: number
|
||||
distribution_site_id?: number
|
||||
available_balance_cent: number
|
||||
frozen_balance_cent: number
|
||||
status: WalletStatus
|
||||
}
|
||||
|
||||
export interface WalletLedger {
|
||||
id: number
|
||||
ledger_no: string
|
||||
user_id: number
|
||||
distribution_site_id?: number
|
||||
order_id?: number | null
|
||||
direction: LedgerDirection
|
||||
amount_cent: number
|
||||
balance_after_cent: number
|
||||
balance_type: LedgerBalanceType
|
||||
biz_type: string
|
||||
biz_no: string
|
||||
remark: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WalletLedgerPage {
|
||||
items: WalletLedger[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export type PaymentAccountType = 'alipay' | 'wechat' | 'bank'
|
||||
|
||||
export interface PaymentAccount {
|
||||
id: number
|
||||
user_id: number
|
||||
account_type: PaymentAccountType
|
||||
account_name: string
|
||||
account_no: string
|
||||
bank_name: string
|
||||
bank_branch: string
|
||||
certificate_urls: string[]
|
||||
is_default: boolean
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PaymentAccountPage {
|
||||
items: PaymentAccount[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CreatePaymentAccountPayload {
|
||||
account_type: PaymentAccountType
|
||||
account_name: string
|
||||
account_no: string
|
||||
bank_name?: string
|
||||
bank_branch?: string
|
||||
certificate_urls?: string[]
|
||||
}
|
||||
|
||||
export interface UpdatePaymentAccountPayload {
|
||||
bank_branch?: string
|
||||
certificate_urls?: string[]
|
||||
is_default?: boolean
|
||||
}
|
||||
|
||||
export type WithdrawalStatus =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'rejected'
|
||||
| 'cancelled'
|
||||
| string
|
||||
|
||||
export interface WithdrawalRecord {
|
||||
id: number
|
||||
withdraw_no: string
|
||||
user_id: number
|
||||
distribution_site_id?: number
|
||||
amount_cent: number
|
||||
fee_cent: number
|
||||
actual_amount_cent: number
|
||||
account_type: PaymentAccountType | string
|
||||
account_name: string
|
||||
account_no: string
|
||||
bank_name: string
|
||||
status: WithdrawalStatus
|
||||
review_remark: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
reviewed_at?: string | null
|
||||
paid_at?: string | null
|
||||
}
|
||||
|
||||
export interface WithdrawalPage {
|
||||
items: WithdrawalRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CreateWithdrawalPayload {
|
||||
payment_account_id: number
|
||||
amount_cent: number
|
||||
}
|
||||
Reference in New Issue
Block a user