Files
soul-yongping/soul-admin/src/pages/admin-users/AdminUsersPage.tsx
2026-03-24 01:22:50 +08:00

423 lines
15 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 { 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>
)
}