feat(wallet): 补齐钱包提现与卖家资金页面
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, DatePicker, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { type Dayjs } from 'dayjs'
|
||||
|
||||
import { fetchFinanceDetails } from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import { orderStatusMap } from '@/features/admin/labels'
|
||||
import type { FinanceDetail } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
interface Filters {
|
||||
order_no: string
|
||||
date_type: 'created' | 'settled' | 'offline_settled'
|
||||
range: [Dayjs, Dayjs] | null
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = { order_no: '', date_type: 'settled', range: null }
|
||||
|
||||
const settlementStatusOptions = [
|
||||
{ value: 'pending', label: '待结算' },
|
||||
{ value: 'settled', label: '已结算' },
|
||||
{ value: 'frozen', label: '冻结中' },
|
||||
]
|
||||
|
||||
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<FinanceDetail> = [
|
||||
{
|
||||
title: '订单',
|
||||
key: 'order',
|
||||
width: 230,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{row.order_no}</strong>
|
||||
<Space size={4} wrap>
|
||||
<Tag bordered={false}>{orderStatusMap[row.order_status]?.label ?? row.order_status}</Tag>
|
||||
<Tag bordered={false} color={row.finance_status === 'settled' ? 'green' : 'gold'}>
|
||||
{row.finance_status === 'settled' ? '已结算' : '待结算'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '租客',
|
||||
key: 'renter',
|
||||
width: 160,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.renter_nickname)}</span>
|
||||
<span className="admin-mono">{formatText(row.renter_phone)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '号主',
|
||||
key: 'owner',
|
||||
width: 160,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.owner_nickname)}</span>
|
||||
<span className="admin-mono">{formatText(row.owner_phone)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '实付 / 退款',
|
||||
key: 'amount',
|
||||
width: 160,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatMoney(row.paid_amount_cent)}</span>
|
||||
<Typography.Text type="secondary">
|
||||
退款 {formatMoney(row.refunded_amount_cent)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '号主结算',
|
||||
key: 'owner_settle',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatMoney(row.owner_effective_settlement_amount_cent)}</span>
|
||||
<Typography.Text type="secondary">
|
||||
钱包 {formatMoney(row.owner_wallet_income_amount_cent)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '平台净额',
|
||||
dataIndex: 'platform_net_amount_cent',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: value => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '结算模式',
|
||||
key: 'mode',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<Tag bordered={false}>{formatText(row.settlement_mode)}</Tag>
|
||||
<span className="admin-mono">{formatText(row.offline_settlement_status)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '结算时间',
|
||||
dataIndex: 'settled_at',
|
||||
width: 170,
|
||||
render: value => (value ? formatDateTime(value) : '—'),
|
||||
},
|
||||
]
|
||||
|
||||
export function AdminFinanceDetailsPage() {
|
||||
const [filters, setFilters] = useState<Filters>(emptyFilters)
|
||||
const [applied, setApplied] = useState<Filters>(emptyFilters)
|
||||
const [settlementStatus, setSettlementStatus] = useState<string>()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view'))
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
order_no: applied.order_no.trim() || undefined,
|
||||
date_type: applied.date_type,
|
||||
settlement_status: settlementStatus,
|
||||
start_date: applied.range?.[0]?.format('YYYY-MM-DD'),
|
||||
end_date: applied.range?.[1]?.format('YYYY-MM-DD'),
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}),
|
||||
[applied, settlementStatus, page, pageSize]
|
||||
)
|
||||
|
||||
const detailsQuery = useQuery({
|
||||
queryKey: ['admin-finance-details', query],
|
||||
queryFn: () => fetchFinanceDetails(query),
|
||||
enabled: canView,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader title="资金明细" eyebrow="JIEYOU OPS · 财务" />
|
||||
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 财务"
|
||||
title="资金明细"
|
||||
description="按订单维度核对实付、退款、号主结算与平台净额。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {detailsQuery.data?.total ?? 0} 单</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={detailsQuery.isFetching}
|
||||
onClick={() => void detailsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
loading={detailsQuery.isFetching}
|
||||
onSearch={() => {
|
||||
setPage(1)
|
||||
setApplied(filters)
|
||||
}}
|
||||
onReset={() => {
|
||||
setFilters(emptyFilters)
|
||||
setApplied(emptyFilters)
|
||||
setSettlementStatus(undefined)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<FilterField label="订单号">
|
||||
<Input
|
||||
allowClear
|
||||
value={filters.order_no}
|
||||
placeholder="完整订单号"
|
||||
onChange={event =>
|
||||
setFilters(current => ({ ...current, order_no: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="日期口径">
|
||||
<Select
|
||||
value={filters.date_type}
|
||||
options={[
|
||||
{ value: 'settled', label: '按结算时间' },
|
||||
{ value: 'created', label: '按下单时间' },
|
||||
{ value: 'offline_settled', label: '按线下结算时间' },
|
||||
]}
|
||||
onChange={value => setFilters(current => ({ ...current, date_type: value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="日期区间" width={280}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={filters.range}
|
||||
allowEmpty={[true, true]}
|
||||
onChange={value =>
|
||||
setFilters(current => ({
|
||||
...current,
|
||||
range: value && value[0] && value[1] ? [value[0], value[1]] : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="结算状态">
|
||||
<Select
|
||||
allowClear
|
||||
value={settlementStatus}
|
||||
options={settlementStatusOptions}
|
||||
placeholder="全部状态"
|
||||
onChange={value => setSettlementStatus(value)}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{detailsQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="资金明细加载失败"
|
||||
description={errorText(detailsQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<FinanceDetail>
|
||||
columns={columns}
|
||||
dataSource={detailsQuery.data?.items ?? []}
|
||||
rowKey="order_id"
|
||||
loading={detailsQuery.isFetching}
|
||||
total={detailsQuery.data?.total ?? 0}
|
||||
page={detailsQuery.data?.page ?? page}
|
||||
pageSize={detailsQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
emptyDescription="所选区间还没有资金明细"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { fetchAdminPayments } from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import type { AdminPaymentQuery, AdminPaymentRow } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
type Filters = Pick<AdminPaymentQuery, 'order_no' | 'biz_type' | 'status' | 'provider'>
|
||||
|
||||
const emptyFilters: Filters = { order_no: '', biz_type: undefined, status: undefined, provider: undefined }
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'pending', label: '待支付' },
|
||||
{ value: 'paid', label: '已支付' },
|
||||
{ value: 'failed', label: '支付失败' },
|
||||
{ value: 'closed', label: '已关闭' },
|
||||
]
|
||||
|
||||
const bizTypeOptions = [
|
||||
{ value: 'order_pay', label: '订单支付' },
|
||||
{ value: 'main_recharge', label: '充值' },
|
||||
]
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'leshua', label: '拉卡拉' },
|
||||
{ value: 'shuncheng', label: '顺诚' },
|
||||
{ value: 'mock', label: '模拟支付' },
|
||||
]
|
||||
|
||||
const statusMeta: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待支付', color: 'gold' },
|
||||
paid: { label: '已支付', color: 'green' },
|
||||
failed: { label: '支付失败', color: 'red' },
|
||||
closed: { label: '已关闭', color: 'default' },
|
||||
}
|
||||
|
||||
function statusTag(value?: string | null) {
|
||||
if (!value) return <Tag>未知</Tag>
|
||||
const meta = statusMeta[value] ?? { label: value, color: 'default' }
|
||||
return (
|
||||
<Tag bordered={false} color={meta.color}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
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<AdminPaymentRow> = [
|
||||
{
|
||||
title: '支付单号',
|
||||
key: 'payment',
|
||||
width: 240,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{row.payment_no}</strong>
|
||||
<span className="admin-mono">{formatText(row.order_no)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.user_phone)}</span>
|
||||
<span className="admin-mono">UID {row.user_id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '渠道 / 支付方式',
|
||||
key: 'channel',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.provider)}</span>
|
||||
<span className="admin-mono">{formatText(row.pay_way)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cent',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: value => <strong>{formatMoney(value)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: value => statusTag(value),
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paid_at',
|
||||
width: 170,
|
||||
render: value => (value ? formatDateTime(value) : '—'),
|
||||
},
|
||||
{
|
||||
title: '失败原因',
|
||||
key: 'error',
|
||||
render: (_, row) =>
|
||||
row.error_message ? (
|
||||
<Typography.Text type="danger">
|
||||
{row.error_code ? `${row.error_code} · ` : ''}
|
||||
{row.error_message}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
]
|
||||
|
||||
export function AdminPaymentsPage() {
|
||||
const [filters, setFilters] = useState<Filters>(emptyFilters)
|
||||
const [applied, setApplied] = useState<Filters>(emptyFilters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view'))
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
order_no: applied.order_no?.trim() || undefined,
|
||||
biz_type: applied.biz_type,
|
||||
status: applied.status,
|
||||
provider: applied.provider,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}),
|
||||
[applied, page, pageSize]
|
||||
)
|
||||
|
||||
const paymentsQuery = useQuery({
|
||||
queryKey: ['admin-payments', query],
|
||||
queryFn: () => fetchAdminPayments(query),
|
||||
enabled: canView,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader title="支付流水" eyebrow="JIEYOU OPS · 财务" />
|
||||
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 财务"
|
||||
title="支付流水"
|
||||
description="查看订单支付、充值等渠道流水,核对金额与回调状态。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {paymentsQuery.data?.total ?? 0} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={paymentsQuery.isFetching}
|
||||
onClick={() => void paymentsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
loading={paymentsQuery.isFetching}
|
||||
onSearch={() => {
|
||||
setPage(1)
|
||||
setApplied(filters)
|
||||
}}
|
||||
onReset={() => {
|
||||
setFilters(emptyFilters)
|
||||
setApplied(emptyFilters)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<FilterField label="订单号">
|
||||
<Input
|
||||
allowClear
|
||||
value={filters.order_no}
|
||||
placeholder="支持完整订单号"
|
||||
onChange={event =>
|
||||
setFilters(current => ({ ...current, order_no: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="业务类型">
|
||||
<Select
|
||||
allowClear
|
||||
value={filters.biz_type}
|
||||
options={bizTypeOptions}
|
||||
placeholder="全部业务"
|
||||
onChange={value => setFilters(current => ({ ...current, biz_type: value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
value={filters.status}
|
||||
options={statusOptions}
|
||||
placeholder="全部状态"
|
||||
onChange={value => setFilters(current => ({ ...current, status: value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="渠道">
|
||||
<Select
|
||||
allowClear
|
||||
value={filters.provider}
|
||||
options={providerOptions}
|
||||
placeholder="全部渠道"
|
||||
onChange={value => setFilters(current => ({ ...current, provider: value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{paymentsQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="支付流水加载失败"
|
||||
description={errorText(paymentsQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminPaymentRow>
|
||||
columns={columns}
|
||||
dataSource={paymentsQuery.data?.items ?? []}
|
||||
rowKey="id"
|
||||
loading={paymentsQuery.isFetching}
|
||||
total={paymentsQuery.data?.total ?? 0}
|
||||
page={paymentsQuery.data?.page ?? page}
|
||||
pageSize={paymentsQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
emptyDescription="还没有支付流水"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, InputNumber, Select, Space, Tag, type TableColumnsType } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { fetchAdminWalletLedger } from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import type { AdminWalletLedgerQuery, AdminWalletLedgerRow } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
const bizTypeOptions = [
|
||||
{ value: 'order', label: '订单结算' },
|
||||
{ value: 'order_pay', label: '订单支付' },
|
||||
{ value: 'withdraw_apply', label: '提现申请' },
|
||||
{ value: 'withdraw_complete', label: '提现到账' },
|
||||
{ value: 'admin_pickup', label: '线下提号' },
|
||||
{ value: 'invite_rebate', label: '邀请返利' },
|
||||
]
|
||||
|
||||
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<AdminWalletLedgerRow> = [
|
||||
{
|
||||
title: '流水号',
|
||||
key: 'ledger',
|
||||
width: 240,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{row.ledger_no}</strong>
|
||||
<span className="admin-mono">{formatText(row.order_no)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{row.user_nickname || formatText(row.user_phone)}</span>
|
||||
<span className="admin-mono">UID {row.user_id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '业务',
|
||||
dataIndex: 'biz_type',
|
||||
width: 140,
|
||||
render: value => <Tag bordered={false}>{formatText(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'direction',
|
||||
width: 90,
|
||||
render: value => (
|
||||
<Tag bordered={false} color={value === 'income' ? 'green' : 'volcano'}>
|
||||
{value === 'income' ? '入账' : '出账'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cent',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (value: number, row) => (
|
||||
<strong style={{ color: row.direction === 'income' ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{row.direction === 'income' ? '+' : '-'}
|
||||
{formatMoney(Math.abs(value))}
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变化后余额',
|
||||
dataIndex: 'balance_after_cent',
|
||||
width: 140,
|
||||
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 AdminWalletLedgerPage() {
|
||||
const [filters, setFilters] = useState<{ user_id?: number; order_id?: number; biz_type?: string }>({})
|
||||
const [applied, setApplied] = useState(filters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view'))
|
||||
|
||||
const query: AdminWalletLedgerQuery = {
|
||||
user_id: applied.user_id,
|
||||
order_id: applied.order_id,
|
||||
biz_type: applied.biz_type,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
|
||||
const ledgerQuery = useQuery({
|
||||
queryKey: ['admin-wallet-ledger', query],
|
||||
queryFn: () => fetchAdminWalletLedger(query),
|
||||
enabled: canView,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader title="钱包流水" eyebrow="JIEYOU OPS · 财务" />
|
||||
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 财务"
|
||||
title="钱包流水"
|
||||
description="按用户、订单与业务类型核对每一笔余额变动。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {ledgerQuery.data?.total ?? 0} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ledgerQuery.isFetching}
|
||||
onClick={() => void ledgerQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
loading={ledgerQuery.isFetching}
|
||||
onSearch={() => {
|
||||
setPage(1)
|
||||
setApplied(filters)
|
||||
}}
|
||||
onReset={() => {
|
||||
setFilters({})
|
||||
setApplied({})
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<FilterField label="用户 ID" width={160}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
value={filters.user_id}
|
||||
onChange={value => setFilters(current => ({ ...current, user_id: value ?? undefined }))}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="订单 ID" width={160}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
value={filters.order_id}
|
||||
onChange={value => setFilters(current => ({ ...current, order_id: value ?? undefined }))}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="业务类型">
|
||||
<Select
|
||||
allowClear
|
||||
value={filters.biz_type}
|
||||
options={bizTypeOptions}
|
||||
placeholder="全部业务"
|
||||
onChange={value => setFilters(current => ({ ...current, biz_type: value }))}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{ledgerQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="钱包流水加载失败"
|
||||
description={errorText(ledgerQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminWalletLedgerRow>
|
||||
columns={columns}
|
||||
dataSource={ledgerQuery.data?.items ?? []}
|
||||
rowKey="id"
|
||||
loading={ledgerQuery.isFetching}
|
||||
total={ledgerQuery.data?.total ?? 0}
|
||||
page={ledgerQuery.data?.page ?? page}
|
||||
pageSize={ledgerQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
emptyDescription="还没有钱包流水"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -98,6 +98,10 @@ import type {
|
||||
FinanceDetailPage,
|
||||
FinanceDisbursementPage,
|
||||
FinanceDisbursementQuery,
|
||||
AdminPaymentPage,
|
||||
AdminPaymentQuery,
|
||||
AdminWalletLedgerPage,
|
||||
AdminWalletLedgerQuery,
|
||||
ManualDisbursementPayload,
|
||||
OperatingExpensePage,
|
||||
OperatingExpensePayload,
|
||||
@@ -1365,3 +1369,30 @@ export async function voidOperatingExpense(id: number | string, reason: string)
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminPayments(query: AdminPaymentQuery = {}) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminPaymentPage>>>(
|
||||
'/admin/payments',
|
||||
{ params: compactParams(query) }
|
||||
)
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminWalletLedgerPage>>>(
|
||||
'/admin/wallet/ledger',
|
||||
{ params: compactParams(query) }
|
||||
)
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -534,6 +534,81 @@ export interface FinanceDetailPage {
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminPaymentRow {
|
||||
id: number
|
||||
payment_no: string
|
||||
order_id: number
|
||||
order_no: string
|
||||
user_id: number
|
||||
payment_config_id: number
|
||||
user_phone: string
|
||||
provider: string
|
||||
merchant_id: string
|
||||
third_order_id: string
|
||||
provider_order_id: string
|
||||
pay_way: string
|
||||
amount_cent: number
|
||||
biz_type: string
|
||||
status: string
|
||||
error_code: string
|
||||
error_message: string
|
||||
paid_at?: string | null
|
||||
notified_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminPaymentPage {
|
||||
items: AdminPaymentRow[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminPaymentQuery {
|
||||
user_id?: number
|
||||
order_id?: number
|
||||
order_no?: string
|
||||
biz_type?: string
|
||||
status?: string
|
||||
provider?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface AdminWalletLedgerRow {
|
||||
id: number
|
||||
ledger_no: string
|
||||
user_id: number
|
||||
user_phone: string
|
||||
user_nickname: string
|
||||
order_id?: number | null
|
||||
order_no: string
|
||||
direction: string
|
||||
amount_cent: number
|
||||
balance_after_cent: number
|
||||
balance_type: string
|
||||
biz_type: string
|
||||
biz_no: string
|
||||
remark: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminWalletLedgerPage {
|
||||
items: AdminWalletLedgerRow[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminWalletLedgerQuery {
|
||||
user_id?: number
|
||||
order_id?: number
|
||||
biz_type?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface AdminAnnouncementQuery {
|
||||
category?: string
|
||||
status?: string
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Space, Table, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api'
|
||||
import { ledgerBalanceTypeLabel, walletBizTypeLabel } 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 columns: TableColumnsType<WalletLedger> = [
|
||||
{
|
||||
title: '流水号',
|
||||
dataIndex: 'ledger_no',
|
||||
width: 220,
|
||||
render: value => <span className="wallet-mono">{formatText(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '业务',
|
||||
dataIndex: 'biz_type',
|
||||
width: 150,
|
||||
render: value => <Tag bordered={false}>{walletBizTypeLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'direction',
|
||||
width: 90,
|
||||
render: value => (
|
||||
<Tag bordered={false} color={value === 'income' ? 'green' : 'volcano'}>
|
||||
{value === 'income' ? '入账' : '出账'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 SellerEarningsPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU · 卖家中心"
|
||||
title="收益流水"
|
||||
description="查看订单结算收入、线下提号结算与提现记录,全部以分为单位结算。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="green">可用 {formatMoney(balanceQuery.data?.available_balance_cent ?? 0)}</Tag>
|
||||
<Tag>冻结 {formatMoney(balanceQuery.data?.frozen_balance_cent ?? 0)}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/wallet/withdrawal')}>
|
||||
申请提现
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/wallet')}>钱包明细</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ledgerQuery.isFetching}
|
||||
onClick={() => {
|
||||
void ledgerQuery.refetch()
|
||||
void balanceQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{ledgerQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="收益流水加载失败"
|
||||
description={(ledgerQuery.error as Error)?.message}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Table<WalletLedger>
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
columns={columns}
|
||||
dataSource={ledgerQuery.data?.items ?? []}
|
||||
loading={ledgerQuery.isFetching}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{ emptyText: '还没有收益流水' }}
|
||||
pagination={{
|
||||
current: ledgerQuery.data?.page ?? page,
|
||||
pageSize: ledgerQuery.data?.page_size ?? pageSize,
|
||||
total: ledgerQuery.data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { ReloadOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { fetchSellerHandoffs } from '@/features/seller/api'
|
||||
import { handoffStatusMeta, orderStatusMeta } from '@/features/orders/labels'
|
||||
import type { UserOrderListItem } from '@/features/orders/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'pending_handoff', label: '待交接' },
|
||||
{ value: 'pending_checkout_accept', label: '待确认结账' },
|
||||
{ value: 'abnormal', label: '异常订单' },
|
||||
]
|
||||
|
||||
export function SellerHandoffsPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const handoffsQuery = useQuery({
|
||||
queryKey: ['seller-handoffs', status, page, pageSize],
|
||||
queryFn: () => fetchSellerHandoffs<UserOrderListItem>(page, pageSize, status),
|
||||
enabled: Boolean(user),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const metrics = handoffsQuery.data?.metrics
|
||||
|
||||
const columns: TableColumnsType<UserOrderListItem> = [
|
||||
{
|
||||
title: '订单',
|
||||
key: 'order',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-order-link"
|
||||
onClick={() => navigate(`/orders/${row.id}`)}
|
||||
>
|
||||
{row.order_no}
|
||||
</button>
|
||||
<strong>{row.title}</strong>
|
||||
<span className="admin-mono">{row.listing_no}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 200,
|
||||
render: (_, row) => (
|
||||
<Space size={4} wrap>
|
||||
<Tag bordered={false}>{orderStatusMeta(row.status).label}</Tag>
|
||||
<Tag bordered={false} color="blue">
|
||||
{handoffStatusMeta(row.handoff_status).label}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品信息',
|
||||
key: 'listing',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.server_region)}</span>
|
||||
<Typography.Text type="secondary">{formatText(row.login_platform)}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{formatMoney(row.display_amount_cent)}</strong>
|
||||
<span className="admin-mono">押金 {formatMoney(row.deposit_amount_cent)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => navigate(`/orders/${row.id}`)}
|
||||
>
|
||||
处理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page__head">
|
||||
<div className="admin-page__head-main">
|
||||
<Typography.Text className="admin-page__eyebrow">JIEYOU · 卖家中心</Typography.Text>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
号主待办
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" className="admin-page__desc">
|
||||
处理待交接、待确认结账与异常订单;点击订单进入详情完成后续操作。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="admin-page__actions">
|
||||
<Space>
|
||||
<Select
|
||||
value={status}
|
||||
options={statusOptions}
|
||||
style={{ width: 160 }}
|
||||
onChange={value => {
|
||||
setStatus(value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={handoffsQuery.isFetching}
|
||||
onClick={() => void handoffsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wallet-metrics" style={{ marginBottom: 16 }}>
|
||||
<div className="wallet-metric">
|
||||
<span>待交接</span>
|
||||
<strong>{metrics?.pending_handoff ?? 0}</strong>
|
||||
<small>需要尽快上号</small>
|
||||
</div>
|
||||
<div className="wallet-metric">
|
||||
<span>待确认结账</span>
|
||||
<strong>{metrics?.pending_checkout ?? 0}</strong>
|
||||
<small>核对消耗后确认</small>
|
||||
</div>
|
||||
<div className="wallet-metric">
|
||||
<span>异常订单</span>
|
||||
<strong>{metrics?.abnormal ?? 0}</strong>
|
||||
<small>需与客服协同处理</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{handoffsQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="待办列表加载失败"
|
||||
description={(handoffsQuery.error as Error)?.message}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card className="admin-table-card" title="待办订单">
|
||||
<DataTable<UserOrderListItem>
|
||||
columns={columns}
|
||||
dataSource={handoffsQuery.data?.items ?? []}
|
||||
rowKey="id"
|
||||
loading={handoffsQuery.isFetching}
|
||||
total={handoffsQuery.data?.total ?? 0}
|
||||
page={handoffsQuery.data?.page ?? page}
|
||||
pageSize={handoffsQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
emptyDescription="当前没有需要处理的订单"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { fetchSellerPickups } from '@/features/seller/api'
|
||||
import type { SellerPickup } from '@/features/seller/api'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
const statusMeta: Record<string, { label: string; color: string }> = {
|
||||
picking_up: { label: '提号中', color: 'gold' },
|
||||
completed: { label: '已完成', color: 'green' },
|
||||
cancelled: { label: '已取消', color: 'default' },
|
||||
}
|
||||
|
||||
const settlementMeta: Record<string, { label: string; color: string }> = {
|
||||
none: { label: '未结算', color: 'default' },
|
||||
pending: { label: '待结算', color: 'gold' },
|
||||
settled: { label: '已结算', color: 'green' },
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'picking_up', label: '提号中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]
|
||||
|
||||
const columns: TableColumnsType<SellerPickup> = [
|
||||
{
|
||||
title: '提号编号',
|
||||
key: 'pickup',
|
||||
width: 260,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{row.pickup_no}</strong>
|
||||
<span className="admin-mono">{formatText(row.listing_no)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
key: 'account',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.account_title)}</span>
|
||||
<Typography.Text type="secondary">
|
||||
{formatText(row.server_region)} · {formatText(row.login_platform)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人 / 平台',
|
||||
key: 'operator',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{formatText(row.operator)}</span>
|
||||
<span className="admin-mono">{formatText(row.platform)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单金额',
|
||||
dataIndex: 'order_amount_cent',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: value => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '应结算',
|
||||
key: 'settle',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{formatMoney(row.effective_settle_amount_cent)}</strong>
|
||||
<span className="admin-mono">原 {formatMoney(row.settle_amount_cent)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 190,
|
||||
render: (_, row) => {
|
||||
const status = statusMeta[row.status] ?? { label: row.status || '未知', color: 'default' }
|
||||
const settlement =
|
||||
settlementMeta[row.offline_settlement_status] ?? {
|
||||
label: row.offline_settlement_status || '—',
|
||||
color: 'default',
|
||||
}
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
<Tag bordered={false} color={status.color}>
|
||||
{status.label}
|
||||
</Tag>
|
||||
<Tag bordered={false} color={settlement.color}>
|
||||
{settlement.label}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'completed_at',
|
||||
width: 170,
|
||||
render: value => (value ? formatDateTime(value) : '—'),
|
||||
},
|
||||
]
|
||||
|
||||
export function SellerPickupsPage() {
|
||||
const user = useSessionStore(state => state.user)
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const pickupsQuery = useQuery({
|
||||
queryKey: ['seller-pickups', status, page, pageSize],
|
||||
queryFn: () => fetchSellerPickups(page, pageSize, status),
|
||||
enabled: Boolean(user),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU · 卖家中心"
|
||||
title="线下提号记录"
|
||||
description="查看账号被平台线下提号的记录、应结算金额与结算状态。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {pickupsQuery.data?.total ?? 0} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
value={status}
|
||||
options={statusOptions}
|
||||
style={{ width: 150 }}
|
||||
onChange={value => {
|
||||
setStatus(value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={pickupsQuery.isFetching}
|
||||
onClick={() => void pickupsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{pickupsQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="提号记录加载失败"
|
||||
description={(pickupsQuery.error as Error)?.message}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<SellerPickup>
|
||||
columns={columns}
|
||||
dataSource={pickupsQuery.data?.items ?? []}
|
||||
rowKey="id"
|
||||
loading={pickupsQuery.isFetching}
|
||||
total={pickupsQuery.data?.total ?? 0}
|
||||
page={pickupsQuery.data?.page ?? page}
|
||||
pageSize={pickupsQuery.data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
}}
|
||||
emptyDescription="还没有线下提号记录"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -59,3 +59,65 @@ export function publishResultText(listing: Listing, editing: boolean) {
|
||||
if (published) return editing ? '修改成功,已上架' : '发布成功,已上架'
|
||||
return editing ? '修改成功,等待后台审核' : '发布成功,等待后台审核'
|
||||
}
|
||||
|
||||
export interface SellerHandoffMetrics {
|
||||
pending_handoff: number
|
||||
pending_checkout: number
|
||||
abnormal: number
|
||||
}
|
||||
|
||||
export interface SellerHandoffPage<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
metrics: SellerHandoffMetrics
|
||||
}
|
||||
|
||||
export interface SellerPickup {
|
||||
id: number
|
||||
pickup_no: string
|
||||
listing_no: string
|
||||
account_title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
operator: string
|
||||
platform: string
|
||||
status: string
|
||||
offline_settlement_status: string
|
||||
order_amount_cent: number
|
||||
settle_amount_cent: number
|
||||
effective_settle_amount_cent: number
|
||||
profit_amount_cent: number
|
||||
created_at: string
|
||||
completed_at?: string | null
|
||||
cancelled_at?: string | null
|
||||
}
|
||||
|
||||
export interface SellerPickupPage {
|
||||
items: SellerPickup[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export async function fetchSellerHandoffs<T>(page = 1, pageSize = 20, status = '') {
|
||||
return unwrapData<SellerHandoffPage<T>>(
|
||||
apiClient.get<ApiResponse<SellerHandoffPage<T>>>('/orders/handoffs', {
|
||||
params: { page, page_size: pageSize, status: status || undefined },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchSellerPickups(page = 1, pageSize = 20, status = '') {
|
||||
return unwrapData<Partial<SellerPickupPage>>(
|
||||
apiClient.get<ApiResponse<Partial<SellerPickupPage>>>('/seller/pickups', {
|
||||
params: { page, page_size: pageSize, status: status || undefined },
|
||||
})
|
||||
).then(data => ({
|
||||
items: data.items ?? [],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? page,
|
||||
page_size: data.page_size ?? pageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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