权限模型(借鉴 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 文档补权限章节
671 lines
30 KiB
TypeScript
671 lines
30 KiB
TypeScript
import { useState, type CSSProperties } from 'react';
|
|
import {
|
|
Search,
|
|
Plus,
|
|
Play,
|
|
Settings,
|
|
Trash2,
|
|
GitBranch,
|
|
Clock,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
Filter,
|
|
LayoutGrid,
|
|
List,
|
|
} from 'lucide-react';
|
|
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';
|
|
|
|
// ---- 工具函数 ----
|
|
function fmtDuration(ms?: number | null): string {
|
|
if (ms == null || ms < 0) return '-';
|
|
const sec = ms / 1000;
|
|
if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
|
|
if (sec < 60) return `${sec.toFixed(1)}s`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
|
|
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
|
|
}
|
|
|
|
function fmtTime(iso?: string | null): string {
|
|
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
|
|
}
|
|
|
|
function timeAgo(iso?: string | null): string {
|
|
if (!iso) return '-';
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
if (diff < 0) return '刚刚';
|
|
const s = Math.floor(diff / 1000);
|
|
if (s < 60) return `${s}秒前`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}分钟前`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}小时前`;
|
|
return `${Math.floor(h / 24)}天前`;
|
|
}
|
|
|
|
// ---- 类型(与后端 GET /api/pipelines 对齐)----
|
|
interface LastRun {
|
|
status: string;
|
|
startedAt: string;
|
|
durationMs: number;
|
|
triggerType: string;
|
|
}
|
|
|
|
interface PipelineDTO {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
type: string; // native | jenkins | tekton
|
|
sourceBranch?: string;
|
|
deployType: string; // host | k8s | gitops
|
|
env?: string;
|
|
lastRun: LastRun | null;
|
|
}
|
|
|
|
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; label: string }> = {
|
|
success: { icon: CheckCircle2, color: '#1a7f37', label: '成功' },
|
|
failed: { icon: XCircle, color: '#cf222e', label: '失败' },
|
|
running: { icon: Loader2, color: '#0969da', label: '执行中' },
|
|
pending: { icon: Clock, color: '#9a6700', label: '排队中' },
|
|
queued: { icon: Clock, color: '#9a6700', label: '排队中' },
|
|
cancelled: { icon: XCircle, color: '#6e7781', label: '已取消' },
|
|
none: { icon: Clock, color: '#6e7781', label: '未执行' },
|
|
};
|
|
|
|
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
|
k8s: { label: 'Kubernetes', color: '#0969da', bg: '#ddf4ff' },
|
|
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
|
|
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
|
|
};
|
|
const fallbackDeploy = { label: '未知', color: '#6e7781', bg: '#f6f8fa' };
|
|
|
|
const triggerTypeLabels: Record<string, string> = {
|
|
manual: '手动',
|
|
webhook: 'Webhook',
|
|
cron: '定时',
|
|
batch: '批量',
|
|
};
|
|
|
|
interface CreatePipelineForm {
|
|
name: string;
|
|
description: string;
|
|
sourceRepo: string;
|
|
sourceBranch: string;
|
|
deployType: string;
|
|
env: string;
|
|
}
|
|
|
|
const emptyForm: CreatePipelineForm = {
|
|
name: '',
|
|
description: '',
|
|
sourceRepo: '',
|
|
sourceBranch: 'main',
|
|
deployType: 'host',
|
|
env: 'staging',
|
|
};
|
|
|
|
const inputStyle: CSSProperties = {
|
|
background: 'var(--panel-bg-secondary)',
|
|
borderColor: 'var(--border-color-light)',
|
|
color: 'var(--text-primary)',
|
|
};
|
|
|
|
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
|
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
|
|
const { isAdmin } = useSession();
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: pipelines = [], isLoading } = useQuery({
|
|
queryKey: ['pipelines'],
|
|
queryFn: () => apiClient.listPipelines(),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: CreatePipelineForm) => apiClient.createPipeline(body),
|
|
onSuccess: (_data, body) => {
|
|
toast.success('流水线已创建', { description: body.name });
|
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
|
setCreateOpen(false);
|
|
setForm(emptyForm);
|
|
},
|
|
onError: (e: Error) => toast.error('创建失败', { description: e.message }),
|
|
});
|
|
|
|
const runMutation = useMutation({
|
|
mutationFn: (id: number | string) => apiClient.createRun(id),
|
|
onSuccess: (run) => toast.success(`已触发运行 ${run?.runId ?? ''}`),
|
|
onError: (e: Error) => toast.error('触发失败', { description: e.message }),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: number | string) => apiClient.deletePipeline(id),
|
|
onSuccess: () => {
|
|
toast.success('流水线已删除');
|
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
|
},
|
|
onError: (e: Error) => toast.error('删除失败', { description: e.message }),
|
|
});
|
|
|
|
const items = (pipelines as PipelineDTO[]).map((p) => {
|
|
const displayStatus = p.lastRun ? p.lastRun.status : 'none';
|
|
return {
|
|
raw: p,
|
|
id: String(p.id),
|
|
name: p.name,
|
|
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',
|
|
status: statusConfig[displayStatus] || statusConfig.none,
|
|
isRunning: displayStatus === 'running',
|
|
durationText: p.lastRun ? fmtDuration(p.lastRun.durationMs) : '-',
|
|
timeText: p.lastRun ? fmtTime(p.lastRun.startedAt) : '-',
|
|
timeAgoText: p.lastRun ? timeAgo(p.lastRun.startedAt) : '',
|
|
triggerText: p.lastRun ? (triggerTypeLabels[p.lastRun.triggerType] || p.lastRun.triggerType) : '-',
|
|
};
|
|
});
|
|
|
|
const filtered = items.filter((p) => {
|
|
const q = searchQuery.toLowerCase();
|
|
const matchSearch = p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q);
|
|
const matchStatus = statusFilter === 'all' || p.mappedStatus === statusFilter;
|
|
return matchSearch && matchStatus;
|
|
});
|
|
|
|
const statusOptions = [
|
|
{ value: 'all', label: '全部状态' },
|
|
{ value: 'success', label: '成功' },
|
|
{ value: 'failed', label: '失败' },
|
|
{ value: 'running', label: '执行中' },
|
|
{ value: 'pending', label: '未执行' },
|
|
];
|
|
|
|
const handleDelete = (p: (typeof items)[number]) => {
|
|
if (window.confirm(`确定删除流水线「${p.name}」?此操作不可恢复。`)) {
|
|
deleteMutation.mutate(p.raw.id);
|
|
}
|
|
};
|
|
|
|
const submitCreate = () => {
|
|
if (!form.name.trim()) {
|
|
toast.error('请输入流水线名称');
|
|
return;
|
|
}
|
|
createMutation.mutate({ ...form, name: form.name.trim() });
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
|
{/* Header toolbar */}
|
|
<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)' }}
|
|
>
|
|
{items.length}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Search */}
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
|
|
style={{
|
|
background: 'var(--panel-bg-secondary)',
|
|
borderColor: 'var(--border-color-light)',
|
|
color: 'var(--text-secondary)',
|
|
}}
|
|
>
|
|
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
|
<input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="搜索流水线..."
|
|
className="bg-transparent outline-none w-40"
|
|
style={{ color: 'var(--text-primary)', fontSize: '13px' }}
|
|
/>
|
|
</div>
|
|
{/* Status filter */}
|
|
<div className="relative group">
|
|
<button
|
|
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
|
|
style={{
|
|
background: 'var(--panel-bg)',
|
|
borderColor: 'var(--border-color-light)',
|
|
color: 'var(--text-secondary)',
|
|
}}
|
|
>
|
|
<Filter className="w-3.5 h-3.5" />
|
|
{statusOptions.find(s => s.value === statusFilter)?.label}
|
|
</button>
|
|
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
|
|
<div
|
|
className="rounded-md border shadow-lg py-1 min-w-[120px]"
|
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
|
>
|
|
{statusOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setStatusFilter(opt.value)}
|
|
className="w-full text-left px-3 py-[6px] text-[13px] hover:bg-black/5 transition-colors"
|
|
style={{ color: statusFilter === opt.value ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* View toggle */}
|
|
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
|
<button
|
|
onClick={() => setViewMode('card')}
|
|
className="p-[6px] transition-colors"
|
|
style={{ background: viewMode === 'card' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
|
|
>
|
|
<LayoutGrid className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('list')}
|
|
className="p-[6px] transition-colors"
|
|
style={{ background: viewMode === 'list' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
|
|
>
|
|
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
|
</button>
|
|
</div>
|
|
{/* 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>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
|
{isLoading ? (
|
|
<div className="flex flex-col items-center justify-center py-20">
|
|
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
|
|
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20">
|
|
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
|
|
<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)' }}>
|
|
{items.length === 0 && !isAdmin
|
|
? '暂无被授权的流水线,请联系管理员在「流水线权限」中授权'
|
|
: '尝试调整搜索条件或新建流水线'}
|
|
</p>
|
|
</div>
|
|
) : viewMode === 'card' ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{filtered.map((pipeline) => {
|
|
const status = pipeline.status;
|
|
const StatusIcon = status.icon;
|
|
return (
|
|
<div
|
|
key={pipeline.id}
|
|
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
|
|
style={{
|
|
background: 'var(--panel-bg)',
|
|
borderColor: 'var(--border-color)',
|
|
}}
|
|
onClick={() => onSelectPipeline(pipeline.id)}
|
|
>
|
|
{/* Card header */}
|
|
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-2 h-2 rounded-full ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
|
style={{ background: status.color }}
|
|
/>
|
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
{pipeline.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span
|
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
|
|
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
|
>
|
|
{pipeline.deploy.label}
|
|
</span>
|
|
{pipeline.type === 'jenkins' && (
|
|
<span
|
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
|
style={{ background: '#fbefff', color: '#8250df' }}
|
|
>
|
|
Jenkins
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Card body */}
|
|
<div className="px-4 py-3">
|
|
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
|
{pipeline.description || '暂无描述'}
|
|
</p>
|
|
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
|
<div className="flex items-center gap-1">
|
|
<GitBranch className="w-3 h-3" />
|
|
{pipeline.branch}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{pipeline.durationText}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Card footer */}
|
|
<div
|
|
className="px-4 py-2 border-t flex items-center justify-between"
|
|
style={{ borderColor: 'var(--border-color-light)' }}
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<StatusIcon
|
|
className={`w-3.5 h-3.5 flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
|
style={{ color: status.color }}
|
|
/>
|
|
<span className="text-[11px] font-medium" style={{ color: status.color }}>
|
|
{status.label}
|
|
</span>
|
|
<span
|
|
className="text-[11px] truncate"
|
|
style={{ color: 'var(--text-tertiary)' }}
|
|
title={pipeline.timeAgoText || undefined}
|
|
>
|
|
{pipeline.timeText}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
|
{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={pipeline.accessLevel >= 3 ? '配置' : '查看'}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectPipeline(pipeline.id);
|
|
}}
|
|
>
|
|
<Settings 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>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
/* List view */
|
|
<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>
|
|
{filtered.map((pipeline) => {
|
|
const status = pipeline.status;
|
|
const StatusIcon = status.icon;
|
|
return (
|
|
<tr
|
|
key={pipeline.id}
|
|
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
|
|
style={{ borderColor: 'var(--border-color-light)' }}
|
|
onClick={() => onSelectPipeline(pipeline.id)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ background: status.color }} />
|
|
<div>
|
|
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
|
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description || '暂无描述'}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
|
<div className="flex items-center gap-1">
|
|
<GitBranch className="w-3 h-3" />
|
|
{pipeline.branch}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1">
|
|
<span
|
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
|
|
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
|
>
|
|
{pipeline.deploy.label}
|
|
</span>
|
|
{pipeline.type === 'jenkins' && (
|
|
<span
|
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
|
style={{ background: '#fbefff', color: '#8250df' }}
|
|
>
|
|
Jenkins
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
|
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
|
|
</div>
|
|
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={pipeline.timeAgoText || undefined}>
|
|
{pipeline.timeText}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.durationText}</td>
|
|
<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">
|
|
{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={pipeline.accessLevel >= 3 ? '配置' : '查看'}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectPipeline(pipeline.id);
|
|
}}
|
|
>
|
|
<Settings 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>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 新建流水线弹窗 */}
|
|
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setForm(emptyForm); }}>
|
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<DialogHeader>
|
|
<DialogTitle style={{ color: 'var(--text-primary)' }}>新建流水线</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
|
名称 <span style={{ color: '#cf222e' }}>*</span>
|
|
</label>
|
|
<input
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
placeholder="例如 aiops-backend"
|
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>描述</label>
|
|
<input
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
placeholder="例如 Go后端服务 - Docker构建 + Kubernetes 部署"
|
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
|
|
<input
|
|
value={form.sourceRepo}
|
|
onChange={(e) => setForm((f) => ({ ...f, sourceRepo: e.target.value }))}
|
|
placeholder="https://gitee.com/xxx.git"
|
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
|
|
<input
|
|
value={form.sourceBranch}
|
|
onChange={(e) => setForm((f) => ({ ...f, sourceBranch: e.target.value }))}
|
|
placeholder="main"
|
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>部署类型</label>
|
|
<Select value={form.deployType} onValueChange={(v) => setForm((f) => ({ ...f, deployType: v }))}>
|
|
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
|
<SelectValue placeholder="选择部署类型" />
|
|
</SelectTrigger>
|
|
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<SelectItem value="host">主机</SelectItem>
|
|
<SelectItem value="k8s">Kubernetes</SelectItem>
|
|
<SelectItem value="gitops">GitOps</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>环境</label>
|
|
<Select value={form.env} onValueChange={(v) => setForm((f) => ({ ...f, env: v }))}>
|
|
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
|
<SelectValue placeholder="选择环境" />
|
|
</SelectTrigger>
|
|
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<SelectItem value="dev">开发 (dev)</SelectItem>
|
|
<SelectItem value="test">测试 (test)</SelectItem>
|
|
<SelectItem value="staging">预发 (staging)</SelectItem>
|
|
<SelectItem value="production">生产 (production)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<button
|
|
onClick={() => setCreateOpen(false)}
|
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
|
|
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
onClick={submitCreate}
|
|
disabled={!form.name.trim() || createMutation.isPending}
|
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-50"
|
|
style={{ background: 'var(--accent-blue)' }}
|
|
>
|
|
{createMutation.isPending ? '创建中...' : '创建'}
|
|
</button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|