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:
1 parent
8be63ac168
commit
a7e2351ad2
37 files changed
+7374
-4891
No files matched your search
@@ -0,0 +1,112 @@
|
||||
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 { Search, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { operationLogs } from '@/mocks/data';
|
||||
import type { OperationLog } from '@/types';
|
||||
|
||||
interface SystemLogsTabProps {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (v: string) => void;
|
||||
}
|
||||
|
||||
export function SystemLogsTab({ searchQuery, setSearchQuery }: SystemLogsTabProps) {
|
||||
const filteredLogs = operationLogs.filter(
|
||||
(log) =>
|
||||
log.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.module.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.action.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getLogResultBadge = (result: OperationLog['result']) => {
|
||||
return result === 'success' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
成功
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Logs 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">IP地址</TableHead>
|
||||
<TableHead className="text-slate-400">结果</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLogs.map((log) => (
|
||||
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{log.username}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{log.module}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{log.action}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{log.resource}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{log.ip}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getLogResultBadge(log.result)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Shield, Edit, Eye } from 'lucide-react';
|
||||
import { roles } from '@/mocks/data';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
interface SystemRolesTabProps {
|
||||
selectedRole: Role | null;
|
||||
setSelectedRole: (role: Role | null) => void;
|
||||
}
|
||||
|
||||
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{roles.map((role) => (
|
||||
<Card key={role.id} className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{role.code}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => setSelectedRole(role)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-400 mb-3">{role.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.permissions.slice(0, 5).map((permission) => (
|
||||
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
{role.permissions.length > 5 && (
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
+{role.permissions.length - 5}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { User } from 'lucide-react';
|
||||
import { roles } from '@/mocks/data';
|
||||
|
||||
export interface UserFormData {
|
||||
username: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
password: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface UserFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingUser: any | null;
|
||||
form: UserFormData;
|
||||
setForm: React.Dispatch<React.SetStateAction<UserFormData>>;
|
||||
saving: boolean;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function UserFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingUser,
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
onSubmit,
|
||||
}: UserFormDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-blue-400" />
|
||||
{editingUser ? '编辑用户' : '新建用户'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
{editingUser ? '修改用户信息' : '创建新的系统用户'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">用户名</label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
disabled={!!editingUser}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="登录用户名"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">昵称</label>
|
||||
<Input
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="显示昵称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">邮箱</label>
|
||||
<Input
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">手机号</label>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">角色</label>
|
||||
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择角色" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.name}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">状态</label>
|
||||
<Select
|
||||
value={String(form.status)}
|
||||
onValueChange={(v) => setForm({ ...form, status: Number(v) })}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="1">正常</SelectItem>
|
||||
<SelectItem value="0">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-500">
|
||||
密码{editingUser ? '(留空则不修改)' : ''}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
placeholder={editingUser ? '留空则不修改' : '设置初始密码'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={onSubmit} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user