423 lines
15 KiB
TypeScript
423 lines
15 KiB
TypeScript
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
|
||
}
|
||
|
||
function confirmDangerousDelete(entity: string): boolean {
|
||
if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false
|
||
const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`)
|
||
return verifyText === '删除'
|
||
}
|
||
|
||
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/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/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/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 (!confirmDangerousDelete('管理员')) {
|
||
setError('已取消删除')
|
||
return
|
||
}
|
||
try {
|
||
const data = await del<{ success?: boolean; error?: string }>(`/api/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>
|
||
)
|
||
}
|