Files
aiops/frontend/src/components/deploy/UserGroupsManager.tsx
T
Logan.Li d19a03d427 refactor(frontend): services/api 迁至 src/api,http 迁至 utils/request
对齐 Vue 后台惯例:
- src/api/          域 API(原 services/api)
- src/utils/request.ts  HTTP 封装(原 services/http.ts)
- src/utils/export.ts  客户端导出工具
- src/utils/index.ts   工具统一出口

全局导入改为 @/api 与 @/utils/request,tsc + build 通过。
2026-06-26 00:09:07 +08:00

172 lines
8.5 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Plus, Trash2, Pencil, RefreshCw, Loader2, Users, UserPlus, UserX, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/api';
interface Group {
id: number; name: string; description: string;
members?: GroupMember[]; created_by: string; created_at: string;
}
interface GroupMember {
id: number; group_id: number; username: string; created_at: string;
}
export default function UserGroupsManager() {
const _confirm = useConfirm();
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
// Group form
const [formOpen, setFormOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState('');
const [saving, setSaving] = useState(false);
// Add member — per-group input state
const [memberUsers, setMemberUsers] = useState<Record<number, string>>({});
const loadGroups = async () => {
setLoading(true);
try { const res = await deployApi.getGroups(); setGroups((res as Record<string, any>)?.list || []); }
catch { toast.error('加载失败'); }
finally { setLoading(false); }
};
useEffect(() => { loadGroups(); }, []);
const handleSave = async () => {
if (!formName.trim()) { toast.error('请输入组名'); return; }
setSaving(true);
try {
if (editId) await deployApi.updateGroup(editId, { name: formName, description: formDesc });
else await deployApi.createGroup({ name: formName, description: formDesc });
toast.success(editId ? '已更新' : '已创建');
setFormOpen(false); loadGroups();
} catch { toast.error('操作失败'); }
finally { setSaving(false); }
};
const handleDelete = async (g: Group) => {
const ok = await _confirm({ title: '删除用户组', description: `确定删除「${g.name}」?所有成员和流水线授权将同时移除。`, variant: 'destructive' });
if (!ok) return;
try { await deployApi.deleteGroup(g.id); toast.success('已删除'); loadGroups(); }
catch { toast.error('删除失败'); }
};
const handleAddMember = async (groupId: number) => {
const username = (memberUsers[groupId] || '').trim();
if (!username) { toast.error('输入用户名'); return; }
try { await deployApi.addGroupMember(groupId, { username }); toast.success('已添加'); loadGroups(); setMemberUsers(prev => ({ ...prev, [groupId]: '' })); }
catch { toast.error('添加失败'); }
};
const handleRemoveMember = async (groupId: number, memberId: number, username: string) => {
const ok = await _confirm({ title: '移除成员', description: `确定从组中移除「${username}」?` });
if (!ok) return;
try { await deployApi.removeGroupMember(groupId, memberId); toast.success('已移除'); loadGroups(); }
catch { toast.error('移除失败'); }
};
const toggleExpand = (gid: number) => setExpanded(p => ({ ...p, [gid]: !p[gid] }));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">用户组管理</h2>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">创建用户组,将组授权给流水线,组内成员自动获得流水线权限</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadGroups}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新建用户组
</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : groups.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
<Users className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p>暂无用户组,点击「新建用户组」创建</p>
</div>
) : (
<div className="space-y-3">
{groups.map(g => (
<div key={g.id} className="border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)]">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(g.id)}>
<Shield className="w-5 h-5 text-blue-400" />
<div>
<div className="font-medium text-[var(--dg-text-primary)]">{g.name}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || '暂无描述'} · {(g.members || []).length} 位成员</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditId(g.id); setFormName(g.name); setFormDesc(g.description || ''); setFormOpen(true); }}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => handleDelete(g)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{expanded[g.id] && (
<div className="border-t border-[var(--dg-border)] px-4 py-3 space-y-3">
{/* Add member */}
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1"><Label className="text-xs">添加用户</Label>
<Input value={memberUsers[g.id] || ''} onChange={e => setMemberUsers(prev => ({ ...prev, [g.id]: e.target.value }))} placeholder="输入用户名" className="h-8 text-sm" onKeyDown={e => e.key === 'Enter' && handleAddMember(g.id)} />
</div>
<Button size="sm" className="h-8" onClick={() => handleAddMember(g.id)}><UserPlus className="w-3.5 h-3.5 mr-1" />添加</Button>
</div>
{/* Members list */}
{(g.members || []).length === 0 ? (
<div className="text-xs text-[var(--dg-text-secondary)] py-2">暂无成员</div>
) : (
<div className="flex flex-wrap gap-2">
{(g.members || []).map(m => (
<Badge key={m.id} variant="secondary" className="flex items-center gap-1 pl-2 pr-1 py-1.5">
<Users className="w-3 h-3" />{m.username}
<button className="ml-1 hover:text-red-400" onClick={() => handleRemoveMember(g.id, m.id, m.username)}>
<UserX className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Form Dialog */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{editId ? '编辑用户组' : '新建用户组'}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>组名</Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="例如: 后端开发组" /></div>
<div className="space-y-1"><Label>描述</Label><Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选" /></div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
<Button variant="outline" onClick={() => setFormOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : '保存'}</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}