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

414 lines
15 KiB
TypeScript
Raw Normal View History

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 { ShieldCheck, Plus, Edit3, Trash2, X, Save, RefreshCw } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
interface AdminUser {
id: number
username: string
role: string
name: string
status: string
createdAt: string
updatedAt?: string
}
interface ListRes {
success?: boolean
records?: AdminUser[]
total?: number
page?: number
pageSize?: number
totalPages?: number
error?: string
}
export function AdminUsersPage() {
const [records, setRecords] = useState<AdminUser[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [totalPages, setTotalPages] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearch = useDebounce(searchTerm, 300)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [formUsername, setFormUsername] = useState('')
const [formPassword, setFormPassword] = useState('')
const [formName, setFormName] = useState('')
const [formRole, setFormRole] = useState<'super_admin' | 'admin'>('admin')
const [formStatus, setFormStatus] = useState<'active' | 'disabled'>('active')
const [saving, setSaving] = useState(false)
async function loadList() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
})
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim())
const data = await get<ListRes>(`/api/admin/admin-users?${params}`)
if (data?.success) {
setRecords((data as ListRes).records || [])
setTotal((data as ListRes).total ?? 0)
setTotalPages((data as ListRes).totalPages ?? 0)
} else {
setError((data as ListRes).error || '加载失败')
}
} catch (e: unknown) {
const err = e as { status?: number; data?: { error?: string } }
setError(err.status === 403 ? '无权限访问' : err?.data?.error || '加载失败')
setRecords([])
} finally {
setLoading(false)
}
}
useEffect(() => {
loadList()
}, [page, pageSize, debouncedSearch])
const handleAdd = () => {
setEditingUser(null)
setFormUsername('')
setFormPassword('')
setFormName('')
setFormRole('admin')
setFormStatus('active')
setShowModal(true)
}
const handleEdit = (u: AdminUser) => {
setEditingUser(u)
setFormUsername(u.username)
setFormPassword('')
setFormName(u.name || '')
setFormRole((u.role === 'super_admin' ? 'super_admin' : 'admin') as 'super_admin' | 'admin')
setFormStatus((u.status === 'disabled' ? 'disabled' : 'active') as 'active' | 'disabled')
setShowModal(true)
}
const handleSave = async () => {
if (!formUsername.trim()) {
setError('用户名不能为空')
return
}
if (!editingUser && !formPassword) {
setError('新建时密码必填,至少 6 位')
return
}
if (formPassword && formPassword.length < 6) {
setError('密码至少 6 位')
return
}
setError(null)
setSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
id: editingUser.id,
password: formPassword || undefined,
name: formName.trim(),
role: formRole,
status: formStatus,
})
if (data?.success) {
setShowModal(false)
loadList()
} else {
setError(data?.error || '保存失败')
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
username: formUsername.trim(),
password: formPassword,
name: formName.trim(),
role: formRole,
})
if (data?.success) {
setShowModal(false)
loadList()
} else {
setError(data?.error || '保存失败')
}
}
} catch (e: unknown) {
const err = e as { data?: { error?: string } }
setError(err?.data?.error || '保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该管理员?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/admin-users?id=${id}`)
if (data?.success) loadList()
else setError(data?.error || '删除失败')
} catch (e: unknown) {
const err = e as { data?: { error?: string } }
setError(err?.data?.error || '删除失败')
}
}
const formatDate = (s: string) => {
if (!s) return '-'
try {
const d = new Date(s)
return isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN')
} catch {
return s
}
}
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1"></p>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="搜索用户名/昵称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-48 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
/>
<Button
variant="outline"
size="sm"
onClick={loadList}
disabled={loading}
className="border-gray-600 text-gray-300"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm flex justify-between items-center">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</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>
{records.map((u) => (
<TableRow key={u.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{u.id}</TableCell>
<TableCell className="text-white font-medium">{u.username}</TableCell>
<TableCell className="text-gray-400">{u.name || '-'}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
u.role === 'super_admin'
? 'border-amber-500/50 text-amber-400'
: 'border-gray-600 text-gray-400'
}
>
{u.role === 'super_admin' ? '超级管理员' : '管理员'}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
u.status === 'active'
? 'border-[#38bdac]/50 text-[#38bdac]'
: 'border-gray-500 text-gray-500'
}
>
{u.status === 'active' ? '正常' : '已禁用'}
</Badge>
</TableCell>
<TableCell className="text-gray-500 text-sm">{formatDate(u.createdAt)}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(u)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(u.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{records.length === 0 && !loading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
{error === '无权限访问' ? '仅超级管理员可查看' : '暂无管理员'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="p-4 border-t border-gray-700/50">
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="text-white">
{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={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
disabled={!!editingUser}
/>
{editingUser && (
<p className="text-xs text-gray-500"></p>
)}
</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 ? '留空表示不修改' : '至少 6 位'}
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="显示名称"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<select
value={formRole}
onChange={(e) => setFormRole(e.target.value as 'super_admin' | 'admin')}
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
>
<option value="admin"></option>
<option value="super_admin"></option>
</select>
</div>
{editingUser && (
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<select
value={formStatus}
onChange={(e) => setFormStatus(e.target.value as 'active' | 'disabled')}
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
>
<option value="active"></option>
<option value="disabled"></option>
</select>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}