Files
soul-yongping/soul-admin/src/pages/distribution/DistributionPage.tsx

1234 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import {
Users,
TrendingUp,
Clock,
Wallet,
Search,
RefreshCw,
CheckCircle,
XCircle,
Calendar,
DollarSign,
Link2,
Eye,
Undo2,
Settings,
} from 'lucide-react'
import { ReferralSettingsPage } from '@/pages/referral-settings/ReferralSettingsPage'
import { Pagination } from '@/components/ui/Pagination'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog'
import { get, put } from '@/api/client'
interface TodayClicksByPageItem {
page: string
clicks: number
}
interface DistributionOverview {
todayClicks: number
todayBindings: number
todayConversions: number
todayEarnings: number
monthClicks: number
monthBindings: number
monthConversions: number
monthEarnings: number
totalClicks: number
totalBindings: number
totalConversions: number
totalEarnings: number
expiringBindings: number
pendingWithdrawals: number
pendingWithdrawAmount: number
conversionRate: string
totalDistributors: number
activeDistributors: number
todayUniqueVisitors?: number
todayClickRate?: number
todayClicksByPage?: TodayClicksByPageItem[]
}
interface Binding {
id: string
referrerId: string
referrerName?: string
referrerCode: string
refereeId: string
refereePhone?: string
refereeNickname?: string
boundAt: string
expiresAt: string
status: 'active' | 'converted' | 'expired' | 'cancelled'
commission?: number
}
interface Withdrawal {
id: string
userId?: string
userName?: string
userPhone?: string
userAvatar?: string
amount: number
method?: 'wechat' | 'alipay'
account?: string
name?: string
status: string
createdAt?: string
processedAt?: string
}
interface User {
id: string
nickname: string
phone: string
referralCode?: string
}
interface Order {
id: string
userId: string
userNickname?: string
userPhone?: string
productType?: string
type?: string
productId?: string
sectionId?: string
bookName?: string
chapterTitle?: string
sectionTitle?: string
amount: number
status: string
paymentMethod?: string
referrerEarnings?: number
referrerId?: string | null
referrerNickname?: string | null
referrerCode?: string | null
referralCode?: string | null
orderSn?: string
refundReason?: string
createdAt: string
}
export function DistributionPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings'>(
'overview',
)
const [orders, setOrders] = useState<Order[]>([])
const [overview, setOverview] = useState<DistributionOverview | null>(null)
const [bindings, setBindings] = useState<Binding[]>([])
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
const [refundOrder, setRefundOrder] = useState<Order | null>(null)
const [refundReason, setRefundReason] = useState('')
const [refundLoading, setRefundLoading] = useState(false)
useEffect(() => {
loadInitialData()
}, [])
useEffect(() => {
setPage(1)
}, [activeTab, statusFilter])
useEffect(() => {
loadTabData(activeTab)
}, [activeTab])
useEffect(() => {
if (['orders', 'bindings', 'withdrawals'].includes(activeTab)) {
loadTabData(activeTab, true)
}
}, [page, pageSize, statusFilter, searchTerm])
async function loadInitialData() {
setError(null)
try {
const overviewData = await get<{ success?: boolean; overview?: DistributionOverview }>(
'/api/admin/distribution/overview',
)
if (overviewData?.success && overviewData.overview) setOverview(overviewData.overview)
} catch (e) {
console.error('[Admin] 概览接口异常:', e)
setError('加载概览失败')
}
try {
const usersData = await get<{ success?: boolean; users?: User[] }>('/api/db/users')
setUsers(usersData?.users || [])
} catch (e) {
console.error('[Admin] 用户数据加载失败:', e)
}
}
async function loadTabData(tab: string, force = false) {
if (!force && loadedTabs.has(tab)) return
setLoading(true)
try {
const usersArr = users
switch (tab) {
case 'overview':
break
case 'orders': {
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
...(statusFilter !== 'all' && { status: statusFilter }),
...(searchTerm && { search: searchTerm }),
})
const ordersData = await get<{ success?: boolean; orders?: Order[]; total?: number }>(`/api/orders?${params}`)
if (ordersData?.success && ordersData.orders) {
const enriched = ordersData.orders.map((order) => {
const user = usersArr.find((u) => u.id === order.userId)
const referrer = order.referrerId
? usersArr.find((u) => u.id === order.referrerId)
: null
return {
...order,
amount: parseFloat(String(order.amount)) || 0,
userNickname: user?.nickname || order.userNickname || '未知用户',
userPhone: user?.phone || order.userPhone || '-',
referrerNickname: referrer?.nickname || null,
referrerCode: referrer?.referralCode ?? null,
type: order.productType || order.type,
}
})
setOrders(enriched)
setTotal(ordersData.total ?? enriched.length)
} else {
setOrders([])
setTotal(0)
}
} catch (e) {
console.error(e)
setError('加载订单失败')
setOrders([])
}
break
}
case 'bindings': {
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
...(statusFilter !== 'all' && { status: statusFilter }),
})
const bindingsData = await get<{ success?: boolean; bindings?: Binding[]; total?: number }>(
`/api/db/distribution?${params}`,
)
setBindings(bindingsData?.bindings || [])
setTotal(bindingsData?.total ?? bindingsData?.bindings?.length ?? 0)
} catch (e) {
console.error(e)
setError('加载绑定数据失败')
setBindings([])
}
break
}
case 'withdrawals': {
try {
const statusParam = statusFilter === 'completed' ? 'success' : statusFilter === 'rejected' ? 'failed' : statusFilter
const params = new URLSearchParams({
...(statusParam && statusParam !== 'all' && { status: statusParam }),
page: String(page),
pageSize: String(pageSize),
})
const withdrawalsData = await get<{
success?: boolean
withdrawals?: Withdrawal[]
total?: number
error?: string
}>(`/api/admin/withdrawals?${params}`)
if (withdrawalsData?.success && withdrawalsData.withdrawals) {
const formatted = withdrawalsData.withdrawals.map((w) => ({
...w,
account: w.account ?? '未绑定微信号',
status:
w.status === 'success' ? 'completed' : w.status === 'failed' ? 'rejected' : w.status,
}))
setWithdrawals(formatted)
setTotal(withdrawalsData?.total ?? formatted.length)
} else {
if (!withdrawalsData?.success)
setError(`获取提现记录失败: ${(withdrawalsData as { error?: string })?.error || '未知错误'}`)
setWithdrawals([])
}
} catch (e) {
console.error(e)
setError('加载提现数据失败')
setWithdrawals([])
}
break
}
}
setLoadedTabs((prev) => new Set(prev).add(tab))
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
async function refreshCurrentTab() {
setError(null)
setLoadedTabs((prev) => {
const next = new Set(prev)
next.delete(activeTab)
return next
})
if (activeTab === 'overview') loadInitialData()
await loadTabData(activeTab, true)
}
async function handleApproveWithdrawal(id: string) {
if (!confirm('确认审核通过并打款?')) return
try {
const res = await put<{ success?: boolean; error?: string; message?: string }>(
'/api/admin/withdrawals',
{ id, action: 'approve' },
)
if (!res?.success) {
const detail = res?.message || res?.error || '操作失败'
alert(detail)
return
}
await refreshCurrentTab()
} catch (e) {
console.error(e)
alert('操作失败')
}
}
async function handleRejectWithdrawal(id: string) {
const reason = prompt('请输入拒绝原因:')
if (!reason) return
try {
const res = await put<{ success?: boolean; error?: string }>(
'/api/admin/withdrawals',
{ id, action: 'reject', errorMessage: reason },
)
if (!res?.success) {
alert(res?.error || '操作失败')
return
}
await refreshCurrentTab()
} catch (e) {
console.error(e)
alert('操作失败')
}
}
async function handleRefund() {
if (!refundOrder?.orderSn && !refundOrder?.id) return
setRefundLoading(true)
setError(null)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/admin/orders/refund', {
orderSn: refundOrder.orderSn || refundOrder.id,
reason: refundReason || undefined,
})
if (res?.success) {
setRefundOrder(null)
setRefundReason('')
await loadTabData('orders', true)
} else {
setError(res?.error || '退款失败')
}
} catch (e) {
const err = e as Error & { data?: { error?: string } }
setError(err?.data?.error || '退款失败,请检查网络后重试')
} finally {
setRefundLoading(false)
}
}
function getStatusBadge(status: string) {
const styles: Record<string, string> = {
active: 'bg-green-500/20 text-green-400',
converted: 'bg-blue-500/20 text-blue-400',
expired: 'bg-gray-500/20 text-gray-400',
cancelled: 'bg-red-500/20 text-red-400',
pending: 'bg-orange-500/20 text-orange-400',
pending_confirm: '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',
}
const labels: Record<string, string> = {
active: '有效',
converted: '已转化',
expired: '已过期',
cancelled: '已取消',
pending: '待审核',
pending_confirm: '待用户确认',
processing: '处理中',
completed: '已完成',
rejected: '已拒绝',
}
return (
<Badge className={`${styles[status] || 'bg-gray-500/20 text-gray-400'} border-0`}>
{labels[status] || status}
</Badge>
)
}
const totalPages = Math.ceil(total / pageSize) || 1
const displayOrders = orders
const displayBindings = bindings.filter((b) => {
if (!searchTerm) return true
const term = searchTerm.toLowerCase()
return (
b.refereeNickname?.toLowerCase().includes(term) ||
b.refereePhone?.includes(term) ||
b.referrerName?.toLowerCase().includes(term) ||
b.referrerCode?.toLowerCase().includes(term)
)
})
const displayWithdrawals = withdrawals.filter((w) => {
if (!searchTerm) return true
const term = searchTerm.toLowerCase()
return (
w.userName?.toLowerCase().includes(term) || (w.account && w.account.toLowerCase().includes(term))
)
})
return (
<div className="p-8 w-full">
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
×
</button>
</div>
)}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">广</h1>
<p className="text-gray-400 mt-1"></p>
</div>
<Button
onClick={refreshCurrentTab}
disabled={loading}
variant="outline"
className="border-gray-700 text-gray-300 hover:bg-gray-800"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="flex gap-2 mb-6 border-b border-gray-700 pb-4 flex-wrap">
{[
{ key: 'overview', label: '数据概览', icon: TrendingUp },
{ key: 'orders', label: '订单管理', icon: DollarSign },
{ key: 'bindings', label: '绑定管理', icon: Link2 },
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
{ key: 'settings', label: '推广设置', icon: Settings },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => {
setActiveTab(tab.key as typeof activeTab)
setStatusFilter('all')
setSearchTerm('')
}}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.key
? 'bg-[#38bdac] text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<RefreshCw className="w-8 h-8 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
<>
{activeTab === 'overview' && overview && (
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayClicks}</p>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<Eye className="w-6 h-6 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayUniqueVisitors ?? 0}</p>
<p className="text-xs text-gray-500 mt-0.5">访</p>
</div>
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center">
<Users className="w-6 h-6 text-cyan-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">
{(overview.todayClickRate ?? 0).toFixed(2)}
</p>
<p className="text-xs text-gray-500 mt-0.5">/</p>
</div>
<div className="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-amber-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayBindings}</p>
</div>
<div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
<Link2 className="w-6 h-6 text-green-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayConversions}</p>
</div>
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-purple-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-[#38bdac] mt-1">
¥{overview.todayEarnings.toFixed(2)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-[#38bdac]/20 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-[#38bdac]" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 每篇文章今日点击 */}
{(overview.todayClicksByPage?.length ?? 0) > 0 && (
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-[#38bdac]" />
/
</CardTitle>
<p className="text-gray-400 text-sm mt-1"></p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700 text-left text-gray-400">
<th className="pb-3 pr-4">/</th>
<th className="pb-3 pr-4 text-right"></th>
<th className="pb-3 text-right"></th>
</tr>
</thead>
<tbody>
{[...(overview.todayClicksByPage ?? [])]
.sort((a, b) => b.clicks - a.clicks)
.map((row, i) => (
<tr key={i} className="border-b border-gray-700/50">
<td className="py-2 pr-4 text-white font-mono">{row.page || '(未区分)'}</td>
<td className="py-2 pr-4 text-right text-white">{row.clicks}</td>
<td className="py-2 text-right text-gray-400">
{overview.todayClicks > 0
? ((row.clicks / overview.todayClicks) * 100).toFixed(1)
: 0}
%
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-2 gap-4">
<Card className="bg-orange-500/10 border-orange-500/30">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center">
<Clock className="w-6 h-6 text-orange-400" />
</div>
<div className="flex-1">
<p className="text-orange-300 font-medium"></p>
<p className="text-2xl font-bold text-white">{overview.expiringBindings} </p>
<p className="text-orange-300/60 text-sm">7</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-blue-500/10 border-blue-500/30">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<Wallet className="w-6 h-6 text-blue-400" />
</div>
<div className="flex-1">
<p className="text-blue-300 font-medium"></p>
<p className="text-2xl font-bold text-white">{overview.pendingWithdrawals} </p>
<p className="text-blue-300/60 text-sm">
¥{overview.pendingWithdrawAmount.toFixed(2)}
</p>
</div>
<Button
onClick={() => setActiveTab('withdrawals')}
variant="outline"
className="border-blue-500/50 text-blue-400 hover:bg-blue-500/20"
>
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-2 gap-6">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.monthClicks}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.monthBindings}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.monthConversions}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-[#38bdac]">
¥{overview.monthEarnings.toFixed(2)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">
{overview.totalClicks.toLocaleString()}
</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">
{overview.totalBindings.toLocaleString()}
</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.totalConversions}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-[#38bdac]">
¥{overview.totalEarnings.toFixed(2)}
</p>
</div>
</div>
<div className="mt-4 p-4 bg-[#38bdac]/10 rounded-lg flex items-center justify-between">
<span className="text-gray-300"></span>
<span className="text-[#38bdac] font-bold text-xl">
{overview.conversionRate}%
</span>
</div>
</CardContent>
</Card>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
广
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-4">
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-white">{overview.totalDistributors}</p>
<p className="text-gray-400 text-sm mt-1">广</p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-green-400">{overview.activeDistributors}</p>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-[#38bdac]">90%</p>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-orange-400">30</p>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{activeTab === 'orders' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索订单号、用户名、手机号..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
>
<option value="all"></option>
<option value="completed"></option>
<option value="pending"></option>
<option value="failed"></option>
<option value="refunded">退</option>
</select>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{orders.length === 0 ? (
<div className="py-12 text-center text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<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-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>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{displayOrders.map((order) => (
<tr key={order.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4 font-mono text-xs text-gray-400">
{order.id?.slice(0, 12)}...
</td>
<td className="p-4">
<div>
<p className="text-white text-sm">{order.userNickname}</p>
<p className="text-gray-500 text-xs">{order.userPhone}</p>
</div>
</td>
<td className="p-4">
<div>
<p className="text-white text-sm">
{(() => {
const type = order.productType || order.type
if (type === 'fullbook')
return `${order.bookName || '《底层逻辑》'} - 全本`
if (type === 'match') return '匹配次数购买'
return `${order.bookName || '《底层逻辑》'} - ${order.sectionTitle || order.chapterTitle || `章节${order.productId || order.sectionId || ''}`}`
})()}
</p>
<p className="text-gray-500 text-xs">
{(() => {
const type = order.productType || order.type
if (type === 'fullbook') return '全书解锁'
if (type === 'match') return '功能权益'
return order.chapterTitle || '单章购买'
})()}
</p>
</div>
</td>
<td className="p-4 text-[#38bdac] font-bold">
¥{typeof order.amount === 'number' ? order.amount.toFixed(2) : parseFloat(String(order.amount || '0')).toFixed(2)}
</td>
<td className="p-4 text-gray-300">
{order.paymentMethod === 'wechat'
? '微信支付'
: order.paymentMethod === 'alipay'
? '支付宝'
: order.paymentMethod || '微信支付'}
</td>
<td className="p-4">
{order.status === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 border-0">
退
</Badge>
) : order.status === 'completed' || order.status === 'paid' ? (
<Badge className="bg-green-500/20 text-green-400 border-0">
</Badge>
) : order.status === 'pending' || order.status === 'created' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 border-0">
</Badge>
)}
</td>
<td className="p-4 text-gray-400 text-sm max-w-[120px]" title={order.refundReason}>
{order.status === 'refunded' && order.refundReason ? order.refundReason : '-'}
</td>
<td className="p-4 text-gray-300 text-sm">
{order.referrerId || order.referralCode ? (
<span
title={
order.referralCode ||
order.referrerCode ||
order.referrerId ||
''
}
>
{order.referrerNickname ||
order.referralCode ||
order.referrerCode ||
order.referrerId?.slice(0, 8)}
{(order.referralCode || order.referrerCode) &&
` (${order.referralCode || order.referrerCode})`}
</span>
) : (
'-'
)}
</td>
<td className="p-4 text-[#FFD700]">
{order.referrerEarnings
? `¥${(typeof order.referrerEarnings === 'number' ? order.referrerEarnings : parseFloat(String(order.referrerEarnings))).toFixed(2)}`
: '-'}
</td>
<td className="p-4 text-gray-400 text-sm">
{order.createdAt
? new Date(order.createdAt).toLocaleString('zh-CN')
: '-'}
</td>
<td className="p-4">
{(order.status === 'paid' || order.status === 'completed') && (
<Button
variant="outline"
size="sm"
className="border-orange-500/50 text-orange-400 hover:bg-orange-500/20"
onClick={() => {
setRefundOrder(order)
setRefundReason('')
}}
>
<Undo2 className="w-3 h-3 mr-1" />
退
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'orders' && (
<Pagination
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
}}
/>
)}
</CardContent>
</Card>
</div>
)}
{activeTab === 'bindings' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索用户昵称、手机号、推广码..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
>
<option value="all"></option>
<option value="active"></option>
<option value="converted"></option>
<option value="expired"></option>
</select>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{displayBindings.length === 0 ? (
<div className="py-12 text-center text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<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>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{displayBindings.map((binding) => (
<tr key={binding.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4">
<div>
<p className="text-white font-medium">
{binding.refereeNickname || '匿名用户'}
</p>
<p className="text-gray-500 text-xs">{binding.refereePhone}</p>
</div>
</td>
<td className="p-4">
<div>
<p className="text-white">{binding.referrerName || '-'}</p>
<p className="text-gray-500 text-xs font-mono">
{binding.referrerCode}
</p>
</div>
</td>
<td className="p-4 text-gray-400">
{binding.boundAt
? new Date(binding.boundAt).toLocaleDateString('zh-CN')
: '-'}
</td>
<td className="p-4 text-gray-400">
{binding.expiresAt
? new Date(binding.expiresAt).toLocaleDateString('zh-CN')
: '-'}
</td>
<td className="p-4">{getStatusBadge(binding.status)}</td>
<td className="p-4">
{binding.commission ? (
<span className="text-[#38bdac] font-medium">
¥{binding.commission.toFixed(2)}
</span>
) : (
<span className="text-gray-500">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'bindings' && (
<Pagination
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
}}
/>
)}
</CardContent>
</Card>
</div>
)}
{activeTab === 'withdrawals' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索用户名称、账号..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
>
<option value="all"></option>
<option value="pending"></option>
<option value="completed"></option>
<option value="rejected"></option>
</select>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{displayWithdrawals.length === 0 ? (
<div className="py-12 text-center text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<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-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{displayWithdrawals.map((withdrawal) => (
<tr key={withdrawal.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4">
<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.userName || withdrawal.name || '?').slice(0, 1)}
</div>
)}
<p className="text-white font-medium">
{withdrawal.userName || withdrawal.name}
</p>
</div>
</td>
<td className="p-4">
<span className="text-[#38bdac] font-bold">
¥{withdrawal.amount.toFixed(2)}
</span>
</td>
<td className="p-4">
<Badge
className={
withdrawal.method === 'wechat'
? 'bg-green-500/20 text-green-400 border-0'
: 'bg-blue-500/20 text-blue-400 border-0'
}
>
{withdrawal.method === 'wechat' ? '微信' : '支付宝'}
</Badge>
</td>
<td className="p-4">
<div>
<p className="text-white font-mono text-xs">
{withdrawal.account}
</p>
<p className="text-gray-500 text-xs">{withdrawal.name}</p>
</div>
</td>
<td className="p-4 text-gray-400">
{withdrawal.createdAt
? new Date(withdrawal.createdAt).toLocaleString('zh-CN')
: '-'}
</td>
<td className="p-4">{getStatusBadge(withdrawal.status)}</td>
<td className="p-4 text-right">
{withdrawal.status === 'pending' && (
<div className="flex gap-2 justify-end">
<Button
size="sm"
onClick={() => handleApproveWithdrawal(withdrawal.id)}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<CheckCircle className="w-4 h-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleRejectWithdrawal(withdrawal.id)}
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
>
<XCircle className="w-4 h-4 mr-1" />
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'withdrawals' && (
<Pagination
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
}}
/>
)}
</CardContent>
</Card>
</div>
)}
</>
)}
<Dialog open={!!refundOrder} onOpenChange={(open) => !open && setRefundOrder(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white">退</DialogTitle>
</DialogHeader>
{refundOrder && (
<div className="space-y-4">
<p className="text-gray-400 text-sm">
{refundOrder.orderSn || refundOrder.id}
</p>
<p className="text-gray-400 text-sm">
退¥{typeof refundOrder.amount === 'number' ? refundOrder.amount.toFixed(2) : parseFloat(String(refundOrder.amount || '0')).toFixed(2)}
</p>
<div>
<label className="text-sm text-gray-400 block mb-2">退</label>
<div className="form-input">
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
placeholder="如:用户申请退款"
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
/>
</div>
</div>
<p className="text-orange-400/80 text-xs">
退退
</p>
</div>
)}
<DialogFooter>
<Button
variant="outline"
className="border-gray-600 text-gray-300"
onClick={() => setRefundOrder(null)}
disabled={refundLoading}
>
</Button>
<Button
className="bg-orange-500 hover:bg-orange-600 text-white"
onClick={handleRefund}
disabled={refundLoading}
>
{refundLoading ? '退款中...' : '确认退款'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 推广设置 Tab */}
{activeTab === 'settings' && (
<div className="-mx-8 -mt-6">
<ReferralSettingsPage embedded />
</div>
)}
</div>
)
}