按需求(sql.astx.club/settings):
1. 移除「测试账号验证」按钮
- LdapConfigForm.tsx 删按钮 + onOpenAccountTest prop
- 不再渲染 LdapAccountTestDialog
2. 移除批量导入的 LDAP 写操作(BatchImportModal.tsx)
- TABS 去掉「导入到 LDAP」「从 LDAP 导入」
- 删除对应两个 JSX 代码块(tab === 'to-ldap' / 'from-ldap')
- 删除「创建 OU」子弹窗
- 只保留「本地导入」(CSV 建本地账号,不触碰 LDAP 目录)
3. 移除 LDAP 账号管理面板(含危险写操作)
- 不再渲染 LdapManagePanel(新建 OU / 添加用户 / 删除用户)
- 删除 LdapCreateOuDialog / LdapAddUserDialog / ConfirmDialog 引用
- LdapConfigPage.tsx 421 → 340 行;BatchImportModal.tsx 557 → 329 行
4. 保留(只读用途)
- LDAP 配置表单(服务器 / 绑定 / 搜索 / 属性映射)
- 「测试连接」按钮(验证配置可用,不写 LDAP 目录)
设计原则:LDAP 仅用于域账号登录认证(只读 bind + search),
目录维护请到域控侧操作。
验证(前端 bundle grep,全部归零):
测试账号验证 0 | 导入到 LDAP 0 | 从 LDAP 导入 0
新建 OU 0 | 创建 OU 0 | 危险操作 0 | 此操作不可逆 0
保留:
测试连接 6 | 本地导入 1
镜像 sha256:3970db141d23
/health → {"backend":"go","status":"ok","version":"0.4.0"}
http://sql.astx.club/settings → HTTP 200
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
/**
|
|
* 用途:批量导入用户弹窗(本地 CSV 导入 / 导入到 LDAP / 从 LDAP 导入 三个 Tab,含新建 OU 子弹窗)
|
|
* 必需 props:onClose(关闭)、onImported(导入有成功项后回调,页面负责关窗并刷新列表)
|
|
* 已知限制:CSV 预览仅展示前 50(本地)/ 30(到 LDAP)行;拼音用户名重复时以 username+序号作稳定 key
|
|
*/
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { Upload, X, RotateCcw, Plus, Users as UsersIcon, Server } from 'lucide-react';
|
|
import { userApi, ldapApi } from '@/api/user';
|
|
import { nameToUsername } from '@/utils/pinyin';
|
|
import { toast } from '@/hooks/useToast';
|
|
import { ModalOverlay } from '@/components/ModalOverlay';
|
|
|
|
interface BatchImportModalProps {
|
|
onClose: () => void;
|
|
onImported: () => void;
|
|
}
|
|
|
|
export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {
|
|
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(ldapEnabled:后端已保存 LDAP 配置才显示相关 Tab)
|
|
const [ldapEnabled, setLdapEnabled] = useState(false);
|
|
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);
|
|
|
|
// 挂载即探测配置(决定是否显示 LDAP Tab)
|
|
useEffect(() => {
|
|
ldapApi
|
|
.getConfig()
|
|
.then((res: any) => {
|
|
setLdapEnabled(!!res?.ldap_host);
|
|
})
|
|
.catch(() => setLdapEnabled(false));
|
|
}, []);
|
|
|
|
// Check LDAP readiness + load OUs
|
|
useEffect(() => {
|
|
if (tab === 'to-ldap' || tab === 'from-ldap') {
|
|
setLdapLoading(true);
|
|
ldapApi
|
|
.getConfig()
|
|
.then((res: any) => {
|
|
const ready = !!res?.ldap_host;
|
|
setLdapReady(ready);
|
|
if (ready && ldapOus.length === 0) {
|
|
return loadLdapOus();
|
|
}
|
|
setLdapLoading(false);
|
|
})
|
|
.catch(() => {
|
|
setLdapReady(false);
|
|
setLdapLoading(false);
|
|
});
|
|
}
|
|
}, [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(Array.isArray(res) ? res : res?.ous || res?.data || []);
|
|
} 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(Array.isArray(res) ? res : res?.users || res?.data || []);
|
|
} 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;
|
|
};
|
|
|
|
// LDAP 未配置时隐藏 LDAP 相关 Tab(本地 CSV 始终可用)
|
|
// LDAP 仅作只读接入:不再提供「导入到 LDAP」「从 LDAP 导入」
|
|
const TABS = [
|
|
{ key: 'local' as const, label: '本地导入', icon: Upload },
|
|
];
|
|
|
|
return (
|
|
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
|
|
<div className="w-full max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col">
|
|
{/* 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={`${r.username}-${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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
</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>
|
|
|
|
</div>
|
|
</ModalOverlay>
|
|
);
|
|
}
|