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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user