更新提现和订单管理逻辑,新增用户佣金信息展示,优化提现审批流程以防止超额提现风险。同时,调整提现页面显示用户佣金详情,提升用户体验。重构API以支持新字段,确保数据一致性和准确性。

This commit is contained in:
乘风
2026-02-06 12:29:56 +08:00
parent f73c2b51ed
commit 678bf297aa
36 changed files with 5021 additions and 3089 deletions

View File

@@ -51,15 +51,31 @@ interface Binding {
interface Withdrawal {
id: string
user_id: string
user_name?: string
userId?: string
user_id?: string // 兼容旧格式
userNickname?: string
user_name?: string // 兼容旧格式
userPhone?: string
userAvatar?: string
referralCode?: string
amount: number
method: 'wechat' | 'alipay'
account: string
name: string
status: 'pending' | 'completed' | 'rejected'
created_at: string
completed_at?: string
method?: 'wechat' | 'alipay'
account?: string
name?: string
status: 'pending' | 'success' | 'failed' | 'completed' | 'rejected' // 支持数据库和前端状态
wechatOpenid?: string
transactionId?: string
errorMessage?: string
createdAt?: string
created_at?: string // 兼容旧格式
processedAt?: string
completed_at?: string // 兼容旧格式
userCommissionInfo?: {
totalCommission: number
withdrawnEarnings: number
pendingWithdrawals: number
availableAfterThis: number
}
}
interface User {
@@ -81,14 +97,20 @@ interface Order {
userId: string
userNickname?: string
userPhone?: string
type: 'section' | 'fullbook' | 'match'
sectionId?: string
sectionTitle?: string
productType: 'section' | 'fullbook' | 'match' // API 返回的字段名
type?: 'section' | 'fullbook' | 'match' // 兼容旧字段名
productId?: string
sectionId?: string // 兼容旧字段名
bookName?: string // 书名
chapterTitle?: string // 章标题
sectionTitle?: string // 节标题
amount: number
status: 'pending' | 'completed' | 'failed'
status: 'pending' | 'completed' | 'failed' | 'paid' | 'created' // 增加更多状态
paymentMethod?: string
referrerEarnings?: number
referrerId?: string | null
referrerNickname?: string | null // 推荐人昵称
referrerCode?: string | null // 推荐码
/** 下单时记录的邀请码(订单表 referral_code */
referralCode?: string | null
createdAt: string
@@ -105,102 +127,207 @@ export default function DistributionAdminPage() {
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 标记哪些数据已加载
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
// 初次加载:加载概览和用户数据
useEffect(() => {
loadData()
loadInitialData()
}, [])
// 切换tab按需加载对应tab的数据
useEffect(() => {
loadTabData(activeTab)
}, [activeTab])
const loadData = async () => {
setLoading(true)
// 初始加载:概览 + 用户数据
const loadInitialData = async () => {
console.log('[Admin] 加载初始数据...')
// 加载概览数据
try {
// === 1. 加载概览数据(新接口:从真实数据库统计) ===
const overviewRes = await fetch('/api/admin/distribution/overview')
const overviewData = await overviewRes.json()
if (overviewData.success && overviewData.overview) {
setOverview(overviewData.overview)
console.log('[Admin] 概览数据加载成功:', overviewData.overview)
} else {
console.error('[Admin] 加载概览数据失败:', overviewData.error)
if (overviewRes.ok) {
const overviewData = await overviewRes.json()
if (overviewData.success && overviewData.overview) {
setOverview(overviewData.overview)
console.log('[Admin] 概览数据加载成功')
}
}
// === 2. 加载用户数据 ===
const usersRes = await fetch('/api/db/users')
const usersData = await usersRes.json()
const usersArr = usersData.users || []
setUsers(usersArr)
// 加载订单数据
const ordersRes = await fetch('/api/orders')
const ordersData = await ordersRes.json()
if (ordersData.success && ordersData.orders) {
// 补充用户信息与推荐人信息
const enrichedOrders = ordersData.orders.map((order: Order) => {
const user = usersArr.find((u: User) => u.id === order.userId)
const referrer = order.referrerId
? usersArr.find((u: User) => u.id === order.referrerId)
: null
return {
...order,
userNickname: user?.nickname || '未知用户',
userPhone: user?.phone || '-',
referrerNickname: referrer?.nickname || null,
referrerCode: referrer?.referral_code || null,
}
})
setOrders(enrichedOrders)
}
// 加载绑定数据
const bindingsRes = await fetch('/api/db/distribution')
const bindingsData = await bindingsRes.json()
setBindings(bindingsData.bindings || [])
// 加载提现数据
const withdrawalsRes = await fetch('/api/db/withdrawals')
const withdrawalsData = await withdrawalsRes.json()
setWithdrawals(withdrawalsData.withdrawals || [])
// 注意:概览数据现在从 /api/admin/distribution/overview 直接获取,不再前端计算
} catch (error) {
console.error('Load distribution data error:', error)
// 如果加载失败,设置空数据
setOverview({
todayClicks: 0,
todayBindings: 0,
todayConversions: 0,
todayEarnings: 0,
monthClicks: 0,
monthBindings: 0,
monthConversions: 0,
monthEarnings: 0,
totalClicks: 0,
totalBindings: 0,
totalConversions: 0,
totalEarnings: 0,
expiringBindings: 0,
pendingWithdrawals: 0,
pendingWithdrawAmount: 0,
conversionRate: '0',
totalDistributors: 0,
activeDistributors: 0,
})
console.error('[Admin] 概览接口异常:', error)
}
// 加载用户数据多个tab都需要用到
try {
const usersRes = await fetch('/api/db/users')
if (usersRes.ok) {
const usersData = await usersRes.json()
setUsers(usersData.users || [])
console.log('[Admin] 用户数据加载成功')
}
} catch (error) {
console.error('[Admin] 用户数据加载失败:', error)
}
}
// 按需加载tab数据
const loadTabData = async (tab: string) => {
// 如果已加载过且不是刷新操作,跳过
if (loadedTabs.has(tab)) {
console.log(`[Admin] ${tab} 数据已缓存,跳过加载`)
return
}
setLoading(true)
console.log(`[Admin] 加载 ${tab} 数据...`)
try {
const usersArr = users // 使用已加载的用户数据
// 根据不同tab加载对应数据
switch (tab) {
case 'overview':
// 概览tab不需要加载额外数据已在初始化时加载
break
case 'orders':
// 加载订单数据
try {
const ordersRes = await fetch('/api/orders')
if (!ordersRes.ok) {
console.error('[Admin] 订单接口错误:', ordersRes.status)
setOrders([])
} else {
const ordersData = await ordersRes.json()
if (ordersData.success && ordersData.orders) {
const enrichedOrders = ordersData.orders.map((order: Order) => {
const user = usersArr.find((u: User) => u.id === order.userId)
const referrer = order.referrerId
? usersArr.find((u: User) => u.id === order.referrerId)
: null
return {
...order,
amount: parseFloat(order.amount as any) || 0,
userNickname: user?.nickname || order.userNickname || '未知用户',
userPhone: user?.phone || order.userPhone || '-',
referrerNickname: referrer?.nickname || null,
referrerCode: referrer?.referral_code || null,
type: order.productType || order.type,
}
})
setOrders(enrichedOrders)
console.log('[Admin] 订单数据加载成功:', enrichedOrders.length, '条')
} else {
setOrders([])
}
}
} catch (error) {
console.error('[Admin] 加载订单数据失败:', error)
setOrders([])
}
break
case 'bindings':
// 加载绑定数据
try {
const bindingsRes = await fetch('/api/db/distribution')
if (bindingsRes.ok) {
const bindingsData = await bindingsRes.json()
setBindings(bindingsData.bindings || [])
console.log('[Admin] 绑定数据加载成功:', bindingsData.bindings?.length || 0, '条')
} else {
setBindings([])
}
} catch (error) {
console.error('[Admin] 加载绑定数据失败:', error)
setBindings([])
}
break
case 'withdrawals':
// 加载提现数据
try {
console.log('[Admin] 请求提现数据...')
const withdrawalsRes = await fetch('/api/admin/withdrawals')
console.log('[Admin] 提现接口响应状态:', withdrawalsRes.status, withdrawalsRes.statusText)
if (withdrawalsRes.ok) {
const withdrawalsData = await withdrawalsRes.json()
console.log('[Admin] 提现接口返回数据:', withdrawalsData)
if (withdrawalsData.success) {
// 数据映射:统一字段名
const formattedWithdrawals = (withdrawalsData.withdrawals || []).map((w: any) => ({
...w,
user_id: w.userId || w.user_id,
user_name: w.userNickname || w.user_name,
created_at: w.createdAt || w.created_at,
completed_at: w.processedAt || w.completed_at,
// 状态统一(数据库用 success/failed前端显示用 completed/rejected
status: w.status === 'success' ? 'completed' : (w.status === 'failed' ? 'rejected' : w.status)
}))
setWithdrawals(formattedWithdrawals)
console.log('[Admin] 提现数据加载成功:', formattedWithdrawals.length, '条')
} else {
console.error('[Admin] 提现接口返回失败:', withdrawalsData.error || withdrawalsData.message)
alert(`获取提现记录失败: ${withdrawalsData.error || withdrawalsData.message || '未知错误'}`)
setWithdrawals([])
}
} else {
// HTTP 错误
const errorText = await withdrawalsRes.text()
console.error('[Admin] 提现接口HTTP错误:', withdrawalsRes.status, errorText)
try {
const errorData = JSON.parse(errorText)
alert(`获取提现记录失败 (${withdrawalsRes.status}): ${errorData.error || errorData.message || '服务器错误'}`)
} catch {
alert(`获取提现记录失败 (${withdrawalsRes.status}): ${errorText || '服务器错误'}`)
}
setWithdrawals([])
}
} catch (error: any) {
console.error('[Admin] 加载提现数据异常:', error)
alert(`加载提现数据失败: ${error.message || '网络错误'}`)
setWithdrawals([])
}
break
}
// 标记该tab已加载
setLoadedTabs(prev => new Set(prev).add(tab))
} catch (error) {
console.error(`[Admin] 加载 ${tab} 数据失败:`, error)
} finally {
setLoading(false)
}
}
// 处理提现审核
const refreshCurrentTab = () => {
setLoadedTabs((prev) => {
const newSet = new Set(prev)
newSet.delete(activeTab)
return newSet
})
if (activeTab === 'overview') {
loadInitialData()
}
loadTabData(activeTab)
}
const handleApproveWithdrawal = async (id: string) => {
if (!confirm('确认审核通过并打款?')) return
try {
await fetch('/api/db/withdrawals', {
await fetch('/api/admin/withdrawals', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status: 'completed' })
body: JSON.stringify({ id, action: 'approve' })
})
loadData()
refreshCurrentTab()
} catch (error) {
console.error('Approve withdrawal error:', error)
alert('操作失败')
@@ -212,12 +339,12 @@ export default function DistributionAdminPage() {
if (!reason) return
try {
await fetch('/api/db/withdrawals', {
await fetch('/api/admin/withdrawals', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status: 'rejected' })
body: JSON.stringify({ id, action: 'reject', errorMessage: reason })
})
loadData()
refreshCurrentTab()
} catch (error) {
console.error('Reject withdrawal error:', error)
alert('操作失败')
@@ -232,6 +359,7 @@ export default function DistributionAdminPage() {
expired: 'bg-gray-500/20 text-gray-400',
cancelled: 'bg-red-500/20 text-red-400',
pending: 'bg-orange-500/20 text-orange-400',
processing: 'bg-blue-500/20 text-blue-400',
completed: 'bg-green-500/20 text-green-400',
rejected: 'bg-red-500/20 text-red-400',
}
@@ -242,6 +370,7 @@ export default function DistributionAdminPage() {
expired: '已过期',
cancelled: '已取消',
pending: '待审核',
processing: '处理中',
completed: '已完成',
rejected: '已拒绝',
}
@@ -290,7 +419,7 @@ export default function DistributionAdminPage() {
<p className="text-gray-400 mt-1"></p>
</div>
<Button
onClick={loadData}
onClick={refreshCurrentTab}
disabled={loading}
variant="outline"
className="border-gray-700 text-gray-300 hover:bg-gray-800"
@@ -589,6 +718,8 @@ export default function DistributionAdminPage() {
order.userNickname?.toLowerCase().includes(term) ||
order.userPhone?.includes(term) ||
order.sectionTitle?.toLowerCase().includes(term) ||
order.chapterTitle?.toLowerCase().includes(term) ||
order.bookName?.toLowerCase().includes(term) ||
(order.referrerCode && order.referrerCode.toLowerCase().includes(term)) ||
(order.referrerNickname && order.referrerNickname.toLowerCase().includes(term))
)
@@ -609,18 +740,34 @@ export default function DistributionAdminPage() {
<td className="p-4">
<div>
<p className="text-white text-sm">
{order.type === 'fullbook' ? '整本购买' :
order.type === 'match' ? '匹配次数' :
order.sectionTitle || `章节${order.sectionId}`}
{(() => {
const type = order.productType || order.type
if (type === 'fullbook') {
return `${order.bookName || '《底层逻辑》'} - 全本`
} else if (type === 'match') {
return '匹配次数购买'
} else {
// section - 单章购买
return `${order.bookName || '《底层逻辑》'} - ${order.sectionTitle || order.chapterTitle || `章节${order.productId || order.sectionId || ''}`}`
}
})()}
</p>
<p className="text-gray-500 text-xs">
{order.type === 'fullbook' ? '全书' :
order.type === 'match' ? '功能' : '单章'}
{(() => {
const type = order.productType || order.type
if (type === 'fullbook') {
return '全书解锁'
} else if (type === 'match') {
return '功能权益'
} else {
return order.chapterTitle || '单章购买'
}
})()}
</p>
</div>
</td>
<td className="p-4 text-[#38bdac] font-bold">
¥{(order.amount || 0).toFixed(2)}
¥{typeof order.amount === 'number' ? order.amount.toFixed(2) : parseFloat(order.amount || '0').toFixed(2)}
</td>
<td className="p-4 text-gray-300">
{order.paymentMethod === 'wechat' ? '微信支付' :
@@ -645,7 +792,11 @@ export default function DistributionAdminPage() {
) : '-'}
</td>
<td className="p-4 text-[#FFD700]">
{order.referrerEarnings ? `¥${order.referrerEarnings.toFixed(2)}` : '-'}
{order.referrerEarnings
? `¥${(typeof order.referrerEarnings === 'number'
? order.referrerEarnings
: parseFloat(order.referrerEarnings)).toFixed(2)}`
: '-'}
</td>
<td className="p-4 text-gray-400 text-sm">
{order.createdAt ? new Date(order.createdAt).toLocaleString('zh-CN') : '-'}
@@ -790,7 +941,16 @@ export default function DistributionAdminPage() {
{filteredWithdrawals.map(withdrawal => (
<tr key={withdrawal.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4">
<p className="text-white font-medium">{withdrawal.user_name || withdrawal.name}</p>
<div className="flex items-center gap-2">
{withdrawal.userAvatar ? (
<img src={withdrawal.userAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center text-white text-sm font-medium">
{(withdrawal.user_name || withdrawal.name || '?').slice(0, 1)}
</div>
)}
<p className="text-white font-medium">{withdrawal.user_name || withdrawal.name}</p>
</div>
</td>
<td className="p-4">
<span className="text-[#38bdac] font-bold">¥{withdrawal.amount.toFixed(2)}</span>

View File

@@ -20,6 +20,12 @@ interface Withdrawal {
errorMessage?: string
createdAt: string
processedAt?: string
userCommissionInfo?: {
totalCommission: number
withdrawnEarnings: number
pendingWithdrawals: number
availableAfterThis: number
}
}
interface Stats {
@@ -61,7 +67,15 @@ export default function WithdrawalsPage() {
// 批准提现
const handleApprove = async (id: string) => {
if (!confirm("确认已完成打款?批准后将更新用户提现记录。")) return
// 检查是否存在超额提现风险
const withdrawal = withdrawals.find(w => w.id === id)
if (withdrawal?.userCommissionInfo && withdrawal.userCommissionInfo.availableAfterThis < 0) {
if (!confirm(`⚠️ 风险警告:该用户审核后余额为负数(¥${withdrawal.userCommissionInfo.availableAfterThis.toFixed(2)}),可能存在超额提现。\n\n确认已核实用户账户并完成打款`)) {
return
}
} else {
if (!confirm("确认已完成打款?批准后将更新用户提现记录。")) return
}
setProcessing(id)
try {
@@ -229,7 +243,8 @@ export default function WithdrawalsPage() {
<tr className="bg-[#0a1628] text-gray-400">
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-right font-medium"></th>
@@ -243,18 +258,52 @@ export default function WithdrawalsPage() {
</td>
<td className="p-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
{w.userNickname?.charAt(0) || "?"}
</div>
{w.userAvatar ? (
<img
src={w.userAvatar}
alt={w.userNickname}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
{w.userNickname?.charAt(0) || "?"}
</div>
)}
<div>
<p className="font-medium text-white">{w.userNickname}</p>
<p className="text-xs text-gray-500">{w.userPhone || w.userId.slice(0, 10)}</p>
<p className="text-xs text-gray-500">{w.userPhone || w.referralCode || w.userId.slice(0, 10)}</p>
</div>
</div>
</td>
<td className="p-4">
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
</td>
<td className="p-4">
{w.userCommissionInfo ? (
<div className="text-xs space-y-1">
<div className="flex justify-between gap-4">
<span className="text-gray-500">:</span>
<span className="text-[#38bdac] font-medium">¥{w.userCommissionInfo.totalCommission.toFixed(2)}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-gray-500">:</span>
<span className="text-gray-400">¥{w.userCommissionInfo.withdrawnEarnings.toFixed(2)}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-gray-500">:</span>
<span className="text-orange-400">¥{w.userCommissionInfo.pendingWithdrawals.toFixed(2)}</span>
</div>
<div className="flex justify-between gap-4 pt-1 border-t border-gray-700/30">
<span className="text-gray-500">:</span>
<span className={w.userCommissionInfo.availableAfterThis >= 0 ? "text-green-400 font-medium" : "text-red-400 font-medium"}>
¥{w.userCommissionInfo.availableAfterThis.toFixed(2)}
</span>
</div>
</div>
) : (
<span className="text-gray-500 text-xs"></span>
)}
</td>
<td className="p-4">
{getStatusBadge(w.status)}
{w.errorMessage && (