2026-03-07 22:58:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 01:22:50 +08:00
|
|
|
|
function confirmDangerousDelete(entity: string): boolean {
|
|
|
|
|
|
if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false
|
|
|
|
|
|
const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`)
|
|
|
|
|
|
return verifyText === '删除'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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())
|
2026-03-24 01:22:50 +08:00
|
|
|
|
const data = await get<ListRes>(`/api/admin/users?${params}`)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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) {
|
2026-03-24 01:22:50 +08:00
|
|
|
|
const data = await put<{ success?: boolean; error?: string }>('/api/admin/users', {
|
2026-03-07 22:58:43 +08:00
|
|
|
|
id: editingUser.id,
|
|
|
|
|
|
password: formPassword || undefined,
|
|
|
|
|
|
name: formName.trim(),
|
|
|
|
|
|
role: formRole,
|
|
|
|
|
|
status: formStatus,
|
|
|
|
|
|
})
|
|
|
|
|
|
if (data?.success) {
|
|
|
|
|
|
setShowModal(false)
|
|
|
|
|
|
loadList()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setError(data?.error || '保存失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-24 01:22:50 +08:00
|
|
|
|
const data = await post<{ success?: boolean; error?: string }>('/api/admin/users', {
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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) => {
|
2026-03-24 01:22:50 +08:00
|
|
|
|
if (!confirmDangerousDelete('管理员')) {
|
|
|
|
|
|
setError('已取消删除')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
try {
|
2026-03-24 01:22:50 +08:00
|
|
|
|
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/users?id=${id}`)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|