Files
shaguabijia-admin-web/src/app/(main)/withdraws/page.tsx
T
zzhyyyyy dd33a4db92 fix(withdraws): 提现账本校验区分现金/邀请金两本账展示
配合后端 ledger-check 分账改造:
- 异常清单按「现金」「邀请金」分别标注(缺扣款/缺退款/重复退款/
  非退款终态退款/余额差额),便于定位问题落在哪本账。
- 绿色(校验正常)时分别展示两账的余额与流水净额。
- WithdrawLedgerCheck 类型同步后端新增的 7 个 invite_* 字段。

修复现象:此前邀请奖励金提现单被误判为「缺扣款/缺退款流水」,
导致提现审核页现金账本报红。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:30:58 +08:00

1014 lines
34 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 {
App,
Alert,
Button,
Card,
Col,
DatePicker,
Descriptions,
Drawer,
Input,
Modal,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
Timeline,
Typography,
} 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 { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type {
AuditLog,
WithdrawBulkResult,
WithdrawDetail,
WithdrawLedgerCheck,
WithdrawListItem,
WithdrawOrder,
WithdrawSummary,
WxpayHealthCheck,
} from '@/lib/types';
import UserRewardPanel from './UserRewardPanel';
const { Text } = Typography;
const { RangePicker } = DatePicker;
// 提现单 / 审计 / 用户均为 UTC 口径(server_default=func.now());现金流水为北京 wall-clock。
const dt = (v: string) => formatUtcTime(v);
const ago = (v: string) => utcFromNow(v);
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: 'amount_cents', label: '提现金额' },
];
// 提现类型:coin_cash=福利页提现(金币兑换现金) / invite_cash=邀请提现(邀请奖励金)
const SOURCE_LABEL: Record<string, string> = {
coin_cash: '福利页提现',
invite_cash: '邀请提现',
};
const SOURCE_COLOR: Record<string, string> = {
coin_cash: 'blue',
invite_cash: 'purple',
};
const SOURCE_OPTIONS = [
{ value: 'coin_cash', label: '福利页提现' },
{ value: 'invite_cash', label: '邀请提现' },
];
const DATE_FIELD_OPTIONS = [
{ value: 'created_at', label: '申请时间' },
{ value: 'updated_at', label: '更新时间' },
];
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(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
const rejectedN = detail.recent_withdraws.filter((o) => o.status === 'rejected').length;
const failedN = detail.recent_withdraws.filter((o) => o.status === 'failed').length;
if (rejectedN) tags.push(`提现拒绝${rejectedN}`);
if (failedN) tags.push(`提现失败${failedN}`);
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 { message, modal } = App.useApp();
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 [ledgerLoading, setLedgerLoading] = useState(false);
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 [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' | 'amount_cents'>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [quickFilter, setQuickFilter] = useState<string | undefined>(undefined);
const [source, setSource] = useState<string | undefined>(undefined);
const filters: Record<string, unknown> = {};
if (activeStatus !== 'all') filters.status = activeStatus;
if (source) filters.source = source;
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, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<WithdrawListItem>('/admin/api/withdraws', filters, 20);
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);
setSource(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 loadHealth = useCallback(async () => {
try {
const { data } = await api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check');
setHealth(data);
} catch (e) {
message.error(errMsg(e));
}
}, []);
// 账本校验是全表对账、相对重,故只在进页时拉一次 + 提供手动按钮,不随每次写操作重算。
const loadLedger = useCallback(async () => {
setLedgerLoading(true);
try {
const { data } = await api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check');
setLedger(data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLedgerLoading(false);
}
}, []);
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 loadHealth();
void loadLedger();
}, [loadHealth, loadLedger, loadSummary]);
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey, page, pageSize]);
const refreshAfterChange = async (outBillNo?: string) => {
setSelectedRowKeys([]);
reload();
// 账本校验偏重,写操作后不重算(只刷 summary + health);需要时点账本 Alert 上的「重新校验」。
await Promise.all([loadSummary(), loadHealth()]);
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<WithdrawListItem> = [
{
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '用户昵称',
dataIndex: 'nickname',
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: 'source',
width: 110,
render: (v: string) => <Tag color={SOURCE_COLOR[v] || 'default'}>{SOURCE_LABEL[v] || v}</Tag>,
},
{
title: '累计提现',
dataIndex: 'cumulative_success_cents',
width: 120,
render: (v: number) => yuan(v ?? 0),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: statusTag,
},
{
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: WithdrawListItem) => {
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 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
? [
// 普通现金账(coin_cash)
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}`
: '',
// 邀请奖励金账(invite_cash)
ledger.invite_balance_diff_cents ? `邀请金余额差额 ${yuan(ledger.invite_balance_diff_cents)}` : '',
ledger.invite_missing_withdraw_txn_count
? `邀请金缺扣款流水 ${ledger.invite_missing_withdraw_txn_count}`
: '',
ledger.invite_missing_refund_txn_count
? `邀请金缺退款流水 ${ledger.invite_missing_refund_txn_count}`
: '',
ledger.invite_duplicate_refund_txn_count
? `邀请金重复退款流水 ${ledger.invite_duplicate_refund_txn_count}`
: '',
ledger.invite_refund_txn_on_non_terminal_count
? `邀请金非退款终态存在退款流水 ${ledger.invite_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)};邀请金 余额 ${yuan(ledger.invite_cash_balance_total_cents)} / 流水 ${yuan(ledger.invite_cash_transaction_total_cents)}`
: ledgerIssues.join('')
}
action={
<Button
size="small"
icon={<ReloadOutlined />}
loading={ledgerLoading}
onClick={() => void loadLedger()}
>
</Button>
}
/>
)}
</Space>
)}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12}>
<Card size="small">
<Statistic title="待审核" value={summary?.reviewing_count || 0} suffix="笔" />
</Card>
</Col>
<Col xs={24} sm={12}>
<Card size="small">
<Statistic title="待审核金额" value={yuan(summary?.reviewing_amount_cents || 0)} />
</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="用户手机号"
value={searchDraft}
style={{ width: 240 }}
onChange={(e) => {
const value = e.target.value;
setSearchDraft(value);
if (!value) setKeyword('');
}}
onSearch={(value) => setKeyword(value.trim())}
/>
<Select
value={source}
allowClear
placeholder="提现类型"
style={{ width: 140 }}
options={SOURCE_OPTIONS}
onChange={(value?: string) => setSource(value)}
/>
<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' | 'amount_cents') => 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>
)}
<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={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1460 }}
onRow={(record) => ({
onClick: () => openDetail(record),
style: { cursor: 'pointer' },
})}
/>
</Card>
<Drawer
title="提现详情"
width="50%"
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="提现类型">
<Tag color={SOURCE_COLOR[detail.order.source] || 'default'}>
{SOURCE_LABEL[detail.order.source] || detail.order.source}
</Tag>
</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>
<UserRewardPanel userId={detail.order.user_id} user={detail.user} />
<div>
<h3></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>
</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>
);
}