feat: 工具配置增强、站点拨测与日志中心多项改进
- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖 - 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败 - Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS - 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率 - 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示 - 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
1 parent
cde7bbe540
commit
7e1c8f583c
84 files changed
+7729
-1507
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus} from 'lucide-react';
|
||||
import { Shield, Plus, Trash2, Save, Loader2, Users, GitBranch, UserPlus, Check, X } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -11,45 +11,62 @@ import { deployApi, userApi, pipelineApi } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import UserGroupsManager from './UserGroupsManager';
|
||||
import { JobRoleCreateDialog } from './deploy-rbac/JobRoleCreateDialog';
|
||||
import { matchPipelines, pipelineDisplayLabel, type PipelineMatchItem } from './deploy-rbac/matchPipeline';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface ItemRole {
|
||||
id: number; name: string; code: string; pattern: string; role: string;
|
||||
description: string; status: number; created_at: string;
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
pattern: string;
|
||||
role: string;
|
||||
description: string;
|
||||
status: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AssignedUser {
|
||||
id: number; user_id: number; item_role_id: number;
|
||||
user?: { id: number; username: string; real_name: string };
|
||||
id: number;
|
||||
user_id: number;
|
||||
item_role_id: number;
|
||||
item_role?: ItemRole;
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
owner: '拥有者',
|
||||
editor: '编辑者',
|
||||
viewer: '查看者',
|
||||
};
|
||||
|
||||
const RBAC_TABS = ['roles', 'users', 'preview', 'groups'] as const;
|
||||
|
||||
// 发布 Job 权限 — 正则匹配 code / job_name,Jenkins Role Strategy 风格 // Logan.Li
|
||||
export default function DeployRBAC() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const tabFromUrl = searchParams.get('tab');
|
||||
const [roles, setRoles] = useState<ItemRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Pipeline list for live preview
|
||||
const [pipelines, setPipelines] = useState<JsonData[]>([]);
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
tabFromUrl && (RBAC_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'roles',
|
||||
);
|
||||
const [pipelines, setPipelines] = useState<PipelineMatchItem[]>([]);
|
||||
const [previewRoleId, setPreviewRoleId] = useState<number | null>(null);
|
||||
|
||||
// User assignment
|
||||
const [users, setUsers] = useState<JsonData[]>([]);
|
||||
const [assignments, setAssignments] = useState<AssignedUser[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [assignRoleId, setAssignRoleId] = useState<string>('');
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [assignRoleId, setAssignRoleId] = useState('');
|
||||
const [assignLoading, setAssignLoading] = useState(false);
|
||||
|
||||
// Create
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newPattern, setNewPattern] = useState('');
|
||||
const [newRole, setNewRole] = useState('editor');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
// Edit
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [editPattern, setEditPattern] = useState('');
|
||||
const [editRole, setEditRole] = useState('');
|
||||
@@ -64,11 +81,10 @@ export default function DeployRBAC() {
|
||||
pipelineApi.getPipelines({ page_size: 200 }),
|
||||
userApi.getUsers({ page_size: 500 }),
|
||||
]);
|
||||
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as JsonData)?.list || (rolesRes as JsonData)?.data || []);
|
||||
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as JsonData)?.list || (pipesRes as JsonData)?.data || []);
|
||||
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as JsonData)?.list || (usersRes as JsonData)?.data || [];
|
||||
setUsers(uList);
|
||||
} catch { /* */ }
|
||||
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as JsonData)?.list || []);
|
||||
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as JsonData)?.list || []);
|
||||
setUsers(Array.isArray(usersRes) ? usersRes : (usersRes as JsonData)?.list || []);
|
||||
} catch { /* silent */ }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -78,40 +94,40 @@ export default function DeployRBAC() {
|
||||
if (!selectedUserId) { setAssignments([]); return; }
|
||||
try {
|
||||
const res: JsonData = await deployApi.getUserItemRoles(Number(selectedUserId));
|
||||
setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []);
|
||||
setAssignments(Array.isArray(res) ? res : res?.list || []);
|
||||
} catch { setAssignments([]); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadAssignments(); }, [selectedUserId]);
|
||||
|
||||
// Compute matched pipeline codes for each role
|
||||
const matchCount = useMemo(() => {
|
||||
const map: Record<number, string[]> = {};
|
||||
roles.forEach(r => {
|
||||
try {
|
||||
const re = new RegExp(r.pattern);
|
||||
map[r.id] = pipelines.filter(p => re.test(p.code)).map(p => p.code);
|
||||
} catch { map[r.id] = []; }
|
||||
});
|
||||
const map: Record<number, PipelineMatchItem[]> = {};
|
||||
roles.forEach((r) => { map[r.id] = matchPipelines(r.pattern, pipelines); });
|
||||
return map;
|
||||
}, [roles, pipelines]);
|
||||
|
||||
const previewRole = previewRoleId ? roles.find(r => r.id === previewRoleId) : null;
|
||||
const previewMatches = previewRole ? (matchCount[previewRoleId!] || []) : [];
|
||||
const previewMatches = previewRoleId ? (matchCount[previewRoleId] || []) : pipelines;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
|
||||
toast.error('名称、代码、正则表达式不能为空'); return;
|
||||
toast.error('请填写名称、标识与正则'); return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await deployApi.createItemRole({ name: newName.trim(), code: newCode.trim(), pattern: newPattern.trim(), role: newRole, description: newDesc });
|
||||
await deployApi.createItemRole({
|
||||
name: newName.trim(),
|
||||
code: newCode.trim(),
|
||||
pattern: newPattern.trim(),
|
||||
role: newRole,
|
||||
description: newDesc,
|
||||
});
|
||||
toast.success('已创建');
|
||||
setShowCreate(false);
|
||||
setCreateOpen(false);
|
||||
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
|
||||
loadData();
|
||||
} catch (e) { toast.error(e?.message || '创建失败'); }
|
||||
finally { setSaving(false); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || '创建失败');
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: number) => {
|
||||
@@ -122,12 +138,17 @@ export default function DeployRBAC() {
|
||||
toast.success('已更新');
|
||||
setEditing(null);
|
||||
loadData();
|
||||
} catch (e) { toast.error(e?.message || '更新失败'); }
|
||||
finally { setSaving(false); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || '更新失败');
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (role: ItemRole) => {
|
||||
const ok = await _confirm({ title: '删除角色', description: `确定删除「${role.name}」?匹配 ${matchCount[role.id]?.length || 0} 条流水线`, variant: 'destructive' });
|
||||
const ok = await _confirm({
|
||||
title: '删除权限',
|
||||
description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个发布任务`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
@@ -142,15 +163,19 @@ export default function DeployRBAC() {
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和角色'); return; }
|
||||
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; }
|
||||
setAssignLoading(true);
|
||||
try {
|
||||
await deployApi.assignItemRole(Number(selectedUserId), { user_id: Number(selectedUserId), item_role_id: Number(assignRoleId) });
|
||||
await deployApi.assignItemRole(Number(selectedUserId), {
|
||||
user_id: Number(selectedUserId),
|
||||
item_role_id: Number(assignRoleId),
|
||||
});
|
||||
toast.success('已分配');
|
||||
setAssignRoleId('');
|
||||
loadAssignments();
|
||||
} catch (e) { toast.error(e?.message || '分配失败'); }
|
||||
finally { setAssignLoading(false); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || '分配失败');
|
||||
} finally { setAssignLoading(false); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
@@ -161,257 +186,211 @@ export default function DeployRBAC() {
|
||||
} catch { toast.error('撤销失败'); }
|
||||
};
|
||||
|
||||
const roleBadgeClass = (role: string) =>
|
||||
role === 'owner' ? 'bg-amber-500/20 dg-text-yellow border-amber-500/30' :
|
||||
role === 'editor' ? 'bg-blue-500/20 dg-text-blue border-blue-500/30' :
|
||||
'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]';
|
||||
|
||||
const selectedUser = users.find(u => String(u.id) === selectedUserId);
|
||||
|
||||
const activeRoles = roles.filter(r => r.status === 1).length;
|
||||
const selectedUser = users.find((u) => String(u.id) === selectedUserId);
|
||||
const pageTabs = [
|
||||
{ id: 'roles', label: '权限策略', badge: roles.length || undefined },
|
||||
{ id: 'users', label: '用户授权' },
|
||||
{ id: 'groups', label: '用户组' },
|
||||
{ id: 'preview', label: '命中预览' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="发布权限"
|
||||
subtitle="Jenkins-style Role Strategy — 按流水线 code 正则匹配分配角色"
|
||||
title="Job 权限"
|
||||
subtitle="按流水线 code / Job 名称正则分配发布权限"
|
||||
icon={<Shield className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: 'Item Role', value: roles.length, color: 'purple' },
|
||||
{ label: '已启用', value: activeRoles, color: 'emerald' },
|
||||
{ label: '流水线', value: pipelines.length, color: 'blue' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
actions={(
|
||||
<Button className="dg-btn-primary" onClick={() => setShowCreate(true)} disabled={showCreate}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建角色
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
actions={activeTab === 'groups' ? undefined : (
|
||||
<Button className="dg-btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建权限
|
||||
</Button>
|
||||
)}
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
{/* ====== Pipeline Preview ====== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-4 h-4 dg-text-blue" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线命中预览</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">— 选择一个角色查看哪些流水线会被匹配</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Button variant={previewRoleId === null ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(null)}>全部 ({pipelines.length})</Button>
|
||||
{roles.map(r => (
|
||||
<Button key={r.id} variant={previewRoleId === r.id ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(r.id)}
|
||||
className={r.status === 0 ? 'opacity-40' : ''}>
|
||||
{r.name} ({matchCount[r.id]?.length || 0})
|
||||
</Button>
|
||||
{activeTab === 'roles' && (
|
||||
roles.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Shield className="h-10 w-10" />}
|
||||
title="暂无 Job 权限策略"
|
||||
description="创建策略后,用户将按正则自动获得匹配流水线的访问权限"
|
||||
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />新建权限</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{roles.map((role) => (
|
||||
<Card key={role.id} className={`dg-card ${role.status === 0 ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{role.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">
|
||||
{ROLE_LABEL[role.role] || role.role}
|
||||
</Badge>
|
||||
{role.status === 0 && <Badge variant="outline" className="text-[10px]">已禁用</Badge>}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] cursor-pointer border-[var(--dg-border)]"
|
||||
onClick={() => { setActiveTab('preview'); setPreviewRoleId(role.id); }}
|
||||
>
|
||||
{matchCount[role.id]?.length || 0} 个 Job
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1 font-mono truncate">
|
||||
{role.pattern}
|
||||
{role.description ? ` · ${role.description}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{editing === role.id ? (
|
||||
<>
|
||||
<Input value={editPattern} onChange={(e) => setEditPattern(e.target.value)} className="h-7 w-32 text-xs font-mono dg-input" />
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者</SelectItem>
|
||||
<SelectItem value="editor">编辑者</SelectItem>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-red)]" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
|
||||
{previewRoleId === null
|
||||
? pipelines.map(p => <Badge key={p.id} variant="secondary" className="text-xs"><GitBranch className="w-3 h-3 mr-0.5" />{p.code}</Badge>)
|
||||
: pipelines.map(p => {
|
||||
const matched = previewMatches.includes(p.code);
|
||||
return (
|
||||
<Badge key={p.id} variant={matched ? 'default' : 'secondary'}
|
||||
className={`text-xs ${matched ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' : 'opacity-30'}`}>
|
||||
<GitBranch className="w-3 h-3 mr-0.5" />{p.code}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
}
|
||||
{previewRoleId !== null && previewMatches.length === 0 && (
|
||||
<span className="text-xs dg-text-red">无匹配 — 请调整正则表达式</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex items-center gap-2"><Plus className="w-4 h-4" />新建 Item Role</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs">名称</Label><Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="如: backend" className="h-8 text-sm" /></div>
|
||||
<div className="space-y-1"><Label className="text-xs">代码 (唯一标识)</Label><Input value={newCode} onChange={e => setNewCode(e.target.value)} placeholder="如: backend" className="h-8 text-sm font-mono" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs flex items-center gap-1"><Regex className="w-3 h-3" />正则匹配 Pattern</Label>
|
||||
<Input value={newPattern} onChange={e => { setNewPattern(e.target.value); }} placeholder='如: ^cex-.*' className="h-8 text-sm font-mono" />
|
||||
{newPattern && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
实时匹配: {(() => { try { const n = pipelines.filter(p => new RegExp(newPattern).test(p.code)).length; return n > 0 ? `✅ 命中 ${n} 条流水线` : '⚠️ 无匹配'; } catch { return '❌ 正则语法错误'; } })()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">角色</Label>
|
||||
<Select value={newRole} onValueChange={setNewRole}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者 (owner)</SelectItem>
|
||||
<SelectItem value="editor">编辑者 (editor)</SelectItem>
|
||||
<SelectItem value="viewer">查看者 (viewer)</SelectItem>
|
||||
{activeTab === 'users' && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="space-y-1 flex-1 min-w-[200px]">
|
||||
<Label className="text-xs">用户</Label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择用户" /></SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id as number} value={String(u.id)}>
|
||||
{(u.real_name as string) || (u.username as string)} ({u.username as string})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">描述 (可选)</Label><Input value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="简要说明此角色的用途" className="h-8 text-sm" /></div>
|
||||
<Button onClick={handleCreate} disabled={saving} className="w-full">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Role list */}
|
||||
{roles.length === 0 ? (
|
||||
<PageEmpty icon={<Shield className="h-10 w-10" />} title="暂无 Item Role" description="创建角色后按正则匹配流水线并分配给用户" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{roles.map(role => (
|
||||
<Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${role.status === 0 ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Regex className="w-4 h-4 dg-text-purple" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{role.name}</span>
|
||||
<Badge className={`text-xs border ${roleBadgeClass(role.role)}`}>{role.role === 'owner' ? '拥有者' : role.role === 'editor' ? '编辑者' : '查看者'}</Badge>
|
||||
{role.status === 0 && <Badge variant="secondary" className="text-xs dg-text-red">已禁用</Badge>}
|
||||
<Badge variant="secondary" className="text-xs cursor-pointer" onClick={() => setPreviewRoleId(previewRoleId === role.id ? null : role.id)}>
|
||||
<GitBranch className="w-3 h-3 mr-0.5" />{matchCount[role.id]?.length || 0} 条
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5 truncate">
|
||||
<code className="font-mono dg-text-purple bg-purple-500/10 px-1 rounded">{role.code}</code>
|
||||
<span className="mx-1">→</span>
|
||||
<code className="font-mono dg-text-blue bg-blue-500/10 px-1 rounded">{role.pattern}</code>
|
||||
{role.description && <span className="ml-2 opacity-60">{role.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{editing === role.id ? (
|
||||
<>
|
||||
<Input value={editPattern} onChange={e => setEditPattern(e.target.value)} className="h-7 w-36 text-xs font-mono" placeholder="正则表达式" />
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者</SelectItem>
|
||||
<SelectItem value="editor">编辑者</SelectItem>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 dg-text-green" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 dg-text-red" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}><Save className="w-3 h-3 mr-1" />编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== User Assignment ====== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserPlus className="w-4 h-4 dg-text-yellow" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">分配角色给用户</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-end mb-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label className="text-xs">选择用户</Label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择用户..." /></SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
<div className="px-2 py-1.5 sticky top-0 bg-popover z-10"><Input placeholder="搜索用户..." className="h-7 text-xs" /></div>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={String(u.id)}>
|
||||
{u.real_name || u.username} ({u.username})
|
||||
{assignments.some(a => a.user_id === u.id) && <span className="ml-2 text-xs dg-text-purple">已分配</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 w-48">
|
||||
<Label className="text-xs">Item Role</Label>
|
||||
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择角色" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.filter(r => r.status === 1).map(r => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({r.role})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="h-8" onClick={handleAssign} disabled={assignLoading || !selectedUserId || !assignRoleId}>
|
||||
{assignLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <UserPlus className="w-3.5 h-3.5 mr-1" />}
|
||||
分配
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Assigned roles for selected user */}
|
||||
{selectedUser && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Users className="w-3 h-3 mr-1" />{selectedUser.real_name || selectedUser.username}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">已分配角色:</span>
|
||||
<div className="space-y-1 w-48">
|
||||
<Label className="text-xs">权限策略</Label>
|
||||
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择策略" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.filter((r) => r.status === 1).map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({ROLE_LABEL[r.role]})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{assignments.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">该用户尚未分配任何 Item Role</p>
|
||||
<Button className="h-8" onClick={handleAssign} disabled={assignLoading || !selectedUserId || !assignRoleId}>
|
||||
{assignLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <UserPlus className="w-3.5 h-3.5 mr-1" />}
|
||||
分配
|
||||
</Button>
|
||||
</div>
|
||||
{selectedUser ? (
|
||||
assignments.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">该用户尚未分配 Job 权限</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{assignments.map(a => {
|
||||
const roleInfo = a.item_role || roles.find(r => r.id === a.item_role_id);
|
||||
<div className="space-y-1.5">
|
||||
{assignments.map((a) => {
|
||||
const roleInfo = a.item_role || roles.find((r) => r.id === a.item_role_id);
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between py-1.5 px-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={`text-xs border ${roleBadgeClass(roleInfo?.role || 'viewer')}`}>
|
||||
{roleInfo?.role === 'owner' ? '拥有者' : roleInfo?.role === 'editor' ? '编辑者' : '查看者'}
|
||||
</Badge>
|
||||
<span className="text-[var(--dg-text-primary)]">{roleInfo?.name || `#${a.item_role_id}`}</span>
|
||||
{roleInfo && (
|
||||
<code className="font-mono text-[var(--dg-text-muted)] bg-[var(--dg-bg-primary)] px-1 rounded text-xs">
|
||||
{roleInfo.pattern}
|
||||
</code>
|
||||
)}
|
||||
<div key={a.id} className="flex items-center justify-between py-2 px-2.5 rounded-md bg-[var(--dg-bg-tertiary)] text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{ROLE_LABEL[roleInfo?.role || ''] || roleInfo?.role}</Badge>
|
||||
<span className="text-[var(--dg-text-primary)]">{roleInfo?.name}</span>
|
||||
<code className="text-[var(--dg-text-muted)] truncate">{roleInfo?.pattern}</code>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 dg-text-red" onClick={() => handleRevoke(a.id)}>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => handleRevoke(a.id)}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" />选择用户后查看已分配权限</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && <UserGroupsManager embedded />}
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button variant={previewRoleId === null ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(null)}>
|
||||
全部 ({pipelines.length})
|
||||
</Button>
|
||||
{roles.map((r) => (
|
||||
<Button
|
||||
key={r.id}
|
||||
variant={previewRoleId === r.id ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className={r.status === 0 ? 'opacity-40' : ''}
|
||||
onClick={() => setPreviewRoleId(r.id)}
|
||||
>
|
||||
{r.name} ({matchCount[r.id]?.length || 0})
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-48 overflow-y-auto">
|
||||
{previewMatches.map((p) => (
|
||||
<Badge key={p.id as number} variant="outline" className="text-xs font-mono border-[var(--dg-border)]">
|
||||
<GitBranch className="w-3 h-3 mr-0.5" />
|
||||
{pipelineDisplayLabel(p)}
|
||||
</Badge>
|
||||
))}
|
||||
{previewRoleId !== null && previewMatches.length === 0 && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">无匹配 — 请调整正则</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<JobRoleCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
pipelines={pipelines}
|
||||
saving={saving}
|
||||
name={newName}
|
||||
code={newCode}
|
||||
pattern={newPattern}
|
||||
role={newRole}
|
||||
description={newDesc}
|
||||
onNameChange={setNewName}
|
||||
onCodeChange={setNewCode}
|
||||
onPatternChange={setNewPattern}
|
||||
onRoleChange={setNewRole}
|
||||
onDescriptionChange={setNewDesc}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user