697 lines
30 KiB
TypeScript
697 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|