feat: 完善后台管理+搜索功能+分销系统

主要更新:
- 后台菜单精简(9项→6项)
- 新增搜索功能(敏感信息过滤)
- 分销绑定和提现系统完善
- 数据库初始化API(自动修复表结构)
- 用户管理:显示绑定关系详情
- 小程序:上下章导航优化、匹配页面重构
- 修复hydration和数据类型问题
This commit is contained in:
卡若
2026-01-25 19:37:59 +08:00
parent 65d2831a45
commit 4dd2f9f4a7
49 changed files with 5921 additions and 636 deletions

View File

@@ -1,172 +1,305 @@
"use client"
import { useState, useEffect } from "react"
import { useStore } from "@/lib/store"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Check, Clock, Wallet, History } from "lucide-react"
import { Check, X, Clock, Wallet, History, RefreshCw, AlertCircle, DollarSign } from "lucide-react"
interface Withdrawal {
id: string
userId: string
userNickname: string
userPhone?: string
userAvatar?: string
referralCode?: string
amount: number
status: 'pending' | 'processing' | 'success' | 'failed'
wechatOpenid?: string
transactionId?: string
errorMessage?: string
createdAt: string
processedAt?: string
}
interface Stats {
total: number
pendingCount: number
pendingAmount: number
successCount: number
successAmount: number
failedCount: number
}
export default function WithdrawalsPage() {
const { withdrawals, completeWithdrawal } = useStore()
const [mounted, setMounted] = useState(false)
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
const [stats, setStats] = useState<Stats>({ total: 0, pendingCount: 0, pendingAmount: 0, successCount: 0, successAmount: 0, failedCount: 0 })
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'pending' | 'success' | 'failed'>('all')
const [processing, setProcessing] = useState<string | null>(null)
// 加载提现记录
const loadWithdrawals = async () => {
setLoading(true)
try {
const res = await fetch(`/api/admin/withdrawals?status=${filter}`)
const data = await res.json()
if (data.success) {
setWithdrawals(data.withdrawals || [])
setStats(data.stats || {})
}
} catch (error) {
console.error('Load withdrawals error:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
setMounted(true)
}, [])
loadWithdrawals()
}, [filter])
if (!mounted) return null
// 批准提现
const handleApprove = async (id: string) => {
if (!confirm("确认已完成打款?批准后将更新用户提现记录。")) return
setProcessing(id)
try {
const res = await fetch('/api/admin/withdrawals', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action: 'approve' })
})
const data = await res.json()
if (data.success) {
loadWithdrawals()
} else {
alert('操作失败: ' + data.error)
}
} catch (error) {
alert('操作失败')
} finally {
setProcessing(null)
}
}
const pendingWithdrawals = withdrawals?.filter((w) => w.status === "pending") || []
const historyWithdrawals =
withdrawals
?.filter((w) => w.status !== "pending")
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) || []
// 拒绝提现
const handleReject = async (id: string) => {
const reason = prompt("请输入拒绝原因(将返还用户余额):")
if (!reason) return
setProcessing(id)
try {
const res = await fetch('/api/admin/withdrawals', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action: 'reject', reason })
})
const data = await res.json()
if (data.success) {
loadWithdrawals()
} else {
alert('操作失败: ' + data.error)
}
} catch (error) {
alert('操作失败')
} finally {
setProcessing(null)
}
}
const totalPending = pendingWithdrawals.reduce((sum, w) => sum + w.amount, 0)
const handleApprove = (id: string) => {
if (confirm("确认打款并完成此提现申请吗?")) {
completeWithdrawal(id)
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return <Badge className="bg-orange-500/20 text-orange-400 hover:bg-orange-500/20 border-0"></Badge>
case 'processing':
return <Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0"></Badge>
case 'success':
return <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge>
case 'failed':
return <Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0"></Badge>
default:
return <Badge className="bg-gray-500/20 text-gray-400 border-0">{status}</Badge>
}
}
return (
<div className="p-8 max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-gray-400 mt-1">
{pendingWithdrawals.length} ¥{totalPending.toFixed(2)}
</p>
<div className="flex justify-between items-start mb-8">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-gray-400 mt-1">
</p>
</div>
<Button
variant="outline"
onClick={loadWithdrawals}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="grid gap-6">
{/* 待处理申请 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<div className="p-2 rounded-lg bg-orange-500/20">
<Clock className="w-5 h-5 text-orange-400" />
{/* 分账规则说明 */}
<Card className="bg-gradient-to-r from-[#38bdac]/10 to-[#0f2137] border-[#38bdac]/30 mb-6">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<DollarSign className="w-5 h-5 text-[#38bdac] mt-0.5" />
<div>
<h3 className="text-white font-medium mb-2"></h3>
<div className="text-sm text-gray-400 space-y-1">
<p> <span className="text-[#38bdac]"></span>广 <span className="text-white font-medium">90%</span></p>
<p> <span className="text-[#38bdac]"></span>广</p>
<p> <span className="text-[#38bdac]"></span></p>
<p> <span className="text-[#38bdac]"></span></p>
</div>
({pendingWithdrawals.length})
</CardTitle>
</CardHeader>
<CardContent>
{pendingWithdrawals.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
</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-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{pendingWithdrawals.map((w) => (
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4 text-gray-400">{new Date(w.createdAt).toLocaleString()}</td>
<td className="p-4">
<div>
<p className="font-medium text-white">{w.name}</p>
<p className="text-xs text-gray-500 font-mono">{w.userId.slice(0, 8)}...</p>
</div>
</div>
</CardContent>
</Card>
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4 mb-6">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-4 text-center">
<div className="text-3xl font-bold text-[#38bdac]">{stats.total}</div>
<div className="text-sm text-gray-400"></div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-4 text-center">
<div className="text-3xl font-bold text-orange-400">{stats.pendingCount}</div>
<div className="text-sm text-gray-400"></div>
<div className="text-xs text-orange-400 mt-1">¥{stats.pendingAmount.toFixed(2)}</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-4 text-center">
<div className="text-3xl font-bold text-green-400">{stats.successCount}</div>
<div className="text-sm text-gray-400"></div>
<div className="text-xs text-green-400 mt-1">¥{stats.successAmount.toFixed(2)}</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-4 text-center">
<div className="text-3xl font-bold text-red-400">{stats.failedCount}</div>
<div className="text-sm text-gray-400"></div>
</CardContent>
</Card>
</div>
{/* 筛选按钮 */}
<div className="flex gap-2 mb-4">
{(['all', 'pending', 'success', 'failed'] as const).map((f) => (
<Button
key={f}
variant={filter === f ? "default" : "outline"}
size="sm"
onClick={() => setFilter(f)}
className={filter === f
? "bg-[#38bdac] hover:bg-[#2da396] text-white"
: "border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
}
>
{f === 'all' ? '全部' : f === 'pending' ? '待处理' : f === 'success' ? '已完成' : '已拒绝'}
</Button>
))}
</div>
{/* 提现记录表格 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : withdrawals.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
</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-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{withdrawals.map((w) => (
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4 text-gray-400">
{new Date(w.createdAt).toLocaleString()}
</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>
</td>
<td className="p-4">
<Badge
className={
w.method === "wechat"
? "bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"
: "bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0"
}
>
{w.method === "wechat" ? "微信" : "支付宝"}
</Badge>
</td>
<td className="p-4 font-mono text-gray-300">{w.account}</td>
<td className="p-4">
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
</td>
<td className="p-4 text-right">
<Button
size="sm"
onClick={() => handleApprove(w.id)}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Check className="w-4 h-4 mr-1" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* 处理历史 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<div className="p-2 rounded-lg bg-gray-700/50">
<History className="w-5 h-5 text-gray-400" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
{historyWithdrawals.length === 0 ? (
<div className="text-center py-12">
<History className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
</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>
<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>
</div>
</div>
</td>
<td className="p-4">
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
</td>
<td className="p-4">
{getStatusBadge(w.status)}
{w.errorMessage && (
<p className="text-xs text-red-400 mt-1">{w.errorMessage}</p>
)}
</td>
<td className="p-4 text-gray-400">
{w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}
</td>
<td className="p-4 text-right">
{w.status === 'pending' && (
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
onClick={() => handleApprove(w.id)}
disabled={processing === w.id}
className="bg-green-600 hover:bg-green-700 text-white"
>
<Check className="w-4 h-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleReject(w.id)}
disabled={processing === w.id}
className="border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent"
>
<X className="w-4 h-4 mr-1" />
</Button>
</div>
)}
{w.status === 'success' && w.transactionId && (
<span className="text-xs text-gray-500 font-mono">{w.transactionId}</span>
)}
</td>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{historyWithdrawals.map((w) => (
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4 text-gray-400">{new Date(w.createdAt).toLocaleString()}</td>
<td className="p-4 text-gray-400">
{w.completedAt ? new Date(w.completedAt).toLocaleString() : "-"}
</td>
<td className="p-4 font-medium text-white">{w.name}</td>
<td className="p-4 text-gray-300">{w.method === "wechat" ? "微信" : "支付宝"}</td>
<td className="p-4 font-medium text-white">¥{w.amount.toFixed(2)}</td>
<td className="p-4">
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}