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,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>
)
}
+215
View File
@@ -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>
)
}
+95
View File
@@ -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`)
)
}
+75
View File
@@ -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 || '—'
}
+115
View File
@@ -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
}