feat: 去 mock 化 —— 全模块接入真实数据 (参考 jianmu/opsx-jenkins/aiops)

后端(35+ 新路由):
- AES-256-GCM 字段加密(enc:v1:),密钥自动生成 data/encryption.key
- 新模型:Host/K8sCluster/Credential/BuildNode/DeployTool/WebhookEndpoint/
  JenkinsServer/SystemSetting;Pipeline 扩展触发器/变量/缓存列,
  PipelineRun 扩展 buildNumber/externalUrl
- 资源 CRUD + 真实测试端点:主机/集群/构建节点 TCP 拨号、
  工具真实执行版本检测(保守安装策略)、Webhook HMAC-SHA256 签名发送、
  入站流水线触发 /api/hooks/pipeline/:id
- Jenkins 完整集成(移植 opsx-jenkins 生产客户端):crumb/BasicAuth/
  buildWithParameters/Location 队列/progressiveText 增量控制台,
  服务器 CRUD+测试、Job 树拉取(folder 压平)、导入为流水线、
  jenkins 类型流水线真实执行回传日志
- 流水线列表附带 lastRun、运行重试、summary 统计、系统设置单行表
- 运行结束触发出站 Webhook 通知;GitOps 同步状态真实推进

前端(14 页全部去 mock):
- 流水线列表/发布记录/批量发布/环境/凭证/构建节点/工具/Webhook/
  系统设置/Jenkins 导入/GitOps(资源树+差异) 全部接入真实 API
- 编辑器 4 Tab 持久化(基本信息/触发设置含真实 Webhook URL/变量缓存/发布历史)
- TopBar 真实流水线切换 + 仅保存/保存并运行
- Sidebar 徽标真实统计;删除未使用组件与 mock 数据文件
This commit is contained in:
Logan.Li committed 2026-10-27 03:15:20 +08:00
1 parent 04d1342b27
commit 26341c3e8a
37 files changed
+5702 -4683

No files matched your search

+396 -147
View File
@@ -1,10 +1,10 @@
import { useState } from 'react';
import {
Search,
Plus,
Play,
Settings,
Trash2,
import { useState, type CSSProperties } from 'react';
import {
Search,
Plus,
Play,
Settings,
Trash2,
GitBranch,
Clock,
CheckCircle2,
@@ -13,97 +13,172 @@ import {
Filter,
LayoutGrid,
List,
MoreHorizontal
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Pipeline {
id: string;
name: string;
description: string;
lastStatus: 'success' | 'failed' | 'running' | 'pending';
lastRunAt: string;
triggerType: string;
duration: string;
branch: string;
deployType: 'host' | 'k8s';
// ---- 工具函数 ----
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)}分`;
}
const mockPipelines: Pipeline[] = [
{
id: '1',
name: 'aiops-backend',
description: 'Go后端服务 - Docker构建 + K8s部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 14:30:15',
triggerType: 'Git Push',
duration: '3分22秒',
branch: 'master',
deployType: 'k8s',
},
{
id: '2',
name: 'aiops-web',
description: 'Vue前端应用 - npm构建 + 主机部署',
lastStatus: 'running',
lastRunAt: '2024-06-20 15:45:00',
triggerType: '手动',
duration: '1分12秒',
branch: 'master',
deployType: 'host',
},
{
id: '3',
name: 'gateway-api',
description: 'API网关服务 - Java构建 + K8s部署',
lastStatus: 'failed',
lastRunAt: '2024-06-20 13:20:00',
triggerType: '手动',
duration: '45秒',
branch: 'release/v2.1',
deployType: 'k8s',
},
{
id: '4',
name: 'admin-dashboard',
description: '管理后台 - React构建 + 主机部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 12:10:00',
triggerType: 'Cron',
duration: '2分05秒',
branch: 'master',
deployType: 'host',
},
{
id: '5',
name: 'data-processor',
description: '数据处理服务 - Python构建 + K8s部署',
lastStatus: 'pending',
lastRunAt: '-',
triggerType: 'Webhook',
duration: '-',
branch: 'develop',
deployType: 'k8s',
},
];
function fmtTime(iso?: string | null): string {
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
}
const statusConfig = {
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
running: { icon: Loader2, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
pending: { icon: Clock, color: '#6e7781', bg: '#f6f8fa', label: '未执行' },
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: 'K8s', 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 [pipelines] = useState<Pipeline[]>(mockPipelines);
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
const filtered = pipelines.filter((p) => {
const matchSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchStatus = statusFilter === 'all' || p.lastStatus === statusFilter;
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,
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;
});
@@ -115,10 +190,24 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
{ 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
<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)' }}
>
@@ -126,18 +215,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>
流水线列表
</h1>
<span
<span
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{filtered.length}
{items.length}
</span>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
style={{
style={{
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
@@ -154,9 +243,9 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</div>
{/* Status filter */}
<div className="relative group">
<button
<button
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
style={{
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
@@ -166,7 +255,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
{statusOptions.find(s => s.value === statusFilter)?.label}
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
<div
<div
className="rounded-md border shadow-lg py-1 min-w-[120px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
@@ -204,7 +293,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
<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={() => toast.info('新建流水线', { description: '功能开发中...' })}
onClick={() => setCreateOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
新建流水线
@@ -214,24 +303,29 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{filtered.length === 0 ? (
{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)' }}>尝试调整搜索条件</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件或新建流水线</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 = statusConfig[pipeline.lastStatus];
const status = pipeline.status;
const StatusIcon = status.icon;
return (
<div
<div
key={pipeline.id}
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
style={{
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
@@ -240,28 +334,35 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
{/* 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"
<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>
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
style={{
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
>
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
</span>
<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}
{pipeline.description || '暂无描述'}
</p>
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<div className="flex items-center gap-1">
@@ -270,46 +371,59 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{pipeline.duration}
{pipeline.durationText}
</div>
</div>
</div>
{/* Card footer */}
<div
<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">
<StatusIcon
className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`}
style={{ color: status.color }}
<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]" style={{ color: 'var(--text-tertiary)' }}>
{pipeline.lastRunAt}
<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">
<button
onClick={(e) => { e.stopPropagation(); toast.success('开始执行', { description: pipeline.name }); }}
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<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
<button
className="p-1 rounded hover:bg-black/5"
title="配置"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
}}
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
@@ -321,7 +435,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</div>
) : (
/* List view */
<div
<div
className="rounded-lg border overflow-hidden"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
@@ -337,21 +451,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</thead>
<tbody>
{filtered.map((pipeline) => {
const status = statusConfig[pipeline.lastStatus];
const status = pipeline.status;
const StatusIcon = status.icon;
return (
<tr
key={pipeline.id}
<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" style={{ background: status.color }} />
<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 className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description || '暂无描述'}</div>
</div>
</div>
</td>
@@ -362,31 +476,65 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</div>
</td>
<td className="px-4 py-3">
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
style={{
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
>
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
</span>
<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.lastStatus === 'running' ? 'animate-spin' : ''}`} style={{ color: status.color }} />
<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.duration}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerType}</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">
<button onClick={(e) => { e.stopPropagation(); toast.success('开始执行'); }} className="p-1 rounded hover:bg-black/5">
<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">
<MoreHorizontal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<button
className="p-1 rounded hover:bg-black/5"
title="配置"
onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
}}
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<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>
@@ -398,6 +546,107 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
</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构建 + K8s部署"
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">K8s</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>
);
}