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
This commit is contained in:
attacker committed 2026-06-15 01:09:49 +08:00
1 parent 8be63ac168
commit a7e2351ad2
37 files changed
+7374 -4891

No files matched your search

@@ -0,0 +1,588 @@
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>
</>
);
}