diff --git a/backend/internal/api/handlers/ansible.go b/backend/internal/api/handlers/ansible.go index 5919d5a..776db90 100644 --- a/backend/internal/api/handlers/ansible.go +++ b/backend/internal/api/handlers/ansible.go @@ -121,6 +121,41 @@ func (h *AnsibleHandler) DeletePlaybook(c *gin.Context) { c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) } +// ClonePlaybook 克隆 Playbook 为自定义模板 // Logan.Li +// POST /api/v1/ansible/playbooks/:id/clone +func (h *AnsibleHandler) ClonePlaybook(c *gin.Context) { + id := c.Param("id") + var src models.AnsiblePlaybook + if err := h.db.First(&src, id); err.Error != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Playbook 不存在"}) + return + } + + var req struct { + Name *string `json:"name"` // 可选自定义名称 + } + c.ShouldBindJSON(&req) + + cloneName := src.Name + " (副本)" + if req.Name != nil && *req.Name != "" { + cloneName = *req.Name + } + + clone := models.AnsiblePlaybook{ + Name: cloneName, + Description: src.Description, + Content: src.Content, + Tags: src.Tags, + Category: src.Category, + CreatedBy: c.GetString("username"), + } + if err := h.db.Create(&clone); err.Error != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "克隆失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: clone}) +} + // BatchDeletePlaybooks 批量删除 Playbook // Logan.Li func (h *AnsibleHandler) BatchDeletePlaybooks(c *gin.Context) { var req models.IDsRequest diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index be776a7..25d7f5a 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -443,6 +443,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c ansible.DELETE("/playbooks/:id", ansibleHandler.DeletePlaybook) ansible.DELETE("/playbooks/batch-delete", ansibleHandler.BatchDeletePlaybooks) ansible.POST("/playbooks/:id/execute", ansibleHandler.ExecutePlaybook) + ansible.POST("/playbooks/:id/clone", ansibleHandler.ClonePlaybook) ansible.GET("/jobs", ansibleHandler.ListJobs) ansible.GET("/jobs/:id", ansibleHandler.GetJob) diff --git a/frontend/src/components/automation/AnsibleWorkflow.tsx b/frontend/src/components/automation/AnsibleWorkflow.tsx index e5a3940..3a332a6 100644 --- a/frontend/src/components/automation/AnsibleWorkflow.tsx +++ b/frontend/src/components/automation/AnsibleWorkflow.tsx @@ -20,6 +20,7 @@ import { Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2, Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap, Boxes, Shield, Database, Activity, Wrench, Network, + Copy, Plus, ExternalLink, } from 'lucide-react'; import { mapPlaybook, categories, getStatusBadge, formatDate, @@ -100,6 +101,18 @@ export default function AnsibleWorkflow() { finally { setLoadingPB(false); } }, []); + // 克隆 Playbook 为自定义模板 // Logan.Li + const clonePlaybook = async (pb: Playbook, e: React.MouseEvent) => { + e.stopPropagation(); + try { + const res = await ansibleApi.clonePlaybook(pb.id); + const cloned = mapPlaybook((res as any)?.data ?? res); + toast.success(`已克隆「${cloned.name}」,可在模板管理中编辑`); + await fetchPlaybooks(); + setSelectedPB(cloned); + } catch { toast.error('克隆失败'); } + }; + const fetchExecutions = useCallback(async () => { setLoadingExecs(true); try { @@ -301,6 +314,22 @@ export default function AnsibleWorkflow() { + {/* 快捷操作栏 */} +