权限模型(借鉴 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 文档补权限章节
122 lines
6.8 KiB
TypeScript
122 lines
6.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Info, Save, Loader2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { apiClient } from '@/lib/api';
|
|
|
|
interface Props {
|
|
pipelineId: number;
|
|
}
|
|
|
|
export default function BasicInfoPanel({ pipelineId }: Props) {
|
|
const queryClient = useQueryClient();
|
|
const { data: detail } = useQuery({
|
|
queryKey: ['pipeline', pipelineId],
|
|
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: '' });
|
|
|
|
useEffect(() => {
|
|
if (p) {
|
|
setForm({
|
|
name: p.name || '', description: p.description || '',
|
|
sourceRepo: p.sourceRepo || '', sourceBranch: p.sourceBranch || 'main',
|
|
deployType: p.deployType || 'host', env: p.env || 'staging',
|
|
notifyWebhookURL: p.notifyWebhookURL || '',
|
|
});
|
|
}
|
|
}, [p?.id, p?.updatedAt]);
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: (body: any) => apiClient.updatePipeline(pipelineId, body),
|
|
onSuccess: () => {
|
|
toast.success('基本信息已保存');
|
|
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
|
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
|
},
|
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
|
});
|
|
|
|
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
|
|
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
|
|
|
return (
|
|
<div className="tab-content-fade w-full px-6 py-6 max-w-3xl">
|
|
<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">
|
|
<div className="flex items-center gap-2">
|
|
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
|
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>基本信息</h3>
|
|
{p?.type === 'jenkins' && (
|
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: '#fbefff', color: '#8250df' }}>Jenkins 导入</span>
|
|
)}
|
|
</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}
|
|
className="flex items-center gap-1 px-3 py-1 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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>流水线名称 *</label>
|
|
<input value={form.name} onChange={(e) => set('name', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
|
|
<input value={form.sourceBranch} onChange={(e) => set('sourceBranch', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>描述</label>
|
|
<input value={form.description} onChange={(e) => set('description', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
|
|
<input value={form.sourceRepo} onChange={(e) => set('sourceRepo', e.target.value)} placeholder="https://gitee.com/org/repo.git" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>部署方式</label>
|
|
<select value={form.deployType} onChange={(e) => set('deployType', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
|
|
<option value="host">主机部署</option>
|
|
<option value="k8s">Kubernetes 部署</option>
|
|
<option value="gitops">GitOps</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认环境</label>
|
|
<select value={form.env} onChange={(e) => set('env', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
|
|
<option value="dev">dev</option>
|
|
<option value="test">test</option>
|
|
<option value="staging">staging</option>
|
|
<option value="production">production</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border p-6 shadow-sm mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<h3 className="text-[14px] font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>发布通知</h3>
|
|
<p className="text-[11px] mb-4" style={{ color: 'var(--text-tertiary)' }}>流水线成功/失败后回调该 Webhook(也在「Webhook 配置」中统一管理)</p>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>通知 Webhook URL</label>
|
|
<input value={form.notifyWebhookURL} onChange={(e) => set('notifyWebhookURL', e.target.value)} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|