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:
1 parent
7090c8efc2
commit
9103b13510
27 files changed
+1690
-190
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user