diff --git a/internal/controllers/file_controller.go b/internal/controllers/file_controller.go index b71d6c8..91ec0ee 100644 --- a/internal/controllers/file_controller.go +++ b/internal/controllers/file_controller.go @@ -269,6 +269,54 @@ func (fc *FileController) MoveFile(c *gin.Context) { utils.Success(c, nil) } +func (fc *FileController) CopyFile(c *gin.Context) { + var req struct { + SourcePath string `json:"sourcePath" binding:"required"` + TargetPath string `json:"targetPath" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + sourceFull, sourceSafe := fc.checkPath(req.SourcePath, false) + targetFull, targetSafe := fc.checkPath(req.TargetPath, false) + + if !sourceSafe || !targetSafe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if sourceFull == targetFull { + utils.Success(c, nil) + return + } + + // Read content + content, err := os.ReadFile(sourceFull) + if err != nil { + utils.NotFound(c, "源文件不存在或无法读取") + return + } + + // 确保目标目录存在 + os.MkdirAll(filepath.Dir(targetFull), 0755) + + // 检查目标是否存在 + if _, err := os.Stat(targetFull); err == nil { + utils.BadRequest(c, "目标已存在") + return + } + + if err := os.WriteFile(targetFull, content, 0644); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, nil) +} + func (fc *FileController) RenameFile(c *gin.Context) { var req struct { OldPath string `json:"oldPath" binding:"required"` diff --git a/internal/controllers/task_controller.go b/internal/controllers/task_controller.go index 39b0bf9..38a5c8d 100644 --- a/internal/controllers/task_controller.go +++ b/internal/controllers/task_controller.go @@ -54,6 +54,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` Command string `json:"command"` + Tags string `json:"tags"` Type string `json:"type"` Config string `json:"config"` Schedule string `json:"schedule"` @@ -90,7 +91,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) { workDir = resolveWorkDir(req.WorkDir) } - task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType) + task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags) // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron if task.AgentID != nil && *task.AgentID > 0 { @@ -107,6 +108,8 @@ func (tc *TaskController) GetTasks(c *gin.Context) { name := c.DefaultQuery("name", "") agentIDStr := c.DefaultQuery("agent_id", "") + tags := c.DefaultQuery("tags", "") + var agentID *uint if agentIDStr != "" { if id, err := strconv.ParseUint(agentIDStr, 10, 32); err == nil { @@ -115,7 +118,7 @@ func (tc *TaskController) GetTasks(c *gin.Context) { } } - tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name, agentID) + tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name, agentID, tags) utils.PaginatedResponse(c, vo.ToTaskVOListFromModels(tasks), total, p) } @@ -152,6 +155,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) { var req struct { Name string `json:"name"` Command string `json:"command"` + Tags string `json:"tags"` Type string `json:"type"` Config string `json:"config"` Schedule string `json:"schedule"` @@ -183,7 +187,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) { workDir = resolveWorkDir(req.WorkDir) } - task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Enabled, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType) + task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Enabled, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags) if task == nil { utils.NotFound(c, "任务不存在") return diff --git a/internal/models/task.go b/internal/models/task.go index d244a55..0ec5c81 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -37,6 +37,7 @@ type Task struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"size:255;not null"` Command string `json:"command" gorm:"type:text"` // 普通任务的命令 + Tags string `json:"tags" gorm:"size:255;default:''"` // 标签,逗号分隔 Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo TriggerType string `json:"trigger_type" gorm:"size:25;default:'cron'"` // 触发类型: constant.TriggerTypeCron, constant.TriggerTypeBaihuStartup Config string `json:"config" gorm:"type:text"` // 配置 JSON(仓库同步配置等) diff --git a/internal/models/vo/task_vo.go b/internal/models/vo/task_vo.go index 4ef3198..516a5f2 100644 --- a/internal/models/vo/task_vo.go +++ b/internal/models/vo/task_vo.go @@ -10,6 +10,7 @@ type TaskVO struct { ID uint `json:"id"` Name string `json:"name"` Command string `json:"command"` + Tags string `json:"tags"` Type string `json:"type"` TriggerType string `json:"trigger_type"` Config string `json:"config"` @@ -36,6 +37,7 @@ func ToTaskVO(task *models.Task) *TaskVO { ID: task.ID, Name: task.Name, Command: task.Command, + Tags: task.Tags, Type: task.Type, TriggerType: task.TriggerType, Config: task.Config, diff --git a/internal/router/router.go b/internal/router/router.go index 6246a85..c2e1e6f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -163,6 +163,7 @@ func Setup(c *Controllers) *gin.Engine { files.POST("/delete", c.File.DeleteFile) files.POST("/rename", c.File.RenameFile) files.POST("/move", c.File.MoveFile) + files.POST("/copy", c.File.CopyFile) files.POST("/upload", c.File.UploadArchive) files.POST("/uploadfiles", c.File.UploadFiles) } diff --git a/internal/services/tasks/task_service.go b/internal/services/tasks/task_service.go index 2765c61..3d878ab 100644 --- a/internal/services/tasks/task_service.go +++ b/internal/services/tasks/task_service.go @@ -12,7 +12,7 @@ func NewTaskService() *TaskService { return &TaskService{} } -func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *uint, languages []map[string]string, triggerType string) *models.Task { +func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *uint, languages []map[string]string, triggerType string, tags string) *models.Task { if taskType == "" { taskType = "task" } @@ -22,6 +22,7 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w task := &models.Task{ Name: name, Command: command, + Tags: tags, Type: taskType, TriggerType: triggerType, Config: config, @@ -48,7 +49,7 @@ func (ts *TaskService) GetTasks() []models.Task { } // GetTasksWithPagination 分页获取任务列表 -func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, agentID *uint) ([]models.Task, int64) { +func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, agentID *uint, tags string) ([]models.Task, int64) { var tasks []models.Task var total int64 @@ -56,6 +57,9 @@ func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, a if name != "" { query = query.Where("name LIKE ?", "%"+name+"%") } + if tags != "" { + query = query.Where("tags LIKE ?", "%"+tags+"%") + } if agentID != nil { query = query.Where("agent_id = ?", *agentID) } @@ -74,13 +78,14 @@ func (ts *TaskService) GetTaskByID(id int) *models.Task { return &task } -func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *uint, languages []map[string]string, triggerType string) *models.Task { +func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *uint, languages []map[string]string, triggerType string, tags string) *models.Task { var task models.Task if err := database.DB.First(&task, id).Error; err != nil { return nil } task.Name = name task.Command = command + task.Tags = tags task.Schedule = schedule task.Timeout = timeout task.WorkDir = workDir diff --git a/web/src/api/index.ts b/web/src/api/index.ts index feb9ead..5a21ef2 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -58,11 +58,12 @@ export const api = { request('/auth/register', { method: 'POST', body: JSON.stringify(data) }) }, tasks: { - list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: number }) => { + list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: number; tags?: string }) => { const query = new URLSearchParams() if (params?.page) query.set('page', String(params.page)) if (params?.page_size) query.set('page_size', String(params.page_size)) if (params?.name) query.set('name', params.name) + if (params?.tags) query.set('tags', params.tags) if (params?.agent_id) query.set('agent_id', String(params.agent_id)) return request(`/tasks?${query}`) }, @@ -160,6 +161,7 @@ export const api = { delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }), rename: (oldPath: string, newPath: string) => request('/files/rename', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }), move: (oldPath: string, newPath: string) => request('/files/move', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }), + copy: (sourcePath: string, targetPath: string) => request('/files/copy', { method: 'POST', body: JSON.stringify({ sourcePath, targetPath }) }), uploadArchive: async (file: File, targetPath?: string) => { const formData = new FormData() formData.append('file', file) @@ -265,6 +267,7 @@ export interface Task { id: number name: string command: string + tags: string type: string trigger_type: string config: string diff --git a/web/src/components/FileTreeNode.vue b/web/src/components/FileTreeNode.vue index d477cce..c51df0f 100644 --- a/web/src/components/FileTreeNode.vue +++ b/web/src/components/FileTreeNode.vue @@ -1,6 +1,6 @@ @@ -295,7 +320,7 @@ onMounted(loadTree) + @download-file="handleDownload" @duplicate="handleCopyFile" /> diff --git a/web/src/views/tasks/RepoDialog.vue b/web/src/views/tasks/RepoDialog.vue index f341ba0..859bcf2 100644 --- a/web/src/views/tasks/RepoDialog.vue +++ b/web/src/views/tasks/RepoDialog.vue @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Switch } from '@/components/ui/switch' import { Checkbox } from '@/components/ui/checkbox' import DirTreeSelect from '@/components/DirTreeSelect.vue' +import { X } from 'lucide-vue-next' import { api, type Task, type RepoConfig, type Agent } from '@/api' import { toast } from 'vue-sonner' @@ -58,6 +59,23 @@ const cleanType = ref('none') const cleanKeep = ref(30) const allAgents = ref([]) const selectedAgentId = ref('local') +const tagInput = ref('') + +function addTag() { + const val = tagInput.value.trim() + if (!val) return + const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : [] + if (!currentTags.includes(val)) { + currentTags.push(val) + form.value.tags = currentTags.join(',') + } + tagInput.value = '' +} + +function removeTag(tagToRemove: string) { + const currentTags = form.value.tags ? form.value.tags.split(',').filter(Boolean) : [] + form.value.tags = currentTags.filter(t => t !== tagToRemove).join(',') +} const concurrencyEnabled = computed({ get: () => repoConfig.value.concurrency === 1, @@ -178,6 +196,25 @@ async function save() { +
+ +
+
+ + +
+
+ + {{ tag }} + + +
+
+
+
+ +
+
+ + +
+
+ + {{ tag }} + + +
+
+
+
diff --git a/web/src/views/tasks/Tasks.vue b/web/src/views/tasks/Tasks.vue index 536f408..0096f5b 100644 --- a/web/src/views/tasks/Tasks.vue +++ b/web/src/views/tasks/Tasks.vue @@ -6,8 +6,8 @@ import { Input } from '@/components/ui/input' import Pagination from '@/components/Pagination.vue' import TaskDialog from './TaskDialog.vue' import RepoDialog from './RepoDialog.vue' -import { Plus, Play, Pencil, Trash2, Search, ScrollText, GitBranch, Terminal, Server, Monitor, X, Loader2, Wifi, WifiOff, Zap, ZapOff } from 'lucide-vue-next' -import { api, type Task, type Agent } from '@/api' +import { Plus, Play, Pencil, Trash2, Search, ScrollText, GitBranch, Terminal, Server, Monitor, X, Loader2, Wifi, WifiOff, Zap, ZapOff, Copy, Tag } from 'lucide-vue-next' +import { api, type Agent, type Task } from '@/api' import { toast } from 'vue-sonner' import { useSiteSettings } from '@/composables/useSiteSettings' import { useRouter, useRoute } from 'vue-router' @@ -28,6 +28,7 @@ const showDeleteDialog = ref(false) const deleteTaskId = ref(null) const filterName = ref('') +const filterTags = ref('') const filterAgentId = ref(null) const currentPage = ref(1) const total = ref(0) @@ -67,6 +68,7 @@ async function loadTasks() { page: currentPage.value, page_size: pageSize.value, name: filterName.value || undefined, + tags: filterTags.value || undefined, agent_id: filterAgentId.value || undefined }) tasks.value = res.data @@ -122,6 +124,21 @@ function openEdit(task: Task) { } } +function duplicateTask(task: Task) { + const newTask = { ...task } + delete (newTask as any).id + delete (newTask as any).last_run + delete (newTask as any).next_run + newTask.name = newTask.name + ' - 副本' + editingTask.value = newTask + isEdit.value = false + if (task.type === TASK_TYPE.REPO) { + showRepoDialog.value = true + } else { + showTaskDialog.value = true + } +} + function confirmDelete(id: number) { deleteTaskId.value = id showDeleteDialog.value = true @@ -203,7 +220,12 @@ watch(() => route.query.agent_id, (newVal) => {
- +
+
+ +
route.query.agent_id, (newVal) => {
- ID - 类型 - 名称 - - - - - - 状态 - 操作 + class="flex flex-wrap sm:flex-nowrap items-center gap-x-2 gap-y-2 sm:gap-4 px-3 sm:px-4 py-2 border-b bg-muted/20 text-xs sm:text-sm text-muted-foreground font-medium min-w-0 sm:min-w-[1000px]"> + ID + 类型 + 名称 + + 命令/地址 + + + 状态 + 操作 +
-
+
暂无任务
- #{{ task.id }} - + class="flex flex-wrap sm:flex-nowrap items-center gap-x-2 gap-y-2 sm:gap-4 px-3 sm:px-4 py-2 hover:bg-muted/30 transition-colors"> + #{{ task.id }} + - {{ task.name }} -