Files
soul-yongping/soul-admin/src/pages/users/UsersPage.tsx

787 lines
30 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 { 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,
Crown,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { SetVipModal } from '@/components/modules/user/SetVipModal'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { get, del, post, put } from '@/api/client'
interface User {
id: string
openId?: string | null
phone?: string | null
nickname: string
wechatId?: string | null
avatar?: string | null
isAdmin?: boolean | number
hasFullBook?: boolean | number
referralCode?: string
earnings: number | string
pendingEarnings?: number | string
withdrawnEarnings?: number | string
referralCount?: number
createdAt: string
updatedAt?: string | null
}
export function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearch = useDebounce(searchTerm, 300)
const [vipFilter, setVipFilter] = useState<'all' | 'vip'>('all')
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<{ referrals?: unknown[]; stats?: Record<string, unknown> }>({
referrals: [],
stats: {},
})
const [referralsLoading, setReferralsLoading] = useState(false)
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
const [showSetVipModal, setShowSetVipModal] = useState(false)
const [selectedUserForVip, setSelectedUserForVip] = useState<User | null>(null)
const [formData, setFormData] = useState({
phone: '',
nickname: '',
password: '',
isAdmin: false,
hasFullBook: false,
})
async function loadUsers() {
setIsLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
search: debouncedSearch,
...(vipFilter === 'vip' && { vip: 'true' }),
})
const data = await get<{
success?: boolean
users?: User[]
total?: number
error?: string
}>(`/api/db/users?${params}`)
if (data?.success) {
setUsers(data.users || [])
setTotal(data.total ?? 0)
} else {
setError(data?.error || '加载失败')
}
} catch (err) {
console.error('Load users error:', err)
setError('网络错误,请检查连接')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setPage(1)
}, [debouncedSearch, vipFilter])
useEffect(() => {
loadUsers()
}, [page, pageSize, debouncedSearch, vipFilter])
const totalPages = Math.ceil(total / pageSize) || 1
async function handleDelete(userId: string) {
if (!confirm('确定要删除这个用户吗?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(
`/api/db/users?id=${encodeURIComponent(userId)}`,
)
if (data?.success) loadUsers()
else alert('删除失败: ' + (data?.error || '未知错误'))
} catch (err) {
console.error('Delete user error:', err)
alert('删除失败')
}
}
const handleEditUser = (user: User) => {
setEditingUser(user)
setFormData({
phone: user.phone || '',
nickname: user.nickname || '',
password: '',
isAdmin: !!(user.isAdmin ?? false),
hasFullBook: !!(user.hasFullBook ?? false),
})
setShowUserModal(true)
}
const handleAddUser = () => {
setEditingUser(null)
setFormData({
phone: '',
nickname: '',
password: '',
isAdmin: false,
hasFullBook: false,
})
setShowUserModal(true)
}
async function handleSaveUser() {
if (!formData.phone || !formData.nickname) {
alert('请填写手机号和昵称')
return
}
setIsSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: editingUser.id,
nickname: formData.nickname,
isAdmin: formData.isAdmin,
hasFullBook: formData.hasFullBook,
...(formData.password && { password: formData.password }),
})
if (!data?.success) {
alert('更新失败: ' + (data?.error || '未知错误'))
return
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', {
phone: formData.phone,
nickname: formData.nickname,
password: formData.password,
isAdmin: formData.isAdmin,
})
if (!data?.success) {
alert('创建失败: ' + (data?.error || '未知错误'))
return
}
}
setShowUserModal(false)
loadUsers()
} catch (err) {
console.error('Save user error:', err)
alert('保存失败')
} finally {
setIsSaving(false)
}
}
const handleChangePassword = (user: User) => {
setEditingUser(user)
setNewPassword('')
setConfirmPassword('')
setShowPasswordModal(true)
}
async function handleViewReferrals(user: User) {
setSelectedUserForReferrals(user)
setShowReferralsModal(true)
setReferralsLoading(true)
try {
const data = await get<{ success?: boolean; referrals?: unknown[]; stats?: Record<string, unknown> }>(
`/api/db/users/referrals?userId=${encodeURIComponent(user.id)}`,
)
if (data?.success) setReferralsData({ referrals: data.referrals || [], stats: data.stats || {} })
else setReferralsData({ referrals: [], stats: {} })
} catch (err) {
console.error('Load referrals error:', err)
setReferralsData({ referrals: [], stats: {} })
} finally {
setReferralsLoading(false)
}
}
const handleViewDetail = (user: User) => {
setSelectedUserIdForDetail(user.id)
setShowDetailModal(true)
}
const handleSetVip = (user: User) => {
setSelectedUserForVip(user)
setShowSetVipModal(true)
}
async function handleSavePassword() {
if (!newPassword) {
alert('请输入新密码')
return
}
if (newPassword !== confirmPassword) {
alert('两次输入的密码不一致')
return
}
if (newPassword.length < 6) {
alert('密码长度不能少于6位')
return
}
setIsSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
id: editingUser?.id,
password: newPassword,
})
if (data?.success) {
alert('密码修改成功')
setShowPasswordModal(false)
} else {
alert('密码修改失败: ' + (data?.error || '未知错误'))
}
} catch (err) {
console.error('Change password error:', err)
alert('密码修改失败')
} finally {
setIsSaving(false)
}
}
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 justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1">
{total}
{vipFilter === 'vip' && `,当前筛选 VIP`}
</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>
<select
value={vipFilter}
onChange={(e) => {
setVipFilter(e.target.value as 'all' | 'vip')
setPage(1)
}}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
>
<option value="all"></option>
<option value="vip">VIP会员</option>
</select>
<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.isAdmin}
onCheckedChange={(checked) => setFormData({ ...formData, isAdmin: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-gray-300"></Label>
<Switch
checked={formData.hasFullBook}
onCheckedChange={(checked) => setFormData({ ...formData, hasFullBook: 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>
<UserDetailModal
open={showDetailModal}
onClose={() => setShowDetailModal(false)}
userId={selectedUserIdForDetail}
onUserUpdated={loadUsers}
/>
<SetVipModal
open={showSetVipModal}
onClose={() => { setShowSetVipModal(false); setSelectedUserForVip(null) }}
userId={selectedUserForVip?.id ?? null}
userNickname={selectedUserForVip?.nickname}
onSaved={loadUsers}
/>
<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 as number) || 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 as number) || 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 as number) || 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 as number) || 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) > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{(referralsData.referrals ?? []).map((ref: unknown, i: number) => {
const r = ref as {
id?: string
nickname?: string
phone?: string
hasOpenId?: boolean
status?: string
purchasedSections?: number
createdAt?: string
}
return (
<div key={r.id || i} 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]">
{r.nickname?.charAt(0) || '?'}
</div>
<div>
<div className="text-white text-sm">{r.nickname}</div>
<div className="text-xs text-gray-500">
{r.phone || (r.hasOpenId ? '微信用户' : '未绑定')}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{r.status === 'vip' && (
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs"></Badge>
)}
{r.status === 'paid' && (
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-xs">
{r.purchasedSections}
</Badge>
)}
{r.status === 'free' && (
<Badge className="bg-gray-500/20 text-gray-400 border-0 text-xs"></Badge>
)}
<span className="text-xs text-gray-500">
{r.createdAt ? new Date(r.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>
) : (
<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>
{users.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.isAdmin && (
<Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs">
</Badge>
)}
{user.openId && !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.openId ? user.openId.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.wechatId && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">💬</span>
<span className="text-gray-300">{user.wechatId}</span>
</div>
)}
{user.openId && (
<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.openId}>
{user.openId.slice(0, 12)}...
</span>
</div>
)}
{!user.phone && !user.wechatId && !user.openId && (
<span className="text-gray-600 text-xs"></span>
)}
</div>
</TableCell>
<TableCell>
{user.hasFullBook ? (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">
VIP
</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.pendingEarnings || 0)) > 0 && (
<div className="text-xs text-yellow-400">
: ¥{parseFloat(String(user.pendingEarnings || 0)).toFixed(2)}
</div>
)}
<div
className="text-xs text-[#38bdac] cursor-pointer hover:underline flex items-center gap-1"
onClick={() => handleViewReferrals(user)}
onKeyDown={(e) => e.key === 'Enter' && handleViewReferrals(user)}
role="button"
tabIndex={0}
>
<Users className="w-3 h-3" />
{user.referralCount || 0}
</div>
</div>
</TableCell>
<TableCell>
<code className="text-[#38bdac] text-xs bg-[#38bdac]/10 px-2 py-0.5 rounded">
{user.referralCode || '-'}
</code>
</TableCell>
<TableCell className="text-gray-400">
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleSetVip(user)}
className="text-gray-400 hover:text-amber-400 hover:bg-amber-400/10"
title="设置 VIP"
>
<Crown className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(user)}
className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10"
title="查看详情"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
title="编辑"
>
<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"
title="修改密码"
>
<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)}
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
}}
/>
</div>
)}
</CardContent>
</Card>
</div>
)
}