feat: 流水线三级权限体系(手动多选+通配符授权)+ 菜单分级 + SSE 认证修复

权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝):
- 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免
- 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*)
- 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝

后端:
- 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD
- RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry
- /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限
- 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库

前端:
- useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫
- 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选)
- 登录页记住密码;GUIDE/API 文档补权限章节
This commit is contained in:
Logan.Li committed 2026-10-27 17:34:41 +08:00
1 parent 7090c8efc2
commit 9103b13510
27 files changed
+1690 -190

No files matched your search

+39 -3
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { Toaster } from 'sonner';
import { useLocation, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Sidebar from '@/components/Sidebar';
import TopBar from '@/components/TopBar';
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
@@ -24,11 +24,19 @@ import AiAssistant from '@/components/ai/AiAssistant';
import Login from '@/pages/Login';
import UserManage from '@/pages/UserManage';
import Docs from '@/pages/Docs';
import PipelineRoles from '@/pages/PipelineRoles';
import { apiClient, auth } from '@/lib/api';
import { useSession } from '@/providers/session';
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings';
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings' | 'pipeline-roles';
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
// 仅管理员可见的模块(资源/集成/系统管理类)
const ADMIN_ONLY_MODULES = new Set<ModuleType>([
'gitops', 'environments', 'credentials', 'build-nodes', 'tools', 'jenkins-import',
'plugins', 'users', 'settings', 'pipeline-roles',
]);
// 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等)
const ROUTE_TO_MODULE: Record<string, ModuleType> = {
'/': 'pipelines',
@@ -45,6 +53,7 @@ const ROUTE_TO_MODULE: Record<string, ModuleType> = {
'/users': 'users',
'/docs': 'docs',
'/settings': 'settings',
'/pipeline-roles': 'pipeline-roles',
};
const MODULE_TO_ROUTE: Record<ModuleType, string> = {
@@ -61,12 +70,15 @@ const MODULE_TO_ROUTE: Record<ModuleType, string> = {
users: '/users',
docs: '/docs',
settings: '/settings',
'pipeline-roles': '/pipeline-roles',
};
export default function App() {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [loggedIn, setLoggedIn] = useState(!!auth.getToken());
const { isAdmin, loading: sessionLoading } = useSession();
// 未登录访问任何页面 → 跳登录页
useEffect(() => {
@@ -85,6 +97,13 @@ export default function App() {
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// 普通用户访问管理员模块 → 回流水线列表(菜单已隐藏,此处防直接输 URL)
useEffect(() => {
if (loggedIn && !sessionLoading && !isAdmin && ADMIN_ONLY_MODULES.has(activeModule)) {
navigate('/pipelines', { replace: true });
}
}, [loggedIn, sessionLoading, isAdmin, activeModule, navigate]);
// Pipeline list/editor view toggle
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
@@ -137,6 +156,8 @@ export default function App() {
const currentPipeline = pipelines.find((p: any) => p.id === currentPipelineId);
const latestStatus = currentRuns[0]?.status as string | undefined;
const isPipelineRunning = latestStatus === 'running' || latestStatus === 'pending';
// 当前用户对该流水线的权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员(列表未加载前按 4 乐观显示,后端强校验)
const editorAccessLevel = currentPipeline?.accessLevel ?? 4;
const handleSelectPipeline = useCallback((id: string) => {
setCurrentPipelineId(Number(id) || 1);
@@ -183,6 +204,7 @@ export default function App() {
isRunning={isPipelineRunning}
executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'}
onBack={() => { setPipelineView('list'); navigate('/pipelines'); }}
accessLevel={editorAccessLevel}
/>
<div className="flex-1 flex overflow-hidden">
{activeTab === '流程配置' && (
@@ -233,6 +255,8 @@ export default function App() {
return <PluginManager />;
case 'users':
return <UserManage />;
case 'pipeline-roles':
return <PipelineRoles />;
case 'docs':
return <Docs />;
case 'settings':
@@ -243,7 +267,19 @@ export default function App() {
};
if (!loggedIn || location.pathname === '/login') {
return <Login onSuccess={() => { setLoggedIn(true); navigate('/pipelines', { replace: true }); }} />;
return (
<>
<Toaster position="top-right" richColors />
<Login
onSuccess={() => {
setLoggedIn(true);
// 刷新会话信息(切换账号后 ['me'] 缓存需要失效)
queryClient.invalidateQueries({ queryKey: ['me'] });
navigate('/pipelines', { replace: true });
}}
/>
</>
);
}
return (
@@ -15,6 +15,8 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
queryFn: () => apiClient.getPipeline(pipelineId),
});
const p = detail?.pipeline;
// 编辑权限:「管理」等级及以上才能保存
const canEdit = (detail?.accessLevel ?? 4) >= 3;
const [form, setForm] = useState({ name: '', description: '', sourceRepo: '', sourceBranch: '', deployType: 'host', env: 'staging', notifyWebhookURL: '' });
@@ -56,6 +58,7 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
</div>
<div className="flex items-center gap-3">
{p && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>更新于 {new Date(p.updatedAt).toLocaleString('zh-CN', { hour12: false })}</span>}
{canEdit && (
<button
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending || !form.name}
@@ -64,6 +67,7 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
</button>
)}
</div>
</div>
@@ -0,0 +1,212 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2, Trash2, UserPlus } from 'lucide-react';
import { apiClient } from '@/lib/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// 三级权限:大白话描述,普通操作员无需理解术语
export const LEVEL_OPTIONS = [
{ value: 'viewer', label: '查看', desc: '只能看流水线与发布成功/失败日志' },
{ value: 'operator', label: '发布', desc: '查看 + 触发构建、批量发布、失败重试' },
{ value: 'owner', label: '管理', desc: '发布 + 编辑流水线配置、管理成员' },
];
export function levelLabel(level: string): string {
return LEVEL_OPTIONS.find((l) => l.value === level)?.label ?? level;
}
export const levelBadgeStyle: Record<string, { bg: string; color: string }> = {
viewer: { bg: '#f6f8fa', color: '#6e7781' },
operator: { bg: '#ddf4ff', color: '#0969da' },
owner: { bg: '#dafbe1', color: '#1a7f37' },
};
interface Props {
pipelineId: number;
pipelineName?: string;
open: boolean;
onClose: () => void;
}
// 单流水线成员弹窗:直接添加成员(选用户 + 点选等级),命中的权限组只读展示
export default function PipelineMembersDialog({ pipelineId, pipelineName, open, onClose }: Props) {
const queryClient = useQueryClient();
const [username, setUsername] = useState('');
const [level, setLevel] = useState('operator');
const membersQuery = useQuery({
queryKey: ['pipeline-members', pipelineId],
queryFn: () => apiClient.listPipelineMembers(pipelineId),
enabled: open,
});
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: apiClient.listUsers,
enabled: open,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['pipeline-members', pipelineId] });
const addMutation = useMutation({
mutationFn: () => apiClient.addPipelineMember(pipelineId, { username, level }),
onSuccess: () => {
toast.success(`已授权 ${username}`);
setUsername('');
invalidate();
},
onError: (err: Error) => toast.error(err.message),
});
const removeMutation = useMutation({
mutationFn: (mid: any) => apiClient.deletePipelineMember(pipelineId, mid),
onSuccess: () => {
toast.success('已移除授权');
invalidate();
},
onError: (err: Error) => toast.error(err.message),
});
const members: any[] = membersQuery.data?.members ?? [];
const matchedRoles: any[] = membersQuery.data?.matchedRoles ?? [];
const userOptions: any[] = users ?? [];
const submit = () => {
if (!username) {
toast.error('请选择用户');
return;
}
addMutation.mutate();
};
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' };
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-[15px]">成员授权 — {pipelineName || `流水线 #${pipelineId}`}</DialogTitle>
</DialogHeader>
{/* 添加成员 */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户</Label>
<Select value={username} onValueChange={setUsername}>
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
<SelectValue placeholder="选择用户" />
</SelectTrigger>
<SelectContent>
{userOptions.map((u) => (
<SelectItem key={u.id} value={u.username} className="text-[12px]">
{u.username}{u.nickname ? `(${u.nickname})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>权限等级</Label>
<div className="flex gap-1">
{LEVEL_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setLevel(opt.value)}
className="flex-1 h-8 text-[12px] rounded-md border transition-colors"
style={
level === opt.value
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)', color: 'var(--accent-blue)', fontWeight: 600 }
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }
}
title={opt.desc}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{LEVEL_OPTIONS.find((l) => l.value === level)?.desc}
</div>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: 'var(--accent-blue)' }}
disabled={addMutation.isPending}
onClick={submit}
>
{addMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <UserPlus className="w-3.5 h-3.5" />}
添加授权
</button>
</div>
{/* 已有直接授权 */}
<div className="space-y-1.5">
<div className="text-[12px] font-medium" style={{ color: 'var(--text-secondary)' }}>直接授权({members.length})</div>
{membersQuery.isLoading ? (
<div className="flex items-center gap-2 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> 加载中...
</div>
) : members.length === 0 ? (
<div className="py-2 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>暂无直接授权成员</div>
) : (
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
{members.map((m) => {
const badge = levelBadgeStyle[m.level] ?? levelBadgeStyle.viewer;
return (
<div
key={m.id}
className="flex items-center justify-between px-3 py-2 border-b last:border-b-0"
style={{ borderColor: 'var(--border-color-light)' }}
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-mono" style={{ color: 'var(--text-primary)' }}>{m.username}</span>
<span className="text-[11px] px-2 py-[2px] rounded font-medium" style={{ background: badge.bg, color: badge.color }}>
{levelLabel(m.level)}
</span>
</div>
<button
className="p-1 rounded hover:bg-black/5"
title="移除授权"
disabled={removeMutation.isPending}
onClick={() => removeMutation.mutate(m.id)}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
);
})}
</div>
)}
</div>
{/* 命中的权限组(只读) */}
{matchedRoles.length > 0 && (
<div className="space-y-1.5">
<div className="text-[12px] font-medium" style={{ color: 'var(--text-secondary)' }}>命中的权限组(只读,在「流水线权限」页管理)</div>
<div className="flex flex-wrap gap-1.5">
{matchedRoles.map((r, i) => {
const badge = levelBadgeStyle[r.level] ?? levelBadgeStyle.viewer;
return (
<span
key={i}
className="text-[11px] px-2 py-1 rounded border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
title={r.mode === 'pattern' ? `名称匹配:${r.pattern}` : '手动选择的流水线'}
>
{r.name} · {r.mode === 'pattern' ? r.pattern : '多选'} ·{' '}
<span style={{ color: badge.color }}>{levelLabel(r.level)}</span>
</span>
);
})}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+8 -1
View File
@@ -44,6 +44,13 @@ export default function ReleasePanel({ pipelineId }: Props) {
enabled: !!expandedRunId,
});
// 当前用户对该流水线的权限(详情接口附带):重试需要「执行」等级
const { data: pipelineDetail } = useQuery({
queryKey: ['pipeline', pipelineId],
queryFn: () => apiClient.getPipeline(pipelineId),
});
const canRun = (pipelineDetail?.accessLevel ?? 4) >= 2;
const expandedRun = runs.find((r) => r.runId === expandedRunId);
const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending';
const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning);
@@ -104,7 +111,7 @@ export default function ReleasePanel({ pipelineId }: Props) {
<span className="text-[11px] w-36 text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })}
</span>
{(r.status === 'failed' || r.status === 'cancelled') && (
{(r.status === 'failed' || r.status === 'cancelled') && canRun && (
<button
onClick={(e) => { e.stopPropagation(); rerunMutation.mutate(r.runId); }}
className="p-1 rounded hover:bg-black/5"
+21 -8
View File
@@ -18,10 +18,11 @@ import {
Sun,
Moon,
LogOut,
ShieldCheck,
} from 'lucide-react';
import { useTheme } from '@/providers/theme';
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings';
import { useSession } from '@/providers/session';
import type { ModuleType } from '@/App';
interface SidebarProps {
activeModule: ModuleType;
@@ -31,18 +32,20 @@ interface SidebarProps {
onLogout?: () => void;
}
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch }[] }[] = [
// adminOnly:普通用户隐藏(资源/集成含敏感配置,系统管理类同理)
const menuGroups: { label: string; adminOnly?: boolean; items: { id: ModuleType; label: string; icon: typeof GitBranch; adminOnly?: boolean }[] }[] = [
{
label: '发布',
items: [
{ id: 'pipelines', label: '流水线', icon: GitBranch },
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync },
{ id: 'batch-release', label: '批量发布', icon: Zap },
{ id: 'release-records', label: '发布记录', icon: History },
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync, adminOnly: true },
],
},
{
label: '资源',
adminOnly: true,
items: [
{ id: 'environments', label: '环境管理', icon: Server },
{ id: 'credentials', label: '凭证管理', icon: Key },
@@ -51,6 +54,7 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
},
{
label: '集成',
adminOnly: true,
items: [
{ id: 'jenkins-import', label: 'Jenkins导入', icon: Download },
{ id: 'tools', label: '工具配置', icon: Wrench },
@@ -60,15 +64,24 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
label: '系统',
items: [
{ id: 'docs', label: '文档中心', icon: BookOpen },
{ id: 'plugins', label: '插件市场', icon: Puzzle },
{ id: 'users', label: '用户管理', icon: Users },
{ id: 'settings', label: '系统设置', icon: Settings },
{ id: 'pipeline-roles', label: '流水线权限', icon: ShieldCheck, adminOnly: true },
{ id: 'plugins', label: '插件市场', icon: Puzzle, adminOnly: true },
{ id: 'users', label: '用户管理', icon: Users, adminOnly: true },
{ id: 'settings', label: '系统设置', icon: Settings, adminOnly: true },
],
},
];
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse, onLogout }: SidebarProps) {
const { toggle: themeToggle, isDark } = useTheme();
const { isAdmin } = useSession();
const visibleGroups = menuGroups
.map((group) => ({
...group,
items: group.items.filter((item) => isAdmin || !item.adminOnly),
}))
.filter((group) => isAdmin || !group.adminOnly);
return (
<div
@@ -99,7 +112,7 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
{/* Menu groups */}
<div className="flex-1 py-2 overflow-y-auto custom-scrollbar">
{menuGroups.map((group) => (
{visibleGroups.map((group) => (
<div key={group.label} className="mb-2">
{!collapsed && (
<div
+53 -24
View File
@@ -1,5 +1,6 @@
import { ChevronLeft, Save, Play, ChevronDown, Loader2 } from 'lucide-react';
import { ChevronLeft, Save, Play, ChevronDown, Loader2, UsersRound } from 'lucide-react';
import { useState } from 'react';
import PipelineMembersDialog from './PipelineMembersDialog';
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
@@ -21,6 +22,7 @@ interface TopBarProps {
isRunning?: boolean;
executionStatus: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
onBack?: () => void;
accessLevel?: number; // 1 查看 / 2 执行 / 3 管理 / 4 管理员
}
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
@@ -38,8 +40,10 @@ export default function TopBar({
isRunning = false,
executionStatus,
onBack,
accessLevel = 4,
}: TopBarProps) {
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const statusConfig: Record<string, { color: string; bg: string; label: string } | null> = {
idle: null,
@@ -146,31 +150,56 @@ export default function TopBar({
))}
</div>
{/* Right: Action Buttons */}
{/* Right: Action Buttons(按权限等级显隐:保存≥管理、运行≥执行、成员≥管理) */}
<div className="flex items-center gap-2 min-w-[280px] justify-end">
<button
onClick={onOnlySave}
disabled={isSaving}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
style={{
color: 'var(--text-secondary)',
background: 'transparent',
border: '1px solid var(--border-color)',
}}
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
仅保存
</button>
<button
onClick={onSaveAndRun}
disabled={isRunning}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors disabled:opacity-60"
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
>
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{isRunning ? '运行中…' : '保存并运行'}
</button>
{accessLevel >= 3 && (
<button
onClick={() => setMembersOpen(true)}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors"
style={{
color: 'var(--text-secondary)',
background: 'transparent',
border: '1px solid var(--border-color)',
}}
title="管理该流水线的成员授权"
>
<UsersRound className="w-4 h-4" />
成员
</button>
)}
{accessLevel >= 3 && (
<button
onClick={onOnlySave}
disabled={isSaving}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
style={{
color: 'var(--text-secondary)',
background: 'transparent',
border: '1px solid var(--border-color)',
}}
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
仅保存
</button>
)}
{accessLevel >= 2 && (
<button
onClick={onSaveAndRun}
disabled={isRunning}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors disabled:opacity-60"
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
>
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{isRunning ? '运行中…' : '保存并运行'}
</button>
)}
</div>
<PipelineMembersDialog
pipelineId={currentPipelineId}
pipelineName={pipelineName}
open={membersOpen}
onClose={() => setMembersOpen(false)}
/>
</div>
);
}
+4
View File
@@ -22,6 +22,8 @@ export default function TriggerPanel({ pipelineId }: Props) {
queryFn: () => apiClient.getPipeline(pipelineId),
});
const p = detail?.pipeline;
// 编辑权限:「管理」等级及以上才能保存
const canEdit = (detail?.accessLevel ?? 4) >= 3;
const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' });
const [copied, setCopied] = useState(false);
@@ -68,6 +70,7 @@ export default function TriggerPanel({ pipelineId }: Props) {
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-5">
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>触发方式</h3>
{canEdit && (
<button
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending}
@@ -76,6 +79,7 @@ export default function TriggerPanel({ pipelineId }: Props) {
>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
</button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
@@ -28,6 +28,8 @@ export default function VariablesPanel({ pipelineId }: Props) {
queryFn: () => apiClient.getPipeline(pipelineId),
});
const p = detail?.pipeline;
// 编辑权限:「管理」等级及以上才能保存
const canEdit = (detail?.accessLevel ?? 4) >= 3;
const [vars, setVars] = useState<VarItem[]>([]);
const [caches, setCaches] = useState<CacheItem[]>([]);
@@ -105,9 +107,13 @@ export default function VariablesPanel({ pipelineId }: Props) {
</div>
<div className="mt-4 flex justify-end">
{canEdit ? (
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} className="flex items-center gap-1 px-4 py-1.5 text-[12px] rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存全部
</button>
) : (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>只读(需要「管理」权限才能编辑)</span>
)}
</div>
</div>
);
@@ -22,7 +22,7 @@ import {
import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import TektonNode, { type TektonNodeData } from './TektonNode';
import { apiClient } from '@/lib/api';
import { apiClient, auth } from '@/lib/api';
interface FlowEditorProps {
pipelineId: number;
@@ -177,7 +177,9 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
setAllNodeStatus('pending');
toast.success(`运行已触发 ${run.runId}`, { description: `${nodes.length} 个节点开始执行` });
const es = new EventSource(`/api/runs/${run.runId}/logs/stream`);
// SSE 无法携带 Authorization 头,token 通过查询参数传递
const token = encodeURIComponent(auth.getToken() ?? '');
const es = new EventSource(`/api/runs/${run.runId}/logs/stream?token=${token}`);
es.addEventListener('log', (evt) => {
try {
const entry = JSON.parse((evt as MessageEvent).data);
@@ -342,9 +344,12 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}Task 库
</button>
<div className="flex items-center gap-1.5">
{(pipelineData?.accessLevel ?? 4) >= 3 && (
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Save className="w-3 h-3" />保存
</button>
)}
{(pipelineData?.accessLevel ?? 4) >= 2 && (
<button
onClick={handleRun}
disabled={isRunning}
@@ -355,6 +360,7 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
? <><CircleDot className="w-3 h-3 animate-spin" />执行中…</>
: <><Play className="w-3 h-3" />执行</>}
</button>
)}
</div>
</div>
</Panel>
+4 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { auth } from '@/lib/api';
export interface LogMessage {
id?: number;
@@ -26,7 +27,9 @@ export function usePipelineLogs(runId: string | null, enabled: boolean) {
return;
}
const es = new EventSource(`/api/runs/${runId}/logs/stream`);
// SSE 无法携带 Authorization 头,token 通过查询参数传递(后端校验签名与 viewer 权限)
const token = encodeURIComponent(auth.getToken() ?? '');
const es = new EventSource(`/api/runs/${runId}/logs/stream?token=${token}`);
es.addEventListener('connected', () => setConnected(true));
es.addEventListener('log', (evt) => {
+12
View File
@@ -74,6 +74,7 @@ export interface PipelineDetail {
pipeline: any
nodes: PipelineNodeDTO[]
edges: PipelineEdgeDTO[]
accessLevel?: number // 1 查看 / 2 执行 / 3 管理 / 4 管理员
}
export interface PipelineRunDTO {
@@ -178,6 +179,7 @@ export const apiClient = {
// 用户与会话
login: (username: string, password: string) =>
api<any>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => api<{ username: string; role: string; isAdmin: boolean }>('/api/me'),
listUsers: () => api<any[]>('/api/users'),
createUser: (b: any) => api<any>('/api/users', { method: 'POST', body: JSON.stringify(b) }),
updateUser: (id: any, b: any) => api(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
@@ -187,4 +189,14 @@ export const apiClient = {
ldapImport: (users: any[]) => api<any>('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }),
testLDAP: (body: any) => api<any>('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }),
getDocs: () => api<any>('/api/docs'),
// 流水线权限组(管理员)与单流水线成员
listPipelineRoles: () => api<any[]>('/api/pipeline-roles'),
createPipelineRole: (b: any) => api<any>('/api/pipeline-roles', { method: 'POST', body: JSON.stringify(b) }),
updatePipelineRole: (id: any, b: any) => api<any>(`/api/pipeline-roles/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deletePipelineRole: (id: any) => api(`/api/pipeline-roles/${id}`, { method: 'DELETE' }),
listPipelineMembers: (pipelineId: number | string) => api<any>(`/api/pipelines/${pipelineId}/members`),
addPipelineMember: (pipelineId: number | string, body: { username: string; level: string }) =>
api<any>(`/api/pipelines/${pipelineId}/members`, { method: 'POST', body: JSON.stringify(body) }),
deletePipelineMember: (pipelineId: number | string, memberId: any) =>
api(`/api/pipelines/${pipelineId}/members/${memberId}`, { method: 'DELETE' }),
}
+33 -2
View File
@@ -3,9 +3,26 @@ import { Rocket, Loader2, KeyRound, User } from 'lucide-react';
import { toast } from 'sonner';
import { apiClient, auth } from '@/lib/api';
const REMEMBER_KEY = 'flowstack-remember';
// 记住密码:明文存 localStorage 仅适合内网工具场景;不勾选则彻底不落盘
function readRemember(): { username: string; password: string } | null {
try {
const raw = localStorage.getItem(REMEMBER_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed?.username && parsed?.password) return parsed;
} catch {
// 脏数据忽略
}
return null;
}
export default function Login({ onSuccess }: { onSuccess: () => void }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const remembered = readRemember();
const [username, setUsername] = useState(remembered?.username ?? '');
const [password, setPassword] = useState(remembered?.password ?? '');
const [remember, setRemember] = useState(!!remembered);
const [loading, setLoading] = useState(false);
const submit = async (e?: React.FormEvent) => {
@@ -18,6 +35,11 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
try {
const res = await apiClient.login(username.trim(), password);
auth.setToken(res.token);
if (remember) {
localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username: username.trim(), password }));
} else {
localStorage.removeItem(REMEMBER_KEY);
}
toast.success(`欢迎,${res.nickname || res.username}`);
onSuccess();
} catch (err) {
@@ -69,6 +91,15 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
/>
</div>
</div>
<label className="flex items-center gap-1.5 cursor-pointer select-none" title="在本机记住账号与密码,公共电脑请勿勾选">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="w-3.5 h-3.5 accent-[var(--accent-blue)]"
/>
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>记住密码</span>
</label>
<button
type="submit"
disabled={loading}
+70 -52
View File
@@ -17,6 +17,7 @@ import {
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { useSession } from '@/providers/session';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -120,6 +121,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
const [statusFilter, setStatusFilter] = useState<string>('all');
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
const { isAdmin } = useSession();
const queryClient = useQueryClient();
@@ -163,6 +165,8 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
description: p.description || '',
branch: p.sourceBranch || '-',
type: p.type,
// 后端附带的当前用户权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员
accessLevel: (p as any).accessLevel ?? 4,
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
mappedStatus: p.lastRun?.status ?? 'pending',
@@ -289,15 +293,17 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* New pipeline button */}
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => setCreateOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
新建流水线
</button>
{/* New pipeline button(新建/删除仅管理员) */}
{isAdmin && (
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => setCreateOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
新建流水线
</button>
)}
</div>
</div>
@@ -314,7 +320,11 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件或新建流水线</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>
{items.length === 0 && !isAdmin
? '暂无被授权的流水线,请联系管理员在「流水线权限」中授权'
: '尝试调整搜索条件或新建流水线'}
</p>
</div>
) : viewMode === 'card' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
@@ -397,19 +407,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</span>
</div>
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
{pipeline.accessLevel >= 2 && (
<button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
)}
<button
className="p-1 rounded hover:bg-black/5"
title="配置"
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
@@ -417,16 +429,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
{isAdmin && (
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
)}
</div>
</div>
</div>
@@ -506,19 +520,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
{pipeline.accessLevel >= 2 && (
<button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
)}
<button
className="p-1 rounded hover:bg-black/5"
title="配置"
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
@@ -526,16 +542,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
{isAdmin && (
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
)}
</div>
</td>
</tr>
+488
View File
@@ -0,0 +1,488 @@
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Plus, Search, Trash2, Edit2, Loader2, ShieldCheck, ListChecks, Wand2, CheckSquare, Square, X,
} from 'lucide-react';
import { apiClient } from '@/lib/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { LEVEL_OPTIONS, levelLabel, levelBadgeStyle } from '@/components/PipelineMembersDialog';
// 与后端 globToRegexp 语义一致:* 任意字符、? 单字符,其余按字面量(不锚定,部分命中即可)
function globMatch(glob: string, s: string): boolean {
try {
const re = new RegExp(glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.'));
return re.test(s);
} catch {
return false;
}
}
interface RoleForm {
name: string;
scopeMode: 'manual' | 'pattern';
pattern: string;
pipelineIds: number[];
level: string;
members: string[];
}
const emptyForm: RoleForm = {
name: '', scopeMode: 'manual', pattern: '', pipelineIds: [], level: 'operator', members: [],
};
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
export default function PipelineRoles() {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState<RoleForm>(emptyForm);
const [pipelineSearch, setPipelineSearch] = useState('');
const [userSearch, setUserSearch] = useState('');
const rolesQuery = useQuery({ queryKey: ['pipeline-roles'], queryFn: apiClient.listPipelineRoles });
const pipelinesQuery = useQuery({ queryKey: ['pipelines'], queryFn: apiClient.listPipelines });
const usersQuery = useQuery({ queryKey: ['users'], queryFn: apiClient.listUsers });
const roles: any[] = rolesQuery.data ?? [];
const pipelines: any[] = pipelinesQuery.data ?? [];
const users: any[] = usersQuery.data ?? [];
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['pipeline-roles'] });
const saveMutation = useMutation({
mutationFn: (f: RoleForm) => {
const body = { name: f.name.trim(), scopeMode: f.scopeMode, pattern: f.pattern.trim(), pipelineIds: f.pipelineIds, level: f.level, members: f.members };
return editing ? apiClient.updatePipelineRole(editing.id, body) : apiClient.createPipelineRole(body);
},
onSuccess: () => {
toast.success(editing ? '权限组已更新' : '权限组已创建');
invalidate();
setDialogOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (id: any) => apiClient.deletePipelineRole(id),
onSuccess: () => {
toast.success('权限组已删除');
invalidate();
},
onError: (err: Error) => toast.error(err.message),
});
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setPipelineSearch('');
setUserSearch('');
setDialogOpen(true);
};
const openEdit = (r: any) => {
setEditing(r);
setForm({
name: r.name, scopeMode: r.scopeMode || 'manual', pattern: r.pattern || '',
pipelineIds: (r.pipelineIds ?? []).map(Number), level: r.level, members: r.members ?? [],
});
setPipelineSearch('');
setUserSearch('');
setDialogOpen(true);
};
const submit = () => {
if (!form.name.trim()) {
toast.error('请填写名称');
return;
}
if (form.scopeMode === 'manual' && form.pipelineIds.length === 0) {
toast.error('请至少勾选一条流水线');
return;
}
if (form.scopeMode === 'pattern' && !form.pattern.trim()) {
toast.error('请填写名称匹配规则,如 cex-*');
return;
}
saveMutation.mutate(form);
};
// 手动多选列表(带搜索)
const filteredPipelines = useMemo(() => {
const q = pipelineSearch.toLowerCase();
return q ? pipelines.filter((p) => p.name?.toLowerCase().includes(q)) : pipelines;
}, [pipelines, pipelineSearch]);
// 通配符实时预览:命中多少条流水线
const patternPreview = useMemo(() => {
if (form.scopeMode !== 'pattern' || !form.pattern.trim()) return null;
const n = pipelines.filter(
(p) => globMatch(form.pattern.trim(), p.name || '') || (p.jenkinsJobName && globMatch(form.pattern.trim(), p.jenkinsJobName)),
).length;
return n;
}, [form.scopeMode, form.pattern, pipelines]);
const filteredUsers = useMemo(() => {
const q = userSearch.toLowerCase();
return q ? users.filter((u: any) => u.username?.toLowerCase().includes(q) || u.nickname?.toLowerCase().includes(q)) : users;
}, [users, userSearch]);
const pipelineNameById = useMemo(() => {
const m = new Map<number, string>();
pipelines.forEach((p) => m.set(Number(p.id), p.name));
return m;
}, [pipelines]);
const togglePipeline = (id: number) =>
setForm((f) => ({
...f,
pipelineIds: f.pipelineIds.includes(id) ? f.pipelineIds.filter((x) => x !== id) : [...f.pipelineIds, id],
}));
const toggleMember = (username: string) =>
setForm((f) => ({
...f,
members: f.members.includes(username) ? f.members.filter((x) => x !== username) : [...f.members, username],
}));
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>流水线权限</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{roles.length}
</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
未被授权的普通用户看不到任何流水线;授权后按等级「查看 / 发布 / 管理」放开
</span>
</div>
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={openCreate}
>
<Plus className="w-3.5 h-3.5" />
新建权限组
</button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{rolesQuery.isLoading ? (
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
<Loader2 className="w-4 h-4 animate-spin" /> 加载中...
</div>
) : roles.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-16">
<ShieldCheck className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>还没有权限组</div>
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
新建一个权限组:勾选流水线 → 选择等级 → 添加成员,全程点选无需输入规则
</div>
</div>
) : (
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['名称', '授权范围', '等级', '成员', '匹配流水线', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{roles.map((r) => {
const badge = levelBadgeStyle[r.level] ?? levelBadgeStyle.viewer;
const memberText = (r.members ?? []).slice(0, 3).join('、') + ((r.members?.length ?? 0) > 3 ? ` 等 ${r.members.length} 人` : '');
return (
<tr key={r.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-4 py-3">
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{r.name}</span>
</td>
<td className="px-4 py-3">
{r.scopeMode === 'pattern' ? (
<span className="inline-flex items-center gap-1 text-[12px] font-mono px-2 py-[3px] rounded" style={{ background: '#fbefff', color: '#8250df' }}>
<Wand2 className="w-3 h-3" />{r.pattern}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[12px] px-2 py-[3px] rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>
<ListChecks className="w-3 h-3" />{r.pipelineIds?.length ?? 0} 条流水线
</span>
)}
</td>
<td className="px-4 py-3">
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: badge.bg, color: badge.color }}>
{levelLabel(r.level)}
</span>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
{r.members?.length ? memberText : <span style={{ color: 'var(--text-tertiary)' }}>—</span>}
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
{r.matchedPipelines ?? 0} 条
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openEdit(r)}>
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={() => {
if (window.confirm(`确定删除权限组「${r.name}」?成员将失去对应授权。`)) deleteMutation.mutate(r.id);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* 新建/编辑弹窗 */}
<Dialog open={dialogOpen} onOpenChange={(v) => { setDialogOpen(v); if (!v) setEditing(null); }}>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto custom-scrollbar">
<DialogHeader>
<DialogTitle className="text-[15px]">{editing ? '编辑权限组' : '新建权限组'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 名称 */}
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称</Label>
<Input
className="h-8 text-[12px]"
style={inputStyle}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="如:cex 发布组"
/>
</div>
{/* 授权范围 */}
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>授权范围</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm({ ...form, scopeMode: 'manual' })}
className="flex items-start gap-2 p-2.5 rounded-md border text-left transition-colors"
style={
form.scopeMode === 'manual'
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)' }
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }
}
>
<ListChecks className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: form.scopeMode === 'manual' ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} />
<div>
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>选择流水线</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>勾选要授权的流水线,直观不踩坑</div>
</div>
</button>
<button
type="button"
onClick={() => setForm({ ...form, scopeMode: 'pattern' })}
className="flex items-start gap-2 p-2.5 rounded-md border text-left transition-colors"
style={
form.scopeMode === 'pattern'
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)' }
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }
}
>
<Wand2 className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: form.scopeMode === 'pattern' ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} />
<div>
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>
名称匹配 <span className="text-[10px] px-1 rounded" style={{ background: '#fff8c5', color: '#9a6700' }}>高级</span>
</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>用 * 匹配一批,新建同名流水线自动纳入</div>
</div>
</button>
</div>
{form.scopeMode === 'manual' ? (
<div className="rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2 px-2.5 py-2 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<input
value={pipelineSearch}
onChange={(e) => setPipelineSearch(e.target.value)}
placeholder="搜索流水线..."
className="bg-transparent outline-none flex-1 text-[12px]"
style={{ color: 'var(--text-primary)' }}
/>
<button className="text-[11px] px-1.5 py-[2px] rounded" style={{ color: 'var(--accent-blue)' }} onClick={() => setForm({ ...form, pipelineIds: filteredPipelines.map((p) => Number(p.id)) })}>
全选
</button>
<button className="text-[11px] px-1.5 py-[2px] rounded" style={{ color: 'var(--text-tertiary)' }} onClick={() => setForm({ ...form, pipelineIds: [] })}>
清空
</button>
</div>
<div className="max-h-44 overflow-y-auto custom-scrollbar">
{filteredPipelines.map((p) => {
const checked = form.pipelineIds.includes(Number(p.id));
return (
<button
key={p.id}
type="button"
className="w-full flex items-center gap-2 px-3 py-[7px] text-left hover:bg-black/[0.02] transition-colors"
onClick={() => togglePipeline(Number(p.id))}
>
{checked ? (
<CheckSquare className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--accent-blue)' }} />
) : (
<Square className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
)}
<span className="text-[12px] font-mono truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
{p.type === 'jenkins' && (
<span className="text-[9px] px-1 rounded flex-shrink-0" style={{ background: '#fbefff', color: '#8250df' }}>Jenkins</span>
)}
</button>
);
})}
{filteredPipelines.length === 0 && (
<div className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>没有匹配的流水线</div>
)}
</div>
<div className="px-3 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
已选 {form.pipelineIds.length} 条
{form.pipelineIds.length > 0 && (
<span className="inline-flex flex-wrap gap-1 ml-2 align-middle">
{form.pipelineIds.slice(0, 5).map((id) => (
<span key={id} className="inline-flex items-center gap-0.5 px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)' }}>
<span className="text-[10px] font-mono" style={{ color: 'var(--text-secondary)' }}>{pipelineNameById.get(id) ?? `#${id}`}</span>
<X className="w-2.5 h-2.5 cursor-pointer" style={{ color: 'var(--text-tertiary)' }} onClick={() => togglePipeline(id)} />
</span>
))}
{form.pipelineIds.length > 5 && <span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>…</span>}
</span>
)}
</div>
</div>
) : (
<div className="space-y-1.5">
<Input
className="h-8 text-[12px] font-mono"
style={inputStyle}
value={form.pattern}
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
placeholder="cex-*"
/>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
只需用 <b>*</b> 代表任意字符,例如 <code>cex-*</code> 授权所有 cex- 开头的流水线,不需要正则表达式。
</div>
{patternPreview !== null && (
<div className="text-[11px]" style={{ color: patternPreview > 0 ? '#1a7f37' : '#cf222e' }}>
当前规则命中 {patternPreview} 条流水线{patternPreview === 0 ? '(请检查写法,如 cex-*)' : ''}
</div>
)}
</div>
)}
</div>
{/* 权限等级 */}
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>权限等级</Label>
<div className="grid grid-cols-3 gap-2">
{LEVEL_OPTIONS.map((opt) => {
const active = form.level === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setForm({ ...form, level: opt.value })}
className="p-2.5 rounded-md border text-left transition-colors"
style={
active
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)' }
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }
}
>
<div className="text-[12px] font-semibold" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-primary)' }}>{opt.label}</div>
<div className="text-[10px] mt-0.5 leading-4" style={{ color: 'var(--text-tertiary)' }}>{opt.desc}</div>
</button>
);
})}
</div>
</div>
{/* 成员 */}
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>成员({form.members.length} 人)</Label>
<div className="rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2 px-2.5 py-2 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<input
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
placeholder="搜索用户..."
className="bg-transparent outline-none flex-1 text-[12px]"
style={{ color: 'var(--text-primary)' }}
/>
</div>
<div className="max-h-36 overflow-y-auto custom-scrollbar">
{filteredUsers.map((u: any) => {
const checked = form.members.includes(u.username);
return (
<button
key={u.id}
type="button"
className="w-full flex items-center gap-2 px-3 py-[6px] text-left hover:bg-black/[0.02] transition-colors"
onClick={() => toggleMember(u.username)}
>
{checked ? (
<CheckSquare className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--accent-blue)' }} />
) : (
<Square className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
)}
<span className="text-[12px] font-mono" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
{u.nickname && <span className="text-[11px] truncate" style={{ color: 'var(--text-tertiary)' }}>{u.nickname}</span>}
<span className="ml-auto text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{u.role === 'admin' ? '管理员' : u.authSource === 'ldap' ? 'LDAP' : '本地'}
</span>
</button>
);
})}
{filteredUsers.length === 0 && (
<div className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>没有匹配的用户</div>
)}
</div>
</div>
</div>
</div>
<DialogFooter>
<button
className="px-3 py-1.5 text-[12px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
onClick={() => setDialogOpen(false)}
>
取消
</button>
<button
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: 'var(--accent-blue)' }}
disabled={saveMutation.isPending}
onClick={submit}
>
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
{editing ? '保存' : '创建'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2 -1
View File
@@ -55,6 +55,7 @@ interface RunRecord {
buildNumber?: number;
externalUrl?: string;
analysis?: string; // 失败时后端自动生成的 AI 失败分析
canRetry?: boolean; // 后端按当前用户权限附加:执行等级及以上才允许重试
}
interface RunStage {
@@ -284,7 +285,7 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Terminal className="w-3 h-3" />查看日志
</button>
{(record.status === 'failed' || record.status === 'cancelled') && (
{(record.status === 'failed' || record.status === 'cancelled') && record.canRetry && (
<button
onClick={() => retryMutation.mutate()}
disabled={retryMutation.isPending}
+19
View File
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '../lib/api'
// 会话信息(菜单分级、按钮权限的依据)。
// 多个组件共享同一 ['me'] 查询缓存;登录成功后由 App invalidate 刷新。
export function useSession() {
const { data, isLoading } = useQuery({
queryKey: ['me'],
queryFn: apiClient.me,
staleTime: 5 * 60 * 1000,
retry: false,
})
return {
username: data?.username ?? '',
role: data?.role ?? '',
isAdmin: !!data?.isAdmin,
loading: isLoading,
}
}