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,196 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { Maximize2, Minimize2, X, FolderOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUserStore } from '@/store';
|
||||
import http from '@/services/http';
|
||||
|
||||
interface WorkspaceTermDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
runId: number | null;
|
||||
buildNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace 终端 Dialog — 进入流水线构建目录查看文件
|
||||
*/
|
||||
export function WorkspaceTermDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
runId,
|
||||
buildNumber,
|
||||
}: WorkspaceTermDialogProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [workspace, setWorkspace] = useState('');
|
||||
|
||||
// 获取 workspace 路径
|
||||
useEffect(() => {
|
||||
if (!open || !runId) return;
|
||||
http.get(`/pipelines/runs/${runId}/workspace`)
|
||||
.then((data: any) => setWorkspace(data?.workspace_path || ''))
|
||||
.catch(() => {});
|
||||
}, [open, runId]);
|
||||
|
||||
// 连接终端
|
||||
useEffect(() => {
|
||||
if (!open || !runId) return;
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#d6d6d6',
|
||||
cursor: '#d6d6d6',
|
||||
selectionBackground: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const token = useUserStore.getState().token || '';
|
||||
const wsUrl = `${proto}://${location.host}/ws/pipeline/${runId}/workspace-shell?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ status: 0, data }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||
term.write(msg.data);
|
||||
}
|
||||
} catch {
|
||||
term.write(typeof event.data === 'string' ? event.data : '');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
try { fit.fit(); term.focus(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
return () => {
|
||||
try { ws.close(); term.dispose(); } catch { /* noop */ }
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [open, runId]);
|
||||
|
||||
// ResizeObserver for fit
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const ro = new ResizeObserver(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (fitRef.current && termRef.current) {
|
||||
try {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const { cols, rows } = termRef.current;
|
||||
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => { clearTimeout(timer); ro.disconnect(); };
|
||||
}, [open]);
|
||||
|
||||
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: '900px', width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
|
||||
<DialogDescription className="sr-only">流水线构建目录终端</DialogDescription>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">
|
||||
#{buildNumber} 构建目录
|
||||
</span>
|
||||
{workspace && (
|
||||
<span className="text-[var(--dg-text-muted)] text-xs truncate hidden sm:inline">
|
||||
{workspace}
|
||||
</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>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-[#0a0e14]"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user