批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换: - 翻译词典: 22 namespaces, 3600+ keys - 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy) - 语言切换器在 Header 中可用 (🇨🇳/🇺🇸) - tsc 零错误, Vite 构建成功
396 lines
17 KiB
TypeScript
396 lines
17 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
||
import { Shield, Plus, Trash2, 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';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Label } from '@/components/ui/label';
|
||
import { toast } from 'sonner';
|
||
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 { 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;
|
||
}
|
||
|
||
interface AssignedUser {
|
||
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);
|
||
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);
|
||
const [users, setUsers] = useState<JsonData[]>([]);
|
||
const [assignments, setAssignments] = useState<AssignedUser[]>([]);
|
||
const [selectedUserId, setSelectedUserId] = useState('');
|
||
const [assignRoleId, setAssignRoleId] = useState('');
|
||
const [assignLoading, setAssignLoading] = 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('');
|
||
const [editing, setEditing] = useState<number | null>(null);
|
||
const [editPattern, setEditPattern] = useState('');
|
||
const [editRole, setEditRole] = useState('');
|
||
|
||
const _confirm = useConfirm();
|
||
|
||
const loadData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [rolesRes, pipesRes, usersRes] = await Promise.all([
|
||
deployApi.getItemRoles(),
|
||
pipelineApi.getPipelines({ page_size: 200 }),
|
||
userApi.getUsers({ page_size: 500 }),
|
||
]);
|
||
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); }
|
||
};
|
||
|
||
useEffect(() => { loadData(); }, []);
|
||
|
||
const loadAssignments = async () => {
|
||
if (!selectedUserId) { setAssignments([]); return; }
|
||
try {
|
||
const res: JsonData = await deployApi.getUserItemRoles(Number(selectedUserId));
|
||
setAssignments(Array.isArray(res) ? res : res?.list || []);
|
||
} catch { setAssignments([]); }
|
||
};
|
||
|
||
useEffect(() => { loadAssignments(); }, [selectedUserId]);
|
||
|
||
const matchCount = useMemo(() => {
|
||
const map: Record<number, PipelineMatchItem[]> = {};
|
||
roles.forEach((r) => { map[r.id] = matchPipelines(r.pattern, pipelines); });
|
||
return map;
|
||
}, [roles, pipelines]);
|
||
|
||
const previewMatches = previewRoleId ? (matchCount[previewRoleId] || []) : pipelines;
|
||
|
||
const handleCreate = async () => {
|
||
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
|
||
toast.error('请填写名称、标识与正则'); return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
await deployApi.createItemRole({
|
||
name: newName.trim(),
|
||
code: newCode.trim(),
|
||
pattern: newPattern.trim(),
|
||
role: newRole,
|
||
description: newDesc,
|
||
});
|
||
toast.success('已创建');
|
||
setCreateOpen(false);
|
||
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
|
||
loadData();
|
||
} catch (e: unknown) {
|
||
toast.error((e as Error)?.message || '创建失败');
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const handleUpdate = async (id: number) => {
|
||
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
|
||
setSaving(true);
|
||
try {
|
||
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
|
||
toast.success('已更新');
|
||
setEditing(null);
|
||
loadData();
|
||
} 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',
|
||
});
|
||
if (!ok) return;
|
||
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
|
||
catch { toast.error('删除失败'); }
|
||
};
|
||
|
||
const handleToggleStatus = async (role: ItemRole) => {
|
||
try {
|
||
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
|
||
toast.success(role.status ? '已禁用' : '已启用');
|
||
loadData();
|
||
} catch { toast.error('操作失败'); }
|
||
};
|
||
|
||
const handleAssign = async () => {
|
||
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; }
|
||
setAssignLoading(true);
|
||
try {
|
||
await deployApi.assignItemRole(Number(selectedUserId), {
|
||
user_id: Number(selectedUserId),
|
||
item_role_id: Number(assignRoleId),
|
||
});
|
||
toast.success('已分配');
|
||
setAssignRoleId('');
|
||
loadAssignments();
|
||
} catch (e: unknown) {
|
||
toast.error((e as Error)?.message || '分配失败');
|
||
} finally { setAssignLoading(false); }
|
||
};
|
||
|
||
const handleRevoke = async (id: number) => {
|
||
try {
|
||
await deployApi.removeItemRole(Number(selectedUserId), id);
|
||
toast.success('已撤销');
|
||
loadAssignments();
|
||
} catch { toast.error('撤销失败'); }
|
||
};
|
||
|
||
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="Job 权限"
|
||
subtitle="按流水线 code / Job 名称正则分配发布权限"
|
||
icon={<Shield className="h-5 w-5" />}
|
||
loading={loading}
|
||
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"
|
||
>
|
||
{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>
|
||
)
|
||
)}
|
||
|
||
{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 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>
|
||
<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.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-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 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>
|
||
)}
|
||
|
||
<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>
|
||
);
|
||
} |