import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload, CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown, UserCheck, UserX, Filter, ChevronLeft, ChevronRight, FileText, Users as UsersIcon, Server, } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { userApi, ldapApi } from '@/api/user'; import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup'; import { nameToUsername } from '@/utils/pinyin'; import { toast } from '@/hooks/useToast'; /* ───────── Types ───────── */ interface UserItem { id: number; username: string; display_name: string; email: string | null; role: string; is_active: boolean; created_at: string; last_login?: string | null; } interface UserActivity { id: string; action: string; target: string; created_at: string; status: 'success' | 'failed'; } interface FormErrors { username?: string; password?: string; email?: string; } /* ───────── Constants ───────── */ const roleNameMap: Record = { admin: '超级管理员', dba: '数据库管理员', developer: '开发人员', auditor: '审计员', }; const roleTagStyle: Record = { admin: 'bg-purple-500/15 text-purple-400', dba: 'bg-blue-500/15 text-blue-400', developer: 'bg-green-500/15 text-green-400', auditor: 'bg-orange-500/15 text-orange-400', }; const roleOptions = [ { value: '', label: '全部角色' }, { value: 'admin', label: '超级管理员' }, { value: 'dba', label: '数据库管理员' }, { value: 'developer', label: '开发人员' }, { value: 'auditor', label: '审计员' }, ]; const statusOptions = [ { value: '', label: '全部状态' }, { value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }, ]; const BATCH_OPTIONS = [ { key: 'enable', label: '批量启用', icon: UserCheck }, { key: 'disable', label: '批量禁用', icon: UserX }, { key: 'delete', label: '批量删除', icon: Trash2 }, ] as const; /* ───────── Validation ───────── */ function validateUserForm(data: { username: string; password: string; email: string }): FormErrors { const errors: FormErrors = {}; if (!data.username.trim()) { errors.username = '用户名不能为空'; } else if (!/^[a-zA-Z0-9_]{3,30}$/.test(data.username)) { errors.username = '用户名只能包含字母、数字和下划线,长度3-30'; } if (!data.password) { errors.password = '密码不能为空'; } else if (data.password.length < 6) { errors.password = '密码长度至少6位'; } if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { errors.email = '邮箱格式不正确'; } return errors; } /* ───────── CSV Export Helper ───────── */ function exportUsersToCSV(users: UserItem[]) { const headers = ['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间']; const rows = users.map(u => [ u.username, u.display_name || '', u.email || '', roleNameMap[u.role] || u.role, u.is_active ? '启用' : '禁用', u.created_at, ]); const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'); const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); } /* ───────── Main Component ───────── */ export default function UsersPage() { const [search, setSearch] = useState(''); const [roleFilter, setRoleFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [users, setUsers] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pageSize] = useState(20); const [loading, setLoading] = useState(false); const [showCreate, setShowCreate] = useState(false); const [showDetail, setShowDetail] = useState(null); const [showEdit, setShowEdit] = useState(null); const [showResetPwd, setShowResetPwd] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [showBatchMenu, setShowBatchMenu] = useState(false); const [showImport, setShowImport] = useState(false); const fetchUsers = useCallback(async () => { setLoading(true); try { const params: any = { page, page_size: pageSize }; if (search) params.search = search; if (roleFilter) params.role = roleFilter; if (statusFilter === 'active') params.is_active = true; else if (statusFilter === 'disabled') params.is_active = false; const res = await userApi.list(params); const data = res as any; setUsers(Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []); setTotal(data?.total ?? data?.length ?? 0); } catch { toast('error', '获取用户列表失败'); } finally { setLoading(false); } }, [page, pageSize, search, roleFilter, statusFilter]); useEffect(() => { fetchUsers(); }, [fetchUsers]); const filtered = users; const allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id)); const toggleSelectAll = () => { if (allSelected) { const next = new Set(selectedIds); filtered.forEach(u => next.delete(u.id)); setSelectedIds(next); } else { const next = new Set(selectedIds); filtered.forEach(u => next.add(u.id)); setSelectedIds(next); } }; const toggleSelect = (id: number) => { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); setSelectedIds(next); }; const handleToggleActive = async (user: UserItem) => { try { await userApi.update(user.id, { is_active: !user.is_active }); toast('success', `用户已${!user.is_active ? '启用' : '禁用'}`); fetchUsers(); } catch { toast('error', '操作失败'); } }; const handleDelete = async (id: number) => { if (!confirm('确定删除该用户?此操作不可恢复。')) return; try { await userApi.delete(id); setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; }); toast('success', '用户已删除'); fetchUsers(); } catch { toast('error', '删除失败'); } }; const handleBatchAction = async (action: string) => { const ids = Array.from(selectedIds); if (ids.length === 0) return; if (action === 'delete' && !confirm(`确定删除选中的 ${ids.length} 个用户?此操作不可恢复。`)) return; try { if (action === 'enable') { await Promise.all(ids.map(id => userApi.update(id, { is_active: true }))); toast('success', `已启用 ${ids.length} 个用户`); } else if (action === 'disable') { await Promise.all(ids.map(id => userApi.update(id, { is_active: false }))); toast('success', `已禁用 ${ids.length} 个用户`); } else if (action === 'delete') { await Promise.all(ids.map(id => userApi.delete(id))); toast('success', `已删除 ${ids.length} 个用户`); } setSelectedIds(new Set()); fetchUsers(); } catch { toast('error', '批量操作失败'); } }; const handleDownloadTemplate = async () => { try { const blob = await userApi.downloadTemplate() as any; const url = URL.createObjectURL(new Blob([blob], { type: 'text/csv' })); const a = document.createElement('a'); a.href = url; a.download = 'users_import_template.csv'; a.click(); URL.revokeObjectURL(url); } catch { toast('error', '下载模板失败'); } }; return (
{/* Header */}

用户管理

管理系统用户,分配角色和数据库访问权限

{/* Filters */}
setSearch(e.target.value)} />
{(search || roleFilter || statusFilter) && ( )}
{/* Batch Actions Bar */} {selectedIds.size > 0 && (
已选择 {selectedIds.size} 项
{showBatchMenu && ( <>
setShowBatchMenu(false)} />
{BATCH_OPTIONS.map(opt => { const Icon = opt.icon; return ( ); })}
)}
)} {/* User Table */} {loading && users.length === 0 ? (
加载中...
) : filtered.length === 0 ? (

没有找到匹配的用户

{(search || roleFilter || statusFilter) && ( )}
) : ( <>
{filtered.map(user => { const isSelected = selectedIds.has(user.id); return ( ); })}
用户 角色 邮箱 最后登录 状态 操作
setShowDetail(user)}>
{user.display_name || user.username}
@{user.username}
{roleNameMap[user.role] || user.role} {user.email || '-'} {user.last_login ? new Date(user.last_login).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}
共 {total} 条 {selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`} {total > pageSize && (
{page} / {Math.ceil(total / pageSize)}
)}
)} {/* Create Modal */} {showCreate && ( setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} /> )} {/* Detail Modal */} {showDetail && ( setShowDetail(null)} /> )} {/* Edit Modal */} {showEdit && ( setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} /> )} {/* Reset Password Modal */} {showResetPwd && ( setShowResetPwd(null)} /> )} {/* Batch Import Modal */} {showImport && ( setShowImport(false)} onImported={() => { setShowImport(false); fetchUsers(); }} /> )}
); } /* ───────── Create User Modal ───────── */ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { const [username, setUsername] = useState(''); const [displayName, setDisplayName] = useState(''); const [password, setPassword] = useState(''); const [email, setEmail] = useState(''); const [role, setRole] = useState('developer'); const [submitting, setSubmitting] = useState(false); const [errors, setErrors] = useState({}); const handleSubmit = async () => { const validation = validateUserForm({ username, password, email }); setErrors(validation); if (Object.keys(validation).length > 0) return; setSubmitting(true); try { await userApi.create({ username, display_name: displayName, password, email: email || undefined, role }); toast('success', '用户创建成功'); onCreated(); } catch (e: any) { toast('error', e?.response?.data?.detail || '创建失败'); } finally { setSubmitting(false); } }; return (
e.stopPropagation()}>

新建用户

{ setUsername(e.target.value); if (errors.username) setErrors(p => ({ ...p, username: undefined })); }} className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.username ? 'border-red-400' : 'border-[var(--dg-border)]'}`} placeholder="3-30位字母、数字或下划线" /> {errors.username &&

{errors.username}

}
setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="用户的显示名称" />
{ setPassword(e.target.value); if (errors.password) setErrors(p => ({ ...p, password: undefined })); }} className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.password ? 'border-red-400' : 'border-[var(--dg-border)]'}`} placeholder="至少6位" /> {errors.password &&

{errors.password}

}
{ setEmail(e.target.value); if (errors.email) setErrors(p => ({ ...p, email: undefined })); }} className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.email ? 'border-red-400' : 'border-[var(--dg-border)]'}`} placeholder="example@company.com" /> {errors.email &&

{errors.email}

}
); } /* ───────── Edit User Modal ───────── */ function EditUserModal({ user, onClose, onUpdated }: { user: UserItem; onClose: () => void; onUpdated: () => void }) { const [displayName, setDisplayName] = useState(user.display_name || ''); const [email, setEmail] = useState(user.email || ''); const [role, setRole] = useState(user.role); const [isActive, setIsActive] = useState(user.is_active); const [submitting, setSubmitting] = useState(false); const [groups, setGroups] = useState([]); const [selectedGroupIds, setSelectedGroupIds] = useState([]); useEffect(() => { instanceGroupApi.list().then((data: InstanceGroup[]) => { setGroups(data); // Reverse lookup: find which groups contain this user const matched = data .filter(g => g.users?.some(u => u.id === user.id)) .map(g => g.id); setSelectedGroupIds(matched); }).catch(() => {}); }, [user.id]); const toggleGroup = (gid: number) => { setSelectedGroupIds(prev => prev.includes(gid) ? prev.filter(id => id !== gid) : [...prev, gid]); }; const handleSubmit = async () => { setSubmitting(true); try { await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive }); // Update group associations if (selectedGroupIds.length >= 0) { await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds); } toast('success', '用户更新成功'); onUpdated(); } catch { toast('error', '更新失败'); } finally { setSubmitting(false); } }; return (
e.stopPropagation()}>

编辑用户

{(user.display_name || user.username).charAt(0)}
{user.display_name || user.username}
@{user.username}
setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
setEmail(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
{groups.length === 0 ? (
暂无可选权限组
) : (
{groups.map(g => ( ))}
)}
{isActive ? '已启用' : '已禁用'}
); } /* ───────── Reset Password Modal ───────── */ function ResetPasswordModal({ user, onClose }: { user: UserItem; onClose: () => void }) { const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const handleSubmit = async () => { setError(''); if (!newPassword) { setError('请输入新密码'); return; } if (newPassword.length < 6) { setError('密码长度至少6位'); return; } if (newPassword !== confirmPassword) { setError('两次输入的密码不一致'); return; } setSubmitting(true); try { await userApi.update(user.id, { password: newPassword }); toast('success', '密码重置成功'); onClose(); } catch { toast('error', '密码重置失败'); } finally { setSubmitting(false); } }; return (
e.stopPropagation()}>

重置密码

为用户 {user.display_name || user.username} 设置新密码

{ setNewPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="至少6位" />
{ setConfirmPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="再次输入" />
{error &&

{error}

}
); } /* ───────── User Detail Modal ───────── */ function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => void }) { return (
e.stopPropagation()}>

用户详情

{/* User Info Card */}
{(user.display_name || user.username).charAt(0)}
{user.display_name || user.username}
@{user.username}
{roleNameMap[user.role] || user.role} {user.is_active ? '启用' : '禁用'}
{/* Detail Fields */}
邮箱
{user.email || '-'}
创建时间
{new Date(user.created_at).toLocaleDateString('zh-CN')}
最后登录
{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '-'}
用户ID
#{user.id}
{/* Activity Log */}

最近活动

暂无活动记录
); } /* ───────── Need Clock icon for detail modal ───────── */ function Clock(props: React.SVGProps) { return ( ); } /* ───────── Batch Import Modal ───────── */ function BatchImportModal({ onClose, onImported }: { onClose: () => void; onImported: () => void }) { const [tab, setTab] = useState<'local' | 'to-ldap' | 'from-ldap'>('local'); const [preview, setPreview] = useState([]); const [skipExisting, setSkipExisting] = useState(true); const [importing, setImporting] = useState(false); const [result, setResult] = useState(null); const fileRef = useRef(null); // LDAP state const [ldapOus, setLdapOus] = useState([]); const [ldapUsers, setLdapUsers] = useState([]); const [selectedLdapUsers, setSelectedLdapUsers] = useState>(new Set()); const [ldapLoading, setLdapLoading] = useState(false); const [targetOu, setTargetOu] = useState(''); const [ldapReady, setLdapReady] = useState(false); // Create OU dialog const [showCreateOu, setShowCreateOu] = useState(false); const [newOuName, setNewOuName] = useState(''); const [newOuDesc, setNewOuDesc] = useState(''); const [creatingOu, setCreatingOu] = useState(false); // Check LDAP readiness useEffect(() => { if (tab === 'to-ldap' || tab === 'from-ldap') { ldapApi.getConfig().then((res: any) => { setLdapReady(!!res?.ldap_host); }).catch(() => setLdapReady(false)); if (ldapOus.length === 0) loadLdapOus(); } }, [tab]); // CSV parse const handleFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const text = reader.result as string; const lines = text.split(/\r?\n/).filter(l => l.trim()); if (lines.length < 2) { toast('error', '文件为空'); return; } const header = lines[0].split(',').map(h => h.trim().replace(/"/g, '')); const nameIdx = header.findIndex(h => /姓名|name|昵称|显示名/.test(h)); const emailIdx = header.findIndex(h => /邮箱|email|mail/.test(h)); const roleIdx = header.findIndex(h => /角色|role/.test(h)); if (nameIdx < 0) { toast('error', 'CSV 需要包含"姓名"列'); return; } const rows = lines.slice(1).map(line => { const cols = line.split(',').map(c => c.trim().replace(/^"|"$/g, '')); const name = cols[nameIdx] || ''; return { display_name: name, username: nameToUsername(name), password: nameToUsername(name), email: emailIdx >= 0 ? cols[emailIdx] : '', role: roleIdx >= 0 ? cols[roleIdx] || 'developer' : 'developer', }; }).filter(r => r.display_name); setPreview(rows); setResult(null); }; reader.readAsText(file); e.target.value = ''; }; // LDAP OU loading const loadLdapOus = async () => { setLdapLoading(true); try { const res = await ldapApi.listOus() as any; setLdapOus(res?.ous || []); } catch { toast('error', '加载 LDAP OU 失败'); } finally { setLdapLoading(false); } }; const loadLdapUsers = async (baseDn: string) => { setLdapLoading(true); try { const res = await ldapApi.listUsers({ base_dn: baseDn }) as any; setLdapUsers(res?.users || []); } catch { toast('error', '加载 LDAP 用户失败'); } finally { setLdapLoading(false); } }; // Create OU const handleCreateOu = async () => { if (!newOuName.trim()) { toast('warning', '请输入 OU 名称'); return; } setCreatingOu(true); try { await ldapApi.createOu({ ou_name: newOuName.trim(), description: newOuDesc.trim() }); toast('success', `OU "${newOuName}" 创建成功`); setShowCreateOu(false); setNewOuName(''); setNewOuDesc(''); await loadLdapOus(); } catch (e: any) { toast('error', e?.response?.data?.detail || '创建 OU 失败'); } finally { setCreatingOu(false); } }; // Import const handleImport = async () => { if (tab === 'local' && preview.length === 0) { toast('warning', '请先上传文件'); return; } if (tab === 'to-ldap' && preview.length === 0) { toast('warning', '请先上传文件'); return; } if (tab === 'to-ldap' && !targetOu) { toast('warning', '请选择目标 OU'); return; } if (tab === 'from-ldap' && selectedLdapUsers.size === 0) { toast('warning', '请选择用户'); return; } setImporting(true); try { let res: any; if (tab === 'local') { res = await userApi.batchImport({ users: preview, import_type: 'local', skip_existing: skipExisting }); } else if (tab === 'to-ldap') { res = await ldapApi.batchImportToLdap({ users: preview, ou_dn: targetOu }); } else { const users = ldapUsers.filter(u => selectedLdapUsers.has(u.dn)); res = await userApi.batchImportLdap({ users }); } setResult(res); if (res.success_count > 0 || res.success) onImported(); if (tab === 'to-ldap') { toast('success', res.message || `成功 ${res.success_count} 个`); } else { toast('success', `成功导入 ${res.success_count} 个用户`); } } catch { toast('error', '导入失败'); } finally { setImporting(false); } }; const canImport = () => { if (importing) return false; if (tab === 'local') return preview.length > 0; if (tab === 'to-ldap') return preview.length > 0 && !!targetOu; return selectedLdapUsers.size > 0; }; const TABS = [ { key: 'local' as const, label: '本地导入', icon: Upload }, { key: 'to-ldap' as const, label: '导入到 LDAP', icon: Server }, { key: 'from-ldap' as const, label: '从 LDAP 导入', icon: UsersIcon }, ]; return (
e.stopPropagation()}> {/* Header */}

批量导入用户

{/* Tabs */}
{TABS.map(t => { const Icon = t.icon; return ( ); })}
{/* Content */}
{/* ── Local Import ── */} {tab === 'local' && ( <>
{preview.length > 0 && (
{preview.slice(0, 50).map((r, i) => ( ))} {preview.length > 50 && ( )}
姓名 用户名 邮箱 角色
{r.display_name} {r.username} {r.email || '-'} {r.role}
... 共 {preview.length} 条
)} )} {/* ── Import to LDAP ── */} {tab === 'to-ldap' && ( <> {!ldapReady && (
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
)} {ldapReady && ( <> {/* OU selector */}
{/* File upload (same CSV format) */}
CSV 需包含"姓名"列,系统自动生成拼音用户名和密码
{preview.length > 0 && (
{preview.slice(0, 30).map((r, i) => ( ))} {preview.length > 30 && ( )}
姓名 用户名(自动) 密码(自动)
{r.display_name} {r.username} {r.password}
... 共 {preview.length} 条
)} )} )} {/* ── From LDAP Import ── */} {tab === 'from-ldap' && ( <> {!ldapReady && (
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
)} {ldapLoading &&

加载中...

} {!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (

选择组织单元:

{ldapOus.map(ou => ( ))}
)} {ldapUsers.length > 0 && (

{ldapUsers.length} 个用户

{ldapUsers.map(u => ( ))}
)} {!ldapLoading && ldapReady && ldapOus.length === 0 && (

LDAP 目录中无可用 OU,请先创建 OU 或检查配置

)} )} {/* Result */} {result && (

成功: {result.success_count ?? (result.success ? 1 : 0)}

{result.skipped_count > 0 &&

跳过: {result.skipped_count}

} {result.failed_count > 0 &&

失败: {result.failed_count}

} {result.failed_users?.map((f: any, i: number) => (

{f.username}: {f.error}

))} {result.message && !result.success_count &&

{result.message}

}
)}
{/* Footer */}
{/* Create OU sub-dialog */} {showCreateOu && (
e.stopPropagation()}>

创建新的组织单位 (OU)

setNewOuName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="例如:研发部、市场部" />
setNewOuDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
)}
); }