diff --git a/src/app/(main)/ad-revenue-report/UserAdRevenueDrawer.tsx b/src/app/(main)/ad-revenue-report/UserAdRevenueDrawer.tsx index 236aefd..9c16731 100644 --- a/src/app/(main)/ad-revenue-report/UserAdRevenueDrawer.tsx +++ b/src/app/(main)/ad-revenue-report/UserAdRevenueDrawer.tsx @@ -15,6 +15,8 @@ interface UserOverviewResp { id: number; phone: string; nickname: string | null; + is_high_risk: boolean; + high_risk_note: string | null; status: string; wechat_nickname: string | null; created_at: string; @@ -49,6 +51,8 @@ export default function UserAdRevenueDrawer({ open, onClose, userId, phone }: Pr id: o.user.id, phone: o.user.phone, nickname: o.user.nickname, + is_high_risk: o.user.is_high_risk, + high_risk_note: o.user.high_risk_note, status: o.user.status, wechat_nickname: o.user.wechat_nickname, // 概览接口已返回微信昵称 wechat_avatar_url: null, diff --git a/src/app/(main)/invite-withdraws/page.tsx b/src/app/(main)/invite-withdraws/page.tsx new file mode 100644 index 0000000..72318b1 --- /dev/null +++ b/src/app/(main)/invite-withdraws/page.tsx @@ -0,0 +1,5 @@ +import { WithdrawReviewPage } from '../withdraws/WithdrawReviewPage'; + +export default function InviteWithdrawsPage() { + return ; +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index b63fdc5..724bbb1 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -75,7 +75,8 @@ const NAV_GROUPS: NavGroup[] = [ icon: , label: '奖励审核', children: [ - { key: '/withdraws', icon: , label: '提现审核' }, + { key: '/invite-withdraws', icon: , label: '邀请提现审核' }, + { key: '/withdraws', icon: , label: '其他提现审核' }, { key: '/price-reports', icon: , label: '低价审核' }, { key: '/feedbacks', icon: , label: '用户反馈' }, ], @@ -144,8 +145,13 @@ export default function MainLayout({ children }: { children: React.ReactNode }) const version = (reviewRefreshVersion.current[key] ?? 0) + 1; reviewRefreshVersion.current[key] = version; let count: number; - if (key === '/withdraws') { - count = (await api.get('/admin/api/withdraws/summary')).data.reviewing_count; + if (key === '/withdraws' || key === '/invite-withdraws') { + const source = key === '/invite-withdraws' ? 'invite_cash' : 'coin_cash'; + count = ( + await api.get('/admin/api/withdraws/summary', { + params: { source }, + }) + ).data.reviewing_count; } else if (key === '/price-reports') { count = (await api.get('/admin/api/price-reports/summary')).data.pending; } else { @@ -158,6 +164,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) const refreshSafely = (key: ReviewBadgeKey) => refresh(key).catch(() => {}); void Promise.all([ refreshSafely('/withdraws'), + refreshSafely('/invite-withdraws'), refreshSafely('/price-reports'), refreshSafely('/feedbacks'), ]); diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx index 60f38f3..f952526 100644 --- a/src/app/(main)/users/page.tsx +++ b/src/app/(main)/users/page.tsx @@ -13,6 +13,11 @@ import { formatUtcTime } from '@/lib/format'; import { usePagedList } from '@/lib/usePagedList'; import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals'; import { DeleteUserModal } from '@/components/DeleteUserModal'; +import { + clearUserRisk, + UserRiskModal, + type RiskUser, +} from '@/components/UserRiskModal'; import type { UserListItem } from '@/lib/types'; const { RangePicker } = DatePicker; @@ -49,6 +54,7 @@ export default function UsersPage() { const [coinUser, setCoinUser] = useState(null); const [cashUser, setCashUser] = useState(null); const [delUser, setDelUser] = useState(null); + const [riskUser, setRiskUser] = useState(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 筛选/排序变化后清空已选(避免选中项跨筛选错位) @@ -134,6 +140,22 @@ export default function UsersPage() { }); }; + const markNormalRisk = (u: UserListItem) => { + modal.confirm({ + title: `确认将用户 ${u.phone} 标记为非高风险?`, + content: '确认后将清空当前高风险备注。', + onOk: async () => { + try { + await clearUserRisk(u.id); + message.success('已标记为非高风险'); + reload(); + } catch (error) { + message.error(errMsg(error)); + } + }, + }); + }; + // 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。 // 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。 const setForceOnboarding = (u: UserListItem, enable: boolean) => { @@ -213,26 +235,38 @@ export default function UsersPage() { }; const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') }, - { title: '手机号', dataIndex: 'phone', width: 130 }, + { title: 'ID', dataIndex: 'id', width: 60, sorter: true, sortOrder: sortOrderOf('id') }, + { title: '手机号', dataIndex: 'phone', width: 115 }, { title: '昵称', dataIndex: 'nickname', - width: 150, + width: 130, ellipsis: true, render: (v: string | null) => v || '-', }, - { title: '渠道', dataIndex: 'register_channel', width: 90 }, + { + title: '用户标识', + dataIndex: 'is_high_risk', + width: 95, + render: (_: boolean, user: UserListItem) => ( + + + {user.is_high_risk ? '高风险' : '非高风险'} + + + ), + }, + { title: '渠道', dataIndex: 'register_channel', width: 70 }, { title: '状态', dataIndex: 'status', - width: 90, + width: 80, render: (s: string) => {s}, }, { title: '注册时间', dataIndex: 'created_at', - width: 160, + width: 140, sorter: true, sortOrder: sortOrderOf('created_at'), render: (v: string) => formatUtcTime(v), @@ -245,7 +279,7 @@ export default function UsersPage() { ), dataIndex: 'last_active_at', - width: 160, + width: 140, sorter: true, // 关掉 antd 默认的「点击升序/降序」表头提示,否则与上面自定义口径 Tooltip 同时弹出互相遮挡 showSorterTooltip: false, @@ -256,9 +290,14 @@ export default function UsersPage() { title: '操作', key: 'op', fixed: 'right', - width: 340, + width: 540, render: (_: unknown, u: UserListItem) => ( - e.stopPropagation()}> + e.stopPropagation()} + > router.push(`/users/${u.id}`)}>详情 {canCoins && setCoinUser(u)}>调金币} {canCoins && setCashUser(u)}>调现金} @@ -270,6 +309,16 @@ export default function UsersPage() { {u.debug_trace_enabled ? '关调试链接' : '开调试链接'} )} + {canStatus && + u.status !== 'deleted' && + (u.is_high_risk ? ( + <> + setRiskUser(u)}>修改高风险备注 + markNormalRisk(u)}>标记为非高风险 + + ) : ( + setRiskUser(u)}>标记为高风险 + ))} {canStatus && u.status !== 'deleted' && (u.force_onboarding ? ( @@ -392,7 +441,7 @@ export default function UsersPage() { showTotal: (t) => `共 ${t} 个用户`, onChange: onPageChange, }} - scroll={{ x: 1200 }} + scroll={{ x: 1400 }} onChange={onTableChange} /> @@ -404,6 +453,12 @@ export default function UsersPage() { onClose={() => setDelUser(null)} onDone={reload} /> + setRiskUser(null)} + onDone={reload} + /> ); } diff --git a/src/app/(main)/withdraws/UserRewardPanel.tsx b/src/app/(main)/withdraws/UserRewardPanel.tsx index 27a0a5e..dc5c4f2 100644 --- a/src/app/(main)/withdraws/UserRewardPanel.tsx +++ b/src/app/(main)/withdraws/UserRewardPanel.tsx @@ -4,11 +4,13 @@ import { useCallback, useEffect, useState } from 'react'; import { DatePicker, Descriptions, + Input, Radio, Space, Table, Tag, Typography, + Tooltip, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; @@ -39,12 +41,18 @@ const PAGE_SIZE = 10; // 金币记录每页条数 interface Props { userId: number; user: WithdrawUserSnapshot | null; + withdrawSource?: 'coin_cash' | 'invite_cash'; // 统计区字段集:withdraw(默认,提现详情:含提现细分) / ad(广告收益详情:只看看广告观看统计,对齐参考图 6 项) statsVariant?: 'withdraw' | 'ad'; } /** 用户看广告/提现详情:基本信息 + 互斥时间筛选 + 统计区(按 statsVariant 取字段集)+ 金币发放记录表。 */ -export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw' }: Props) { +export default function UserRewardPanel({ + userId, + user, + withdrawSource, + statsVariant = 'withdraw', +}: Props) { const [mode, setMode] = useState<'all' | 'range'>('all'); // all=注册至今 / range=自定义区间 const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null); const [stats, setStats] = useState(null); @@ -69,7 +77,10 @@ export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw setStatsLoading(true); try { const { data } = await api.get(`/admin/api/users/${userId}/reward-stats`, { - params: JSON.parse(paramsKey), + params: { + ...JSON.parse(paramsKey), + ...(withdrawSource ? { withdraw_source: withdrawSource } : {}), + }, }); setStats(data); } catch (e) { @@ -77,7 +88,7 @@ export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw } finally { setStatsLoading(false); } - }, [userId, paramsKey]); + }, [userId, paramsKey, withdrawSource]); const loadRecords = useCallback( async (p: number) => { @@ -135,6 +146,13 @@ export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw 手机号 {user?.phone || '-'} + {user && ( + + + {user.is_high_risk ? '高风险' : '非高风险'} + + + )} 昵称 {user?.nickname || '-'} @@ -160,6 +178,7 @@ export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw setRange(v as [Dayjs, Dayjs] | null)} /> @@ -168,7 +187,7 @@ export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw {/* 统计区(受时间筛选;现金余额为当前快照)。ad 视图只保留看广告观看统计(对齐参考图 6 项) */} - + {stats ? yuan(stats.withdraw_success_cents) : '-'} @@ -176,40 +195,40 @@ export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw {statsVariant === 'ad' ? ( <> - + {stats?.reward_video_count ?? '-'} {/* eCPM 均按元展示(reward_video / feed avg_ecpm 原始为分/千次,÷100 转元) */} - + {stats ? `${(stats.reward_video_avg_ecpm / 100).toFixed(2)} 元/千` : '-'} - + {stats?.feed_count ?? '-'} - + {stats ? `${(stats.feed_avg_ecpm / 100).toFixed(2)} 元/千` : '-'} ) : ( <> - {stats?.withdraw_total ?? '-'} - + {stats?.withdraw_total ?? '-'} + {stats ? yuan(stats.traditional_task_cash_cents) : '-'} - + {stats?.reward_video_count ?? '-'} - + {stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'} - + {stats ? yuan(stats.reward_video_cash_cents) : '-'} - {stats?.feed_count ?? '-'} - + {stats?.feed_count ?? '-'} + {stats ? `${stats.feed_avg_ecpm} 分/千` : '-'} - + {stats ? yuan(stats.feed_cash_cents) : '-'} @@ -217,12 +236,24 @@ export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw {statsVariant === 'ad' - ? '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 均按元展示(原始分/千次 ÷100)。' - : '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。'} + ? '统计随上方时间筛选(现金余额除外,为当前快照);次数仅统计成功发奖记录,信息流按奖励份数统计;eCPM 为预估值。' + : '统计随上方时间筛选(现金余额除外,为当前快照);提现按申请提交时间统计;累计收益为已发金币按当前汇率折算,不代表实际已提现金额;eCPM 为预估值。'} - {/* 金币记录 */} -

金币记录

+ {user?.is_high_risk && ( +
+ 高风险备注 + +
+ )} + + {/* 主要金币发放记录 */} +

主要金币发放记录

`${r.source}-${r.created_at}-${r.coin}-${r.ecpm ?? ''}`} size="small" diff --git a/src/app/(main)/withdraws/WithdrawReviewPage.tsx b/src/app/(main)/withdraws/WithdrawReviewPage.tsx new file mode 100644 index 0000000..bed7a0f --- /dev/null +++ b/src/app/(main)/withdraws/WithdrawReviewPage.tsx @@ -0,0 +1,1247 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { Key } from 'react'; +import { + App, + Alert, + Button, + Card, + Col, + DatePicker, + Descriptions, + Drawer, + Input, + Modal, + Radio, + Row, + Select, + Space, + Spin, + Statistic, + Table, + Tabs, + Tag, + Timeline, + Tooltip, + Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + CheckCircleOutlined, + ClearOutlined, + CloseCircleOutlined, + 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 { refreshReviewBadge } from '@/lib/reviewBadge'; +import { usePagedList } from '@/lib/usePagedList'; +import type { + AuditLog, + InviteeDetail, + WithdrawBulkResult, + WithdrawDetail, + WithdrawListItem, + WithdrawOrder, + WithdrawSummary, + WxpayHealthCheck, +} from '@/lib/types'; +import UserRewardPanel from './UserRewardPanel'; +import { + clearUserRisk, + UserRiskModal, + type RiskUser, +} from '@/components/UserRiskModal'; + +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 = { + reviewing: 'gold', + pending: 'orange', + success: 'green', + failed: 'red', + rejected: 'default', +}; + +const STATUS_LABEL: Record = { + 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 SORT_OPTIONS = [ + { value: 'created_at', label: '申请时间' }, +]; + +function statusTag(status: string) { + return {STATUS_LABEL[status] || status}; +} + +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 = { + 'withdraw.approve': '审核通过', + 'withdraw.reject': '审核拒绝', + 'withdraw.refresh': '查单刷新', + }; + 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.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 type WithdrawReviewMode = 'invite' | 'other'; + +export function WithdrawReviewPage({ mode = 'other' }: { mode?: WithdrawReviewMode }) { + const { message, modal } = App.useApp(); + const [activeStatus, setActiveStatus] = useState('reviewing'); + const [summary, setSummary] = useState(null); + const [health, setHealth] = useState(null); + const [healthLoading, setHealthLoading] = useState(false); + const [healthCheckedAt, setHealthCheckedAt] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [rejecting, setRejecting] = useState(null); + const [bulkRejecting, setBulkRejecting] = useState([]); + const [rejectReason, setRejectReason] = useState(''); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [searchDraft, setSearchDraft] = useState(''); + const [keyword, setKeyword] = useState(''); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); + const [sortBy, setSortBy] = useState<'created_at'>('created_at'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [riskUser, setRiskUser] = useState(null); + const [inviteDetailPeriod, setInviteDetailPeriod] = useState<'all' | 'range'>('all'); + const [inviteDetailRange, setInviteDetailRange] = useState<[Dayjs, Dayjs] | null>(null); + const source = mode === 'invite' ? 'invite_cash' : 'coin_cash'; + + const filters: Record = {}; + if (activeStatus !== 'all') filters.status = activeStatus; + 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(); + filters.date_field = 'created_at'; + filters.sort_by = sortBy; + filters.sort_order = sortOrder; + const filterKey = JSON.stringify(filters); + const inviteDetailParams: Record = + inviteDetailPeriod === 'range' && inviteDetailRange?.[0] && inviteDetailRange?.[1] + ? { + date_from: inviteDetailRange[0].startOf('day').toISOString(), + date_to: inviteDetailRange[1].endOf('day').toISOString(), + } + : {}; + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/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); + setSortBy('created_at'); + setSortOrder('desc'); + setRiskUser(null); + }; + + const changeStatus = (status: string) => { + setActiveStatus(status); + }; + + const loadSummary = useCallback(async () => { + try { + const { data } = await api.get('/admin/api/withdraws/summary', { + params: { source }, + }); + setSummary(data); + } catch (e) { + message.error(errMsg(e)); + } + }, [source]); + + const loadHealth = useCallback(async (showFeedback = false) => { + setHealthLoading(true); + try { + const { data } = await api.get('/admin/api/withdraws/health-check'); + setHealth(data); + setHealthCheckedAt(Date.now()); + if (showFeedback) { + message.success(data.ok ? '检测完成,微信提现配置正常' : '检测完成,仍有配置项需要处理'); + } + } catch (e) { + message.error(errMsg(e)); + } finally { + setHealthLoading(false); + } + }, []); + + const loadDetail = useCallback(async ( + outBillNo: string, + inviteParams?: Record, + ) => { + setDetailLoading(true); + try { + const { data } = await api.get(`/admin/api/withdraws/${outBillNo}`, { + params: inviteParams, + }); + setDetail(data); + } catch (e) { + message.error(errMsg(e)); + } finally { + setDetailLoading(false); + } + }, []); + + useEffect(() => { + void loadSummary(); + void loadHealth(); + }, [loadHealth, loadSummary]); + + useEffect(() => { + setSelectedRowKeys([]); + }, [filterKey, page, pageSize]); + + const refreshAfterChange = async (outBillNo?: string) => { + setSelectedRowKeys([]); + reload(); + await Promise.all([loadSummary(), loadHealth()]); + if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) { + await loadDetail(outBillNo, inviteDetailParams); + } + }; + + const openDetail = (o: WithdrawOrder) => { + setDrawerOpen(true); + setDetail(null); + setInviteDetailPeriod('all'); + setInviteDetailRange(null); + void loadDetail(o.out_bill_no, {}); + }; + + const approve = (o: WithdrawListItem) => { + modal.confirm({ + title: `确认通过并打款 ${yuan(o.amount_cents)}?`, + content: ( +
+
手机号: {o.phone || '-'}
+
用户昵称: {o.nickname || '-'}
+
提现单号: {o.out_bill_no}
+
+ 通过后会立即发起微信转账,请确认该笔提现已经人工审核通过。 +
+
+ ), + okText: '通过并打款', + okButtonProps: { danger: true, icon: }, + onOk: async () => { + try { + await api.post(`/admin/api/withdraws/${o.out_bill_no}/approve`); + refreshReviewBadge(mode === 'invite' ? '/invite-withdraws' : '/withdraws'); + message.success('已通过审核并发起打款'); + await refreshAfterChange(o.out_bill_no); + } catch (e) { + message.error(errMsg(e)); + } + }, + }); + }; + + const confirmReject = async () => { + if (rejectSubmitting) return; + const targets = bulkRejecting.length ? bulkRejecting : rejecting ? [rejecting] : []; + if (!targets.length) return; + const trimmed = rejectReason.trim(); + if (!trimmed) { + message.warning('请填写拒绝理由'); + return; + } + setRejectSubmitting(true); + try { + if (bulkRejecting.length) { + const { data } = await api.post('/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('已拒绝并退回余额'); + } + refreshReviewBadge(mode === 'invite' ? '/invite-withdraws' : '/withdraws'); + setRejecting(null); + setBulkRejecting([]); + setRejectReason(''); + await refreshAfterChange(targets.length === 1 ? targets[0].out_bill_no : undefined); + } catch (e) { + message.error(errMsg(e)); + } finally { + setRejectSubmitting(false); + } + }; + + 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: ( + + + 合计 {yuan(amount)},通过后会逐笔发起微信打款。 + + 这是资金操作,请输入“确认打款”后继续。 + { confirmText = e.target.value.trim(); }} /> + + ), + okText: '批量通过并打款', + okButtonProps: { danger: true, icon: }, + onOk: async () => { + if (confirmText !== '确认打款') { + message.warning('请输入“确认打款”'); + return Promise.reject(new Error('confirm required')); + } + try { + const { data } = await api.post('/admin/api/withdraws/bulk/approve', { + out_bill_nos: selectedReviewing.map((item) => item.out_bill_no), + }); + refreshReviewBadge(mode === 'invite' ? '/invite-withdraws' : '/withdraws'); + 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: }, + onOk: async () => { + try { + const { data } = await api.post('/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.status === 'failed' + ? `重新查询 ${o.out_bill_no} 的微信失败原因?` + : `重新查询 ${o.out_bill_no} 的微信状态?`, + content: + o.status === 'failed' + ? '只补充微信返回的失败原因和微信转账单号,不会重复退款。' + : '会按微信最新状态归一化为成功、失败退款或撤销未确认。', + okText: o.status === 'failed' ? '刷新失败原因' : '刷新查单', + okButtonProps: { icon: }, + onOk: async () => { + try { + const { data } = await api.post( + `/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 riskTarget = (o: WithdrawListItem): RiskUser => ({ + id: o.user_id, + phone: o.phone || `用户 ${o.user_id}`, + is_high_risk: o.is_high_risk, + high_risk_note: o.high_risk_note, + }); + + const markNormalRisk = (o: WithdrawListItem) => { + modal.confirm({ + title: `确认将 ${o.phone || `用户 ${o.user_id}`} 标记为非高风险?`, + content: '确认后将清空当前高风险备注。', + onOk: async () => { + try { + await clearUserRisk(o.user_id); + message.success('已标记为非高风险'); + await refreshAfterChange(o.out_bill_no); + } catch (error) { + message.error(errMsg(error)); + } + }, + }); + }; + + const riskActions = (o: WithdrawListItem) => + o.is_high_risk ? ( + <> + + + + ) : ( + + ); + + const columns: ColumnsType = [ + { + title: '手机号', + dataIndex: 'phone', + width: 130, + render: (v: string | null) => v || -, + }, + { + title: '用户昵称', + dataIndex: 'nickname', + width: 130, + render: (v: string | null) => v || -, + }, + { + title: '用户标识', + dataIndex: 'is_high_risk', + width: 110, + render: (_: boolean, o: WithdrawListItem) => ( + + + {o.is_high_risk ? '高风险' : '非高风险'} + + + ), + }, + { + title: '提现金额', + dataIndex: 'amount_cents', + width: 120, + render: (v: number) => {yuan(v)}, + }, + { + 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) => ( + + {dt(v)} + {ago(v)} + + ), + }, + { + title: '提交单号', + dataIndex: 'out_bill_no', + ellipsis: true, + }, + { + title: '失败/拒绝原因', + dataIndex: 'fail_reason', + ellipsis: true, + render: (v: string | null) => v || -, + }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 450, + render: (_: unknown, o: WithdrawListItem) => { + if (!canManage) return -; + if (o.status === 'reviewing') { + return ( + e.stopPropagation()} + > + + + {riskActions(o)} + + ); + } + if (o.status === 'pending' || o.status === 'failed') { + return ( + e.stopPropagation()} + > + + {riskActions(o)} + + ); + } + return ( + e.stopPropagation()} + > + {riskActions(o)} + + ); + }, + }, + ]; + + const inviteeColumns: ColumnsType = [ + { title: '手机号', dataIndex: 'phone', width: 130 }, + { title: '注册时间', dataIndex: 'registered_at', width: 170, render: dt }, + { + title: '首次比价商家', + dataIndex: 'first_compare_store', + width: 150, + render: (value: string | null) => value || '-', + }, + { + title: '首次比价商品', + dataIndex: 'first_compare_products', + width: 180, + ellipsis: true, + render: (value: string | null) => value || '-', + }, + { + title: '首次下单商家', + dataIndex: 'first_order_store', + width: 150, + render: (value: string | null) => value || '-', + }, + { + title: '首次下单商品', + dataIndex: 'first_order_products', + width: 180, + ellipsis: true, + render: (value: string | null) => value || '-', + }, + { + title: '下单金额', + dataIndex: 'first_order_amount_cents', + width: 110, + render: (value: number | null) => (value == null ? '-' : yuan(value)), + }, + ]; + + const auditTimeline = detail + ? [ + { + children: ( + + 用户提交提现申请 {dt(detail.order.created_at)} + + ), + }, + ...[...detail.audit_logs].reverse().map((log: AuditLog) => ({ + children: ( + + {actionLabel(log.action)} · {log.admin_username} + {dt(log.created_at)} + + ), + })), + { + children: ( + + 当前状态 {statusTag(detail.order.status)} + {dt(detail.order.updated_at)} + + ), + }, + ] + : []; + + const risks = riskTags(detail); + const rejectTargets = bulkRejecting.length ? bulkRejecting : rejecting ? [rejecting] : []; + const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0); + const payoutReady = Boolean( + health?.wxpay_configured && health.private_key_loadable && health.public_key_loadable, + ); + const hasBlockingPayoutIssue = Boolean(health && !payoutReady); + + return ( +
+
+
+

+ {mode === 'invite' ? '邀请提现审核' : '其他提现审核'} +

+ + {mode === 'invite' + ? '针对赚钱-邀请页面的账户。提现审核通过会立即发起微信打款,拒绝会退回用户现金余额。如发现用户邀请历史里有违规记录,拒绝提现申请,将其标识为高风险用户并填写备注。' + : '针对赚钱页面的账户。审核通过会立即发起微信打款,拒绝会退回用户现金余额。'} + +
+
+ + {hasBlockingPayoutIssue && ( + + {health && hasBlockingPayoutIssue && ( + + 微信支付配置不完整,点击“通过并打款”将失败。请联系部署管理员处理。 + {healthCheckedAt && ( + + 最近检测:{dayjs(healthCheckedAt).format('HH:mm:ss')} + + )} +
+ 查看技术详情 +
    + {health.issues.map((issue) => ( +
  • + {issue} +
  • + ))} +
+
+
+ } + action={ + + } + /> + )} + + )} + + {health && payoutReady && ( + + 微信打款可用 + + )} + + +
+ + + + + + + + + + + + + { + const count = summaryCount(summary, tab.key); + return { + key: tab.key, + label: + count == null ? ( + tab.label + ) : ( + + {tab.label} + {count} + + ), + }; + })} + /> + + + } + placeholder="用户手机号" + value={searchDraft} + style={{ width: 240 }} + onChange={(e) => { + const value = e.target.value; + setSearchDraft(value); + if (!value) setKeyword(''); + }} + onSearch={(value) => setKeyword(value.trim())} + /> + setDateRange(values as [Dayjs, Dayjs] | null)} + /> + + + setSortOrder(value)} + /> + + + + {canManage && selectedOrders.length > 0 && ( + + + 已选 {selectedOrders.length} 笔,待审核 {selectedReviewing.length} 笔,打款中{' '} + {selectedPending.length} 笔 + + + + + + + + + )} +
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: 1580 }} + onRow={(record) => ({ + onClick: () => openDetail(record), + style: { cursor: 'pointer' }, + })} + /> + + + setDrawerOpen(false)} + extra={ + detail && ( + + ) + } + > + + {detail && ( + + + {statusTag(detail.order.status)} + + {yuan(detail.order.amount_cents)} + + + {detail.order.source === 'invite_cash' ? '邀请奖励提现' : '福利页提现'} + + + {yuan(detail.cumulative_success_cents)} + + + {dt(detail.order.created_at)} + + + {detail.order.wechat_state || '-'} + + + {detail.order.out_bill_no} + + {detail.order.status !== 'rejected' && ( + + {detail.order.status === 'reviewing' + ? '审核通过后生成' + : detail.order.status === 'failed' + ? detail.order.transfer_bill_no || '微信未受理' + : detail.order.transfer_bill_no || '等待微信返回'} + + )} + + {detail.order.fail_reason || '-'} + + + + {mode === 'invite' &&

邀请详情

} + {detail.user && ( + + + 用户标识 + + + {detail.user.is_high_risk ? '高风险' : '非高风险'} + + + + {canManage && + (detail.user.is_high_risk ? ( + + + + + ) : ( + + ))} + + )} + + {mode === 'invite' && ( + <> + {detail.user && ( + + + + 手机号 {detail.user.phone} + + + 昵称 {detail.user.nickname || '-'} + + + 微信昵称 {detail.user.wechat_nickname || '-'} + + + 注册天数{' '} + + {dayjs().diff(utcDayjs(detail.user.created_at), 'day')} 天 + + + + + { + const period = event.target.value as 'all' | 'range'; + setInviteDetailPeriod(period); + if (period === 'all') { + setInviteDetailRange(null); + void loadDetail(detail.order.out_bill_no, {}); + } + }} + > + 注册至今 + 自定义区间 + + { + const nextRange = values as [Dayjs, Dayjs] | null; + setInviteDetailRange(nextRange); + if (nextRange?.[0] && nextRange?.[1]) { + void loadDetail(detail.order.out_bill_no, { + date_from: nextRange[0].startOf('day').toISOString(), + date_to: nextRange[1].endOf('day').toISOString(), + }); + } + }} + /> + + + )} + +
+ + + + + + + + + + + + + + + +
+

受邀用户详情

+
+ + + )} + + {mode === 'invite' && detail.user?.is_high_risk && ( +
+

高风险备注

+ +
+ )} + + {mode === 'other' && ( + + )} + +
+

风险提示

+ {risks.length ? ( + + {risks.map((risk) => ( + + {risk} + + ))} + + ) : ( + 暂无明显风险标签 + )} +
+ +
+

处理时间线

+ +
+ + )} + + + + }} + onOk={confirmReject} + onCancel={() => { + if (rejectSubmitting) return; + setRejecting(null); + setBulkRejecting([]); + setRejectReason(''); + }} + > + {!!rejectTargets.length && ( + + + 拒绝后会把 {yuan(rejectAmount)} 退回用户现金余额。 + + + {REJECT_TEMPLATES.map((tpl) => ( + + ))} + + setRejectReason(e.target.value)} + /> + + )} + + setRiskUser(null)} + onDone={async () => { + await refreshAfterChange(detail?.order.out_bill_no); + }} + /> + + ); +} diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index 7acef8b..170ad54 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -1,985 +1,5 @@ -'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, - 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 { refreshReviewBadge } from '@/lib/reviewBadge'; -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 = { - reviewing: 'gold', - pending: 'orange', - success: 'green', - failed: 'red', - rejected: 'default', -}; - -const STATUS_LABEL: Record = { - 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 = ['账号异常', '提现金额异常', '微信实名不匹配', '风控拦截', '其他原因']; - -function statusTag(status: string) { - return {STATUS_LABEL[status] || status}; -} - -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 = { - '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}` : ''}`; -} +import { WithdrawReviewPage } from './WithdrawReviewPage'; export default function WithdrawsPage() { - const { message, modal } = App.useApp(); - const [activeStatus, setActiveStatus] = useState('reviewing'); - const [summary, setSummary] = useState(null); - const [health, setHealth] = useState(null); - const [healthLoading, setHealthLoading] = useState(false); - const [healthCheckedAt, setHealthCheckedAt] = useState(null); - const [ledger, setLedger] = useState(null); - const [ledgerLoading, setLedgerLoading] = useState(false); - const [ledgerCheckedAt, setLedgerCheckedAt] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [detail, setDetail] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [rejecting, setRejecting] = useState(null); - const [bulkRejecting, setBulkRejecting] = useState([]); - const [rejectReason, setRejectReason] = useState(''); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [searchDraft, setSearchDraft] = useState(''); - const [keyword, setKeyword] = useState(''); - const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - - const filters: Record = {}; - 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(); - filters.date_field = 'created_at'; - filters.sort_by = 'created_at'; - filters.sort_order = sortOrder; - const filterKey = JSON.stringify(filters); - const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = - usePagedList('/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); - setSortOrder('desc'); - }; - - const loadSummary = useCallback(async () => { - try { - const { data } = await api.get('/admin/api/withdraws/summary'); - setSummary(data); - } catch (e) { - message.error(errMsg(e)); - } - }, []); - - const loadHealth = useCallback(async (showFeedback = false) => { - setHealthLoading(true); - try { - const { data } = await api.get('/admin/api/withdraws/health-check'); - setHealth(data); - setHealthCheckedAt(Date.now()); - if (showFeedback) { - message.success(data.ok ? '检测完成,微信提现配置正常' : '检测完成,仍有配置项需要处理'); - } - } catch (e) { - message.error(errMsg(e)); - } finally { - setHealthLoading(false); - } - }, []); - - // 账本校验是全表对账、相对重,故只在进页时拉一次 + 提供手动按钮,不随每次写操作重算。 - const loadLedger = useCallback(async (showFeedback = false) => { - setLedgerLoading(true); - try { - const { data } = await api.get('/admin/api/withdraws/ledger-check'); - setLedger(data); - setLedgerCheckedAt(Date.now()); - if (showFeedback) { - if (data.ok) { - message.success('校验完成,现金账本正常'); - } else { - message.warning('校验完成,发现现金账本异常'); - } - } - } catch (e) { - message.error(errMsg(e)); - } finally { - setLedgerLoading(false); - } - }, []); - - const loadDetail = useCallback(async (outBillNo: string) => { - setDetailLoading(true); - try { - const { data } = await api.get(`/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: ( -
-
用户 ID: {o.user_id}
-
提现实名: {o.user_name || '-'}
-
提现单号: {o.out_bill_no}
-
- 通过后会立即发起微信转账,请确认该笔提现已经人工审核通过。 -
-
- ), - okText: '通过并打款', - okButtonProps: { danger: true, icon: }, - onOk: async () => { - try { - await api.post(`/admin/api/withdraws/${o.out_bill_no}/approve`); - refreshReviewBadge('/withdraws'); - 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('/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('已拒绝并退回余额'); - } - refreshReviewBadge('/withdraws'); - 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: ( - - - 合计 {yuan(amount)},通过后会逐笔发起微信打款。 - - 这是资金操作,请输入“确认打款”后继续。 - { confirmText = e.target.value.trim(); }} /> - - ), - okText: '批量通过并打款', - okButtonProps: { danger: true, icon: }, - onOk: async () => { - if (confirmText !== '确认打款') { - message.warning('请输入“确认打款”'); - return Promise.reject(new Error('confirm required')); - } - try { - const { data } = await api.post('/admin/api/withdraws/bulk/approve', { - out_bill_nos: selectedReviewing.map((item) => item.out_bill_no), - }); - refreshReviewBadge('/withdraws'); - 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: }, - onOk: async () => { - try { - const { data } = await api.post('/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: }, - onOk: async () => { - try { - const { data } = await api.post( - `/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 = [ - { - title: '手机号', - dataIndex: 'phone', - width: 130, - render: (v: string | null) => v || -, - }, - { - title: '用户昵称', - dataIndex: 'nickname', - width: 130, - render: (v: string | null) => v || -, - }, - { - title: '提现金额', - dataIndex: 'amount_cents', - width: 120, - render: (v: number) => {yuan(v)}, - }, - { - 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) => ( - - {dt(v)} - {ago(v)} - - ), - }, - { - title: '提交单号', - dataIndex: 'out_bill_no', - ellipsis: true, - }, - { - title: '失败/拒绝原因', - dataIndex: 'fail_reason', - ellipsis: true, - render: (v: string | null) => v || -, - }, - { - title: '操作', - key: 'op', - fixed: 'right', - width: 210, - render: (_: unknown, o: WithdrawListItem) => { - if (!canManage) return -; - if (o.status === 'reviewing') { - return ( - e.stopPropagation()}> - - - - ); - } - if (o.status === 'pending') { - return ( - - ); - } - return -; - }, - }, - ]; - - const auditTimeline = detail - ? [ - { - children: ( - - 用户提交提现申请 {dt(detail.order.created_at)} - - ), - }, - ...[...detail.audit_logs].reverse().map((log: AuditLog) => ({ - children: ( - - {actionLabel(log.action)} · {log.admin_username} - {dt(log.created_at)} - - ), - })), - { - children: ( - - 当前状态 {statusTag(detail.order.status)} - {dt(detail.order.updated_at)} - - ), - }, - ] - : []; - - 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) - : []; - const payoutReady = Boolean( - health?.wxpay_configured && health.private_key_loadable && health.public_key_loadable, - ); - const hasBlockingPayoutIssue = Boolean(health && !payoutReady); - - return ( -
- -
-

提现审核

- - 针对赚钱页面的账户。审核通过会立即发起微信提现,拒绝会退回用户现金余额。 - -
- {canManage && ( - - )} -
- - {(hasBlockingPayoutIssue || (ledger && !ledger.ok)) && ( - - {health && hasBlockingPayoutIssue && ( - - 微信支付配置不完整,点击“通过并打款”将失败。请联系部署管理员处理。 - {healthCheckedAt && ( - - 最近检测:{dayjs(healthCheckedAt).format('HH:mm:ss')} - - )} -
- 查看技术详情 -
    - {health.issues.map((issue) => ( -
  • - {issue} -
  • - ))} -
-
-
- } - action={ - - } - /> - )} - {ledger && !ledger.ok && ( - } - loading={ledgerLoading} - onClick={() => void loadLedger(true)} - > - 重新校验 - - } - /> - )} - - )} - - {(health || ledger) && ( - - {health && payoutReady && 微信打款可用} - {ledger?.ok && ( - - - 账本正常 - {ledgerCheckedAt && ( - - · {dayjs(ledgerCheckedAt).format('HH:mm:ss')} 校验 - - )} - - - )} - - )} - - -
- - - - - - - - - - - - - { - const count = summaryCount(summary, tab.key); - return { - key: tab.key, - label: - count == null ? ( - tab.label - ) : ( - - {tab.label} - {count} - - ), - }; - })} - /> - - } - placeholder="用户手机号" - value={searchDraft} - style={{ width: 240 }} - onChange={(e) => { - const value = e.target.value; - setSearchDraft(value); - if (!value) setKeyword(''); - }} - onSearch={(value) => setKeyword(value.trim())} - /> - setDateRange(values as [Dayjs, Dayjs] | null)} - /> -
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: 1350 }} - onRow={(record) => ({ - onClick: () => openDetail(record), - style: { cursor: 'pointer' }, - })} - /> - - - setDrawerOpen(false)} - extra={ - detail && ( - - ) - } - > - - {detail && ( - - - {statusTag(detail.order.status)} - - {yuan(detail.order.amount_cents)} - - - {detail.order.user_name || '-'} - - - {detail.order.wechat_state || '-'} - - - {detail.order.out_bill_no} - - - {detail.order.transfer_bill_no || '-'} - - - {detail.order.fail_reason || '-'} - - - - - -
-

风险提示

- {risks.length ? ( - - {risks.map((risk) => ( - - {risk} - - ))} - - ) : ( - 暂无明显风险标签 - )} -
- -
-

处理时间线

- -
-
- )} -
-
- - }} - onOk={confirmReject} - onCancel={() => { - setRejecting(null); - setBulkRejecting([]); - setRejectReason(''); - }} - > - {!!rejectTargets.length && ( - - - 拒绝后会把 {yuan(rejectAmount)} 退回用户现金余额。 - - - {REJECT_TEMPLATES.map((tpl) => ( - - ))} - - setRejectReason(e.target.value)} - /> - - )} - - - ); + return ; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 1e182e7..2babb5e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,26 @@ 'use client'; import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; import { getToken } from '@/lib/auth'; export default function Home() { - const router = useRouter(); useEffect(() => { - router.replace(getToken() ? '/dashboard' : '/login'); - }, [router]); - return null; + window.location.replace(getToken() ? '/dashboard' : '/login'); + }, []); + + return ( +
+ 正在进入运营后台… +
+ ); } diff --git a/src/components/UserRiskModal.tsx b/src/components/UserRiskModal.tsx new file mode 100644 index 0000000..8ac5bc5 --- /dev/null +++ b/src/components/UserRiskModal.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { App, Input, Modal } from 'antd'; +import { api, errMsg } from '@/lib/api'; + +export type RiskUser = { + id: number; + phone: string; + is_high_risk: boolean; + high_risk_note: string | null; +}; + +type Props = { + user: RiskUser | null; + open: boolean; + onClose: () => void; + onDone: () => void | Promise; +}; + +export function UserRiskModal({ user, open, onClose, onDone }: Props) { + const { message } = App.useApp(); + const [note, setNote] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) setNote(user?.high_risk_note ?? ''); + }, [open, user]); + + const save = async () => { + if (!user) return; + const trimmed = note.trim(); + if (!trimmed) { + message.warning('请输入标记为高风险的原因'); + return; + } + setSaving(true); + try { + await api.post(`/admin/api/users/${user.id}/risk`, { + is_high_risk: true, + note: trimmed, + }); + message.success(user.is_high_risk ? '高风险备注已更新' : '已标记为高风险'); + await onDone(); + onClose(); + } catch (error) { + message.error(errMsg(error)); + } finally { + setSaving(false); + } + }; + + return ( + + setNote(event.target.value)} + /> + + ); +} + +export async function clearUserRisk(userId: number) { + await api.post(`/admin/api/users/${userId}/risk`, { + is_high_risk: false, + note: null, + }); +} diff --git a/src/lib/reviewBadge.ts b/src/lib/reviewBadge.ts index 78e95d4..d6d04c0 100644 --- a/src/lib/reviewBadge.ts +++ b/src/lib/reviewBadge.ts @@ -1,4 +1,8 @@ -export type ReviewBadgeKey = '/withdraws' | '/price-reports' | '/feedbacks'; +export type ReviewBadgeKey = + | '/invite-withdraws' + | '/withdraws' + | '/price-reports' + | '/feedbacks'; export const REVIEW_BADGE_REFRESH_EVENT = 'review-badge-refresh'; diff --git a/src/lib/types.ts b/src/lib/types.ts index 26d8d95..e11b6a6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,6 +52,8 @@ export interface UserListItem { status: string; // 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接 debug_trace_enabled: boolean; + is_high_risk: boolean; + high_risk_note: string | null; // 运营「一键开启新手引导」:true = 该用户下次打开 App 会被强制重走新手引导,走完自动取消 force_onboarding: boolean; wechat_openid: string | null; @@ -143,7 +145,6 @@ export interface WithdrawOrder { amount_cents: number; // 提现类型:coin_cash(福利页提现,金币兑换现金) / invite_cash(邀请提现,邀请奖励金) source: string; - user_name: string | null; status: string; // reviewing / pending / success / failed / rejected wechat_state: string | null; transfer_bill_no: string | null; @@ -157,6 +158,8 @@ export interface WithdrawOrder { export interface WithdrawListItem extends WithdrawOrder { phone: string | null; nickname: string | null; + is_high_risk: boolean; + high_risk_note: string | null; cumulative_success_cents: number; // 累计成功提现(分) } @@ -174,6 +177,8 @@ export interface WithdrawUserSnapshot { id: number; phone: string; nickname: string | null; + is_high_risk: boolean; + high_risk_note: string | null; status: string; wechat_nickname: string | null; wechat_avatar_url: string | null; @@ -184,14 +189,34 @@ export interface WithdrawUserSnapshot { withdraw_success_cents: number; } +export interface InviteeDetail { + user_id: number; + phone: string; + registered_at: string; + invite_success: boolean; + first_compare_store: string | null; + first_compare_products: string | null; + first_order_store: string | null; + first_order_products: string | null; + first_order_amount_cents: number | null; +} + +export interface InviteOverview { + invite_total: number; + invite_success_total: number; + items: InviteeDetail[]; +} + export interface WithdrawDetail { order: WithdrawOrder; user: WithdrawUserSnapshot | null; + cumulative_success_cents: number; risk_flags: string[]; risk_score: number; recent_withdraws: WithdrawOrder[]; recent_cash_transactions: CashTxn[]; audit_logs: AuditLog[]; + invite_overview: InviteOverview | null; } // 提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。 @@ -318,26 +343,6 @@ export interface WithdrawBulkResult { items: WithdrawBulkItemResult[]; } -export interface WithdrawLedgerCheck { - ok: boolean; - // 普通现金账(coin_cash) - cash_balance_total_cents: number; - cash_transaction_total_cents: number; - balance_diff_cents: number; - missing_withdraw_txn_count: number; - missing_refund_txn_count: number; - duplicate_refund_txn_count: number; - refund_txn_on_non_terminal_count: number; - // 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账) - invite_cash_balance_total_cents: number; - invite_cash_transaction_total_cents: number; - invite_balance_diff_cents: number; - invite_missing_withdraw_txn_count: number; - invite_missing_refund_txn_count: number; - invite_duplicate_refund_txn_count: number; - invite_refund_txn_on_non_terminal_count: number; -} - export interface WxpayHealthCheck { ok: boolean; wxpay_configured: boolean;