feat(admin): 新增后台提现审核与打款确认

This commit is contained in:
yml2213
2026-10-30 13:13:29 +08:00
parent a754a71f71
commit f2cc2bb857
10 changed files with 763 additions and 5 deletions
@@ -0,0 +1,334 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { App, Button, Image, Input, Modal, Space, Tag, Typography } from 'antd'
import { CheckOutlined, CloseOutlined, WalletOutlined } from '@ant-design/icons'
import {
confirmAdminWithdrawalPayment,
fetchAdminWithdrawal,
reviewAdminWithdrawal,
} from '@/features/admin/api'
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
import { StatusTag } from '@/features/admin/components/StatusTag'
import { readStatusMeta, withdrawalStatusMap } from '@/features/admin/labels'
import type { AdminWithdrawal } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { emptyText, formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
type WithdrawalAction = 'approve' | 'reject' | 'pay'
const actionCopy: Record<WithdrawalAction, { title: string; okText: string; hint: string }> = {
approve: {
title: '通过提现审核',
okText: '通过审核',
hint: '通过后进入待打款状态,请确认收款账户与金额无误。',
},
reject: {
title: '驳回提现申请',
okText: '确认驳回',
hint: '驳回后资金会退回用户可用余额,请填写驳回原因。',
},
pay: {
title: '确认已打款',
okText: '确认打款',
hint: '确认后提现单完结;建议填写打款备注或凭证地址,便于对账。',
},
}
function buildSections(item: AdminWithdrawal): DetailSection[] {
const sections: DetailSection[] = [
{
key: 'overview',
title: '申请概览',
fields: [
{
key: 'withdraw_no',
label: '提现单号',
value: <span className="admin-mono">{item.withdraw_no}</span>,
},
{
key: 'status',
label: '提现状态',
value: <StatusTag meta={readStatusMeta(withdrawalStatusMap, item.status)} />,
},
{ key: 'user', label: '用户', value: `${item.user_nickname || '未设置昵称'} · ${item.user_phone}` },
{ key: 'user_id', label: '用户 ID', value: `UID ${item.user_id}` },
{
key: 'site',
label: '来源分站',
value: item.distribution_site_name
? `${item.distribution_site_name}${item.distribution_site_code ? ` · ${item.distribution_site_code}` : ''}`
: '主站',
},
{ key: 'created', label: '申请时间', value: formatDateTime(item.created_at) },
{ key: 'updated', label: '更新时间', value: formatDateTime(item.updated_at) },
],
},
{
key: 'amount',
title: '提现金额',
fields: [
{ key: 'amount', label: '申请金额', value: formatMoney(item.amount_cent) },
{ key: 'fee', label: '手续费', value: formatMoney(item.fee_cent) },
{ key: 'actual', label: '实际到账', value: formatMoney(item.actual_amount_cent) },
{
key: 'account_id',
label: '收款账户 ID',
value: item.payment_account_id ? `#${item.payment_account_id}` : emptyText,
},
],
},
{
key: 'account',
title: '收款账户',
fields: [
{ key: 'type', label: '账户类型', value: formatText(item.account_type) },
{ key: 'name', label: '收款人', value: formatText(item.account_name) },
{
key: 'no',
label: '收款账号',
value: <span className="admin-mono">{formatText(item.account_no)}</span>,
span: 2,
},
{ key: 'bank', label: '开户银行', value: formatText(item.bank_name) },
{ key: 'branch', label: '开户支行', value: formatText(item.bank_branch) },
],
},
{
key: 'audit',
title: '审核与打款',
fields: [
{ key: 'reviewed_by', label: '审核人', value: formatText(item.reviewed_by_name) },
{ key: 'reviewed_at', label: '审核时间', value: formatDateTime(item.reviewed_at) },
{ key: 'review_remark', label: '审核备注', value: formatText(item.review_remark), span: 2 },
{ key: 'paid_by', label: '打款人', value: formatText(item.paid_by_name) },
{ key: 'paid_at', label: '打款时间', value: formatDateTime(item.paid_at) },
{ key: 'payment_remark', label: '打款备注', value: formatText(item.payment_remark), span: 2 },
{
key: 'proof',
label: '打款凭证',
span: 2,
value: item.payment_proof_url ? (
<a href={item.payment_proof_url} target="_blank" rel="noreferrer">
查看打款凭证
</a>
) : (
emptyText
),
},
],
},
]
if (item.certificate_urls?.length > 0) {
sections.push({
key: 'certificates',
title: '收款凭证',
fields: [
{
key: 'images',
label: '凭证图片',
span: 2,
value: (
<Image.PreviewGroup>
<Space size={8} wrap>
{item.certificate_urls.map((url, index) => (
<Image
key={url}
src={url}
alt={`收款凭证 ${index + 1}`}
width={120}
height={90}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
))}
</Space>
</Image.PreviewGroup>
),
},
],
})
}
return sections
}
interface AdminWithdrawalDetailDrawerProps {
withdrawalId: number | null
open: boolean
onClose: () => void
onChanged?: () => void
}
export function AdminWithdrawalDetailDrawer({
withdrawalId,
open,
onClose,
onChanged,
}: AdminWithdrawalDetailDrawerProps) {
const { message } = App.useApp()
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const can = (code: string) => Boolean(permissions?.includes('*') || permissions?.includes(code))
const [action, setAction] = useState<WithdrawalAction | null>(null)
const [remark, setRemark] = useState('')
const [proofURL, setProofURL] = useState('')
const [submitting, setSubmitting] = useState(false)
const query = useQuery({
queryKey: ['admin-withdrawal', withdrawalId],
queryFn: () => fetchAdminWithdrawal(withdrawalId as number),
enabled: open && Boolean(withdrawalId),
})
const item = query.data
const openAction = (next: WithdrawalAction) => {
setRemark('')
setProofURL('')
setAction(next)
}
const closeAction = () => {
if (submitting) return
setAction(null)
}
const submit = async () => {
if (!item || !action) return
const text = remark.trim()
if (action === 'reject' && !text) {
message.warning('请填写驳回原因')
return
}
setSubmitting(true)
try {
if (action === 'approve') {
await reviewAdminWithdrawal(item.id, true, text)
message.success('提现已通过审核')
} else if (action === 'reject') {
await reviewAdminWithdrawal(item.id, false, text)
message.success('提现已驳回')
} else {
await confirmAdminWithdrawalPayment(item.id, {
remark: text || undefined,
payment_proof_url: proofURL.trim() || undefined,
})
message.success('已确认打款')
}
setAction(null)
await query.refetch()
onChanged?.()
} catch (error) {
const text =
error && typeof error === 'object' && 'response' in error
? ((error as { response?: { data?: { message?: string } } }).response?.data?.message ??
'操作失败')
: error instanceof Error
? error.message
: '操作失败'
message.error(text)
} finally {
setSubmitting(false)
}
}
const copy = action ? actionCopy[action] : null
const canReview = item?.status === 'pending' && can('withdrawal:review')
const canPay = item?.status === 'processing' && can('withdrawal:pay')
return (
<>
<DetailDrawer
open={open}
onClose={onClose}
loading={query.isPending && Boolean(withdrawalId)}
error={query.isError ? '提现详情没有加载出来,请重试或重新登录后台。' : null}
onRetry={() => void query.refetch()}
title={item ? item.withdraw_no : '提现详情'}
subtitle={
item ? (
<span>
{item.user_nickname || item.user_phone} · {formatMoney(item.actual_amount_cent)} 到账
</span>
) : null
}
tags={
item ? (
<Space size={6} wrap>
<StatusTag meta={readStatusMeta(withdrawalStatusMap, item.status)} />
{item.distribution_site_name ? <Tag>{item.distribution_site_name}</Tag> : null}
</Space>
) : null
}
sections={item ? buildSections(item) : []}
footer={
item ? (
<Space wrap>
{canReview ? (
<>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={() => openAction('approve')}
>
通过审核
</Button>
<Button danger icon={<CloseOutlined />} onClick={() => openAction('reject')}>
驳回
</Button>
</>
) : null}
{canPay ? (
<Button
type="primary"
icon={<WalletOutlined />}
onClick={() => openAction('pay')}
>
确认已打款
</Button>
) : null}
<Button onClick={onClose}>关闭</Button>
</Space>
) : (
<Button onClick={onClose}>关闭</Button>
)
}
/>
<Modal
open={action !== null}
title={copy?.title}
okText={copy?.okText}
cancelText="取消"
confirmLoading={submitting}
okButtonProps={{ danger: action === 'reject' }}
onOk={() => void submit()}
onCancel={closeAction}
>
<Typography.Paragraph type="secondary">{copy?.hint}</Typography.Paragraph>
{item ? (
<Typography.Paragraph>
提现单 {item.withdraw_no},申请 {formatMoney(item.amount_cent)},到账{' '}
{formatMoney(item.actual_amount_cent)},收款人 {item.account_name}(
{item.account_no})。
</Typography.Paragraph>
) : null}
<Input.TextArea
rows={3}
value={remark}
maxLength={200}
showCount
placeholder={action === 'reject' ? '驳回原因(必填)' : '备注(可选)'}
onChange={event => setRemark(event.target.value)}
/>
{action === 'pay' ? (
<Input
style={{ marginTop: 12 }}
value={proofURL}
placeholder="打款凭证地址(可选)"
onChange={event => setProofURL(event.target.value)}
/>
) : null}
</Modal>
</>
)
}
@@ -0,0 +1,249 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { fetchAdminWithdrawals } from '@/features/admin/api'
import { AdminWithdrawalDetailDrawer } from '@/features/admin/AdminWithdrawalDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import { readStatusMeta, withdrawalStatusMap, withdrawalStatusOptions } from '@/features/admin/labels'
import type { AdminWithdrawal, AdminWithdrawalQuery } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const emptyFilters: AdminWithdrawalQuery = {
status: '',
}
function buildColumns(
onOpen: (row: AdminWithdrawal) => void,
canView: boolean
): TableColumnsType<AdminWithdrawal> {
return [
{
title: '提现单',
dataIndex: 'withdraw_no',
width: 230,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
{canView ? (
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
{row.withdraw_no}
</button>
) : (
<span className="admin-mono">{row.withdraw_no}</span>
)}
<strong>{row.user_nickname || row.user_phone || `UID ${row.user_id}`}</strong>
<span>
{formatText(row.user_phone)}
{row.distribution_site_name ? ` · ${row.distribution_site_name}` : ''}
</span>
</div>
),
},
{
title: '提现金额',
key: 'amount',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.amount_cent)}</strong>
<span>手续费 {formatMoney(row.fee_cent)}</span>
<span className="admin-amount__accent">到账 {formatMoney(row.actual_amount_cent)}</span>
</div>
),
},
{
title: '收款账户',
key: 'account',
width: 220,
render: (_, row) => (
<div className="admin-stack">
<span>
{formatText(row.account_type)} · {formatText(row.account_name)}
</span>
<span className="admin-mono">{formatText(row.account_no)}</span>
<span>
{formatText(row.bank_name)}
{row.bank_branch ? ` · ${row.bank_branch}` : ''}
</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => <StatusTag meta={readStatusMeta(withdrawalStatusMap, value)} />,
},
{
title: '申请时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '审核 / 打款',
key: 'audit',
width: 190,
render: (_, row) => (
<div className="admin-stack">
<span>审核 {row.reviewed_by_name || '—'}</span>
<span>打款 {row.paid_by_name || '—'}</span>
<span>{formatDateTime(row.paid_at || row.reviewed_at)}</span>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 96,
fixed: 'right',
render: (_, row) =>
canView ? (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
处理
</Button>
) : (
<Typography.Text type="secondary">无权限</Typography.Text>
),
},
]
}
export function AdminWithdrawalsPage() {
const [draft, setDraft] = useState<AdminWithdrawalQuery>(emptyFilters)
const [applied, setApplied] = useState<AdminWithdrawalQuery>(emptyFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [detailId, setDetailId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(
permissions?.includes('*') ||
permissions?.includes('withdrawal:list') ||
permissions?.includes('withdrawal:detail')
)
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['admin-withdrawals', applied, page, pageSize],
queryFn: () => fetchAdminWithdrawals({ ...applied, page, page_size: pageSize }),
placeholderData: keepPreviousData,
})
const columns = useMemo(() => buildColumns(row => {
setDetailId(row.id)
setDetailOpen(true)
}, canView), [canView])
const patchFilter = (patch: Partial<AdminWithdrawalQuery>) => {
setDraft(current => ({ ...current, ...patch }))
}
const applyFilters = () => {
setApplied({ ...draft })
setPage(1)
}
const resetFilters = () => {
setDraft(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}
const hasActiveFilters = Boolean(draft.status)
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="提现审核"
description="核对收款账户与金额,审核通过后进入待打款,再确认实际打款结果。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {data?.total ?? 0} 笔</Tag>
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
刷新数据
</Button>
}
/>
<FilterBar
onSearch={applyFilters}
onReset={hasActiveFilters ? resetFilters : undefined}
loading={isFetching}
>
<FilterField label="提现状态" width={180}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={withdrawalStatusOptions}
onChange={value => patchFilter({ status: value ?? '' })}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="提现列表没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminWithdrawal>
title={<span>提现申请</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isFetching}
total={data?.total ?? 0}
page={data?.page ?? page}
pageSize={data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
onRowClick={canView ? row => {
setDetailId(row.id)
setDetailOpen(true)
} : undefined}
emptyDescription="没有匹配的提现申请"
/>
{canView ? (
<AdminWithdrawalDetailDrawer
withdrawalId={detailId}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onChanged={() => {
void refetch()
}}
/>
) : null}
</div>
)
}
+46
View File
@@ -13,6 +13,9 @@ import type {
AdminUserItem,
AdminUserPage,
AdminUserQuery,
AdminWithdrawal,
AdminWithdrawalPage,
AdminWithdrawalQuery,
DashboardData,
} from '@/features/admin/types'
@@ -232,3 +235,46 @@ export async function approveAdminRefund(id: number | string) {
export async function rejectAdminRefund(id: number | string, action: 'restore' | 'close') {
await adminClient.post(`/admin/orders/${id}/refund/reject`, { action })
}
export async function fetchAdminWithdrawals(query: AdminWithdrawalQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminWithdrawalPage>>>(
'/admin/withdrawals',
{ 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 fetchAdminWithdrawal(id: number | string) {
const { data } = await adminClient.get<ApiResponse<AdminWithdrawal>>(
`/admin/withdrawals/${id}`
)
return data.data
}
export async function reviewAdminWithdrawal(
id: number | string,
approved: boolean,
remark: string
) {
const { data } = await adminClient.post<ApiResponse<AdminWithdrawal>>(
`/admin/withdrawals/${id}/review`,
{ approved, remark }
)
return data.data
}
export async function confirmAdminWithdrawalPayment(
id: number | string,
payload: { payment_proof_url?: string; remark?: string }
) {
const { data } = await adminClient.post<ApiResponse<AdminWithdrawal>>(
`/admin/withdrawals/${id}/confirm-payment`,
payload
)
return data.data
}
+10
View File
@@ -179,3 +179,13 @@ export const riskStatusMap: Record<string, StatusMeta> = {
export const userStatusOptions = toOptions(userStatusMap)
export const realnameStatusOptions = toOptions(realnameStatusMap)
export const riskStatusOptions = toOptions(riskStatusMap)
export const withdrawalStatusMap: Record<string, StatusMeta> = {
pending: { label: '待审核', tone: 'gold' },
processing: { label: '待打款', tone: 'blue' },
completed: { label: '已完成', tone: 'success' },
rejected: { label: '已驳回', tone: 'red' },
cancelled: { label: '已取消', tone: 'default' },
}
export const withdrawalStatusOptions = toOptions(withdrawalStatusMap)
+47
View File
@@ -304,3 +304,50 @@ export interface AdminUserPage {
page: number
page_size: number
}
export interface AdminWithdrawalQuery {
page?: number
page_size?: number
status?: string
user_id?: number
}
export interface AdminWithdrawal {
id: number
withdraw_no: string
user_id: number
distribution_site_id: number
distribution_site_name: string
distribution_site_code: string
user_nickname: string
user_phone: string
amount_cent: number
fee_cent: number
actual_amount_cent: number
payment_account_id?: number | null
account_type: string
account_name: string
account_no: string
bank_name: string
bank_branch: string
certificate_urls: string[]
status: string
reviewed_by?: number | null
reviewed_by_name: string
reviewed_at?: string | null
review_remark: string
paid_by?: number | null
paid_by_name: string
paid_at?: string | null
payment_proof_url: string
payment_remark: string
created_at: string
updated_at: string
}
export interface AdminWithdrawalPage {
items: AdminWithdrawal[]
total: number
page: number
page_size: number
}