Files
FlowStack/frontend/src/pages/UserManage.tsx
T

697 lines
30 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 { useMemo, useState } from 'react';
import {
Plus,
Loader2,
KeyRound,
Trash2,
ShieldCheck,
Copy,
CloudDownload,
AlertCircle,
UserCog,
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, auth } from '@/lib/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
function fmtTime(v?: string): string {
if (!v || v.startsWith('0001-')) return '—';
return v.replace('T', ' ').slice(0, 16);
}
const roleBadge = {
admin: { label: '管理员', bg: '#dafbe1', color: '#1a7f37' },
user: { label: '普通用户', bg: '#f6f8fa', color: '#6e7781' },
} as const;
const sourceBadge = {
ldap: { label: 'LDAP 导入', bg: '#fbefff', color: '#8250df' },
local: { label: '本地账号', bg: '#ddf4ff', color: '#0969da' },
} as const;
interface UserForm {
username: string;
nickname: string;
email: string;
role: string;
password: string;
}
const emptyForm: UserForm = { username: '', nickname: '', email: '', role: 'user', password: '' };
const inputStyle = {
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-primary)',
};
interface LdapEntry {
username: string;
displayName?: string;
email?: string;
}
interface ImportResult {
created: number;
converted: number;
skipped: number;
failed: number;
failedUsers: string[];
}
export default function UserManage() {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<UserForm>(emptyForm);
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
const [ldapOpen, setLdapOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [importResult, setImportResult] = useState<ImportResult | null>(null);
// 批量删除
const [batchIds, setBatchIds] = useState<Set<number>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const currentUsername = (() => {
try {
const token = auth.getToken() || '';
const raw = atob(token.replace(/-/g, '+').replace(/_/g, '/'));
return raw.split('|')[0];
} catch { return ''; }
})();
const toggleBatch = (id: number) => {
setBatchIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const batchDelete = async () => {
const targets = users.filter((u: any) => batchIds.has(u.id));
if (targets.length === 0) return;
const preview = targets.slice(0, 5).map((u: any) => u.username).join('、') + (targets.length > 5 ? ` 等 ${targets.length} 个` : '');
if (!window.confirm(`确定删除 ${preview} 吗?`)) return;
setBatchDeleting(true);
let ok = 0;
const failed: string[] = [];
for (const u of targets) {
try {
await apiClient.deleteUser(u.id);
ok++;
} catch (err) {
failed.push(`${u.username}(${(err as Error).message})`);
}
}
setBatchDeleting(false);
setBatchIds(new Set());
invalidateUsers();
if (failed.length === 0) toast.success(`已删除 ${ok} 个用户`);
else toast.warning(`删除完成:成功 ${ok},失败 ${failed.length}`, { description: failed.join(';') });
};
const usersQuery = useQuery({ queryKey: ['users'], queryFn: apiClient.listUsers });
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
// LDAP 就绪(已启用且关键配置齐全)时才显示「从 LDAP 导入」入口
const ldapReady = !!(settings?.ldapEnabled && settings?.ldapHost && settings?.ldapBindDN && settings?.ldapBaseDN);
const users: any[] = usersQuery.data ?? [];
const selectableUsers = users.filter((u: any) => u.username !== currentUsername);
const allChecked = selectableUsers.length > 0 && selectableUsers.every((u: any) => batchIds.has(u.id));
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['users'] });
// LDAP 预览列表(仅弹窗打开且未进入结果态时请求)
const ldapQuery = useQuery({
queryKey: ['ldap-users'],
queryFn: apiClient.ldapListUsers,
enabled: ldapOpen && !importResult,
});
const ldapEntries: LdapEntry[] = (ldapQuery.data as LdapEntry[]) ?? [];
// 本地用户索引(按用户名小写匹配,目录服务通常大小写不敏感)
const localMap = useMemo(() => {
const m = new Map<string, any>();
users.forEach((u) => m.set(String(u.username || '').toLowerCase(), u));
return m;
}, [users]);
// ---- 新建用户 ----
const createMutation = useMutation({
mutationFn: (body: any) => apiClient.createUser(body),
onSuccess: (res: any) => {
invalidateUsers();
setDialogOpen(false);
if (res?.initialPassword) {
setCreatedResult({ username: form.username.trim(), password: String(res.initialPassword) });
} else {
toast.success('用户已创建');
}
},
onError: (err: Error) => toast.error(err.message),
});
const submitCreate = () => {
if (!form.username.trim()) {
toast.error('请填写用户名');
return;
}
const body: any = {
username: form.username.trim(),
nickname: form.nickname.trim(),
email: form.email.trim(),
role: form.role,
};
if (form.password) body.password = form.password;
createMutation.mutate(body);
};
// ---- 重置密码 ----
const resetMutation = useMutation({
mutationFn: (id: any) => apiClient.resetUserPassword(id),
onSuccess: (res: any) => {
if (res?.initialPassword) {
toast.success('密码已重置', { description: `一次性密码:${res.initialPassword}(仅显示一次,请立即保存)` });
} else {
toast.success('密码已重置');
}
},
onError: (err: Error) => toast.error(err.message),
});
// ---- 角色切换 ----
const roleMutation = useMutation({
mutationFn: (vars: { id: any; role: string }) => apiClient.updateUser(vars.id, { role: vars.role }),
onSuccess: (_d, vars) => {
toast.success(vars.role === 'admin' ? '已设为管理员' : '已降为普通用户');
invalidateUsers();
},
onError: (err: Error) => toast.error(err.message),
});
// ---- 删除 ----
const deleteMutation = useMutation({
mutationFn: (id: any) => apiClient.deleteUser(id),
onSuccess: () => {
toast.success('用户已删除');
invalidateUsers();
},
onError: (err: Error) => toast.error(err.message),
});
// ---- LDAP 导入 ----
const importMutation = useMutation({
mutationFn: (list: LdapEntry[]) => apiClient.ldapImport(list),
onSuccess: (res: any) => {
setImportResult({
created: res?.created ?? 0,
converted: res?.converted ?? 0,
skipped: res?.skipped ?? 0,
failed: res?.failed ?? 0,
failedUsers: res?.failedUsers ?? [],
});
invalidateUsers();
},
onError: (err: Error) => toast.error('导入失败', { description: err.message }),
});
const openLdapDialog = () => {
setSelected(new Set());
setImportResult(null);
setLdapOpen(true);
};
const closeLdapDialog = (open: boolean) => {
setLdapOpen(open);
if (!open) {
setSelected(new Set());
setImportResult(null);
invalidateUsers();
}
};
const toggleSelected = (username: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(username)) next.delete(username);
else next.add(username);
return next;
});
};
const selectedEntries = ldapEntries.filter((e) => selected.has(e.username));
const copyText = (text: string) => {
navigator.clipboard
.writeText(text)
.then(() => toast.success('已复制', { description: text }))
.catch(() => toast.error('复制失败'));
};
const importStats = importResult
? [
{ key: 'created', label: '新增', value: importResult.created, bg: '#dafbe1', color: '#1a7f37' },
{ key: 'converted', label: '转换', value: importResult.converted, bg: '#ddf4ff', color: '#0969da' },
{ key: 'skipped', label: '跳过', value: importResult.skipped, bg: '#f6f8fa', color: '#6e7781' },
{ key: 'failed', label: '失败', value: importResult.failed, bg: '#ffebe9', color: '#cf222e' },
]
: [];
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
<div
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>用户管理</h1>
<span
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{users.length}
</span>
</div>
<div className="flex items-center gap-2">
{ldapReady && (
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={openLdapDialog}
>
<CloudDownload className="w-3.5 h-3.5" />
从 LDAP 导入
</button>
)}
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => {
setForm(emptyForm);
setDialogOpen(true);
}}
>
<Plus className="w-3.5 h-3.5" />
新建用户
</button>
{batchIds.size > 0 && (
<button
onClick={batchDelete}
disabled={batchDeleting}
className="flex items-center gap-1.5 px-3 py-[6px] text-[12px] font-medium rounded-md border disabled:opacity-60"
style={{ background: '#ffebe9', borderColor: '#cf222e', color: '#cf222e' }}
>
{batchDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
批量删除({batchIds.size})
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{usersQuery.isLoading ? (
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />
加载中...
</div>
) : usersQuery.isError ? (
<div className="flex flex-col items-center gap-3 py-16">
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(usersQuery.error as Error).message}</div>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
onClick={() => usersQuery.refetch()}
>
重试
</button>
</div>
) : (
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
<th className="w-9 px-3 py-2">
<input
type="checkbox"
checked={allChecked}
onChange={(e) => setBatchIds(e.target.checked ? new Set(selectableUsers.map((u: any) => u.id)) : new Set())}
title="全选(不含当前登录用户)"
/>
</th>
{['用户名', '昵称', '邮箱', '角色', '来源', '创建时间', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => {
const role = (roleBadge as any)[u.role] ?? roleBadge.user;
const source = (sourceBadge as any)[u.authSource] ?? sourceBadge.local;
const isAdmin = u.role === 'admin';
return (
<tr key={u.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-3 py-3">
{u.username === currentUsername ? (
<input type="checkbox" disabled title="当前登录用户不可删除" />
) : (
<input type="checkbox" checked={batchIds.has(u.id)} onChange={() => toggleBatch(u.id)} />
)}
</td>
<td className="px-4 py-3">
<span className="text-[13px] font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
{u.username === currentUsername && (
<span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-tertiary)' }}>(当前登录)</span>
)}
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.nickname || '—'}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.email || '—'}</td>
<td className="px-4 py-3">
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: role.bg, color: role.color }}>
{role.label}
</span>
</td>
<td className="px-4 py-3">
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: source.bg, color: source.color }}>
{source.label}
</span>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(u.createdAt)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
{u.authSource !== 'ldap' && (
<button
className="p-1 rounded hover:bg-black/5"
title="重置密码"
disabled={resetMutation.isPending}
onClick={() => resetMutation.mutate(u.id)}
>
<KeyRound className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
)}
<button
className="p-1 rounded hover:bg-black/5"
title={isAdmin ? '降为普通用户' : '设为管理员'}
disabled={roleMutation.isPending}
onClick={() => roleMutation.mutate({ id: u.id, role: isAdmin ? 'user' : 'admin' })}
>
{isAdmin ? (
<UserCog className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} />
) : (
<ShieldCheck className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
)}
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={() => {
if (window.confirm(`确定删除用户 ${u.username} 吗?`)) deleteMutation.mutate(u.id);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
</tr>
);
})}
{users.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
暂无用户,点击右上角「新建用户」创建
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* 新建用户弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>新建用户</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名 *</Label>
<Input
className="h-8 text-[12px] font-mono"
style={inputStyle}
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
placeholder="zhangsan"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>昵称</Label>
<Input
className="h-8 text-[12px]"
style={inputStyle}
value={form.nickname}
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
placeholder="张三"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>角色</Label>
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
<SelectValue placeholder="选择角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user" className="text-[12px]">普通用户</SelectItem>
<SelectItem value="admin" className="text-[12px]">管理员</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>邮箱</Label>
<Input
className="h-8 text-[12px]"
style={inputStyle}
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="zhangsan@example.com"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</Label>
<Input
type="password"
className="h-8 text-[12px] font-mono"
style={inputStyle}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
placeholder="留空由系统随机生成"
/>
</div>
</div>
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={() => setDialogOpen(false)}
>
取消
</button>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: 'var(--accent-blue)' }}
disabled={createMutation.isPending}
onClick={submitCreate}
>
{createMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
创建
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 创建成功:一次性密码 */}
<Dialog open={!!createdResult} onOpenChange={(open) => !open && setCreatedResult(null)}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>用户已创建</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
用户 <span className="font-mono font-medium" style={{ color: 'var(--text-primary)' }}>{createdResult?.username}</span> 创建成功。
系统已生成一次性初始密码,仅显示此次,请立即复制并妥善交付给用户:
</p>
<div
className="flex items-center justify-between gap-2 px-3 py-2.5 rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
>
<code className="text-[14px] font-mono font-semibold tracking-wide break-all" style={{ color: 'var(--accent-blue)' }}>
{createdResult?.password}
</code>
<button
className="flex items-center gap-1 px-2 py-1 text-[11px] rounded border flex-shrink-0"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={() => createdResult && copyText(createdResult.password)}
>
<Copy className="w-3 h-3" />
复制
</button>
</div>
</div>
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => setCreatedResult(null)}
>
我已保存
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* LDAP 导入弹窗 */}
<Dialog open={ldapOpen} onOpenChange={closeLdapDialog}>
<DialogContent className="sm:max-w-lg" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>从 LDAP 导入用户</DialogTitle>
</DialogHeader>
{importResult ? (
/* 结果态:四色统计 + 失败名单 */
<div className="space-y-4">
<div className="grid grid-cols-4 gap-2">
{importStats.map((s) => (
<div key={s.key} className="rounded-md px-3 py-2.5 text-center" style={{ background: s.bg }}>
<div className="text-[18px] font-bold" style={{ color: s.color }}>{s.value}</div>
<div className="text-[11px] font-medium" style={{ color: s.color }}>{s.label}</div>
</div>
))}
</div>
{importResult.failedUsers.length > 0 && (
<div
className="rounded-md border px-3 py-2.5 space-y-1.5"
style={{ background: '#ffebe9', borderColor: '#ff818266' }}
>
<div className="flex items-center gap-1.5 text-[12px] font-medium" style={{ color: '#cf222e' }}>
<AlertCircle className="w-3.5 h-3.5" />
以下用户导入失败:
</div>
<div className="flex flex-wrap gap-1.5">
{importResult.failedUsers.map((name) => (
<code key={name} className="text-[11px] font-mono px-1.5 py-[2px] rounded" style={{ background: '#ffffff99', color: '#cf222e' }}>
{name}
</code>
))}
</div>
</div>
)}
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
「转换」表示本地同名账号已切换为 LDAP 来源;「跳过」表示该用户已是 LDAP 账号。
</p>
</div>
) : ldapQuery.isLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" />
正在从 LDAP 目录读取用户...
</div>
) : ldapQuery.isError ? (
<div className="flex flex-col items-center gap-3 py-10">
<div className="text-[13px] text-center" style={{ color: 'var(--text-secondary)' }}>
{(ldapQuery.error as Error).message}
</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>请到系统设置检查 LDAP 配置</div>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
onClick={() => ldapQuery.refetch()}
>
重试
</button>
</div>
) : (
/* 选择态 */
<div className="space-y-3">
<div className="max-h-[340px] overflow-y-auto custom-scrollbar rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
{ldapEntries.map((entry) => {
const local = localMap.get(String(entry.username || '').toLowerCase());
const isImported = local?.authSource === 'ldap';
const willConvert = !!local && local.authSource !== 'ldap';
const checked = selected.has(entry.username);
return (
<label
key={entry.username}
className={`flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 ${isImported ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
style={{ borderColor: 'var(--border-color-light)', background: checked ? 'var(--accent-blue-light)' : 'transparent' }}
>
<input
type="checkbox"
className="w-3.5 h-3.5 flex-shrink-0"
style={{ accentColor: 'var(--accent-blue)' }}
checked={checked}
disabled={isImported}
onChange={() => toggleSelected(entry.username)}
/>
<span className="text-[12px] font-mono font-medium flex-shrink-0" style={{ color: 'var(--text-primary)' }}>
{entry.username}
</span>
<span className="text-[11px] truncate flex-1" style={{ color: 'var(--text-tertiary)' }}>
{[entry.displayName, entry.email].filter(Boolean).join(' · ') || '—'}
</span>
{isImported && (
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#f6f8fa', color: '#6e7781' }}>已导入</span>
)}
{willConvert && (
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#ddf4ff', color: '#0969da' }}>将转为 LDAP</span>
)}
</label>
);
})}
{ldapEntries.length === 0 && (
<div className="px-3 py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
目录中未读取到用户
</div>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
共 {ldapEntries.length} 个用户,已选 {selected.size} 个
</span>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: 'var(--accent-blue)' }}
disabled={selected.size === 0 || importMutation.isPending}
onClick={() => importMutation.mutate(selectedEntries)}
>
{importMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
导入 {selected.size} 个用户
</button>
</div>
</div>
)}
{importResult && (
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => closeLdapDialog(false)}
>
完成
</button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</div>
);
}