feat: LDAP 账号管理 — OU管理/添加用户/删除用户/批量导入

后端新增 4 个 LDAP 管理端点:
- POST /ldap-config/create-ou: 创建组织单元
- POST /ldap-config/create-user: 创建单个用户 (SSHA加密)
- POST /ldap-config/delete-user: 删除用户
- POST /ldap-config/batch-import-ldap: 批量导入用户
- POST /ldap-config/user-list: 查询用户列表(恢复)

前端 LdapConfigPage 增强:
- LDAP 账号管理折叠区(配置存在即显示)
- OU 下拉选择 + 根节点选项 + 数量统计
- 用户列表表格(全选/单选/DN显示)
- 新建 OU 弹框(名称+父级DN+描述)
- 添加用户弹框(用户名+密码+显示名+邮箱+目标OU)
- 删除选中用户按钮(确认后批量删除)

前端 UsersPage BatchImportModal 三 Tab:
- 本地导入 / 导入到 LDAP / 从 LDAP 导入

测试修复:
- test_user_endpoint.py: 分页格式兼容
- test_users.py: 分页格式兼容
- test_system_settings.py: 精简后配置项匹配
This commit is contained in:
logan committed 2026-05-25 17:34:53 +08:00
1 parent 344c519209
commit be2d6f3894
20 files changed
+2229 -1019

No files matched your search

File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -80,10 +80,10 @@ function CleanupActions() {
/* ── 分组元信息 ── */
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
query: { label: '查询控制', icon: '🔍', desc: '查询权限、LIMIT 上限、超时设置' },
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略、审计日志' },
ticket: { label: '工单流程', icon: '📋', desc: '审批流程、回滚控制' },
cleanup: { label: '日志清理', icon: '🧹', desc: '数据变更工单、审计日志、查询历史的自动清理策略' },
query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT' },
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略' },
cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史保留天数' },
executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时' },
};
export default function PlatformConfigPage() {
+117 -307
View File
@@ -1,71 +1,57 @@
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo } from 'react';
import {
Crown, Database, Code, Eye, ChevronRight, Check, Save,
Search, X, Copy, Users, Lock, BarChart3, XCircle,
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
Crown, Database, Code, Eye, ChevronRight, Check,
Search, X, Users, Shield,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { permissionTreeData } from '@/lib/constants';
import { toast } from '@/hooks/useToast';
/* ───────── Types ───────── */
interface Role {
id: string;
name: string;
description: string;
icon: string;
iconClass: string;
permissions: string[];
userCount: number;
permissionCount: number;
}
interface PermissionChange {
id: string;
label: string;
action: 'added' | 'removed';
}
interface PermissionPreset {
interface RolePreset {
key: string;
name: string;
description: string;
icon: React.ElementType;
iconClass: string;
permissions: string[];
}
/* ───────── Constants ───────── */
/* ───────── Role Definitions (固定预览) ───────── */
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
const PERMISSION_PRESETS: PermissionPreset[] = [
const ROLES: RolePreset[] = [
{
key: 'admin',
name: '超级管理员',
description: '系统全部权限',
icon: Crown,
iconClass: 'text-yellow-400',
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'instance-groups', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
},
{
key: 'dba',
name: 'DBA 模板',
description: '数据库管理员:完整的数据源管理、工单审批、查询数据权限',
name: 'DBA',
description: '数据库管理员:数据源管理、工单审批、查询权限',
icon: Database,
iconClass: 'text-blue-400',
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'developer',
name: '开发模板',
description: '开发人员:工单提交、查询执行、数据导出',
name: '开发人员',
description: '工单提交、查询执行、数据导出',
icon: Code,
iconClass: 'text-green-400',
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
},
{
key: 'auditor',
name: '审计模板',
description: '审计人员:查看查询日志、审计规则、审批流程',
name: '审计员',
description: '查看查询日志、审计规则、审批流程',
icon: Eye,
permissions: ['dashboard', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
{
key: 'admin',
name: '管理员模板',
description: '超级管理员:系统全部权限',
icon: Crown,
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
iconClass: 'text-purple-400',
permissions: ['dashboard', 'tickets', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
},
];
@@ -83,267 +69,104 @@ function collectAllIds(nodes: typeof permissionTreeData): string[] {
return ids;
}
function searchTree(nodes: typeof permissionTreeData, query: string): typeof permissionTreeData {
if (!query.trim()) return nodes;
const lower = query.toLowerCase();
return nodes.reduce<typeof permissionTreeData>((acc, node) => {
const matchesSelf = node.label.toLowerCase().includes(lower);
const filteredChildren = node.children ? searchTree(node.children, query) : [];
if (matchesSelf || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
}
return acc;
}, []);
}
const ALL_PERM_IDS = collectAllIds(permissionTreeData);
/* ───────── Main Component ───────── */
export default function RolesPage() {
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set());
const [selectedRole, setSelectedRole] = useState<string>('admin');
const [searchQuery, setSearchQuery] = useState('');
const [showDiff, setShowDiff] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const hasChanges = useMemo(() => {
const allIds = collectAllIds(permissionTreeData);
return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id));
}, [checkedKeys, originalKeys]);
const role = ROLES.find(r => r.key === selectedRole)!;
const checkedKeys = useMemo(() => new Set(role.permissions), [role]);
const changes = useMemo(() => {
const allIds = collectAllIds(permissionTreeData);
const idToLabel: Record<string, string> = {};
const buildLabelMap = (nodes: typeof permissionTreeData) => {
nodes.forEach(n => {
idToLabel[n.id] = n.label;
if (n.children) buildLabelMap(n.children);
});
};
buildLabelMap(permissionTreeData);
const result: PermissionChange[] = [];
allIds.forEach(id => {
if (checkedKeys.has(id) && !originalKeys.has(id)) {
result.push({ id, label: idToLabel[id] || id, action: 'added' });
} else if (!checkedKeys.has(id) && originalKeys.has(id)) {
result.push({ id, label: idToLabel[id] || id, action: 'removed' });
}
});
return result;
}, [originalKeys, checkedKeys]);
const filteredTree = useMemo(() => {
if (!searchQuery.trim()) return permissionTreeData;
const lower = searchQuery.toLowerCase();
const filter = (nodes: typeof permissionTreeData): typeof permissionTreeData =>
nodes.reduce<typeof permissionTreeData>((acc, node) => {
const matchesSelf = node.label.toLowerCase().includes(lower);
const filteredChildren = node.children ? filter(node.children) : [];
if (matchesSelf || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children });
}
return acc;
}, []);
return filter(permissionTreeData);
}, [searchQuery]);
const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]);
const togglePermission = useCallback((id: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleChildren = useCallback((node: typeof permissionTreeData[0], checked: boolean) => {
setCheckedKeys(prev => {
const next = new Set(prev);
const toggleNode = (n: typeof node) => {
if (checked) next.add(n.id);
else next.delete(n.id);
n.children?.forEach(toggleNode);
};
toggleNode(node);
return next;
});
}, []);
const applyPreset = (preset: PermissionPreset) => {
setCheckedKeys(new Set(preset.permissions));
setOriginalKeys(new Set(preset.permissions));
setShowPresets(false);
setToast({ message: `已应用「${preset.name}」预设`, type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const handleSave = () => {
setOriginalKeys(new Set(checkedKeys));
setToast({ message: '权限配置已保存(演示模式)', type: 'success' });
setTimeout(() => setToast(null), 2000);
};
const handleDiscard = () => {
setCheckedKeys(new Set(originalKeys));
setShowDiff(false);
setToast({ message: '已撤销未保存的更改', type: 'error' });
setTimeout(() => setToast(null), 2000);
};
const totalPerms = ALL_PERM_IDS.length;
const grantedPerms = role.permissions.filter(p => ALL_PERM_IDS.includes(p)).length;
return (
<div className="space-y-5">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{toast.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{toast.message}
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">角色权限管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
<h1 className="text-2xl font-semibold">角色权限</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">查看各角色的权限配置</p>
</div>
</div>
{/* Empty state info */}
<div className="dg-card p-5 flex items-start gap-3 bg-blue-500/5 border-blue-500/20">
<Users className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-[var(--dg-text-secondary)]">
角色数据暂未接入后端 API,当前为权限配置演示模式。您可以使用下方的权限树和预设模板来规划角色权限方案。
</p>
</div>
{/* Role Selector */}
<div className="flex gap-3">
{ROLES.map(r => {
const Icon = r.icon;
const isActive = selectedRole === r.key;
return (
<button
key={r.key}
onClick={() => setSelectedRole(r.key)}
className={`flex items-center gap-2.5 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]'
: 'hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Icon className={`w-5 h-5 ${r.iconClass}`} />
<div className="text-left">
<div className="text-sm font-medium">{r.name}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{r.description}</div>
</div>
</button>
);
})}
</div>
{/* Permission Config */}
{/* Permission Tree */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
<CardTitle className="text-[15px] font-semibold">权限配置</CardTitle>
<div className="flex items-center gap-2">
{/* Presets */}
<div className="relative">
<button
onClick={() => setShowPresets(!showPresets)}
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
</button>
{showPresets && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
{PERMISSION_PRESETS.map(preset => {
const Icon = preset.icon;
return (
<button
key={preset.key}
onClick={() => applyPreset(preset)}
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="text-sm font-medium">{preset.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
</div>
</button>
);
})}
</div>
</>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Shield className="w-4 h-4 text-[var(--dg-text-muted)]" />
<span className="text-sm text-[var(--dg-text-secondary)]">
{grantedPerms} / {totalPerms} 个权限已启用
</span>
</div>
{/* Search */}
<div className="relative w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限..."
className="w-full pl-9 pr-8 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Diff Toggle */}
{hasChanges && (
<button
onClick={() => setShowDiff(!showDiff)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
变更对比
</button>
)}
{/* Discard */}
{hasChanges && (
<button
onClick={handleDiscard}
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" /> 撤销
</button>
)}
{/* Save */}
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
>
<Save className="w-4 h-4" /> 保存权限
</button>
</div>
</CardHeader>
<CardContent className="p-5">
<div className="flex gap-5">
{/* Permission Tree */}
<div className="flex-1 min-w-0">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索权限名称..."
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Tree */}
<div className="space-y-1">
{filteredTree.map(node => (
<PermissionNodeComponent
key={node.id}
node={node}
checkedKeys={checkedKeys}
onToggle={togglePermission}
onToggleChildren={toggleChildren}
depth={0}
/>
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
未找到匹配的权限
</div>
)}
</div>
</div>
{/* Diff Panel */}
{showDiff && hasChanges && (
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
<h3 className="text-sm font-semibold mb-3">变更详情</h3>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{changes.map(change => (
<div
key={change.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
}`}
>
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
<span className="flex-1 min-w-0 truncate">{change.label}</span>
</div>
))}
</div>
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
</div>
</div>
{/* Tree */}
<div className="space-y-0.5">
{filteredTree.map(node => (
<PermissionNode key={node.id} node={node} checkedKeys={checkedKeys} depth={0} />
))}
{filteredTree.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">未找到匹配的权限</div>
)}
</div>
</CardContent>
@@ -352,15 +175,11 @@ export default function RolesPage() {
);
}
/* ───────── Permission Node Component ───────── */
/* ───────── Permission Node (只读展示) ───────── */
function PermissionNodeComponent({
node, checkedKeys, onToggle, onToggleChildren, depth
}: {
function PermissionNode({ node, checkedKeys, depth }: {
node: typeof permissionTreeData[0];
checkedKeys: Set<string>;
onToggle: (id: string) => void;
onToggleChildren: (node: typeof permissionTreeData[0], checked: boolean) => void;
depth: number;
}) {
const [expanded, setExpanded] = useState(true);
@@ -372,43 +191,34 @@ function PermissionNodeComponent({
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
className="flex items-center gap-2 py-1.5 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-default"
style={{ paddingLeft: `${depth * 20 + 12}px` }}
onClick={() => { if (hasChildren) setExpanded(!expanded); }}
>
{hasChildren && (
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
{hasChildren ? (
<button onClick={() => setExpanded(!expanded)} className="p-0">
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
) : (
<div className="w-4" />
)}
{!hasChildren && <div className="w-4" />}
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
className={`w-4 h-4 rounded flex items-center justify-center ${
isChecked || allChildrenChecked
? 'bg-blue-500 border-blue-500'
? 'bg-blue-500'
: someChildrenChecked
? 'bg-blue-500/50 border-blue-500'
: 'border-[var(--dg-text-muted)]'
? 'bg-blue-500/40'
: 'bg-[var(--dg-bg-tertiary)]'
}`}
onClick={e => {
e.stopPropagation();
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
else onToggle(node.id);
}}
>
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
{(isChecked || allChildrenChecked) && <Check className="w-3 h-3 text-white" />}
{someChildrenChecked && !allChildrenChecked && <Check className="w-3 h-3 text-white/60" />}
</div>
<span className="text-sm select-none">{node.label}</span>
</div>
{hasChildren && expanded && (
<div>
{node.children!.map(child => (
<PermissionNodeComponent
key={child.id}
node={child}
checkedKeys={checkedKeys}
onToggle={onToggle}
onToggleChildren={onToggleChildren}
depth={depth + 1}
/>
<PermissionNode key={child.id} node={child} checkedKeys={checkedKeys} depth={depth + 1} />
))}
</div>
)}
+8
View File
@@ -183,6 +183,14 @@ export default function TicketsPage() {
useEffect(() => { fetchTickets(); }, [fetchTickets]);
useEffect(() => { fetchStats(); }, [fetchStats]);
// executing 状态工单自动轮询(3秒)
const hasExecuting = tickets.some(t => t.status === 'executing');
useEffect(() => {
if (!hasExecuting) return;
const timer = setInterval(() => { fetchTickets(); fetchStats(); }, 3000);
return () => clearInterval(timer);
}, [hasExecuting, fetchTickets, fetchStats]);
const totalPages = Math.ceil(total / pageSize);
const sortedTickets = useMemo(() => {
+480 -110
View File
@@ -1,12 +1,15 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
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,
UserCheck, UserX, Filter, ChevronLeft, ChevronRight, FileText,
Users as UsersIcon, Server,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { userApi } from '@/api/user';
import { userApi, ldapApi } from '@/api/user';
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
import { nameToUsername } from '@/utils/pinyin';
import { toast } from '@/hooks/useToast';
/* ───────── Types ───────── */
@@ -41,14 +44,14 @@ const roleNameMap: Record<string, string> = {
admin: '超级管理员',
dba: '数据库管理员',
developer: '开发人员',
guest: '访客',
auditor: '审计员',
};
const roleTagStyle: Record<string, string> = {
admin: 'bg-purple-500/15 text-purple-400',
dba: 'bg-blue-500/15 text-blue-400',
developer: 'bg-green-500/15 text-green-400',
guest: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
auditor: 'bg-orange-500/15 text-orange-400',
};
const roleOptions = [
@@ -119,8 +122,10 @@ export default function UsersPage() {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [loginTimeFilter, setLoginTimeFilter] = useState('');
const [users, setUsers] = useState<UserItem[]>([]);
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<UserItem | null>(null);
@@ -128,45 +133,30 @@ export default function UsersPage() {
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [showBatchMenu, setShowBatchMenu] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [showImport, setShowImport] = useState(false);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await userApi.list();
const data = (res as any)?.items || res;
setUsers(Array.isArray(data) ? data : []);
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 {
setToast({ message: '获取用户列表失败', type: 'error' });
toast('error', '获取用户列表失败');
} finally {
setLoading(false);
}
}, []);
}, [page, pageSize, search, roleFilter, statusFilter]);
useEffect(() => { fetchUsers(); }, [fetchUsers]);
// Auto-dismiss toast
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(t);
}, [toast]);
const filtered = useMemo(() => {
return users.filter(u => {
if (roleFilter && u.role !== roleFilter) return false;
if (statusFilter === 'active' && !u.is_active) return false;
if (statusFilter === 'disabled' && u.is_active) return false;
if (search && !u.username.toLowerCase().includes(search.toLowerCase()) && !(u.email || '').toLowerCase().includes(search.toLowerCase()) && !(u.display_name || '').toLowerCase().includes(search.toLowerCase())) return false;
if (loginTimeFilter && u.last_login) {
const days = Math.floor((Date.now() - new Date(u.last_login).getTime()) / 86400000);
if (loginTimeFilter === '7d' && days > 7) return false;
if (loginTimeFilter === '30d' && days > 30) return false;
if (loginTimeFilter === '90d' && days > 90) return false;
}
return true;
});
}, [users, roleFilter, statusFilter, search, loginTimeFilter]);
const filtered = users;
const allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id));
@@ -192,11 +182,9 @@ export default function UsersPage() {
const handleToggleActive = async (user: UserItem) => {
try {
await userApi.update(user.id, { is_active: !user.is_active });
setToast({ message: `用户已${!user.is_active ? '启用' : '禁用'}`, type: 'success' });
toast('success', `用户已${!user.is_active ? '启用' : '禁用'}`);
fetchUsers();
} catch {
setToast({ message: '操作失败', type: 'error' });
}
} catch { toast('error', '操作失败'); }
};
const handleDelete = async (id: number) => {
@@ -204,11 +192,9 @@ export default function UsersPage() {
try {
await userApi.delete(id);
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
setToast({ message: '用户已删除', type: 'success' });
toast('success', '用户已删除');
fetchUsers();
} catch {
setToast({ message: '删除失败', type: 'error' });
}
} catch { toast('error', '删除失败'); }
};
const handleBatchAction = async (action: string) => {
@@ -218,51 +204,33 @@ export default function UsersPage() {
try {
if (action === 'enable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
setToast({ message: `已启用 ${ids.length} 个用户`, type: 'success' });
toast('success', `已启用 ${ids.length} 个用户`);
} else if (action === 'disable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
setToast({ message: `已禁用 ${ids.length} 个用户`, type: 'success' });
toast('success', `已禁用 ${ids.length} 个用户`);
} else if (action === 'delete') {
await Promise.all(ids.map(id => userApi.delete(id)));
setToast({ message: `已删除 ${ids.length} 个用户`, type: 'success' });
toast('success', `已删除 ${ids.length} 个用户`);
}
setSelectedIds(new Set());
fetchUsers();
} catch {
setToast({ message: '批量操作失败', type: 'error' });
}
} catch { toast('error', '批量操作失败'); }
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
// Simulate import
setToast({ message: `文件 "${file.name}" 导入成功`, type: 'success' });
fetchUsers();
} catch {
setToast({ message: '导入失败,请检查文件格式', type: 'error' });
}
};
reader.readAsText(file);
e.target.value = '';
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 (
<div className="space-y-5">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
}`}>
{toast.type === 'success' ? <CheckSquare className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{toast.message}
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
@@ -270,10 +238,18 @@ export default function UsersPage() {
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all cursor-pointer">
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<FileText className="w-4 h-4" /> 模板
</button>
<button
onClick={() => setShowImport(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<Upload className="w-4 h-4" /> 导入
<input type="file" accept=".csv" className="hidden" onChange={handleImport} />
</label>
</button>
<button
onClick={() => exportUsersToCSV(filtered)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
@@ -282,7 +258,7 @@ export default function UsersPage() {
</button>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" /> 新建用户
</button>
@@ -317,19 +293,9 @@ export default function UsersPage() {
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
value={loginTimeFilter}
onChange={e => setLoginTimeFilter(e.target.value)}
>
<option value="">全部登录时间</option>
<option value="7d">7天内登录</option>
<option value="30d">30天内登录</option>
<option value="90d">90天内登录</option>
</select>
{(search || roleFilter || statusFilter || loginTimeFilter) && (
{(search || roleFilter || statusFilter) && (
<button
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setPage(1); }}
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<RotateCcw className="w-3 h-3" /> 重置
@@ -386,9 +352,9 @@ export default function UsersPage() {
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">没有找到匹配的用户</p>
{(search || roleFilter || statusFilter || loginTimeFilter) && (
{(search || roleFilter || statusFilter) && (
<button
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); }}
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
>
清除筛选条件
@@ -431,9 +397,6 @@ export default function UsersPage() {
</td>
<td className="px-4 py-3.5">
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setShowDetail(user)}>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
{(user.display_name || user.username).charAt(0)}
</div>
<div>
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
@@ -504,9 +467,30 @@ export default function UsersPage() {
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
<span className="text-sm text-[var(--dg-text-muted)]">
共 <strong className="text-[var(--dg-text-primary)]">{filtered.length}</strong> 条记录
共 <strong className="text-[var(--dg-text-primary)]">{total}</strong> 条
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
</span>
{total > pageSize && (
<div className="flex items-center gap-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
{page} / {Math.ceil(total / pageSize)}
</span>
<button
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
disabled={page >= Math.ceil(total / pageSize)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</>
)}
@@ -515,7 +499,7 @@ export default function UsersPage() {
{/* Create Modal */}
{showCreate && (
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} setToast={setToast} />
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
)}
{/* Detail Modal */}
@@ -525,12 +509,17 @@ export default function UsersPage() {
{/* Edit Modal */}
{showEdit && (
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} setToast={setToast} />
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} />
)}
{/* Reset Password Modal */}
{showResetPwd && (
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} setToast={setToast} />
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} />
)}
{/* Batch Import Modal */}
{showImport && (
<BatchImportModal onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); fetchUsers(); }} />
)}
</div>
);
@@ -538,7 +527,7 @@ export default function UsersPage() {
/* ───────── Create User Modal ───────── */
function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void; onCreated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
@@ -555,10 +544,10 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
setSubmitting(true);
try {
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
setToast({ message: '用户创建成功', type: 'success' });
toast('success', '用户创建成功');
onCreated();
} catch (e: any) {
setToast({ message: e?.response?.data?.detail || '创建失败', type: 'error' });
toast('error', e?.response?.data?.detail || '创建失败');
} finally {
setSubmitting(false);
}
@@ -614,7 +603,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
<option value="admin">超级管理员</option>
<option value="dba">数据库管理员</option>
<option value="developer">开发人员</option>
<option value="guest">访客</option>
<option value="auditor">审计员</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-2">
@@ -631,7 +620,7 @@ function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void
/* ───────── Edit User Modal ───────── */
function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem; onClose: () => void; onUpdated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
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);
@@ -663,10 +652,10 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
if (selectedGroupIds.length >= 0) {
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
}
setToast({ message: '用户更新成功', type: 'success' });
toast('success', '用户更新成功');
onUpdated();
} catch {
setToast({ message: '更新失败', type: 'error' });
toast('error', '更新失败');
} finally {
setSubmitting(false);
}
@@ -703,7 +692,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
<option value="admin">超级管理员</option>
<option value="dba">数据库管理员</option>
<option value="developer">开发人员</option>
<option value="guest">访客</option>
<option value="auditor">审计员</option>
</select>
</div>
<div>
@@ -745,7 +734,7 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
/* ───────── Reset Password Modal ───────── */
function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClose: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
function ResetPasswordModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
@@ -760,10 +749,10 @@ function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClo
setSubmitting(true);
try {
await userApi.update(user.id, { password: newPassword });
setToast({ message: '密码重置成功', type: 'success' });
toast('success', '密码重置成功');
onClose();
} catch {
setToast({ message: '密码重置失败', type: 'error' });
toast('error', '密码重置失败');
} finally {
setSubmitting(false);
}
@@ -875,3 +864,384 @@ function Clock(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
/* ───────── 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<any[]>([]);
const [skipExisting, setSkipExisting] = useState(true);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<any>(null);
const fileRef = useRef<HTMLInputElement>(null);
// LDAP state
const [ldapOus, setLdapOus] = useState<any[]>([]);
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
const [selectedLdapUsers, setSelectedLdapUsers] = useState<Set<string>>(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<HTMLInputElement>) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="w-full max-w-2xl max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4">
<h2 className="text-lg font-semibold">批量导入用户</h2>
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 pb-3">
{TABS.map(t => {
const Icon = t.icon;
return (
<button key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors ${tab === t.key ? 'bg-[var(--dg-blue)] text-white' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
<Icon className="w-4 h-4" />{t.label}
</button>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* ── Local Import ── */}
{tab === 'local' && (
<>
<div className="flex items-center gap-3">
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
<Upload className="w-4 h-4" />选择 CSV 文件
</button>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<input type="checkbox" checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)} className="rounded" />
跳过已存在用户
</label>
</div>
{preview.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-2 pr-3">姓名</th>
<th className="py-2 pr-3">用户名</th>
<th className="py-2 pr-3">邮箱</th>
<th className="py-2">角色</th>
</tr>
</thead>
<tbody>
{preview.slice(0, 50).map((r, i) => (
<tr key={i} className="border-b border-[var(--dg-border)]/50">
<td className="py-2 pr-3">{r.display_name}</td>
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{r.email || '-'}</td>
<td className="py-2 text-[var(--dg-text-muted)]">{r.role}</td>
</tr>
))}
{preview.length > 50 && (
<tr><td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
)}
</tbody>
</table>
</div>
)}
</>
)}
{/* ── Import to LDAP ── */}
{tab === 'to-ldap' && (
<>
{!ldapReady && (
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
</div>
)}
{ldapReady && (
<>
{/* OU selector */}
<div>
<label className="block text-sm font-medium mb-1.5">目标 OU</label>
<div className="flex items-center gap-2">
<select
value={targetOu}
onChange={e => setTargetOu(e.target.value)}
className="flex-1 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"
>
<option value="">Base DN (根)</option>
{ldapOus.map((ou: any) => (
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
))}
</select>
<button onClick={loadLdapOus} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新 OU">
<RotateCcw className="w-4 h-4" />
</button>
<button onClick={() => setShowCreateOu(true)} className="flex items-center gap-1 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm hover:bg-[var(--dg-bg-hover)]">
<Plus className="w-3.5 h-3.5" />新建 OU
</button>
</div>
</div>
{/* File upload (same CSV format) */}
<div className="flex items-center gap-3">
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
<Upload className="w-4 h-4" />选择 CSV 文件
</button>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
<span className="text-xs text-[var(--dg-text-muted)]">CSV 需包含"姓名"列,系统自动生成拼音用户名和密码</span>
</div>
{preview.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-2 pr-3">姓名</th>
<th className="py-2 pr-3">用户名(自动)</th>
<th className="py-2">密码(自动)</th>
</tr>
</thead>
<tbody>
{preview.slice(0, 30).map((r, i) => (
<tr key={i} className="border-b border-[var(--dg-border)]/50">
<td className="py-2 pr-3">{r.display_name}</td>
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
<td className="py-2 text-[var(--dg-text-muted)]">{r.password}</td>
</tr>
))}
{preview.length > 30 && (
<tr><td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
)}
</tbody>
</table>
</div>
)}
</>
)}
</>
)}
{/* ── From LDAP Import ── */}
{tab === 'from-ldap' && (
<>
{!ldapReady && (
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
</div>
)}
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
{!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (
<div className="space-y-1">
<p className="text-sm text-[var(--dg-text-muted)] mb-2">选择组织单元:</p>
{ldapOus.map(ou => (
<button key={ou.dn} onClick={() => loadLdapUsers(ou.dn)} className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] flex items-center gap-2">
<UsersIcon className="w-4 h-4 text-[var(--dg-text-muted)]" />
<span>{ou.name}</span>
{ou.description && <span className="text-[var(--dg-text-muted)]">— {ou.description}</span>}
</button>
))}
</div>
)}
{ldapUsers.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm text-[var(--dg-text-muted)]">{ldapUsers.length} 个用户</p>
<button onClick={() => setSelectedLdapUsers(prev => prev.size === ldapUsers.length ? new Set() : new Set(ldapUsers.map(u => u.dn)))} className="text-xs text-[var(--dg-blue)]">
{selectedLdapUsers.size === ldapUsers.length ? '取消全选' : '全选'}
</button>
</div>
{ldapUsers.map(u => (
<label key={u.dn} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer">
<input type="checkbox" checked={selectedLdapUsers.has(u.dn)} onChange={() => setSelectedLdapUsers(prev => { const n = new Set(prev); n.has(u.dn) ? n.delete(u.dn) : n.add(u.dn); return n; })} />
<span className="text-sm font-medium">{u.nickname || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">@{u.username}</span>
{u.email && <span className="text-xs text-[var(--dg-text-muted)]">{u.email}</span>}
</label>
))}
</div>
)}
{!ldapLoading && ldapReady && ldapOus.length === 0 && (
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">LDAP 目录中无可用 OU,请先创建 OU 或检查配置</p>
)}
</>
)}
{/* Result */}
{result && (
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-sm space-y-1">
<p className="text-green-400">成功: {result.success_count ?? (result.success ? 1 : 0)}</p>
{result.skipped_count > 0 && <p className="text-yellow-400">跳过: {result.skipped_count}</p>}
{result.failed_count > 0 && <p className="text-red-400">失败: {result.failed_count}</p>}
{result.failed_users?.map((f: any, i: number) => (
<p key={i} className="text-red-400 text-xs">{f.username}: {f.error}</p>
))}
{result.message && !result.success_count && <p className="text-[var(--dg-text-secondary)]">{result.message}</p>}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4">
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">关闭</button>
<button onClick={handleImport} disabled={!canImport()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
{importing ? '导入中...' : '确认导入'}
</button>
</div>
{/* Create OU sub-dialog */}
{showCreateOu && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">创建新的组织单位 (OU)</h3>
<button onClick={() => setShowCreateOu(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">OU 名称 *</label>
<input value={newOuName} onChange={e => 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="例如:研发部、市场部" />
</div>
<div>
<label className="block text-sm font-medium mb-1">描述</label>
<input value={newOuDesc} onChange={e => 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="可选描述" />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setShowCreateOu(false)} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={handleCreateOu} disabled={creatingOu || !newOuName.trim()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
{creatingOu ? '创建中...' : '创建 OU'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}