Files
soul/app/admin/users/page.tsx
卡若 4dd2f9f4a7 feat: 完善后台管理+搜索功能+分销系统
主要更新:
- 后台菜单精简(9项→6项)
- 新增搜索功能(敏感信息过滤)
- 分销绑定和提现系统完善
- 数据库初始化API(自动修复表结构)
- 用户管理:显示绑定关系详情
- 小程序:上下章导航优化、匹配页面重构
- 修复hydration和数据类型问题
2026-01-25 19:37:59 +08:00

680 lines
27 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.

"use client"
import { useState, useEffect, Suspense } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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, Trash2, Edit3, Key, Save, X, RefreshCw, Users, Eye } from "lucide-react"
interface User {
id: string
open_id?: string | null
phone?: string | null
nickname: string
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 | null
earnings: number | string
pending_earnings: number | string
withdrawn_earnings?: number | string
referral_count: number
match_count_today?: number
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({
phone: "",
nickname: "",
password: "",
is_admin: false,
has_full_book: false,
})
// 加载用户列表
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 (err) {
console.error('Load users error:', err)
setError('网络错误,请检查连接')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadUsers()
}, [])
const filteredUsers = users.filter((u) =>
u.nickname?.includes(searchTerm) || u.phone?.includes(searchTerm)
)
// 删除用户
const handleDelete = async (userId: string) => {
if (!confirm("确定要删除这个用户吗?")) return
try {
const res = await fetch(`/api/db/users?id=${userId}`, { method: 'DELETE' })
const data = await res.json()
if (data.success) {
loadUsers()
} else {
alert("删除失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error('Delete user error:', error)
alert("删除失败")
}
}
// 打开编辑用户弹窗
const handleEditUser = (user: User) => {
setEditingUser(user)
setFormData({
phone: user.phone || "",
nickname: user.nickname || "",
password: "",
is_admin: user.is_admin || false,
has_full_book: user.has_full_book || false,
})
setShowUserModal(true)
}
// 打开新建用户弹窗
const handleAddUser = () => {
setEditingUser(null)
setFormData({
phone: "",
nickname: "",
password: "",
is_admin: false,
has_full_book: false,
})
setShowUserModal(true)
}
// 保存用户
const handleSaveUser = async () => {
if (!formData.phone || !formData.nickname) {
alert("请填写手机号和昵称")
return
}
setIsSaving(true)
try {
if (editingUser) {
// 更新用户
const res = await fetch('/api/db/users', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingUser.id,
nickname: formData.nickname,
is_admin: formData.is_admin,
has_full_book: formData.has_full_book,
...(formData.password && { password: formData.password }),
})
})
const data = await res.json()
if (!data.success) {
alert("更新失败: " + (data.error || "未知错误"))
return
}
} else {
// 创建用户
const res = await fetch('/api/db/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: formData.phone,
nickname: formData.nickname,
password: formData.password,
is_admin: formData.is_admin,
})
})
const data = await res.json()
if (!data.success) {
alert("创建失败: " + (data.error || "未知错误"))
return
}
}
setShowUserModal(false)
loadUsers()
} catch (error) {
console.error('Save user error:', error)
alert("保存失败")
} finally {
setIsSaving(false)
}
}
// 打开修改密码弹窗
const handleChangePassword = (user: User) => {
setEditingUser(user)
setNewPassword("")
setConfirmPassword("")
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) {
alert("请输入新密码")
return
}
if (newPassword !== confirmPassword) {
alert("两次输入的密码不一致")
return
}
if (newPassword.length < 6) {
alert("密码长度不能少于6位")
return
}
setIsSaving(true)
try {
const res = await fetch('/api/db/users', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingUser?.id,
password: newPassword,
})
})
const data = await res.json()
if (data.success) {
alert("密码修改成功")
setShowPasswordModal(false)
} else {
alert("密码修改失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error('Change password error:', error)
alert("密码修改失败")
} finally {
setIsSaving(false)
}
}
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1"> {users.length} </p>
</div>
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={loadUsers}
disabled={isLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
type="text"
placeholder="搜索用户..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button onClick={handleAddUser} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<UserPlus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 用户编辑弹窗 */}
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}
{editingUser ? "编辑用户" : "添加用户"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请输入手机号"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
disabled={!!editingUser}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请输入昵称"
value={formData.nickname}
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">{editingUser ? "新密码 (留空则不修改)" : "密码"}</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder={editingUser ? "留空则不修改" : "请输入密码"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-gray-300"></Label>
<Switch
checked={formData.is_admin}
onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-gray-300"></Label>
<Switch
checked={formData.has_full_book}
onCheckedChange={(checked) => setFormData({ ...formData, has_full_book: checked })}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowUserModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSaveUser}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 修改密码弹窗 */}
<Dialog open={showPasswordModal} onOpenChange={setShowPasswordModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Key className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-[#0a1628] rounded-lg p-3">
<p className="text-gray-400 text-sm">{editingUser?.nickname}</p>
<p className="text-gray-400 text-sm">{editingUser?.phone}</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请输入新密码 (至少6位)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowPasswordModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSavePassword}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSaving ? "保存中..." : "确认修改"}
</Button>
</DialogFooter>
</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 ? (
<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>
) : (
<Table>
<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-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<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.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">
<p className="font-medium text-white">{user.nickname}</p>
{user.is_admin && (
<Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs">
</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 font-mono">
{user.open_id ? user.open_id.slice(0, 12) + '...' : user.id?.slice(0, 12)}
</p>
</div>
</div>
</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>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600">
</Badge>
)}
</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>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleChangePassword(user)}
className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10"
>
<Key className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{filteredUsers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}
export default function UsersPage() {
return (
<Suspense fallback={null}>
<UsersContent />
</Suspense>
)
}