Files
aiops/frontend/src/components/system/SystemUserTab.tsx
T
attacker a7e2351ad2 feat: 定时任务编辑入口全栈打通 + 修复ApprovalFlow编译错误 + 完成大文件拆分重构
- CronJobManager: 编辑按钮接通 onClick → openEdit(job),弹出编辑对话框预填数据,
  复用新建/编辑共用 Dialog(editingId 区分模式),标题/按钮文案动态切换,
  保存时根据 editingId 调用 createJob 或 updateJob — 消除占位按钮
- ApprovalFlow: 修复 API 方法名不匹配导致 tsc -b 编译失败
  (getInstances→getProcesses, getMyApprovals→getProcesses({status:'pending'}),
   approveInstance→approveProcess, rejectInstance→rejectProcess)
  + 添加 ApprovalFlowManager 导出别名兼容 App.tsx 导入
- 合并已完成的大文件拆分重构: automation.go→automation_cron/script/playbook,
  ldap.go→ldap_ou/ldap_users, server.go→cloud_accounts/domain/platform/region,
  user.go→user_admin; 前端 Dashboard/K8s/Server/Approval/Monitor 子组件拆分
  // Logan.Li
2026-06-15 01:09:49 +08:00

589 lines
25 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 { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Search,
Plus,
Upload,
Network,
Mail,
Phone,
CheckCircle,
XCircle,
Eye,
Edit,
Trash2,
Download,
Key,
Loader2,
RefreshCw,
User as UserIcon,
} from 'lucide-react';
import { userApi } from '@/services/api';
import { UserFormDialog, type UserFormData } from './UserFormDialog';
interface SystemUserTabProps {
// Search
searchQuery: string;
setSearchQuery: (v: string) => void;
// Users
filteredUsers: any[];
loading: boolean;
selectedUser: any | null;
setSelectedUser: (u: any | null) => void;
getUserStatusBadge: (status: 'active' | 'inactive') => React.ReactNode;
// Create / Edit
dialogOpen: boolean;
setDialogOpen: (open: boolean) => void;
editingUser: any | null;
form: UserFormData;
setForm: React.Dispatch<React.SetStateAction<UserFormData>>;
saving: boolean;
handleSubmit: () => void;
openCreate: () => void;
openEdit: (user: any) => void;
handleDelete: (user: any) => void;
// CSV import
importDialogOpen: 'csv' | 'ldap' | null;
setImportDialogOpen: (v: 'csv' | 'ldap' | null) => void;
importPreview: any[];
setImportPreview: (p: any[]) => void;
importing: boolean;
importResult: any;
setImportResult: (r: any) => void;
handleCsvUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleBatchImport: () => void;
// LDAP import
ldapUsers: any[];
ldapSelected: Set<string>;
setLdapSelected: (s: Set<string>) => void;
ldapLoading: boolean;
ldapImportOUs: any[];
ldapImportOUDN: string;
setLdapImportOUDN: (v: string) => void;
loadLdapUsers: (ouDn?: string) => void;
handleLdapImport: () => void;
setLdapUsers: (u: any[]) => void;
}
export function SystemUserTab({
searchQuery,
setSearchQuery,
filteredUsers,
loading,
selectedUser,
setSelectedUser,
getUserStatusBadge,
dialogOpen,
setDialogOpen,
editingUser,
form,
setForm,
saving,
handleSubmit,
openCreate,
openEdit,
handleDelete,
importDialogOpen,
setImportDialogOpen,
importPreview,
setImportPreview,
importing,
importResult,
setImportResult,
handleCsvUpload,
handleBatchImport,
ldapUsers,
ldapSelected,
setLdapSelected,
ldapLoading,
ldapImportOUs,
ldapImportOUDN,
setLdapImportOUDN,
loadLdapUsers,
handleLdapImport,
setLdapUsers,
}: SystemUserTabProps) {
return (
<>
{/* Filters */}
<div className="flex items-center justify-between">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索用户..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
<Upload className="h-4 w-4 mr-2" />
批量导入
</Button>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
<Network className="h-4 w-4 mr-2" />
LDAP 导入
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建用户
</Button>
</div>
</div>
{/* Users Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">用户信息</TableHead>
<TableHead className="text-slate-400">联系方式</TableHead>
<TableHead className="text-slate-400">角色</TableHead>
<TableHead className="text-slate-400">来源</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">最后登录</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={7} className="text-center py-12">
<div className="flex items-center justify-center">
<div className="h-6 w-6 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
</div>
</TableCell>
</TableRow>
) : filteredUsers.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={7} className="text-center py-12 text-sm text-slate-500">
暂无用户数据
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
{(user.nickname || user.username || '?').charAt(0)}
</div>
<div>
<p className="text-sm font-medium text-slate-200">{user.nickname || user.username}</p>
<p className="text-xs text-slate-500">@{user.username}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm text-slate-400">
<Mail className="h-3.5 w-3.5" />
{user.email || '--'}
</div>
{user.phone && (
<div className="flex items-center gap-1 text-sm text-slate-400">
<Phone className="h-3.5 w-3.5" />
{user.phone}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(user.roles || []).map((role: string) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{user.authSource === 'ldap' ? (
<Badge variant="outline" className="border-cyan-500/30 text-cyan-400 bg-cyan-500/10">
LDAP
</Badge>
) : (
<Badge variant="outline" className="border-slate-600/30 text-slate-400 bg-slate-600/10">
本地
</Badge>
)}
</TableCell>
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedUser(user)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => openEdit(user)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(user)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* User Detail Dialog */}
<Dialog open={!!selectedUser} onOpenChange={() => setSelectedUser(null)}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<UserIcon className="h-5 w-5 text-blue-400" />
{selectedUser?.nickname}
</DialogTitle>
<DialogDescription className="text-slate-500">
@{selectedUser?.username} · {selectedUser?.email}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
<div>
<p className="text-xs text-slate-500 mb-1">用户名</p>
<p className="text-sm text-slate-200">{selectedUser?.username}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">昵称</p>
<p className="text-sm text-slate-200">{selectedUser?.nickname}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">邮箱</p>
<p className="text-sm text-slate-200">{selectedUser?.email}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">手机号</p>
<p className="text-sm text-slate-200">{selectedUser?.phone || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">状态</p>
<p className="text-sm text-slate-200">{selectedUser && getUserStatusBadge(selectedUser.status)}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">创建时间</p>
<p className="text-sm text-slate-200">
{selectedUser && selectedUser.createdAt ? new Date(selectedUser.createdAt).toLocaleDateString() : '--'}
</p>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">角色</h4>
<div className="flex flex-wrap gap-2">
{selectedUser?.roles.map((role: string) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role}
</Badge>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Key className="h-4 w-4 mr-2" />
重置密码
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => {
if (selectedUser) {
setSelectedUser(null);
openEdit(selectedUser);
}
}}
>
<Edit className="h-4 w-4 mr-2" />
编辑
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create / Edit User Dialog */}
<UserFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingUser={editingUser}
form={form}
setForm={setForm}
saving={saving}
onSubmit={handleSubmit}
/>
{/* CSV 批量导入 Dialog */}
<Dialog open={importDialogOpen === 'csv'} onOpenChange={(v) => !v && setImportDialogOpen(null)}>
<DialogContent className="bg-slate-900 border-slate-800 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Upload className="h-5 w-5 text-blue-400" />
批量导入用户(CSV)
</DialogTitle>
<DialogDescription className="text-slate-500">
上传 CSV 文件批量创建用户。请先下载模板,按格式填写后上传。
</DialogDescription>
</DialogHeader>
{!importResult ? (
<>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => userApi.downloadTemplate()}>
<Download className="h-4 w-4 mr-1.5" />
下载模板
</Button>
<label className="inline-flex items-center px-3 py-1.5 rounded-md text-sm bg-slate-800 border border-slate-700 text-slate-200 cursor-pointer hover:bg-slate-700 transition-colors">
<Upload className="h-4 w-4 mr-1.5" />
选择 CSV 文件
<input type="file" accept=".csv" className="hidden" onChange={handleCsvUpload} />
</label>
</div>
{importPreview.length > 0 && (
<div className="rounded-lg border border-slate-800 overflow-hidden">
<div className="px-3 py-2 bg-slate-800/50 text-xs text-slate-400">
预览:{importPreview.length} 条数据
</div>
<Table>
<TableHeader>
<TableRow className="border-slate-800">
<TableHead className="text-slate-400 text-xs">用户名</TableHead>
<TableHead className="text-slate-400 text-xs">昵称</TableHead>
<TableHead className="text-slate-400 text-xs">邮箱</TableHead>
<TableHead className="text-slate-400 text-xs">手机</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importPreview.slice(0, 5).map((u, i) => (
<TableRow key={i} className="border-slate-800">
<TableCell className="text-slate-200 text-xs">{u.username}</TableCell>
<TableCell className="text-slate-300 text-xs">{u.nickname}</TableCell>
<TableCell className="text-slate-400 text-xs">{u.email}</TableCell>
<TableCell className="text-slate-400 text-xs">{u.phone}</TableCell>
</TableRow>
))}
{importPreview.length > 5 && (
<TableRow className="border-slate-800">
<TableCell colSpan={4} className="text-slate-500 text-xs text-center">
还有 {importPreview.length - 5} 条...
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => { setImportDialogOpen(null); setImportPreview([]); }}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleBatchImport} disabled={importing || importPreview.length === 0}>
{importing ? '导入中...' : `确认导入 (${importPreview.length})`}
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4 py-2">
<div className={`flex items-center gap-3 p-4 rounded-lg ${importResult.code === 200 ? 'bg-emerald-500/10' : 'bg-red-500/10'}`}>
{importResult.code === 200 ? (
<CheckCircle className="h-6 w-6 text-emerald-400 shrink-0" />
) : (
<XCircle className="h-6 w-6 text-red-400 shrink-0" />
)}
<span className="text-sm text-slate-200">{importResult.message}</span>
</div>
{importResult.errors?.length > 0 && (
<div className="max-h-32 overflow-auto rounded-lg bg-slate-800/50 p-3 space-y-1">
{importResult.errors.map((err: string, i: number) => (
<div key={i} className="text-xs text-red-400">{err}</div>
))}
</div>
)}
<DialogFooter>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => { setImportDialogOpen(null); setImportPreview([]); setImportResult(null); }}>
完成
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
{/* LDAP 导入 Dialog */}
<Dialog open={importDialogOpen === 'ldap'} onOpenChange={(v) => !v && setImportDialogOpen(null)}>
<DialogContent className="bg-slate-900 border-slate-800 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Network className="h-5 w-5 text-cyan-400" />
从 LDAP 导入用户
</DialogTitle>
<DialogDescription className="text-slate-500">
从 LDAP 服务器检索用户列表,选择需要导入的用户
</DialogDescription>
</DialogHeader>
{!importResult ? (
<>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 whitespace-nowrap">选择 OU</span>
<select
className="bg-slate-800 border border-slate-700 text-slate-200 text-xs rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={ldapImportOUDN}
onChange={(e) => {
setLdapImportOUDN(e.target.value);
loadLdapUsers(e.target.value);
}}
>
<option value="">根 (全部)</option>
{ldapImportOUs.map((ou) => (
<option key={ou.dn} value={ou.dn}>{ou.name}</option>
))}
</select>
</div>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 ml-auto" onClick={() => loadLdapUsers(ldapImportOUDN)} disabled={ldapLoading}>
{ldapLoading ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-1.5" />}
刷新
</Button>
</div>
<p className="text-xs text-slate-500">
{ldapUsers.length > 0 ? `找到 ${ldapUsers.length} 个用户` : '选择 OU 或点击刷新获取用户列表'}
</p>
{ldapUsers.length > 0 && (
<div className="max-h-80 overflow-auto rounded-lg border border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 sticky top-0 bg-slate-900">
<TableHead className="w-10 text-slate-400 text-xs">
<input
type="checkbox"
className="accent-blue-600"
checked={ldapSelected.size === ldapUsers.length && ldapUsers.length > 0}
onChange={(e) => {
if (e.target.checked) {
setLdapSelected(new Set(ldapUsers.map((u) => u.dn)));
} else {
setLdapSelected(new Set());
}
}}
/>
</TableHead>
<TableHead className="text-slate-400 text-xs">用户名</TableHead>
<TableHead className="text-slate-400 text-xs">显示名</TableHead>
<TableHead className="text-slate-400 text-xs">邮箱</TableHead>
<TableHead className="text-slate-400 text-xs">DN</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ldapUsers.map((u) => (
<TableRow key={u.dn} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<input
type="checkbox"
className="accent-blue-600"
checked={ldapSelected.has(u.dn)}
onChange={(e) => {
const next = new Set(ldapSelected);
if (e.target.checked) next.add(u.dn);
else next.delete(u.dn);
setLdapSelected(next);
}}
/>
</TableCell>
<TableCell className="text-slate-200 text-xs">{u.uid}</TableCell>
<TableCell className="text-slate-300 text-xs">{u.cn}</TableCell>
<TableCell className="text-slate-400 text-xs">{u.mail}</TableCell>
<TableCell className="text-slate-500 text-xs font-mono truncate max-w-[200px]">{u.dn}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => { setImportDialogOpen(null); setLdapUsers([]); }}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleLdapImport} disabled={importing || ldapSelected.size === 0}>
{importing ? '导入中...' : `导入选中 (${ldapSelected.size})`}
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4 py-2">
<div className={`flex items-center gap-3 p-4 rounded-lg ${importResult.code === 200 ? 'bg-emerald-500/10' : 'bg-red-500/10'}`}>
{importResult.code === 200 ? (
<CheckCircle className="h-6 w-6 text-emerald-400 shrink-0" />
) : (
<XCircle className="h-6 w-6 text-red-400 shrink-0" />
)}
<span className="text-sm text-slate-200">{importResult.message}</span>
</div>
<DialogFooter>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => { setImportDialogOpen(null); setLdapUsers([]); setImportResult(null); }}>
完成
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}