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,270 @@
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import {
FolderOpen,
FileText,
Download,
ChevronRight,
Home,
ArrowLeft,
X,
Maximize2,
Minimize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import http from '@/services/http';
import { useUserStore } from '@/store';
interface FileItem {
name: string;
path: string;
is_dir: boolean;
size: number;
mod_time: string;
}
interface ListingData {
current: string;
dirs: FileItem[];
files: FileItem[];
}
interface FileBrowserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
runId: number | null;
buildNumber: number;
}
function formatSize(bytes: number): string {
if (bytes === 0) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
/**
* 工作区文件浏览器 — 浏览/下载流水线构建目录文件
*/
export function FileBrowserDialog({
open,
onOpenChange,
runId,
buildNumber,
}: FileBrowserDialogProps) {
const [listing, setListing] = useState<ListingData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [fullscreen, setFullscreen] = useState(false);
// 加载目录
const loadDir = async (path: string) => {
if (!runId) return;
setLoading(true);
setError('');
try {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
const data = await http.get(`/pipelines/runs/${runId}/files${params}`);
setListing(data);
setCurrentPath(data?.current || '');
} catch (err: any) {
setError(err?.message || '加载失败');
setListing(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open && runId) {
setCurrentPath('');
loadDir('');
}
}, [open, runId]);
// 下载文件
const downloadFile = async (file: FileItem) => {
const token = useUserStore.getState().token || '';
const url = `${location.protocol}//${location.host}/api/v1/pipelines/runs/${runId}/files/download?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}`;
window.open(url, '_blank');
};
// 面包屑导航
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
const breadcrumbs = [''].concat(
parts.map((_, i) => '/' + parts.slice(0, i + 1).join('/'))
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '700px', width: '85vw' }}
>
<DialogTitle className="sr-only">文件浏览器 #{buildNumber}</DialogTitle>
<DialogDescription className="sr-only">流水线构建目录文件浏览</DialogDescription>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
<div className="flex items-center gap-2 min-w-0">
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium text-sm">
#{buildNumber} 工作区文件
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] overflow-x-auto shrink-0">
{breadcrumbs.map((path, i) => (
<span key={path} className="flex items-center gap-0.5">
{i === 0 ? (
<button
onClick={() => loadDir('')}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
>
<Home className="h-3.5 w-3.5" />
</button>
) : (
<>
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
<button
onClick={() => loadDir(path)}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] truncate max-w-[120px]"
>
{parts[i - 1]}
</button>
</>
)}
</span>
))}
</div>
{/* Content */}
<div className="overflow-y-auto" style={{ height: fullscreen ? 'calc(100vh - 110px)' : '420px' }}>
{loading && (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)] text-sm">
加载中...
</div>
)}
{error && (
<div className="flex items-center justify-center h-full text-red-400 text-sm">
{error}
</div>
)}
{listing && !loading && (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)]">
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
<th className="py-2 pl-4 pr-2 font-medium">名称</th>
<th className="py-2 px-2 font-medium w-24 text-right">大小</th>
<th className="py-2 pr-4 pl-2 font-medium w-40 hidden sm:table-cell">修改时间</th>
<th className="py-2 pr-4 pl-2 font-medium w-16" />
</tr>
</thead>
<tbody>
{/* Parent dir */}
{currentPath && (
<tr
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={() => {
const parent = currentPath.split('/').slice(0, -1).join('/');
loadDir(parent);
}}
>
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
<ArrowLeft className="h-4 w-4 text-[var(--dg-text-secondary)]" />
<span className="text-[var(--dg-text-secondary)]">..</span>
</td>
<td className="py-2 px-2 text-right" />
<td className="py-2 pr-4 pl-2 hidden sm:table-cell" />
<td className="py-2 pr-4 pl-2" />
</tr>
)}
{/* Directories */}
{listing.dirs?.map((dir) => (
<tr
key={dir.path}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={() => loadDir(dir.path)}
>
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-amber-400 shrink-0" />
<span className="text-[var(--dg-text-primary)]">{dir.name}</span>
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">-</td>
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{dir.mod_time}</td>
<td className="py-2 pr-4 pl-2" />
</tr>
))}
{/* Files */}
{listing.files?.map((file) => (
<tr
key={file.path}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
>
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
<FileText className="h-4 w-4 text-sky-400 shrink-0" />
<span className="text-[var(--dg-text-primary)]">{file.name}</span>
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">{formatSize(file.size)}</td>
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{file.mod_time}</td>
<td className="py-2 pr-4 pl-2">
<button
onClick={(e) => { e.stopPropagation(); downloadFile(file); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
title="下载"
>
<Download className="h-3.5 w-3.5" />
</button>
</td>
</tr>
))}
{/* Empty */}
{listing.dirs?.length === 0 && listing.files?.length === 0 && !currentPath && (
<tr>
<td colSpan={4} className="py-8 text-center text-[var(--dg-text-muted)]">
{loading ? '加载中...' : '目录为空'}
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}