e377234137
新增提现审核列表的关键词搜索、日期范围筛选、快捷筛选、排序控件和重置筛选能力。 新增提现详情抽屉,展示用户快照、风险提示、处理时间线、最近提现和现金流水。 支持批量通过、批量拒绝、批量刷新查单,并优化批量操作确认与结果提示。 修复筛选请求竞态,避免旧请求晚返回覆盖新的筛选结果。
1075 lines
36 KiB
TypeScript
1075 lines
36 KiB
TypeScript
'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>
|
||
);
|
||
}
|