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 = { 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 = { 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 = { 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('all'); const [createOpen, setCreateOpen] = useState(false); const [form, setForm] = useState(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 (
{/* Header toolbar */}

流水线列表

{items.length}
{/* Search */}
setSearchQuery(e.target.value)} placeholder="搜索流水线..." className="bg-transparent outline-none w-40" style={{ color: 'var(--text-primary)', fontSize: '13px' }} />
{/* Status filter */}
{statusOptions.map((opt) => ( ))}
{/* View toggle */}
{/* New pipeline button(新建/删除仅管理员) */} {isAdmin && ( )}
{/* Content */}
{isLoading ? (

加载中...

) : filtered.length === 0 ? (

未找到流水线

{items.length === 0 && !isAdmin ? '暂无被授权的流水线,请联系管理员在「流水线权限」中授权' : '尝试调整搜索条件或新建流水线'}

) : viewMode === 'card' ? (
{filtered.map((pipeline) => { const status = pipeline.status; const StatusIcon = status.icon; return (
onSelectPipeline(pipeline.id)} > {/* Card header */}
{pipeline.name}
{pipeline.deploy.label} {pipeline.type === 'jenkins' && ( Jenkins )}
{/* Card body */}

{pipeline.description || '暂无描述'}

{pipeline.branch}
{pipeline.durationText}
{/* Card footer */}
{status.label} {pipeline.timeText}
{pipeline.accessLevel >= 2 && ( )} {isAdmin && ( )}
); })}
) : ( /* List view */
{['流水线', '分支', '部署方式', '最近执行', '耗时', '触发方式', '操作'].map((h) => ( ))} {filtered.map((pipeline) => { const status = pipeline.status; const StatusIcon = status.icon; return ( onSelectPipeline(pipeline.id)} > ); })}
{h}
{pipeline.name}
{pipeline.description || '暂无描述'}
{pipeline.branch}
{pipeline.deploy.label} {pipeline.type === 'jenkins' && ( Jenkins )}
{status.label}
{pipeline.timeText}
{pipeline.durationText} {pipeline.triggerText}
{pipeline.accessLevel >= 2 && ( )} {isAdmin && ( )}
)}
{/* 新建流水线弹窗 */} { setCreateOpen(open); if (!open) setForm(emptyForm); }}> 新建流水线
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} />
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} />
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} />
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} />
); }