221 lines
10 KiB
TypeScript
221 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Plus, Trash2, Pencil, RefreshCw, Users, UserPlus, UserX, Shield } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { PageShell } from '@/components/layout/PageShell';
|
|
import { PageEmpty } from '@/components/layout/PageEmpty';
|
|
import { StatStrip } from '@/components/ui/stat-strip';
|
|
|
|
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';
|
|
import type { JsonData } from '@/types/common';
|
|
import i18n from '@/i18n';
|
|
|
|
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({ embedded = false }: { embedded?: boolean }) {
|
|
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 JsonData)?.list || []); }
|
|
catch { toast.error(i18n.t('kubernetes.msg.loadFailed')); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
useEffect(() => { loadGroups(); }, []);
|
|
|
|
const handleSave = async () => {
|
|
if (!formName.trim()) { toast.error(i18n.t('deploy.rbac.groupPlaceholder')); 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 ? i18n.t('deploy.toast.updated') : i18n.t('kubernetes.event.created'));
|
|
setFormOpen(false); loadGroups();
|
|
} catch { toast.error(i18n.t('cloud.operationFailed')); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleDelete = async (g: Group) => {
|
|
const ok = await _confirm({ title: i18n.t('deploy.rbac.deleteGroup'), description: `确定删除「${g.name}」?所有成员和流水线授权将同时移除。`, variant: 'destructive' });
|
|
if (!ok) return;
|
|
try { await deployApi.deleteGroup(g.id); toast.success(i18n.t('monitor.silenceRules.deleted')); loadGroups(); }
|
|
catch { toast.error(i18n.t('kubernetes.msg.deleteFailed2')); }
|
|
};
|
|
|
|
const handleAddMember = async (groupId: number) => {
|
|
const username = (memberUsers[groupId] || '').trim();
|
|
if (!username) { toast.error(i18n.t('pipeline.usernamePlaceholder')); return; }
|
|
try { await deployApi.addGroupMember(groupId, { username }); toast.success(i18n.t('deploy.toast.added')); loadGroups(); setMemberUsers(prev => ({ ...prev, [groupId]: '' })); }
|
|
catch { toast.error(i18n.t('monitor.dashboard.custom.toastPanelFailed')); }
|
|
};
|
|
|
|
const handleRemoveMember = async (groupId: number, memberId: number, username: string) => {
|
|
const ok = await _confirm({ title: i18n.t('pipeline.removeMember'), description: `确定从组中移除「${username}」?` });
|
|
if (!ok) return;
|
|
try { await deployApi.removeGroupMember(groupId, memberId); toast.success(i18n.t('deploy.toast.removed')); loadGroups(); }
|
|
catch { toast.error(i18n.t('deploy.toast.removeFailed')); }
|
|
};
|
|
|
|
const toggleExpand = (gid: number) => setExpanded(p => ({ ...p, [gid]: !p[gid] }));
|
|
|
|
const memberCount = groups.reduce((sum, g) => sum + (g.members || []).length, 0);
|
|
|
|
const toolbar = embedded ? (
|
|
<div className="flex items-center justify-end gap-2 mb-3">
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadGroups}>
|
|
<RefreshCw className="w-4 h-4 mr-1" />刷新
|
|
</Button>
|
|
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />新建用户组
|
|
</Button>
|
|
</div>
|
|
) : null;
|
|
|
|
const listBody = !loading && groups.length === 0 ? (
|
|
<PageEmpty
|
|
icon={<Users className="h-10 w-10" />}
|
|
title={i18n.t('deploy.userGroup.noGroups')}
|
|
description={i18n.t('deploy.userGroup.createFirstHint')}
|
|
action={(
|
|
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />新建用户组
|
|
</Button>
|
|
)}
|
|
/>
|
|
) : (
|
|
<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 dg-text-blue" />
|
|
<div>
|
|
<div className="font-medium text-[var(--dg-text-primary)]">{g.name}</div>
|
|
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || i18n.t('system.roleDescFallback')} · {(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"> {i18n.t('system.addUser')} </Label>
|
|
<Input value={memberUsers[g.id] || ''} onChange={e => setMemberUsers(prev => ({ ...prev, [g.id]: e.target.value }))} placeholder={i18n.t('pipeline.usernamePlaceholder')} 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" /> {i18n.t('monitor.dashboard.custom.addBtn')} </Button>
|
|
</div>
|
|
{/* Members list */}
|
|
{(g.members || []).length === 0 ? (
|
|
<div className="text-xs text-[var(--dg-text-secondary)] py-2"> {i18n.t('deploy.rbac.noMembers')} </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:dg-text-red" onClick={() => handleRemoveMember(g.id, m.id, m.username)}>
|
|
<UserX className="w-3 h-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const formDialog = (
|
|
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>{editId ? i18n.t('deploy.rbac.groupEditTitle') : i18n.t('pipeline.newGroup')}</DialogTitle></DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1"><Label> {i18n.t('monitor.groupName')} </Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder={i18n.t('deploy.userGroup.namePlaceholder')} /></div>
|
|
<div className="space-y-1"><Label> {i18n.t('aiops.llmModelDesc')} </Label><Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder={i18n.t('rbac.jobRole.placeholder.description')} /></div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
|
|
<Button variant="outline" onClick={() => setFormOpen(false)}> {i18n.t('rbac.jobRole.cancel')} </Button>
|
|
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : i18n.t('task.script.save')}</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
|
|
if (embedded) {
|
|
return (
|
|
<>
|
|
{toolbar}
|
|
{listBody}
|
|
{formDialog}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title={i18n.t('pipeline.releaseTitle')}
|
|
subtitle={i18n.t('deploy.userGroup.subtitle')}
|
|
icon={<Users className="h-5 w-5" />}
|
|
loading={loading}
|
|
metrics={(
|
|
<StatStrip
|
|
items={[
|
|
{ label: i18n.t('pipeline.groupLabel'), value: groups.length, color: 'blue' },
|
|
{ label: i18n.t('deploy.userGroup.memberCount'), value: memberCount, color: 'purple' },
|
|
]}
|
|
/>
|
|
)}
|
|
actions={(
|
|
<>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadGroups}>
|
|
<RefreshCw className="w-4 h-4 mr-1" />刷新
|
|
</Button>
|
|
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />新建用户组
|
|
</Button>
|
|
</>
|
|
)}
|
|
contentClassName="space-y-4"
|
|
>
|
|
{listBody}
|
|
{formDialog}
|
|
</PageShell>
|
|
);
|
|
}
|