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:
1 parent
0fea0ccc20
commit
24f955832e
40 files changed
+7530
-1032
No files matched your search
@@ -0,0 +1,297 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { deployApi } from '@/services/api';
|
||||
import CredentialFormDialog from './CredentialFormDialog';
|
||||
|
||||
interface Repo {
|
||||
id: number; name: string; description: string; url: string;
|
||||
auth_type: string; credential_id: number | null; enabled: boolean;
|
||||
created_by: string; updated_at: string;
|
||||
}
|
||||
interface Registry {
|
||||
id: number; name: string; description: string; registry_url: string; namespace: string;
|
||||
auth_type: string; credential_id: number | null; enabled: boolean;
|
||||
created_by: string; updated_at: string;
|
||||
}
|
||||
interface Environment {
|
||||
id: number; name: string; display_name: string; type: string; color: string;
|
||||
sort_order: number; description: string; enabled: boolean;
|
||||
}
|
||||
|
||||
function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; onClose: () => void; onSaved: () => void; editData: Environment | null }) {
|
||||
const [name, setName] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [envType, setEnvType] = useState('development');
|
||||
const [color, setColor] = useState('#3B82F6');
|
||||
const [description, setDescription] = useState('');
|
||||
const [sortOrder, setSortOrder] = useState(50);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
setName(editData.name);
|
||||
setDisplayName(editData.display_name);
|
||||
setEnvType(editData.type);
|
||||
setColor(editData.color);
|
||||
setDescription(editData.description || '');
|
||||
setSortOrder(editData.sort_order);
|
||||
} else {
|
||||
setName(''); setDisplayName(''); setEnvType('development'); setColor('#3B82F6'); setDescription(''); setSortOrder(50);
|
||||
}
|
||||
}, [editData, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !displayName.trim()) { toast.error('请填写名称'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (editData) await deployApi.updateEnvironment(editData.id, { name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
|
||||
else await deployApi.createEnvironment({ name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
|
||||
toast.success(editData ? '已更新' : '已创建');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch { toast.error('操作失败'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editData ? '编辑环境' : '新增环境'}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1"><Label>环境标识</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
|
||||
<div className="space-y-1"><Label>显示名称</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="生产环境" /></div>
|
||||
<div className="space-y-1"><Label>环境类型</Label>
|
||||
<Select value={envType} onValueChange={setEnvType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="production">生产环境</SelectItem>
|
||||
<SelectItem value="staging">预发布</SelectItem>
|
||||
<SelectItem value="testing">测试环境</SelectItem>
|
||||
<SelectItem value="development">开发环境</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label>标志颜色</Label><Input value={color} onChange={e => setColor(e.target.value)} type="color" className="w-16 h-9 p-1" /></div>
|
||||
<div className="space-y-1"><Label>排序序号</Label><Input value={sortOrder} onChange={e => setSortOrder(Number(e.target.value))} type="number" /></div>
|
||||
<div className="space-y-1"><Label>描述</Label><Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选" /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '...' : editData ? '保存' : '创建'}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeployCredentials() {
|
||||
const _confirm = useConfirm();
|
||||
const [repos, setRepos] = useState<Repo[]>([]);
|
||||
const [registries, setRegistries] = useState<Registry[]>([]);
|
||||
const [environments, setEnvironments] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editType, setEditType] = useState<'repo' | 'registry'>('repo');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [envDialogOpen, setEnvDialogOpen] = useState(false);
|
||||
const [editEnv, setEditEnv] = useState<Environment | null>(null);
|
||||
|
||||
const fetchAll = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
|
||||
setRepos((rR as any)?.list || []);
|
||||
setRegistries((rReg as any)?.list || []);
|
||||
setEnvironments((rEnv as any)?.list || []);
|
||||
} catch { toast.error('获取数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchAll(); }, []);
|
||||
|
||||
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: any) => {
|
||||
const ok = await _confirm({ title: '删除', description: `确定删除「${item.name || item.display_name}」?`, confirmText: '删除', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (type === 'repo') await deployApi.deleteRepo(item.id);
|
||||
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
|
||||
else await deployApi.deleteEnvironment(item.id);
|
||||
toast.success('已删除');
|
||||
fetchAll();
|
||||
} catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const envTypeLabel: Record<string, string> = { production: '生产', staging: '预发布', testing: '测试', development: '开发' };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">发布配置管理</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
|
||||
<Button size="sm" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增 Git 仓库
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增镜像仓库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="repos">
|
||||
<TabsList>
|
||||
<TabsTrigger value="repos" className="gap-1"><GitBranch className="w-4 h-4" />Git 仓库 ({repos.length})</TabsTrigger>
|
||||
<TabsTrigger value="registries" className="gap-1"><Container className="w-4 h-4" />镜像仓库 ({registries.length})</TabsTrigger>
|
||||
<TabsTrigger value="environments" className="gap-1"><Globe className="w-4 h-4" />环境管理 ({environments.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="repos" className="mt-3">
|
||||
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
|
||||
: repos.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无 Git 仓库认证配置</div>
|
||||
: (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">仓库 URL</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">认证方式</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repos.map(r => (
|
||||
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('repo'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('repo', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="registries" className="mt-3">
|
||||
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
|
||||
: registries.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无镜像仓库认证配置</div>
|
||||
: (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">命名空间</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">认证</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{registries.map(r => (
|
||||
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[250px] truncate">{r.registry_url}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('registry'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('registry', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Environments tab */}
|
||||
<TabsContent value="environments" className="mt-3">
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增环境
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
|
||||
: environments.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无环境配置,点击"新增环境"创建</div>
|
||||
: (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">环境标识</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">显示名称</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">颜色</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">排序</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{environments.map(env => (
|
||||
<tr key={env.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-mono text-xs font-medium text-[var(--dg-text-primary)]">{env.name}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-primary)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full inline-block" style={{ backgroundColor: env.color }} />
|
||||
{env.display_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2"><Badge variant="secondary" className="text-xs">{envTypeLabel[env.type] || env.type}</Badge></td>
|
||||
<td className="px-3 py-2"><div className="w-6 h-6 rounded border" style={{ backgroundColor: env.color }} /></td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{env.sort_order}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={env.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{env.enabled ? '启用' : '禁用'}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditEnv(env); setEnvDialogOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('env', env)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CredentialFormDialog
|
||||
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
|
||||
repos={repos} registries={registries} onSave={fetchAll}
|
||||
/>
|
||||
|
||||
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user