c6a8cb2cf0
用户反馈后台 + 提现审核后台的运营字段与筛选。 - 反馈页:新增「反馈类型」列(比价反馈/普通反馈 + 场景)、顶部「反馈类型」筛选;审核抽屉采纳/拒绝 都能填「给用户的回复」(用户端可见),并回显反馈类型/场景/运营回复。 - 提现页:新增「提现类型」列(福利页提现/邀请提现)、顶部「提现类型」筛选,详情抽屉展示提现类型。 - types.ts:Feedback 加 source/scene/admin_reply,WithdrawOrder 加 source。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
999 lines
33 KiB
TypeScript
999 lines
33 KiB
TypeScript
'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>
|
||
);
|
||
}
|