Files
shaguabijia-admin-web/src/app/(main)/withdraws/page.tsx
T
OuYingJun1024 e377234137 feat: 运营后台完善微信提现审核列表和批量处理
新增提现审核列表的关键词搜索、日期范围筛选、快捷筛选、排序控件和重置筛选能力。

新增提现详情抽屉,展示用户快照、风险提示、处理时间线、最近提现和现金流水。

支持批量通过、批量拒绝、批量刷新查单,并优化批量操作确认与结果提示。

修复筛选请求竞态,避免旧请求晚返回覆盖新的筛选结果。
2026-06-08 17:37:40 +08:00

1075 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { Key } from 'react';
import {
Alert,
Button,
Card,
Col,
DatePicker,
Descriptions,
Drawer,
Input,
Modal,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
Timeline,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CheckCircleOutlined,
ClearOutlined,
CloseCircleOutlined,
FilterOutlined,
ReloadOutlined,
SearchOutlined,
SortAscendingOutlined,
SyncOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
import type {
AuditLog,
CashTxn,
WithdrawBulkResult,
WithdrawDetail,
WithdrawLedgerCheck,
WithdrawOrder,
WithdrawSummary,
WxpayHealthCheck,
} from '@/lib/types';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
const { Text } = Typography;
const { RangePicker } = DatePicker;
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
const apiTime = (v: string) => {
const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
return dayjs(hasTimezone ? v : `${v}Z`);
};
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
const ago = (v: string) => apiTime(v).fromNow();
const STATUS_COLOR: Record<string, string> = {
reviewing: 'gold',
pending: 'orange',
success: 'green',
failed: 'red',
rejected: 'default',
};
const STATUS_LABEL: Record<string, string> = {
reviewing: '待审核',
pending: '打款中',
success: '已到账',
failed: '打款失败',
rejected: '已拒绝',
};
const STATUS_TABS = [
{ key: 'reviewing', label: '待审核' },
{ key: 'pending', label: '打款中' },
{ key: 'success', label: '已到账' },
{ key: 'rejected', label: '已拒绝' },
{ key: 'failed', label: '打款失败' },
{ key: 'all', label: '全部' },
];
const REJECT_TEMPLATES = ['账号异常', '提现金额异常', '微信实名不匹配', '风控拦截', '其他原因'];
const QUICK_FILTER_OPTIONS = [
{ value: 'abnormal', label: '异常单' },
{ value: 'failed', label: '打款失败' },
{ value: 'overdue_reviewing', label: '待审核超30分钟' },
{ value: 'pending_overdue', label: '打款中超15分钟' },
{ value: 'today', label: '今日申请' },
{ value: 'high_risk', label: '高风险用户' },
];
const QUICK_FILTER_STATUS: Record<string, string> = {
abnormal: 'all',
failed: 'failed',
overdue_reviewing: 'reviewing',
pending_overdue: 'pending',
high_risk: 'all',
};
const SORT_OPTIONS = [
{ value: 'created_at', label: '申请时间' },
{ value: 'updated_at', label: '更新时间' },
{ value: 'amount_cents', label: '提现金额' },
{ value: 'id', label: '记录 ID' },
];
const DATE_FIELD_OPTIONS = [
{ value: 'created_at', label: '申请时间' },
{ value: 'updated_at', label: '更新时间' },
];
const CASH_BIZ_LABEL: Record<string, string> = {
exchange_in: '金币兑入',
withdraw: '提现扣款',
withdraw_refund: '提现退款',
};
function statusTag(status: string) {
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
}
function refreshResultText(order: WithdrawOrder) {
if (order.status === 'success') return '已查询: 微信已到账';
if (order.status === 'failed') return '已查询: 打款未成功,余额已退回';
if (order.status === 'rejected') return '已查询: 已拒绝,余额已退回';
if (order.status === 'pending') {
return order.fail_reason ? `已查询: ${order.fail_reason}` : '已查询: 仍在打款中';
}
return `已查询: ${STATUS_LABEL[order.status] || order.status}`;
}
function actionLabel(action: string) {
const labels: Record<string, string> = {
'withdraw.approve': '审核通过',
'withdraw.reject': '审核拒绝',
'withdraw.refresh': '查单刷新',
'withdraw.reconcile': '批量对账',
};
return labels[action] || action;
}
function summaryCount(summary: WithdrawSummary | null, key: string) {
if (!summary) return undefined;
if (key === 'reviewing') return summary.reviewing_count;
if (key === 'pending') return summary.pending_count;
if (key === 'failed') return summary.failed_count;
return undefined;
}
function riskTags(detail: WithdrawDetail | null) {
if (!detail) return [];
if (detail.risk_flags?.length) return detail.risk_flags;
const tags: string[] = [];
if (!detail.order.user_name) tags.push('缺少提现实名');
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
if (detail.user && dayjs().diff(apiTime(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
tags.push('历史异常单');
}
return tags;
}
function bulkResultText(action: string, result: WithdrawBulkResult) {
const firstError = result.items.find((item) => !item.ok)?.error;
return `${action}: 成功 ${result.success} / ${result.total}${
result.failed ? `,失败 ${result.failed}` : ''
}${firstError ? `,首个失败原因: ${firstError}` : ''}`;
}
export default function WithdrawsPage() {
const [activeStatus, setActiveStatus] = useState('reviewing');
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
const [ledger, setLedger] = useState<WithdrawLedgerCheck | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detail, setDetail] = useState<WithdrawDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [rejecting, setRejecting] = useState<WithdrawOrder | null>(null);
const [bulkRejecting, setBulkRejecting] = useState<WithdrawOrder[]>([]);
const [rejectReason, setRejectReason] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const [pageSize, setPageSize] = useState(20);
const [searchDraft, setSearchDraft] = useState('');
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
const [dateField, setDateField] = useState<'created_at' | 'updated_at'>('created_at');
const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'amount_cents' | 'id'>(
'created_at',
);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [quickFilter, setQuickFilter] = useState<string | undefined>(undefined);
const filters: Record<string, unknown> = {};
if (activeStatus !== 'all') filters.status = activeStatus;
if (keyword.trim()) filters.keyword = keyword.trim();
if (dateRange?.[0]) filters.date_from = dateRange[0].startOf('day').toISOString();
if (dateRange?.[1]) filters.date_to = dateRange[1].endOf('day').toISOString();
if (quickFilter) filters.quick_filter = quickFilter;
filters.date_field = dateField;
filters.sort_by = sortBy;
filters.sort_order = sortOrder;
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<WithdrawOrder>(
'/admin/api/withdraws',
filters,
pageSize,
);
const canManage = canDo(['finance']);
const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id));
const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing');
const selectedPending = selectedOrders.filter((item) => item.status === 'pending');
const resetFilters = () => {
setActiveStatus('reviewing');
setSearchDraft('');
setKeyword('');
setDateRange(null);
setDateField('created_at');
setSortBy('created_at');
setSortOrder('desc');
setQuickFilter(undefined);
};
const applyQuickFilter = (value?: string) => {
setQuickFilter(value);
const targetStatus = value ? QUICK_FILTER_STATUS[value] : undefined;
if (targetStatus) setActiveStatus(targetStatus);
};
const changeStatus = (status: string) => {
setActiveStatus(status);
setQuickFilter((current) => {
if (!current) return current;
const targetStatus = QUICK_FILTER_STATUS[current];
if (!targetStatus || status === targetStatus) return current;
return undefined;
});
};
const loadSummary = useCallback(async () => {
try {
const { data } = await api.get<WithdrawSummary>('/admin/api/withdraws/summary');
setSummary(data);
} catch (e) {
message.error(errMsg(e));
}
}, []);
const loadChecks = useCallback(async () => {
try {
const [{ data: healthData }, { data: ledgerData }] = await Promise.all([
api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check'),
api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check'),
]);
setHealth(healthData);
setLedger(ledgerData);
} catch (e) {
message.error(errMsg(e));
}
}, []);
const loadDetail = useCallback(async (outBillNo: string) => {
setDetailLoading(true);
try {
const { data } = await api.get<WithdrawDetail>(`/admin/api/withdraws/${outBillNo}`);
setDetail(data);
} catch (e) {
message.error(errMsg(e));
} finally {
setDetailLoading(false);
}
}, []);
useEffect(() => {
void loadSummary();
void loadChecks();
}, [loadChecks, loadSummary]);
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey, pageSize]);
const refreshAfterChange = async (outBillNo?: string) => {
setSelectedRowKeys([]);
reload();
await Promise.all([loadSummary(), loadChecks()]);
if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) {
await loadDetail(outBillNo);
}
};
const openDetail = (o: WithdrawOrder) => {
setDrawerOpen(true);
setDetail(null);
void loadDetail(o.out_bill_no);
};
const approve = (o: WithdrawOrder) => {
Modal.confirm({
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
content: (
<div>
<div> ID: {o.user_id}</div>
<div>: {o.user_name || '-'}</div>
<div>: {o.out_bill_no}</div>
<div style={{ marginTop: 8, color: '#8c8c8c' }}>
</div>
</div>
),
okText: '通过并打款',
okButtonProps: { danger: true, icon: <CheckCircleOutlined /> },
onOk: async () => {
try {
await api.post(`/admin/api/withdraws/${o.out_bill_no}/approve`);
message.success('已通过审核并发起打款');
await refreshAfterChange(o.out_bill_no);
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const confirmReject = async () => {
const targets = bulkRejecting.length ? bulkRejecting : rejecting ? [rejecting] : [];
if (!targets.length) return;
const trimmed = rejectReason.trim();
if (!trimmed) {
message.warning('请填写拒绝理由');
return;
}
try {
if (bulkRejecting.length) {
const { data } = await api.post<WithdrawBulkResult>('/admin/api/withdraws/bulk/reject', {
out_bill_nos: targets.map((item) => item.out_bill_no),
reason: trimmed,
});
const text = bulkResultText('批量拒绝完成', data);
if (data.failed) message.warning(text);
else message.success(text);
} else {
await api.post(`/admin/api/withdraws/${targets[0].out_bill_no}/reject`, { reason: trimmed });
message.success('已拒绝并退回余额');
}
setRejecting(null);
setBulkRejecting([]);
setRejectReason('');
await refreshAfterChange(targets.length === 1 ? targets[0].out_bill_no : undefined);
} catch (e) {
message.error(errMsg(e));
}
};
const bulkApprove = () => {
if (!selectedReviewing.length) {
message.warning('请选择待审核的提现单');
return;
}
const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0);
let confirmText = '';
Modal.confirm({
title: `确认批量通过 ${selectedReviewing.length} 笔提现?`,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
<Text>
<Text strong>{yuan(amount)}</Text>
</Text>
<Text type="danger"></Text>
<Input placeholder="确认打款" onChange={(e) => { confirmText = e.target.value.trim(); }} />
</Space>
),
okText: '批量通过并打款',
okButtonProps: { danger: true, icon: <CheckCircleOutlined /> },
onOk: async () => {
if (confirmText !== '确认打款') {
message.warning('请输入“确认打款”');
return Promise.reject(new Error('confirm required'));
}
try {
const { data } = await api.post<WithdrawBulkResult>('/admin/api/withdraws/bulk/approve', {
out_bill_nos: selectedReviewing.map((item) => item.out_bill_no),
});
const text = bulkResultText('批量通过完成', data);
if (data.failed) message.warning(text);
else message.success(text);
await refreshAfterChange();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const bulkRefresh = () => {
if (!selectedPending.length) {
message.warning('请选择打款中的提现单');
return;
}
Modal.confirm({
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
content: '会逐笔向微信查单,并按最新状态归一化。',
okText: '批量刷新查单',
okButtonProps: { icon: <SyncOutlined /> },
onOk: async () => {
try {
const { data } = await api.post<WithdrawBulkResult>('/admin/api/withdraws/bulk/refresh', {
out_bill_nos: selectedPending.map((item) => item.out_bill_no),
});
const text = bulkResultText('批量刷新完成', data);
if (data.failed) message.warning(text);
else message.success(text);
await refreshAfterChange();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const retry = (o: WithdrawOrder) => {
Modal.confirm({
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
okText: '刷新查单',
okButtonProps: { icon: <SyncOutlined /> },
onOk: async () => {
try {
const { data } = await api.post<WithdrawOrder>(
`/admin/api/withdraws/${o.out_bill_no}/refresh`,
);
const text = refreshResultText(data);
if (data.status === 'pending' && data.fail_reason) message.warning(text);
else message.success(text);
await refreshAfterChange(o.out_bill_no);
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const reconcile = () => {
Modal.confirm({
title: '批量对账?',
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
okText: '开始对账',
onOk: async () => {
try {
const { data } = await api.post<{ checked: number; resolved: number }>(
'/admin/api/withdraws/reconcile',
);
message.success(`对账完成: 检查 ${data.checked} 单,解决 ${data.resolved}`);
await refreshAfterChange();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const columns: ColumnsType<WithdrawOrder> = [
{
title: '用户',
dataIndex: 'user_id',
width: 110,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '提现实名',
dataIndex: 'user_name',
width: 130,
render: (v: string | null) => v || <Text type="secondary"></Text>,
},
{
title: '金额',
dataIndex: 'amount_cents',
width: 120,
render: (v: number) => <Text strong>{yuan(v)}</Text>,
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: statusTag,
},
{
title: '微信状态',
dataIndex: 'wechat_state',
width: 150,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 180,
render: (v: string) => (
<Space direction="vertical" size={0}>
<span>{dt(v)}</span>
<Text type="secondary">{ago(v)}</Text>
</Space>
),
},
{
title: '提现单号',
dataIndex: 'out_bill_no',
ellipsis: true,
},
{
title: '失败/拒绝原因',
dataIndex: 'fail_reason',
ellipsis: true,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '操作',
key: 'op',
fixed: 'right',
width: 210,
render: (_: unknown, o: WithdrawOrder) => {
if (!canManage) return <Text type="secondary">-</Text>;
if (o.status === 'reviewing') {
return (
<Space onClick={(e) => e.stopPropagation()}>
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => approve(o)}
>
</Button>
<Button
size="small"
type="link"
danger
icon={<CloseCircleOutlined />}
onClick={() => {
setRejecting(o);
setBulkRejecting([]);
setRejectReason('');
}}
>
</Button>
</Space>
);
}
if (o.status === 'pending') {
return (
<Button
size="small"
type="link"
icon={<SyncOutlined />}
onClick={(e) => {
e.stopPropagation();
retry(o);
}}
>
</Button>
);
}
return <Text type="secondary">-</Text>;
},
},
];
const cashColumns: ColumnsType<CashTxn> = [
{
title: '类型',
dataIndex: 'biz_type',
width: 130,
render: (v: string) => CASH_BIZ_LABEL[v] || v,
},
{
title: '金额',
dataIndex: 'amount_cents',
width: 100,
render: (v: number) => (
<Text style={{ color: v < 0 ? '#cf1322' : '#389e0d' }}>
{v > 0 ? '+' : ''}
{yuan(v)}
</Text>
),
},
{ title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan },
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
];
const recentWithdrawColumns: ColumnsType<WithdrawOrder> = [
{ title: '单号', dataIndex: 'out_bill_no', ellipsis: true },
{ title: '金额', dataIndex: 'amount_cents', width: 90, render: yuan },
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
];
const auditTimeline = detail
? [
{
children: (
<span>
<Text type="secondary">{dt(detail.order.created_at)}</Text>
</span>
),
},
...[...detail.audit_logs].reverse().map((log: AuditLog) => ({
children: (
<span>
{actionLabel(log.action)} · {log.admin_username}
<Text type="secondary"> {dt(log.created_at)}</Text>
</span>
),
})),
{
children: (
<span>
{statusTag(detail.order.status)}
<Text type="secondary"> {dt(detail.order.updated_at)}</Text>
</span>
),
},
]
: [];
const risks = riskTags(detail);
const rejectTargets = bulkRejecting.length ? bulkRejecting : rejecting ? [rejecting] : [];
const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0);
const ledgerIssues = ledger
? [
ledger.balance_diff_cents ? `余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
ledger.missing_withdraw_txn_count ? `缺扣款流水 ${ledger.missing_withdraw_txn_count}` : '',
ledger.missing_refund_txn_count ? `缺退款流水 ${ledger.missing_refund_txn_count}` : '',
ledger.duplicate_refund_txn_count ? `重复退款流水 ${ledger.duplicate_refund_txn_count}` : '',
ledger.refund_txn_on_non_terminal_count
? `非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count}`
: '',
].filter(Boolean)
: [];
return (
<div>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h2 style={{ marginBottom: 4 }}></h2>
<Text type="secondary">退</Text>
</div>
{canManage && (
<Button danger icon={<ReloadOutlined />} onClick={reconcile}>
</Button>
)}
</Space>
{(health || ledger) && (
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
{health && (
<Alert
showIcon
type={health.ok ? 'success' : 'warning'}
message={health.ok ? '微信提现配置正常' : '微信提现配置需检查'}
description={
health.ok
? `自动对账已${health.auto_reconcile_enabled ? '开启' : '关闭'},每 ${health.auto_reconcile_interval_sec}s 扫描 ${health.auto_reconcile_older_than_minutes} 分钟前的打款中订单`
: health.issues.join('')
}
/>
)}
{ledger && (
<Alert
showIcon
type={ledger.ok ? 'success' : 'error'}
message={ledger.ok ? '现金账本校验正常' : '现金账本存在异常'}
description={
ledger.ok
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
: ledgerIssues.join('')
}
/>
)}
</Space>
)}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} xl={5}>
<Card size="small">
<Statistic title="待审核" value={summary?.reviewing_count || 0} suffix="笔" />
<Text type="secondary"> {yuan(summary?.reviewing_amount_cents || 0)}</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={5}>
<Card size="small">
<Statistic title="今日拒绝" value={summary?.today_rejected_count || 0} suffix="笔" />
<Text type="secondary"></Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={5}>
<Card size="small">
<Statistic title="打款中" value={summary?.pending_count || 0} suffix="笔" />
<Text type="secondary"></Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={4}>
<Card size="small">
<Statistic title="失败待处理" value={summary?.failed_count || 0} suffix="笔" />
<Text type="secondary">退</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={5}>
<Card size="small">
<Statistic title="今日到账" value={summary?.today_success_count || 0} suffix="笔" />
<Text type="secondary"> {yuan(summary?.today_success_amount_cents || 0)}</Text>
</Card>
</Col>
</Row>
<Card>
<Tabs
activeKey={activeStatus}
onChange={changeStatus}
items={STATUS_TABS.map((tab) => {
const count = summaryCount(summary, tab.key);
return {
key: tab.key,
label:
count == null ? (
tab.label
) : (
<Space size={4}>
<span>{tab.label}</span>
<Tag>{count}</Tag>
</Space>
),
};
})}
/>
<Space
wrap
align="start"
style={{
width: '100%',
justifyContent: 'space-between',
marginBottom: 12,
paddingBottom: 12,
borderBottom: '1px solid #f0f0f0',
}}
>
<Space wrap>
<Input.Search
allowClear
enterButton="搜索"
prefix={<SearchOutlined />}
placeholder="用户ID / 手机号 / 单号 / 原因"
value={searchDraft}
style={{ width: 320 }}
onChange={(e) => {
const value = e.target.value;
setSearchDraft(value);
if (!value) setKeyword('');
}}
onSearch={(value) => setKeyword(value.trim())}
/>
<Select
value={quickFilter}
allowClear
placeholder="快捷筛选"
style={{ width: 170 }}
suffixIcon={<FilterOutlined />}
options={QUICK_FILTER_OPTIONS}
onChange={applyQuickFilter}
/>
<Select
value={dateField}
style={{ width: 120 }}
options={DATE_FIELD_OPTIONS}
onChange={(value: 'created_at' | 'updated_at') => setDateField(value)}
/>
<RangePicker
value={dateRange}
allowClear
style={{ width: 260 }}
presets={[
{ label: '今天', value: [dayjs().startOf('day'), dayjs().endOf('day')] },
{
label: '最近7天',
value: [dayjs().subtract(6, 'day').startOf('day'), dayjs().endOf('day')],
},
{
label: '最近30天',
value: [dayjs().subtract(29, 'day').startOf('day'), dayjs().endOf('day')],
},
]}
onChange={(values) => setDateRange(values as [Dayjs, Dayjs] | null)}
/>
</Space>
<Space wrap>
<Select
value={sortBy}
style={{ width: 130 }}
suffixIcon={<SortAscendingOutlined />}
options={SORT_OPTIONS}
onChange={(value: 'created_at' | 'updated_at' | 'amount_cents' | 'id') =>
setSortBy(value)
}
/>
<Select
value={sortOrder}
style={{ width: 96 }}
options={[
{ value: 'desc', label: '倒序' },
{ value: 'asc', label: '正序' },
]}
onChange={(value: 'asc' | 'desc') => setSortOrder(value)}
/>
<Button icon={<ClearOutlined />} onClick={resetFilters}>
</Button>
</Space>
</Space>
{canManage && selectedOrders.length > 0 && (
<Space
wrap
style={{
width: '100%',
justifyContent: 'space-between',
marginBottom: 12,
padding: '8px 0',
}}
>
<Text type="secondary">
{selectedOrders.length} {selectedReviewing.length} {' '}
{selectedPending.length}
</Text>
<Space wrap>
<Button
icon={<CheckCircleOutlined />}
disabled={!selectedReviewing.length}
onClick={bulkApprove}
>
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
disabled={!selectedReviewing.length}
onClick={() => {
setRejecting(null);
setBulkRejecting(selectedReviewing);
setRejectReason('');
}}
>
</Button>
<Button
icon={<SyncOutlined />}
disabled={!selectedPending.length}
onClick={bulkRefresh}
>
</Button>
<Button onClick={() => setSelectedRowKeys([])}></Button>
</Space>
</Space>
)}
<Space
wrap
style={{
width: '100%',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<Text type="secondary">
{items.length} {nextCursor ? ',还有更多' : ',已到最后一页'}
</Text>
<Space>
<Text type="secondary"></Text>
<Select
value={pageSize}
style={{ width: 96 }}
onChange={(value: number) => setPageSize(value)}
options={[
{ value: 20, label: '20 条' },
{ value: 50, label: '50 条' },
{ value: 100, label: '100 条' },
]}
/>
</Space>
</Space>
<Table
rowKey="id"
rowSelection={
canManage
? {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
getCheckboxProps: (record) => ({
disabled: record.status !== 'reviewing' && record.status !== 'pending',
}),
}
: undefined
}
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
scroll={{ x: 1350 }}
onRow={(record) => ({
onClick: () => openDetail(record),
style: { cursor: 'pointer' },
})}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? `继续加载 ${pageSize}` : '没有更多了'}
</Button>
</div>
</Card>
<Drawer
title="提现详情"
width={720}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (
<Button
icon={<SearchOutlined />}
onClick={() => void loadDetail(detail.order.out_bill_no)}
>
</Button>
)
}
>
<Spin spinning={detailLoading}>
{detail && (
<Space direction="vertical" size={18} style={{ width: '100%' }}>
<Descriptions bordered size="small" column={2} title="提现单">
<Descriptions.Item label="状态">{statusTag(detail.order.status)}</Descriptions.Item>
<Descriptions.Item label="金额">
<Text strong>{yuan(detail.order.amount_cents)}</Text>
</Descriptions.Item>
<Descriptions.Item label="提现实名">
{detail.order.user_name || '-'}
</Descriptions.Item>
<Descriptions.Item label="微信状态">
{detail.order.wechat_state || '-'}
</Descriptions.Item>
<Descriptions.Item label="提现单号" span={2}>
<Text copyable>{detail.order.out_bill_no}</Text>
</Descriptions.Item>
<Descriptions.Item label="微信转账单号" span={2}>
{detail.order.transfer_bill_no || '-'}
</Descriptions.Item>
<Descriptions.Item label="失败/拒绝原因" span={2}>
{detail.order.fail_reason || '-'}
</Descriptions.Item>
</Descriptions>
<Descriptions bordered size="small" column={2} title="用户与钱包">
<Descriptions.Item label="用户 ID">#{detail.order.user_id}</Descriptions.Item>
<Descriptions.Item label="账号状态">
{detail.user ? <Tag>{detail.user.status}</Tag> : '-'}
</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.user?.nickname || '-'}</Descriptions.Item>
<Descriptions.Item label="微信昵称">
{detail.user?.wechat_nickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="现金余额">
{yuan(detail.user?.cash_balance_cents || 0)}
</Descriptions.Item>
<Descriptions.Item label="提现总次数">
{detail.user?.withdraw_total ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="成功提现">
{yuan(detail.user?.withdraw_success_cents || 0)}
</Descriptions.Item>
</Descriptions>
<div>
<h3> {detail.risk_score ? <Tag color="red">{detail.risk_score}</Tag> : null}</h3>
{risks.length ? (
<Space wrap>
{risks.map((risk) => (
<Tag color="orange" key={risk}>
{risk}
</Tag>
))}
</Space>
) : (
<Text type="secondary"></Text>
)}
</div>
<div>
<h3>线</h3>
<Timeline items={auditTimeline} />
</div>
<div>
<h3></h3>
<Table
rowKey="id"
size="small"
columns={recentWithdrawColumns}
dataSource={detail.recent_withdraws}
pagination={false}
/>
</div>
<div>
<h3></h3>
<Table
rowKey="id"
size="small"
columns={cashColumns}
dataSource={detail.recent_cash_transactions}
pagination={false}
/>
</div>
</Space>
)}
</Spin>
</Drawer>
<Modal
title={bulkRejecting.length ? `批量拒绝 ${bulkRejecting.length} 笔提现` : '拒绝提现'}
open={!!rejectTargets.length}
okText="确认拒绝并退款"
okButtonProps={{ danger: true, icon: <CloseCircleOutlined /> }}
onOk={confirmReject}
onCancel={() => {
setRejecting(null);
setBulkRejecting([]);
setRejectReason('');
}}
>
{!!rejectTargets.length && (
<Space direction="vertical" style={{ width: '100%' }}>
<Text>
<Text strong>{yuan(rejectAmount)}</Text> 退
</Text>
<Space wrap>
{REJECT_TEMPLATES.map((tpl) => (
<Button size="small" key={tpl} onClick={() => setRejectReason(tpl)}>
{tpl}
</Button>
))}
</Space>
<Input.TextArea
rows={4}
maxLength={200}
value={rejectReason}
placeholder="请输入拒绝理由,用户端会看到这段说明"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Space>
)}
</Modal>
</div>
);
}