diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index fc85551..b869749 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -1,35 +1,447 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { Key } from 'react'; +import { + Alert, + Button, + Card, + Col, + DatePicker, + Descriptions, + Drawer, + Input, + Modal, + Row, + Select, + Space, + Spin, + Statistic, + Table, + Tabs, + Tag, + Timeline, + Typography, + message, +} from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Modal, Select, Space, Table, Tag, message } from 'antd'; +import { + CheckCircleOutlined, + ClearOutlined, + CloseCircleOutlined, + FilterOutlined, + ReloadOutlined, + SearchOutlined, + SortAscendingOutlined, + SyncOutlined, +} from '@ant-design/icons'; +import dayjs, { type Dayjs } from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/zh-cn'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { useCursorList } from '@/lib/useCursorList'; -import type { WithdrawOrder } from '@/lib/types'; +import type { + AuditLog, + CashTxn, + WithdrawBulkResult, + WithdrawDetail, + WithdrawLedgerCheck, + WithdrawOrder, + WithdrawSummary, + WxpayHealthCheck, +} from '@/lib/types'; + +dayjs.extend(relativeTime); +dayjs.locale('zh-cn'); + +const { Text } = Typography; +const { RangePicker } = DatePicker; const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); -const STATUS_COLOR: Record = { pending: 'orange', success: 'green', failed: 'red' }; +const apiTime = (v: string) => { + const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v); + return dayjs(hasTimezone ? v : `${v}Z`); +}; +const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm'); +const ago = (v: string) => apiTime(v).fromNow(); + +const STATUS_COLOR: Record = { + 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 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 = { + abnormal: 'all', + failed: 'failed', + overdue_reviewing: 'reviewing', + pending_overdue: 'pending', + high_risk: 'all', +}; +const SORT_OPTIONS = [ + { value: 'created_at', label: '申请时间' }, + { value: 'updated_at', label: '更新时间' }, + { value: 'amount_cents', label: '提现金额' }, + { value: 'id', label: '记录 ID' }, +]; +const DATE_FIELD_OPTIONS = [ + { value: 'created_at', label: '申请时间' }, + { value: 'updated_at', label: '更新时间' }, +]; +const CASH_BIZ_LABEL: Record = { + exchange_in: '金币兑入', + withdraw: '提现扣款', + withdraw_refund: '提现退款', +}; + +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(apiTime(detail.user.created_at), 'hour') < 24) tags.push('新注册用户'); + if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户'); + if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) { + tags.push('历史异常单'); + } + return tags; +} + +function bulkResultText(action: string, result: WithdrawBulkResult) { + const firstError = result.items.find((item) => !item.ok)?.error; + return `${action}: 成功 ${result.success} / ${result.total} 笔${ + result.failed ? `,失败 ${result.failed} 笔` : '' + }${firstError ? `,首个失败原因: ${firstError}` : ''}`; +} export default function WithdrawsPage() { - const [status, setStatus] = useState(); - const [filters, setFilters] = useState>({}); + const [activeStatus, setActiveStatus] = useState('reviewing'); + const [summary, setSummary] = useState(null); + const [health, setHealth] = useState(null); + const [ledger, setLedger] = 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 [pageSize, setPageSize] = useState(20); + const [searchDraft, setSearchDraft] = useState(''); + const [keyword, setKeyword] = useState(''); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); + const [dateField, setDateField] = useState<'created_at' | 'updated_at'>('created_at'); + const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'amount_cents' | 'id'>( + 'created_at', + ); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [quickFilter, setQuickFilter] = useState(undefined); + + 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(); + if (quickFilter) filters.quick_filter = quickFilter; + filters.date_field = dateField; + filters.sort_by = sortBy; + filters.sort_order = sortOrder; + const filterKey = JSON.stringify(filters); const { items, nextCursor, loading, loadMore, reload } = useCursorList( '/admin/api/withdraws', filters, + pageSize, ); const canManage = canDo(['finance']); + const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id)); + const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing'); + const selectedPending = selectedOrders.filter((item) => item.status === 'pending'); + + const resetFilters = () => { + setActiveStatus('reviewing'); + setSearchDraft(''); + setKeyword(''); + setDateRange(null); + setDateField('created_at'); + setSortBy('created_at'); + setSortOrder('desc'); + setQuickFilter(undefined); + }; + + const applyQuickFilter = (value?: string) => { + setQuickFilter(value); + const targetStatus = value ? QUICK_FILTER_STATUS[value] : undefined; + if (targetStatus) setActiveStatus(targetStatus); + }; + + const changeStatus = (status: string) => { + setActiveStatus(status); + setQuickFilter((current) => { + if (!current) return current; + const targetStatus = QUICK_FILTER_STATUS[current]; + if (!targetStatus || status === targetStatus) return current; + return undefined; + }); + }; + + const loadSummary = useCallback(async () => { + try { + const { data } = await api.get('/admin/api/withdraws/summary'); + setSummary(data); + } catch (e) { + message.error(errMsg(e)); + } + }, []); + + const loadChecks = useCallback(async () => { + try { + const [{ data: healthData }, { data: ledgerData }] = await Promise.all([ + api.get('/admin/api/withdraws/health-check'), + api.get('/admin/api/withdraws/ledger-check'), + ]); + setHealth(healthData); + setLedger(ledgerData); + } catch (e) { + message.error(errMsg(e)); + } + }, []); + + const loadDetail = useCallback(async (outBillNo: string) => { + setDetailLoading(true); + try { + const { data } = await api.get(`/admin/api/withdraws/${outBillNo}`); + setDetail(data); + } catch (e) { + message.error(errMsg(e)); + } finally { + setDetailLoading(false); + } + }, []); + + useEffect(() => { + void loadSummary(); + void loadChecks(); + }, [loadChecks, loadSummary]); + + useEffect(() => { + setSelectedRowKeys([]); + }, [filterKey, pageSize]); + + const refreshAfterChange = async (outBillNo?: string) => { + setSelectedRowKeys([]); + reload(); + await Promise.all([loadSummary(), loadChecks()]); + if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) { + await loadDetail(outBillNo); + } + }; + + const openDetail = (o: WithdrawOrder) => { + setDrawerOpen(true); + setDetail(null); + void loadDetail(o.out_bill_no); + }; + + const approve = (o: WithdrawOrder) => { + Modal.confirm({ + title: `确认通过并打款 ${yuan(o.amount_cents)}?`, + content: ( +
+
用户 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`); + 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('已拒绝并退回余额'); + } + 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), + }); + 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: '会按微信最新状态归一化(成功/退款/撤销未确认)。', + title: `重新查询 ${o.out_bill_no} 的微信状态?`, + content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。', + okText: '刷新查单', + okButtonProps: { icon: }, onOk: async () => { try { - await api.post(`/admin/api/withdraws/${o.out_bill_no}/refresh`); - message.success('已查询'); - reload(); + 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)); } @@ -40,14 +452,15 @@ export default function WithdrawsPage() { const reconcile = () => { Modal.confirm({ title: '批量对账?', - content: '扫描超过 15 分钟仍 pending 的提现单,逐单向微信查实并归一化。', + 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} 单`); - reload(); + message.success(`对账完成: 检查 ${data.checked} 单,解决 ${data.resolved} 单`); + await refreshAfterChange(); } catch (e) { message.error(errMsg(e)); } @@ -56,70 +469,606 @@ export default function WithdrawsPage() { }; const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, - { title: '用户', dataIndex: 'user_id', width: 80 }, - { title: '金额', dataIndex: 'amount_cents', render: yuan, width: 100 }, + { + title: '用户', + dataIndex: 'user_id', + width: 110, + render: (v: number) => #{v}, + }, + { + title: '提现实名', + dataIndex: 'user_name', + width: 130, + render: (v: string | null) => v || 未填写, + }, + { + title: '金额', + dataIndex: 'amount_cents', + width: 120, + render: (v: number) => {yuan(v)}, + }, { title: '状态', dataIndex: 'status', - width: 90, - render: (s: string) => {s}, + width: 110, + render: statusTag, + }, + { + title: '微信状态', + dataIndex: 'wechat_state', + width: 150, + render: (v: string | null) => v || -, + }, + { + 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: '微信状态', dataIndex: 'wechat_state', render: (v: string | null) => v || '-', width: 130 }, - { title: '单号', dataIndex: 'out_bill_no' }, - { title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' }, - { title: '时间', dataIndex: 'created_at', render: dt, width: 180 }, { title: '操作', key: 'op', - width: 100, - render: (_: unknown, o: WithdrawOrder) => - canManage && o.status === 'pending' ? ( - retry(o)}>重试查单 - ) : ( - - - ), + fixed: 'right', + width: 210, + render: (_: unknown, o: WithdrawOrder) => { + if (!canManage) return -; + if (o.status === 'reviewing') { + return ( + e.stopPropagation()}> + + + + ); + } + if (o.status === 'pending') { + return ( + + ); + } + return -; + }, }, ]; + const cashColumns: ColumnsType = [ + { + title: '类型', + dataIndex: 'biz_type', + width: 130, + render: (v: string) => CASH_BIZ_LABEL[v] || v, + }, + { + title: '金额', + dataIndex: 'amount_cents', + width: 100, + render: (v: number) => ( + + {v > 0 ? '+' : ''} + {yuan(v)} + + ), + }, + { title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan }, + { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' }, + { title: '时间', dataIndex: 'created_at', width: 150, render: dt }, + ]; + + const recentWithdrawColumns: ColumnsType = [ + { title: '单号', dataIndex: 'out_bill_no', ellipsis: true }, + { title: '金额', dataIndex: 'amount_cents', width: 90, render: yuan }, + { title: '状态', dataIndex: 'status', width: 90, render: statusTag }, + { title: '时间', dataIndex: 'created_at', width: 150, render: dt }, + ]; + + const auditTimeline = detail + ? [ + { + children: ( + + 用户提交提现申请 {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 + ? [ + 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 (
-

提现管理

- - } + options={QUICK_FILTER_OPTIONS} + onChange={applyQuickFilter} + /> + } + options={SORT_OPTIONS} + onChange={(value: 'created_at' | 'updated_at' | 'amount_cents' | 'id') => + setSortBy(value) + } + /> + setPageSize(value)} + options={[ + { value: 20, label: '20 条' }, + { value: 50, label: '50 条' }, + { value: 100, label: '100 条' }, + ]} + /> + + + setSelectedRowKeys(keys), + getCheckboxProps: (record) => ({ + disabled: record.status !== 'reviewing' && record.status !== 'pending', + }), + } + : undefined + } + columns={columns} + dataSource={items} + loading={loading} + pagination={false} + scroll={{ x: 1350 }} + onRow={(record) => ({ + onClick: () => openDetail(record), + style: { cursor: 'pointer' }, + })} + /> +
+ +
+ + + 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 || '-'} + + + + + #{detail.order.user_id} + + {detail.user ? {detail.user.status} : '-'} + + {detail.user?.phone || '-'} + {detail.user?.nickname || '-'} + + {detail.user?.wechat_nickname || '-'} + + + {yuan(detail.user?.cash_balance_cents || 0)} + + + {detail.user?.withdraw_total ?? '-'} + + + {yuan(detail.user?.withdraw_success_cents || 0)} + + + +
+

风险提示 {detail.risk_score ? {detail.risk_score}分 : null}

+ {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)} + /> + + )} + ); } diff --git a/src/lib/types.ts b/src/lib/types.ts index e1d76f4..6ff6cd0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -71,7 +71,8 @@ export interface WithdrawOrder { user_id: number; out_bill_no: string; amount_cents: number; - status: string; // pending / success / failed + user_name: string | null; + status: string; // reviewing / pending / success / failed / rejected wechat_state: string | null; transfer_bill_no: string | null; fail_reason: string | null; @@ -79,6 +80,82 @@ export interface WithdrawOrder { updated_at: string; } +export interface WithdrawSummary { + reviewing_count: number; + reviewing_amount_cents: number; + pending_count: number; + failed_count: number; + today_success_count: number; + today_success_amount_cents: number; + today_rejected_count: number; +} + +export interface WithdrawUserSnapshot { + id: number; + phone: string; + nickname: string | null; + status: string; + wechat_nickname: string | null; + wechat_avatar_url: string | null; + created_at: string; + last_login_at: string; + cash_balance_cents: number; + withdraw_total: number; + withdraw_success_cents: number; +} + +export interface WithdrawDetail { + order: WithdrawOrder; + user: WithdrawUserSnapshot | null; + risk_flags: string[]; + risk_score: number; + recent_withdraws: WithdrawOrder[]; + recent_cash_transactions: CashTxn[]; + audit_logs: AuditLog[]; +} + +export interface WithdrawBulkItemResult { + out_bill_no: string; + ok: boolean; + status: string | null; + error: string | null; +} + +export interface WithdrawBulkResult { + total: number; + success: number; + failed: number; + items: WithdrawBulkItemResult[]; +} + +export interface WithdrawLedgerCheck { + ok: boolean; + 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; +} + +export interface WxpayHealthCheck { + ok: boolean; + wxpay_configured: boolean; + wxpay_auth_configured: boolean; + private_key_path: string; + private_key_exists: boolean; + private_key_loadable: boolean; + public_key_path: string; + public_key_exists: boolean; + public_key_loadable: boolean; + auth_notify_url_configured: boolean; + auto_reconcile_enabled: boolean; + auto_reconcile_interval_sec: number; + auto_reconcile_older_than_minutes: number; + issues: string[]; +} + export interface Feedback { id: number; user_id: number; diff --git a/src/lib/useCursorList.ts b/src/lib/useCursorList.ts index 290f057..78ce78d 100644 --- a/src/lib/useCursorList.ts +++ b/src/lib/useCursorList.ts @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from './api'; import type { CursorPage } from './types'; @@ -12,26 +12,32 @@ export function useCursorList(url: string, filters: Record, const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [loading, setLoading] = useState(false); + const requestSeq = useRef(0); const filtersKey = JSON.stringify(filters); const load = useCallback( async (cursor: number | null) => { + const seq = requestSeq.current + 1; + requestSeq.current = seq; setLoading(true); try { const params: Record = { ...JSON.parse(filtersKey), limit }; if (cursor != null) params.cursor = cursor; const { data } = await api.get>(url, { params }); + if (requestSeq.current !== seq) return; setItems((prev) => (cursor == null ? data.items : [...prev, ...data.items])); setNextCursor(data.next_cursor); } finally { - setLoading(false); + if (requestSeq.current === seq) setLoading(false); } }, [url, filtersKey, limit], ); useEffect(() => { + setItems([]); + setNextCursor(null); load(null); }, [load]);