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

@@ -9,36 +9,48 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
import { Search, UserPlus, Eye, Trash2, Edit3, Key, Save, X, RefreshCw } from "lucide-react"
import { Search, UserPlus, Trash2, Edit3, Key, Save, X, RefreshCw, Users, Eye } from "lucide-react"
interface User {
id: string
phone: string
open_id?: string | null
phone?: string | null
nickname: string
password?: string
is_admin?: boolean
has_full_book?: boolean
password?: string | null
wechat_id?: string | null
avatar?: string | null
is_admin?: boolean | number
has_full_book?: boolean | number
referral_code: string
referred_by?: string
earnings: number
pending_earnings: number
withdrawn_earnings: number
referred_by?: string | null
earnings: number | string
pending_earnings: number | string
withdrawn_earnings?: number | string
referral_count: number
match_count_today?: number
last_match_date?: string
last_match_date?: string | null
purchased_sections?: string[] | string | null
created_at: string
updated_at?: string | null
}
function UsersContent() {
const [users, setUsers] = useState<User[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showUserModal, setShowUserModal] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isSaving, setIsSaving] = useState(false)
// 绑定关系弹窗
const [showReferralsModal, setShowReferralsModal] = useState(false)
const [referralsData, setReferralsData] = useState<any>({ referrals: [], stats: {} })
const [referralsLoading, setReferralsLoading] = useState(false)
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
// 初始表单状态
const [formData, setFormData] = useState({
@@ -52,14 +64,18 @@ function UsersContent() {
// 加载用户列表
const loadUsers = async () => {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/db/users')
const data = await res.json()
if (data.success) {
setUsers(data.users || [])
} else {
setError(data.error || '加载失败')
}
} catch (error) {
console.error('Load users error:', error)
} catch (err) {
console.error('Load users error:', err)
setError('网络错误,请检查连接')
} finally {
setIsLoading(false)
}
@@ -95,8 +111,8 @@ function UsersContent() {
const handleEditUser = (user: User) => {
setEditingUser(user)
setFormData({
phone: user.phone,
nickname: user.nickname,
phone: user.phone || "",
nickname: user.nickname || "",
password: "",
is_admin: user.is_admin || false,
has_full_book: user.has_full_book || false,
@@ -181,6 +197,28 @@ function UsersContent() {
setShowPasswordModal(true)
}
// 查看绑定关系
const handleViewReferrals = async (user: User) => {
setSelectedUserForReferrals(user)
setShowReferralsModal(true)
setReferralsLoading(true)
try {
const res = await fetch(`/api/db/users/referrals?userId=${user.id}`)
const data = await res.json()
if (data.success) {
setReferralsData(data)
} else {
setReferralsData({ referrals: [], stats: {} })
}
} catch (err) {
console.error('Load referrals error:', err)
setReferralsData({ referrals: [], stats: {} })
} finally {
setReferralsLoading(false)
}
}
// 保存密码
const handleSavePassword = async () => {
if (!newPassword) {
@@ -384,6 +422,92 @@ function UsersContent() {
</DialogContent>
</Dialog>
{/* 绑定关系弹窗 */}
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
- {selectedUserForReferrals?.nickname}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 统计信息 */}
<div className="grid grid-cols-4 gap-3">
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-[#38bdac]">{referralsData.stats?.total || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-400">{referralsData.stats?.purchased || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-yellow-400">¥{(referralsData.stats?.earnings || 0).toFixed(2)}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-orange-400">¥{(referralsData.stats?.pendingEarnings || 0).toFixed(2)}</div>
<div className="text-xs text-gray-400"></div>
</div>
</div>
{/* 绑定用户列表 */}
{referralsLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : referralsData.referrals?.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{referralsData.referrals.map((ref: any) => (
<div key={ref.id} className="flex items-center justify-between bg-[#0a1628] rounded-lg p-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
{ref.nickname?.charAt(0) || "?"}
</div>
<div>
<div className="text-white text-sm">{ref.nickname}</div>
<div className="text-xs text-gray-500">
{ref.phone || (ref.hasOpenId ? '微信用户' : '未绑定')}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{ref.status === 'vip' && (
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs"></Badge>
)}
{ref.status === 'paid' && (
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-xs">{ref.purchasedSections}</Badge>
)}
{ref.status === 'free' && (
<Badge className="bg-gray-500/20 text-gray-400 border-0 text-xs"></Badge>
)}
<span className="text-xs text-gray-500">
{ref.createdAt ? new Date(ref.createdAt).toLocaleDateString() : ''}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowReferralsModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
@@ -396,10 +520,10 @@ function UsersContent() {
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">广</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
@@ -410,7 +534,11 @@ function UsersContent() {
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
{user.nickname?.charAt(0) || "?"}
{user.avatar ? (
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : (
user.nickname?.charAt(0) || "?"
)}
</div>
<div>
<div className="flex items-center gap-2">
@@ -420,12 +548,45 @@ function UsersContent() {
</Badge>
)}
{user.open_id && !user.id?.startsWith('user_') && (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs">
</Badge>
)}
</div>
<p className="text-xs text-gray-500">ID: {user.id?.slice(0, 8)}</p>
<p className="text-xs text-gray-500 font-mono">
{user.open_id ? user.open_id.slice(0, 12) + '...' : user.id?.slice(0, 12)}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-gray-300">{user.phone}</TableCell>
<TableCell>
<div className="space-y-1">
{user.phone && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">📱</span>
<span className="text-gray-300">{user.phone}</span>
</div>
)}
{user.wechat_id && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">💬</span>
<span className="text-gray-300">{user.wechat_id}</span>
</div>
)}
{user.open_id && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">🔗</span>
<span className="text-gray-500 truncate max-w-[100px]" title={user.open_id}>
{user.open_id.slice(0, 12)}...
</span>
</div>
)}
{!user.phone && !user.wechat_id && !user.open_id && (
<span className="text-gray-600 text-xs"></span>
)}
</div>
</TableCell>
<TableCell>
{user.has_full_book ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge>
@@ -435,8 +596,31 @@ function UsersContent() {
</Badge>
)}
</TableCell>
<TableCell className="text-white font-medium">¥{(user.earnings || 0).toFixed(2)}</TableCell>
<TableCell className="text-gray-300">{user.match_count_today || 0}/3</TableCell>
<TableCell>
<div className="space-y-1">
<div className="text-white font-medium">¥{parseFloat(String(user.earnings || 0)).toFixed(2)}</div>
{parseFloat(String(user.pending_earnings || 0)) > 0 && (
<div className="text-xs text-yellow-400">: ¥{parseFloat(String(user.pending_earnings || 0)).toFixed(2)}</div>
)}
<div
className="text-xs text-[#38bdac] cursor-pointer hover:underline flex items-center gap-1"
onClick={() => handleViewReferrals(user)}
>
<Users className="w-3 h-3" />
{user.referral_count || 0}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<code className="text-[#38bdac] text-xs bg-[#38bdac]/10 px-2 py-0.5 rounded">
{user.referral_code || '-'}
</code>
{user.referred_by && (
<div className="text-xs text-gray-500">: {user.referred_by.slice(0, 8)}</div>
)}
</div>
</TableCell>
<TableCell className="text-gray-400">
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
</TableCell>