Files
shaguabijia-admin-web/src/app/(main)/withdraws/page.tsx
T
zzhyyyyy c6a8cb2cf0 feat: 反馈加反馈类型/运营回复 + 提现加提现类型筛选
用户反馈后台 + 提现审核后台的运营字段与筛选。

- 反馈页:新增「反馈类型」列(比价反馈/普通反馈 + 场景)、顶部「反馈类型」筛选;审核抽屉采纳/拒绝
  都能填「给用户的回复」(用户端可见),并回显反馈类型/场景/运营回复。
- 提现页:新增「提现类型」列(福利页提现/邀请提现)、顶部「提现类型」筛选,详情抽屉展示提现类型。
- types.ts:Feedback 加 source/scene/admin_reply,WithdrawOrder 加 source。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:43:17 +08:00

999 lines
33 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
? [
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('')
}
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>
);
}