From 837f50daae8cc2f8fbc4f5bba9cb64d2ddc54f8c Mon Sep 17 00:00:00 2001 From: attacker Date: Mon, 15 Jun 2026 05:04:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B5=81=E6=B0=B4=E7=BA=BF=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=85=A8=E6=A0=88=E5=AE=8C=E5=96=84=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=BF=AE=E5=A4=8D+=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=8A=9F=E8=83=BD+toast=E5=8F=8D=E9=A6=88+=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=A1=86+=E4=B8=BB=E9=A2=98=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/deploy/PipelineManager.tsx | 530 ++++++++++-------- 1 file changed, 305 insertions(+), 225 deletions(-) diff --git a/frontend/src/components/deploy/PipelineManager.tsx b/frontend/src/components/deploy/PipelineManager.tsx index acd8c93..a6c29d5 100644 --- a/frontend/src/components/deploy/PipelineManager.tsx +++ b/frontend/src/components/deploy/PipelineManager.tsx @@ -1,11 +1,9 @@ -import { useState, useEffect } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { useState, useEffect, useCallback } from '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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Progress } from '@/components/ui/progress'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Dialog, DialogContent, @@ -14,13 +12,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { @@ -35,29 +26,25 @@ import { Play, Plus, Search, - Settings, GitBranch, GitCommit, Clock, CheckCircle, XCircle, Loader2, - MoreHorizontal, Edit, Trash2, - Copy, Eye, - Terminal, - Pause, - RotateCw, - History, ChevronRight, ChevronDown, - FileCode, Workflow, + PackageOpen, } from 'lucide-react'; import { pipelineApi } from '@/services/api'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { toast } from 'sonner'; +// 流水线阶段定义 // Logan.Li interface PipelineStage { id: string; name: string; @@ -66,6 +53,7 @@ interface PipelineStage { logs: string[]; } +// 流水线模板(新建时快捷选择) // Logan.Li const pipelineTemplates = [ { id: 'template-1', name: 'Node.js 项目', description: '适用于 Node.js 前端项目', stages: ['代码检出', '依赖安装', '代码检查', '构建打包', '部署'] }, { id: 'template-2', name: 'Go 项目', description: '适用于 Go 后端项目', stages: ['代码检出', '单元测试', '构建', '构建镜像', '部署'] }, @@ -73,6 +61,7 @@ const pipelineTemplates = [ { id: 'template-4', name: 'Python 项目', description: '适用于 Python 项目', stages: ['代码检出', '依赖安装', '代码检查', '测试', '部署'] }, ]; +// 解析 stages 字段(兼容 JSON 数组 / 逗号分隔字符串) // Logan.Li function parseStages(stages: any): PipelineStage[] { if (Array.isArray(stages)) return stages; if (typeof stages === 'string') { @@ -86,41 +75,58 @@ function parseStages(stages: any): PipelineStage[] { return []; } +// 由流水线名称生成 code(kebab-case) // Logan.Li +function nameToCode(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || `pipeline-${Date.now()}`; +} + export function PipelineManager() { const [searchQuery, setSearchQuery] = useState(''); const [pipelines, setPipelines] = useState([]); - const [loading, setLoading] = useState(true); + const [initialLoading, setInitialLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [selectedPipeline, setSelectedPipeline] = useState(null); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isFormDialogOpen, setIsFormDialogOpen] = useState(false); const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); + const [editMode, setEditMode] = useState(false); const [activeStage, setActiveStage] = useState(null); const [pipelineRuns, setPipelineRuns] = useState([]); const [loadingRuns, setLoadingRuns] = useState(false); - const [newPipeline, setNewPipeline] = useState({ + const [formData, setFormData] = useState({ name: '', description: '', - repository: '', + source: '', branch: 'main', template: '', }); - const loadData = async () => { + const _confirm = useConfirm(); + + // 加载流水线列表(支持首次/刷新双模式) // Logan.Li + const loadData = useCallback(async (isRefresh = false) => { + if (isRefresh) setRefreshing(true); else setInitialLoading(true); try { - setLoading(true); const data = await pipelineApi.getPipelines(); setPipelines(data.list || []); } catch (error) { console.error('Failed to load pipelines:', error); + toast.error('获取流水线列表失败'); } finally { - setLoading(false); + setRefreshing(false); + setInitialLoading(false); } - }; + }, []); useEffect(() => { loadData(); - }, []); + }, [loadData]); + // 加载流水线执行历史 // Logan.Li const loadPipelineRuns = async (pipelineId: number | string) => { try { setLoadingRuns(true); @@ -140,12 +146,34 @@ export function PipelineManager() { loadPipelineRuns(pipeline.id); }; + // 打开新建弹窗 // Logan.Li + const openCreateDialog = () => { + setEditMode(false); + setFormData({ name: '', description: '', source: '', branch: 'main', template: '' }); + setIsFormDialogOpen(true); + }; + + // 打开编辑弹窗(回填数据) // Logan.Li + const openEditDialog = (pipeline: any) => { + setEditMode(true); + setSelectedPipeline(pipeline); + setFormData({ + name: pipeline.name || '', + description: pipeline.description || '', + source: pipeline.source || '', + branch: pipeline.branch || 'main', + template: '', + }); + setIsFormDialogOpen(true); + }; + const filteredPipelines = pipelines.filter( (p) => (p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) || (p.description || '').toLowerCase().includes(searchQuery.toLowerCase()) ); + // 状态徽章(使用后端 last_status 字段) // Logan.Li const getStatusBadge = (status: any) => { switch (status) { case 'success': @@ -171,7 +199,7 @@ export function PipelineManager() { ); default: return ( - + 从未运行 ); @@ -187,79 +215,110 @@ export function PipelineManager() { case 'running': return ; case 'skipped': - return 跳过; + return 跳过; default: - return 待执行; + return 待执行; } }; - const handleCreatePipeline = async () => { + // 创建/编辑流水线 // Logan.Li + const handleSavePipeline = async () => { + if (!formData.name.trim()) { + toast.error('请填写流水线名称'); + return; + } try { - const selectedTemplate = pipelineTemplates.find(t => t.id === newPipeline.template); - const stages = selectedTemplate + const selectedTemplate = pipelineTemplates.find(t => t.id === formData.template); + const stages = selectedTemplate && !editMode ? JSON.stringify(selectedTemplate.stages.map((name, i) => ({ id: `stage-${i + 1}`, name, status: 'pending', duration: '-', logs: [], }))) - : ''; - const code = newPipeline.name - .toLowerCase() - .replace(/[^a-z0-9]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') || `pipeline-${Date.now()}`; - await pipelineApi.createPipeline({ - name: newPipeline.name, - code, - description: newPipeline.description, - source: newPipeline.repository, - branch: newPipeline.branch, + : editMode ? (selectedPipeline?.stages || '') : ''; + const payload = { + name: formData.name, + code: editMode ? (selectedPipeline?.code || nameToCode(formData.name)) : nameToCode(formData.name), + description: formData.description, + source: formData.source, + branch: formData.branch, type: 'deploy', stages, - }); - setIsCreateDialogOpen(false); - setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' }); - loadData(); + }; + if (editMode && selectedPipeline) { + await pipelineApi.updatePipeline(selectedPipeline.id, payload); + toast.success('流水线已更新'); + } else { + await pipelineApi.createPipeline(payload); + toast.success('流水线已创建'); + } + setIsFormDialogOpen(false); + loadData(true); } catch (error) { - console.error('Failed to create pipeline:', error); + console.error('Failed to save pipeline:', error); + toast.error(editMode ? '更新失败' : '创建失败'); } }; - const handleDeletePipeline = async (id: any) => { + // 删除流水线(带确认框) // Logan.Li + const handleDeletePipeline = async (pipeline: any) => { + const ok = await _confirm({ + title: '删除流水线', + description: `确定删除「${pipeline.name}」吗?此操作不可恢复。`, + variant: 'destructive', + }); + if (!ok) return; try { - await pipelineApi.deletePipeline(id); - loadData(); + await pipelineApi.deletePipeline(pipeline.id); + toast.success('流水线已删除'); + loadData(true); } catch (error) { console.error('Failed to delete pipeline:', error); + toast.error('删除失败'); } }; + // 触发流水线执行 // Logan.Li const handleRunPipeline = async () => { if (!selectedPipeline) return; try { await pipelineApi.triggerPipeline(selectedPipeline.id); + toast.success('流水线已触发'); setIsRunDialogOpen(false); - loadData(); + loadData(true); if (isDetailDialogOpen) { loadPipelineRuns(selectedPipeline.id); } } catch (error) { console.error('Failed to trigger pipeline:', error); + toast.error('触发失败'); } }; + // 统计卡片数据 // Logan.Li + const stats = { + total: pipelines.length, + running: pipelines.filter(p => p.last_status === 'running').length, + success: pipelines.filter(p => p.last_status === 'success').length, + failed: pipelines.filter(p => p.last_status === 'failed').length, + }; + return (
{/* Header */}
-

流水线管理

-

CI/CD 流水线配置与管理

+

流水线管理

+

CI/CD 流水线配置与管理

- - @@ -268,12 +327,12 @@ export function PipelineManager() { {/* Stats */}
- +
-

流水线总数

-

{pipelines.length}

+

流水线总数

+

{stats.total}

@@ -281,14 +340,12 @@ export function PipelineManager() {
- +
-

运行中

-

- {pipelines.filter((p) => p.status === 'running').length} -

+

运行中

+

{stats.running}

@@ -296,14 +353,12 @@ export function PipelineManager() {
- +
-

今日成功

-

- {pipelines.filter((p) => p.status === 'success').length} -

+

成功

+

{stats.success}

@@ -311,14 +366,12 @@ export function PipelineManager() {
- +
-

今日失败

-

- {pipelines.filter((p) => p.status === 'failed').length} -

+

失败

+

{stats.failed}

@@ -331,47 +384,50 @@ export function PipelineManager() { {/* Filters */}
- + setSearchQuery(e.target.value)} - className="pl-10 bg-slate-800 border-slate-700 text-slate-100" + className="pl-10 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
- {loading ? ( + {initialLoading ? (
- +
) : filteredPipelines.length === 0 ? ( -
暂无流水线数据
+
+ +

暂无流水线数据

+
) : null} {/* Pipeline List */}
{filteredPipelines.map((pipeline) => ( - +
-

{pipeline.name}

- {getStatusBadge(pipeline.status)} - - {pipeline.trigger_type === 'auto' ? '自动触发' : '手动触发'} +

{pipeline.name}

+ {getStatusBadge(pipeline.last_status)} + + {pipeline.run_count > 0 ? `已运行 ${pipeline.run_count} 次` : '未执行'}
-

{pipeline.description}

-
+

{pipeline.description}

+
- {pipeline.branch} + {pipeline.branch || 'main'} - 上次运行: {pipeline.updated_at || '--'} + 上次运行: {pipeline.last_run_at ? new Date(pipeline.last_run_at).toLocaleString('zh-CN') : '--'}
@@ -388,14 +444,14 @@ export function PipelineManager() { ? 'bg-red-500/20 text-red-400' : stage.status === 'running' ? 'bg-blue-500/20 text-blue-400' - : 'bg-slate-700 text-slate-400' + : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]' }`} > {getStageStatusIcon(stage.status)} {stage.name}
{index < parseStages(pipeline.stages).length - 1 && ( - + )}
))} @@ -419,15 +475,25 @@ export function PipelineManager() { - -
@@ -437,90 +503,94 @@ export function PipelineManager() { ))}
- {/* Create Pipeline Dialog */} - - + {/* Create/Edit Pipeline Dialog */} + + - 新建流水线 - 配置新的 CI/CD 流水线 + {editMode ? '编辑流水线' : '新建流水线'} + + {editMode ? '修改流水线配置' : '配置新的 CI/CD 流水线'} +
- + setNewPipeline({ ...newPipeline, name: e.target.value })} + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="例如: frontend-build" - className="bg-slate-800 border-slate-700 text-slate-100 mt-1" + className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" />
- +