feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化

- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理
  - 正则匹配流水线 code,实时命中预览
  - 内置用户分配面板,无需跳转系统管理
  - 支持创建/编辑/删除/启用禁用 Item Role
- 修复 PipelineEditor 成员管理 http 双解包 bug
- 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns)
- 新增批量删除流水线运行记录 API
- Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组
- 恢复系统管理菜单(不受发布权限改动影响)
- 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
attacker committed 2026-06-21 05:26:01 +00:00
1 parent 0fea0ccc20
commit 24f955832e
40 files changed
+7530 -1032

No files matched your search

@@ -0,0 +1,411 @@
import { useState, useEffect, useMemo } from 'react';
import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus, Search } from 'lucide-react';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { deployApi, userApi, pipelineApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
interface ItemRole {
id: number; name: string; code: string; pattern: string; role: string;
description: string; status: number; created_at: string;
}
interface AssignedUser {
id: number; user_id: number; item_role_id: number;
user?: { id: number; username: string; real_name: string };
item_role?: ItemRole;
}
export default function DeployRBAC() {
const [roles, setRoles] = useState<ItemRole[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Pipeline list for live preview
const [pipelines, setPipelines] = useState<any[]>([]);
const [previewRoleId, setPreviewRoleId] = useState<number | null>(null);
// User assignment
const [users, setUsers] = useState<any[]>([]);
const [assignments, setAssignments] = useState<AssignedUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [assignRoleId, setAssignRoleId] = useState<string>('');
const [assignLoading, setAssignLoading] = useState(false);
// Create
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newCode, setNewCode] = useState('');
const [newPattern, setNewPattern] = useState('');
const [newRole, setNewRole] = useState('editor');
const [newDesc, setNewDesc] = useState('');
// Edit
const [editing, setEditing] = useState<number | null>(null);
const [editPattern, setEditPattern] = useState('');
const [editRole, setEditRole] = useState('');
const _confirm = useConfirm();
const loadData = async () => {
setLoading(true);
try {
const [rolesRes, pipesRes, usersRes] = await Promise.all([
deployApi.getItemRoles(),
pipelineApi.getPipelines({ page_size: 200 }),
userApi.getUsers({ page_size: 500 }),
]);
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as any)?.list || (rolesRes as any)?.data || []);
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as any)?.list || (pipesRes as any)?.data || []);
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as any)?.list || (usersRes as any)?.data || [];
setUsers(uList);
} catch { /* */ }
finally { setLoading(false); }
};
useEffect(() => { loadData(); }, []);
const loadAssignments = async () => {
if (!selectedUserId) { setAssignments([]); return; }
try {
const res: any = await deployApi.getUserItemRoles(Number(selectedUserId));
setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []);
} catch { setAssignments([]); }
};
useEffect(() => { loadAssignments(); }, [selectedUserId]);
// Compute matched pipeline codes for each role
const matchCount = useMemo(() => {
const map: Record<number, string[]> = {};
roles.forEach(r => {
try {
const re = new RegExp(r.pattern);
map[r.id] = pipelines.filter(p => re.test(p.code)).map(p => p.code);
} catch { map[r.id] = []; }
});
return map;
}, [roles, pipelines]);
const previewRole = previewRoleId ? roles.find(r => r.id === previewRoleId) : null;
const previewMatches = previewRole ? (matchCount[previewRoleId!] || []) : [];
const handleCreate = async () => {
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
toast.error('名称、代码、正则表达式不能为空'); return;
}
setSaving(true);
try {
await deployApi.createItemRole({ name: newName.trim(), code: newCode.trim(), pattern: newPattern.trim(), role: newRole, description: newDesc });
toast.success('已创建');
setShowCreate(false);
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
loadData();
} catch (e: any) { toast.error(e?.message || '创建失败'); }
finally { setSaving(false); }
};
const handleUpdate = async (id: number) => {
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
setSaving(true);
try {
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
toast.success('已更新');
setEditing(null);
loadData();
} catch (e: any) { toast.error(e?.message || '更新失败'); }
finally { setSaving(false); }
};
const handleDelete = async (role: ItemRole) => {
const ok = await _confirm({ title: '删除角色', description: `确定删除「${role.name}」?匹配 ${matchCount[role.id]?.length || 0} 条流水线`, variant: 'destructive' });
if (!ok) return;
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
catch { toast.error('删除失败'); }
};
const handleToggleStatus = async (role: ItemRole) => {
try {
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
toast.success(role.status ? '已禁用' : '已启用');
loadData();
} catch { toast.error('操作失败'); }
};
const handleAssign = async () => {
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和角色'); return; }
setAssignLoading(true);
try {
await deployApi.assignItemRole(Number(selectedUserId), { user_id: Number(selectedUserId), item_role_id: Number(assignRoleId) });
toast.success('已分配');
setAssignRoleId('');
loadAssignments();
} catch (e: any) { toast.error(e?.message || '分配失败'); }
finally { setAssignLoading(false); }
};
const handleRevoke = async (id: number) => {
try {
await deployApi.removeItemRole(Number(selectedUserId), id);
toast.success('已撤销');
loadAssignments();
} catch { toast.error('撤销失败'); }
};
const roleBadgeClass = (role: string) =>
role === 'owner' ? 'bg-amber-500/20 text-amber-400 border-amber-500/30' :
role === 'editor' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
'bg-slate-500/20 text-slate-400 border-slate-500/30';
const selectedUser = users.find(u => String(u.id) === selectedUserId);
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2 text-[var(--dg-text-primary)]">
<Shield className="w-5 h-5 text-purple-500" />发布权限
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
Jenkins-style Role Strategy — 按流水线 code 正则匹配分配角色
</p>
</div>
<Button onClick={() => setShowCreate(true)} disabled={showCreate}>
<Plus className="w-4 h-4 mr-1" />新建角色
</Button>
</div>
{/* ====== Pipeline Preview ====== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线命中预览</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 选择一个角色查看哪些流水线会被匹配</span>
</div>
<div className="flex gap-2 mb-3 flex-wrap">
<Button variant={previewRoleId === null ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(null)}>全部 ({pipelines.length})</Button>
{roles.map(r => (
<Button key={r.id} variant={previewRoleId === r.id ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(r.id)}
className={r.status === 0 ? 'opacity-40' : ''}>
{r.name} ({matchCount[r.id]?.length || 0})
</Button>
))}
</div>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
{previewRoleId === null
? pipelines.map(p => <Badge key={p.id} variant="secondary" className="text-xs"><GitBranch className="w-3 h-3 mr-0.5" />{p.code}</Badge>)
: pipelines.map(p => {
const matched = previewMatches.includes(p.code);
return (
<Badge key={p.id} variant={matched ? 'default' : 'secondary'}
className={`text-xs ${matched ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' : 'opacity-30'}`}>
<GitBranch className="w-3 h-3 mr-0.5" />{p.code}
</Badge>
);
})
}
{previewRoleId !== null && previewMatches.length === 0 && (
<span className="text-xs text-red-400">无匹配 — 请调整正则表达式</span>
)}
</div>
</CardContent>
</Card>
{/* Create form */}
{showCreate && (
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex items-center gap-2"><Plus className="w-4 h-4" />新建 Item Role</span>
<Button variant="ghost" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs">名称</Label><Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="如: backend" className="h-8 text-sm" /></div>
<div className="space-y-1"><Label className="text-xs">代码 (唯一标识)</Label><Input value={newCode} onChange={e => setNewCode(e.target.value)} placeholder="如: backend" className="h-8 text-sm font-mono" /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs flex items-center gap-1"><Regex className="w-3 h-3" />正则匹配 Pattern</Label>
<Input value={newPattern} onChange={e => { setNewPattern(e.target.value); }} placeholder='如: ^cex-.*' className="h-8 text-sm font-mono" />
{newPattern && (
<p className="text-xs text-[var(--dg-text-muted)]">
实时匹配: {(() => { try { const n = pipelines.filter(p => new RegExp(newPattern).test(p.code)).length; return n > 0 ? `✅ 命中 ${n} 条流水线` : '⚠️ 无匹配'; } catch { return '❌ 正则语法错误'; } })()}
</p>
)}
</div>
<div className="space-y-1">
<Label className="text-xs">角色</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者 (owner)</SelectItem>
<SelectItem value="editor">编辑者 (editor)</SelectItem>
<SelectItem value="viewer">查看者 (viewer)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1"><Label className="text-xs">描述 (可选)</Label><Input value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="简要说明此角色的用途" className="h-8 text-sm" /></div>
<Button onClick={handleCreate} disabled={saving} className="w-full">
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
{saving ? '创建中...' : '创建'}
</Button>
</CardContent>
</Card>
)}
{/* Role list */}
{roles.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
<Shield className="w-10 h-10 mx-auto mb-2 opacity-20" />
<p className="text-sm">暂无 Item Role</p>
</div>
) : (
<div className="space-y-2">
{roles.map(role => (
<Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${role.status === 0 ? 'opacity-50' : ''}`}>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Regex className="w-4 h-4 text-purple-400" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{role.name}</span>
<Badge className={`text-xs border ${roleBadgeClass(role.role)}`}>{role.role === 'owner' ? '拥有者' : role.role === 'editor' ? '编辑者' : '查看者'}</Badge>
{role.status === 0 && <Badge variant="secondary" className="text-xs text-red-400">已禁用</Badge>}
<Badge variant="secondary" className="text-xs cursor-pointer" onClick={() => setPreviewRoleId(previewRoleId === role.id ? null : role.id)}>
<GitBranch className="w-3 h-3 mr-0.5" />{matchCount[role.id]?.length || 0} 条
</Badge>
</div>
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5 truncate">
<code className="font-mono text-purple-400 bg-purple-500/10 px-1 rounded">{role.code}</code>
<span className="mx-1">→</span>
<code className="font-mono text-blue-400 bg-blue-500/10 px-1 rounded">{role.pattern}</code>
{role.description && <span className="ml-2 opacity-60">{role.description}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{editing === role.id ? (
<>
<Input value={editPattern} onChange={e => setEditPattern(e.target.value)} className="h-7 w-36 text-xs font-mono" placeholder="正则表达式" />
<Select value={editRole} onValueChange={setEditRole}>
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者</SelectItem>
<SelectItem value="editor">编辑者</SelectItem>
<SelectItem value="viewer">查看者</SelectItem>
</SelectContent>
</Select>
<Button size="icon" variant="ghost" className="h-7 w-7 text-green-400" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
</>
) : (
<>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}><Save className="w-3 h-3 mr-1" />编辑</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* ====== User Assignment ====== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<UserPlus className="w-4 h-4 text-amber-400" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">分配角色给用户</span>
</div>
<div className="flex gap-3 items-end mb-4">
<div className="space-y-1 flex-1">
<Label className="text-xs">选择用户</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择用户..." /></SelectTrigger>
<SelectContent className="max-h-60">
<div className="px-2 py-1.5 sticky top-0 bg-popover z-10"><Input placeholder="搜索用户..." className="h-7 text-xs" /></div>
{users.map(u => (
<SelectItem key={u.id} value={String(u.id)}>
{u.real_name || u.username} ({u.username})
{assignments.some(a => a.user_id === u.id) && <span className="ml-2 text-xs text-purple-400">已分配</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1 w-48">
<Label className="text-xs">Item Role</Label>
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择角色" /></SelectTrigger>
<SelectContent>
{roles.filter(r => r.status === 1).map(r => (
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({r.role})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="h-8" onClick={handleAssign} disabled={assignLoading || !selectedUserId || !assignRoleId}>
{assignLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <UserPlus className="w-3.5 h-3.5 mr-1" />}
分配
</Button>
</div>
{/* Assigned roles for selected user */}
{selectedUser && (
<div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="secondary" className="text-xs">
<Users className="w-3 h-3 mr-1" />{selectedUser.real_name || selectedUser.username}
</Badge>
<span className="text-xs text-[var(--dg-text-muted)]">已分配角色:</span>
</div>
{assignments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">该用户尚未分配任何 Item Role</p>
) : (
<div className="space-y-1">
{assignments.map(a => {
const roleInfo = a.item_role || roles.find(r => r.id === a.item_role_id);
return (
<div key={a.id} className="flex items-center justify-between py-1.5 px-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
<div className="flex items-center gap-2">
<Badge className={`text-xs border ${roleBadgeClass(roleInfo?.role || 'viewer')}`}>
{roleInfo?.role === 'owner' ? '拥有者' : roleInfo?.role === 'editor' ? '编辑者' : '查看者'}
</Badge>
<span className="text-[var(--dg-text-primary)]">{roleInfo?.name || `#${a.item_role_id}`}</span>
{roleInfo && (
<code className="font-mono text-[var(--dg-text-muted)] bg-[var(--dg-bg-primary)] px-1 rounded text-xs">
{roleInfo.pattern}
</code>
)}
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-400" onClick={() => handleRevoke(a.id)}>
<X className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}