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() { + {/* 快捷操作栏 */} +
+ + + 点击卡片选择模板执行 · 悬停卡片可克隆为自定义模板 + +
+ {loadingPB ? (
加载中… @@ -314,12 +343,20 @@ export default function AnsibleWorkflow() {
setSelectedPB(selected ? null : pb)} - className={`p-4 rounded-lg border cursor-pointer transition-all ${ + className={`group relative p-4 rounded-lg border cursor-pointer transition-all ${ selected ? 'border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30' : 'border-slate-700 bg-slate-800/50 hover:border-slate-600' }`} > + {/* 悬浮克隆按钮 */} +
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 54feee6..6b06f58 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -747,6 +747,10 @@ export const ansibleApi = { executePlaybook: async (id: number | string, targetHosts?: string[]) => { return http.post(`/ansible/playbooks/${id}/execute`, targetHosts ? { target_hosts: targetHosts } : undefined); }, + // 克隆 Playbook 为自定义模板 // Logan.Li + clonePlaybook: async (id: number | string, name?: string) => { + return http.post(`/ansible/playbooks/${id}/clone`, name ? { name } : undefined); + }, // 主机匹配预览 // Logan.Li matchHosts: async (params: { pattern?: string; match_type?: string; page?: number; page_size?: number }) => { return http.get('/ansible/match-hosts', params);