feat: add multi env setting support

This commit is contained in:
engigu
2026-02-13 19:26:20 +08:00
parent 8a52be9241
commit 3ab1ddc03a
13 changed files with 322 additions and 302 deletions
+22 -28
View File
@@ -43,17 +43,16 @@ type WSMessage struct {
} }
type AgentTask struct { type AgentTask struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Command string `json:"command"` Command string `json:"command"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
Cron string `json:"cron"` Cron string `json:"cron"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"` WorkDir string `json:"work_dir"`
Envs string `json:"envs"` Envs string `json:"envs"`
Language string `json:"language"` Languages []map[string]string `json:"languages"`
LangVersion string `json:"lang_version"` Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
} }
func (t *AgentTask) GetID() string { func (t *AgentTask) GetID() string {
@@ -80,12 +79,8 @@ func (t *AgentTask) GetEnvs() string {
return t.Envs return t.Envs
} }
func (t *AgentTask) GetLanguage() string { func (t *AgentTask) GetLanguages() []map[string]string {
return t.Language return t.Languages
}
func (t *AgentTask) GetLangVersion() string {
return t.LangVersion
} }
func (t *AgentTask) GetUseMise() bool { func (t *AgentTask) GetUseMise() bool {
@@ -513,17 +508,16 @@ func (a *Agent) handleExecute(data json.RawMessage) {
// 准备执行请求 // 准备执行请求
execReq := &executor.ExecutionRequest{ execReq := &executor.ExecutionRequest{
TaskID: fmt.Sprintf("%d", task.ID), TaskID: fmt.Sprintf("%d", task.ID),
LogID: req.LogID, LogID: req.LogID,
Name: task.Name, Name: task.Name,
Command: task.Command, Command: task.Command,
WorkDir: task.WorkDir, WorkDir: task.WorkDir,
Envs: executor.ParseEnvVars(task.Envs), Envs: executor.ParseEnvVars(task.Envs),
Timeout: task.Timeout, Timeout: task.Timeout,
Language: task.Language, Languages: task.Languages,
LangVersion: task.LangVersion, UseMise: false,
UseMise: false, Type: executor.TaskTypeManual,
Type: executor.TaskTypeManual,
} }
// 立即执行任务(加入队列) // 立即执行任务(加入队列)
+25 -27
View File
@@ -52,18 +52,17 @@ func resolveWorkDir(workDir string) string {
func (tc *TaskController) CreateTask(c *gin.Context) { func (tc *TaskController) CreateTask(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Command string `json:"command"` Command string `json:"command"`
Type string `json:"type"` Type string `json:"type"`
Config string `json:"config"` Config string `json:"config"`
Schedule string `json:"schedule" binding:"required"` Schedule string `json:"schedule" binding:"required"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"` WorkDir string `json:"work_dir"`
CleanConfig string `json:"clean_config"` CleanConfig string `json:"clean_config"`
Envs string `json:"envs"` Envs string `json:"envs"`
Language string `json:"language"` Languages []map[string]string `json:"languages"`
LangVersion string `json:"lang_version"` AgentID *uint `json:"agent_id"`
AgentID *uint `json:"agent_id"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -88,7 +87,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
workDir = resolveWorkDir(req.WorkDir) 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.Language, req.LangVersion) 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)
// 如果是 Agent 任务,通知 Agent;否则添加到本地 cron // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
if task.AgentID != nil && *task.AgentID > 0 { if task.AgentID != nil && *task.AgentID > 0 {
@@ -148,19 +147,18 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
} }
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
Command string `json:"command"` Command string `json:"command"`
Type string `json:"type"` Type string `json:"type"`
Config string `json:"config"` Config string `json:"config"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"` WorkDir string `json:"work_dir"`
CleanConfig string `json:"clean_config"` CleanConfig string `json:"clean_config"`
Envs string `json:"envs"` Envs string `json:"envs"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Language string `json:"language"` Languages []map[string]string `json:"languages"`
LangVersion string `json:"lang_version"` AgentID *uint `json:"agent_id"`
AgentID *uint `json:"agent_id"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -181,7 +179,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
workDir = resolveWorkDir(req.WorkDir) 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.Language, req.LangVersion) 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)
if task == nil { if task == nil {
utils.NotFound(c, "任务不存在") utils.NotFound(c, "任务不存在")
return return
+10 -12
View File
@@ -78,8 +78,7 @@ func (m *CronManager) AddTask(task CronTask) error {
timeout := task.GetTimeout() timeout := task.GetTimeout()
workDir := task.GetWorkDir() workDir := task.GetWorkDir()
envs := task.GetEnvs() envs := task.GetEnvs()
language := task.GetLanguage() languages := task.GetLanguages()
langVersion := task.GetLangVersion()
useMise := task.UseMise() useMise := task.UseMise()
entryID, err := m.cron.AddFunc(task.GetSchedule(), func() { entryID, err := m.cron.AddFunc(task.GetSchedule(), func() {
@@ -91,16 +90,15 @@ func (m *CronManager) AddTask(task CronTask) error {
m.logger.Infof("[CronManager] 触发计划任务 #%s (%s)", taskID, name) m.logger.Infof("[CronManager] 触发计划任务 #%s (%s)", taskID, name)
req := &ExecutionRequest{ req := &ExecutionRequest{
TaskID: taskID, TaskID: taskID,
Name: name, Name: name,
Command: cmd, Command: cmd,
Type: TaskTypeCron, Type: TaskTypeCron,
Timeout: timeout, Timeout: timeout,
WorkDir: workDir, WorkDir: workDir,
Envs: ParseEnvVars(envs), Envs: ParseEnvVars(envs),
Language: language, Languages: languages,
LangVersion: langVersion, UseMise: useMise,
UseMise: useMise,
} }
// 如果有关联的 Scheduler,加入队列执行 // 如果有关联的 Scheduler,加入队列执行
+27 -16
View File
@@ -23,8 +23,7 @@ type Task interface {
GetTimeout() int GetTimeout() int
GetWorkDir() string GetWorkDir() string
GetEnvs() string GetEnvs() string
GetLanguage() string GetLanguages() []map[string]string
GetLangVersion() string
GetUseMise() bool GetUseMise() bool
} }
@@ -37,13 +36,12 @@ type CronTask interface {
// Request 任务执行请求 // Request 任务执行请求
type Request struct { type Request struct {
Command string Command string
WorkDir string WorkDir string
Envs []string Envs []string
Timeout int // 任务超时时间(分钟) Timeout int // 任务超时时间(分钟)
Language string Languages []map[string]string
LangVersion string UseMise bool
UseMise bool
} }
// Result 任务执行结果 // Result 任务执行结果
@@ -104,7 +102,7 @@ func ExecuteWithHooks(ctx context.Context, req Request, stdout, stderr io.Writer
finalCommand := req.Command finalCommand := req.Command
if req.UseMise { if req.UseMise {
finalCommand = BuildLanguageCommand(req.Command, req.Language, req.LangVersion) finalCommand = BuildLanguageCommand(req.Command, req.Languages)
} }
shell, args := utils.GetShellCommand(finalCommand) shell, args := utils.GetShellCommand(finalCommand)
cmd := exec.CommandContext(execCtx, shell, args...) cmd := exec.CommandContext(execCtx, shell, args...)
@@ -283,15 +281,28 @@ func ExecuteWithHooks(ctx context.Context, req Request, stdout, stderr io.Writer
} }
// BuildLanguageCommand 构建语言环境执行命令 (使用 mise) // BuildLanguageCommand 构建语言环境执行命令 (使用 mise)
func BuildLanguageCommand(command, language, version string) string { func BuildLanguageCommand(command string, languages []map[string]string) string {
if language == "" { if len(languages) == 0 {
return command return command
} }
v := version
if v == "" { var builder strings.Builder
v = "latest" builder.WriteString("mise exec")
for _, lang := range languages {
name := lang["name"]
version := lang["version"]
if name == "" {
continue
}
if version == "" {
version = "latest"
}
builder.WriteString(" " + name + "@" + version)
} }
return "mise exec " + language + "@" + v + " -- " + command
builder.WriteString(" -- " + command)
return builder.String()
} }
// ParseEnvVars 解析环境变量字符串 "KEY1=VALUE1,KEY2=VALUE2" // ParseEnvVars 解析环境变量字符串 "KEY1=VALUE1,KEY2=VALUE2"
+18 -20
View File
@@ -61,18 +61,17 @@ const (
// ExecutionRequest 执行请求(标准接口) // ExecutionRequest 执行请求(标准接口)
type ExecutionRequest struct { type ExecutionRequest struct {
TaskID string // 任务 ID TaskID string // 任务 ID
LogID uint // 日志 ID LogID uint // 日志 ID
Name string // 任务名称 Name string // 任务名称
Type TaskType // 任务类型 Type TaskType // 任务类型
Command string // 命令 Command string // 命令
WorkDir string // 工作目录 WorkDir string // 工作目录
Envs []string // 环境变量 Envs []string // 环境变量
Timeout int // 超时时间(分钟) Timeout int // 超时时间(分钟)
Language string // 语言环境 Languages []map[string]string // 语言环境配置
LangVersion string // 语言版本 UseMise bool // 是否使用 mise
UseMise bool // 是否使用 mise Metadata map[string]interface{} // 额外元数据
Metadata map[string]interface{} // 额外元数据
} }
// ExecutionResult 执行结果(标准接口) // ExecutionResult 执行结果(标准接口)
@@ -197,13 +196,12 @@ func NewScheduler(config SchedulerConfig, handler SchedulerEventHandler) *Schedu
executor: func(ctx context.Context, req *ExecutionRequest, stdout, stderr io.Writer) (*Result, error) { executor: func(ctx context.Context, req *ExecutionRequest, stdout, stderr io.Writer) (*Result, error) {
hooks := &schedulerHooksAdapter{handler: handler, req: req} hooks := &schedulerHooksAdapter{handler: handler, req: req}
return ExecuteWithHooks(ctx, Request{ return ExecuteWithHooks(ctx, Request{
Command: req.Command, Command: req.Command,
WorkDir: req.WorkDir, WorkDir: req.WorkDir,
Envs: req.Envs, Envs: req.Envs,
Timeout: req.Timeout, Timeout: req.Timeout,
Language: req.Language, Languages: req.Languages,
LangVersion: req.LangVersion, UseMise: req.UseMise,
UseMise: req.UseMise,
}, stdout, stderr, hooks) }, stdout, stderr, hooks)
}, },
taskQueue: make(chan *ExecutionRequest, config.QueueSize), taskQueue: make(chan *ExecutionRequest, config.QueueSize),
@@ -317,7 +315,7 @@ func (s *Scheduler) executeTask(req *ExecutionRequest) (*ExecutionResult, error)
finalCommand := req.Command finalCommand := req.Command
if req.UseMise { if req.UseMise {
finalCommand = BuildLanguageCommand(req.Command, req.Language, req.LangVersion) finalCommand = BuildLanguageCommand(req.Command, req.Languages)
} }
s.logger.Infof("[Scheduler] 实际执行命令: %s", finalCommand) s.logger.Infof("[Scheduler] 实际执行命令: %s", finalCommand)
+9 -10
View File
@@ -52,16 +52,15 @@ func (AgentToken) TableName() string {
// AgentTask Agent 任务配置(用于下发给 Agent) // AgentTask Agent 任务配置(用于下发给 Agent)
type AgentTask struct { type AgentTask struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Command string `json:"command"` Command string `json:"command"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"` WorkDir string `json:"work_dir"`
Envs string `json:"envs"` Envs string `json:"envs"`
Language string `json:"language"` Languages []map[string]string `json:"languages"`
LangVersion string `json:"lang_version"` Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
} }
// AgentTaskResult Agent 上报的任务执行结果 // AgentTaskResult Agent 上报的任务执行结果
+21 -26
View File
@@ -34,26 +34,25 @@ type TaskConfig struct {
// Task 代表一个计划任务 // Task 代表一个计划任务
type Task struct { type Task struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"` Name string `json:"name" gorm:"size:255;not null"`
Command string `json:"command" gorm:"type:text"` // 普通任务的命令 Command string `json:"command" gorm:"type:text"` // 普通任务的命令
Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo
Config string `json:"config" gorm:"type:text"` // 配置 JSON(仓库同步配置等) Config string `json:"config" gorm:"type:text"` // 配置 JSON(仓库同步配置等)
Schedule string `json:"schedule" gorm:"size:100"` // cron 表达式 Schedule string `json:"schedule" gorm:"size:100"` // cron 表达式
Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟 Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟
WorkDir string `json:"work_dir" gorm:"size:255;default:''"` // 工作目录,为空则使用 scripts 目录 WorkDir string `json:"work_dir" gorm:"size:255;default:''"` // 工作目录,为空则使用 scripts 目录
CleanConfig string `json:"clean_config" gorm:"size:255;default:''"` // 清理配置 JSON CleanConfig string `json:"clean_config" gorm:"size:255;default:''"` // 清理配置 JSON
Envs string `json:"envs" gorm:"size:255;default:''"` // 环境变量ID列表,逗号分隔 Envs string `json:"envs" gorm:"size:255;default:''"` // 环境变量ID列表,逗号分隔
Language string `json:"language" gorm:"size:50;default:''"` // 针对本地任务的语言名称 (mise) Languages []map[string]string `json:"languages" gorm:"serializer:json;type:text"` // 针对本地任务的语言配置列表
LangVersion string `json:"lang_version" gorm:"size:50;default:''"` // 针对本地任务的语言版本 (mise) AgentID *uint `json:"agent_id" gorm:"index"` // Agent ID,为空表示本地执行
AgentID *uint `json:"agent_id" gorm:"index"` // Agent ID,为空表示本地执行 Enabled bool `json:"enabled" gorm:"default:true"`
Enabled bool `json:"enabled" gorm:"default:true"` RunningGo string `json:"running_go" gorm:"type:text"` // 正在运行的 go routine id 数组 (JSON)
RunningGo string `json:"running_go" gorm:"type:text"` // 正在运行的 go routine id 数组 (JSON) LastRun *LocalTime `json:"last_run"`
LastRun *LocalTime `json:"last_run"` NextRun *LocalTime `json:"next_run"`
NextRun *LocalTime `json:"next_run"` CreatedAt LocalTime `json:"created_at"`
CreatedAt LocalTime `json:"created_at"` UpdatedAt LocalTime `json:"updated_at"`
UpdatedAt LocalTime `json:"updated_at"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
} }
func (Task) TableName() string { func (Task) TableName() string {
@@ -84,12 +83,8 @@ func (t *Task) GetEnvs() string {
return t.Envs return t.Envs
} }
func (t *Task) GetLanguage() string { func (t *Task) GetLanguages() []map[string]string {
return t.Language return t.Languages
}
func (t *Task) GetLangVersion() string {
return t.LangVersion
} }
func (t *Task) GetUseMise() bool { func (t *Task) GetUseMise() bool {
+18 -20
View File
@@ -7,24 +7,23 @@ import (
// TaskVO 任务视图对象 // TaskVO 任务视图对象
type TaskVO struct { type TaskVO struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Command string `json:"command"` Command string `json:"command"`
Type string `json:"type"` Type string `json:"type"`
Config string `json:"config"` Config string `json:"config"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"` WorkDir string `json:"work_dir"`
CleanConfig string `json:"clean_config"` CleanConfig string `json:"clean_config"`
Envs string `json:"envs"` Envs string `json:"envs"`
Language string `json:"language"` Languages []map[string]string `json:"languages"`
LangVersion string `json:"lang_version"` AgentID *uint `json:"agent_id"`
AgentID *uint `json:"agent_id"` Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"` LastRun *models.LocalTime `json:"last_run"`
LastRun *models.LocalTime `json:"last_run"` NextRun *models.LocalTime `json:"next_run"`
NextRun *models.LocalTime `json:"next_run"` CreatedAt models.LocalTime `json:"created_at"`
CreatedAt models.LocalTime `json:"created_at"` UpdatedAt models.LocalTime `json:"updated_at"`
UpdatedAt models.LocalTime `json:"updated_at"`
} }
// ToTaskVO 将 Task 模型转换为 TaskVO // ToTaskVO 将 Task 模型转换为 TaskVO
@@ -43,8 +42,7 @@ func ToTaskVO(task *models.Task) *TaskVO {
WorkDir: task.WorkDir, WorkDir: task.WorkDir,
CleanConfig: task.CleanConfig, CleanConfig: task.CleanConfig,
Envs: task.Envs, Envs: task.Envs,
Language: task.Language, Languages: task.Languages,
LangVersion: task.LangVersion,
AgentID: task.AgentID, AgentID: task.AgentID,
Enabled: task.Enabled, Enabled: task.Enabled,
LastRun: task.LastRun, LastRun: task.LastRun,
+9 -10
View File
@@ -311,16 +311,15 @@ func (s *AgentService) GetTasks(agentID uint) []models.AgentTask {
envVarsStr := s.buildEnvVarsString(task.Envs) envVarsStr := s.buildEnvVarsString(task.Envs)
result[i] = models.AgentTask{ result[i] = models.AgentTask{
ID: task.ID, ID: task.ID,
Name: task.Name, Name: task.Name,
Command: task.Command, Command: task.Command,
Schedule: task.Schedule, Schedule: task.Schedule,
Timeout: task.Timeout, Timeout: task.Timeout,
WorkDir: task.WorkDir, WorkDir: task.WorkDir,
Envs: envVarsStr, // 传递 "KEY1=VALUE1,KEY2=VALUE2" 格式 Envs: envVarsStr, // 传递 "KEY1=VALUE1,KEY2=VALUE2" 格式
Language: task.Language, Languages: task.Languages,
LangVersion: task.LangVersion, Enabled: task.Enabled,
Enabled: task.Enabled,
} }
} }
+15 -14
View File
@@ -352,13 +352,12 @@ func (es *ExecutorService) ExecuteDispatcher(ctx context.Context, req *executor.
// 本地任务 // 本地任务
hooks := &LocalTaskHooks{es: es, logID: req.LogID} hooks := &LocalTaskHooks{es: es, logID: req.LogID}
return executor.ExecuteWithHooks(ctx, executor.Request{ return executor.ExecuteWithHooks(ctx, executor.Request{
Command: req.Command, Command: req.Command,
WorkDir: req.WorkDir, WorkDir: req.WorkDir,
Envs: req.Envs, Envs: req.Envs,
Timeout: req.Timeout, Timeout: req.Timeout,
Language: task.Language, Languages: task.Languages,
LangVersion: task.LangVersion, UseMise: true, // 本地业务任务启用 mise
UseMise: true, // 本地业务任务启用 mise
}, stdout, stderr, hooks) }, stdout, stderr, hooks)
} }
@@ -470,13 +469,15 @@ func (es *ExecutorService) ExecuteTask(taskID int, extraEnvs []string) *executor
} }
req := &executor.ExecutionRequest{ req := &executor.ExecutionRequest{
TaskID: fmt.Sprintf("%d", task.ID), TaskID: fmt.Sprintf("%d", task.ID),
Name: task.Name, Name: task.Name,
Command: task.Command, Command: task.Command,
WorkDir: task.WorkDir, WorkDir: task.WorkDir,
Envs: envs, Envs: envs,
Timeout: task.Timeout, Timeout: task.Timeout,
Type: executor.TaskTypeManual, Languages: task.Languages,
UseMise: task.UseMise(),
Type: executor.TaskTypeManual,
} }
es.scheduler.EnqueueOrExecute(req) es.scheduler.EnqueueOrExecute(req)
+4 -6
View File
@@ -11,7 +11,7 @@ func NewTaskService() *TaskService {
return &TaskService{} return &TaskService{}
} }
func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *uint, language, langVersion 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) *models.Task {
if taskType == "" { if taskType == "" {
taskType = "task" taskType = "task"
} }
@@ -25,8 +25,7 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w
WorkDir: workDir, WorkDir: workDir,
CleanConfig: cleanConfig, CleanConfig: cleanConfig,
Envs: envs, Envs: envs,
Language: language, Languages: languages,
LangVersion: langVersion,
AgentID: agentID, AgentID: agentID,
Enabled: true, Enabled: true,
} }
@@ -67,7 +66,7 @@ func (ts *TaskService) GetTaskByID(id int) *models.Task {
return &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, language, langVersion 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) *models.Task {
var task models.Task var task models.Task
if err := database.DB.First(&task, id).Error; err != nil { if err := database.DB.First(&task, id).Error; err != nil {
return nil return nil
@@ -81,8 +80,7 @@ func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeou
task.Envs = envs task.Envs = envs
task.Enabled = enabled task.Enabled = enabled
task.AgentID = agentID task.AgentID = agentID
task.Language = language task.Languages = languages
task.LangVersion = langVersion
if taskType != "" { if taskType != "" {
task.Type = taskType task.Type = taskType
} }
+1 -2
View File
@@ -271,8 +271,7 @@ export interface Task {
work_dir: string work_dir: string
clean_config: string clean_config: string
envs: string envs: string
language: string languages: { name: string; version: string }[]
lang_version: string
agent_id: number | null agent_id: number | null
enabled: boolean enabled: boolean
last_run: string last_run: string
+143 -111
View File
@@ -89,13 +89,11 @@ const onlineAgents = computed(() => {
// 语言环境相关 // 语言环境相关
const installedLangs = ref<MiseLanguage[]>([]) const installedLangs = ref<MiseLanguage[]>([])
const loadingLangs = ref(false) const loadingLangs = ref(false)
const selectedLangs = ref<{ name: string; version: string; availableVersions: string[] }[]>([])
const availablePlugins = ref<string[]>([]) const availablePlugins = ref<string[]>([])
const pluginSearch = ref('') const pluginSearch = ref('')
const openPluginPopover = ref(false)
const availableVersions = ref<string[]>([])
const versionSearch = ref('') const versionSearch = ref('')
const openVersionPopover = ref(false)
const filteredPlugins = computed(() => { const filteredPlugins = computed(() => {
if (!pluginSearch.value) return availablePlugins.value if (!pluginSearch.value) return availablePlugins.value
@@ -103,11 +101,11 @@ const filteredPlugins = computed(() => {
return availablePlugins.value.filter(p => p.toLowerCase().includes(s)) return availablePlugins.value.filter(p => p.toLowerCase().includes(s))
}) })
const filteredVersions = computed(() => { function getFilteredVersions(versions: string[]) {
if (!versionSearch.value) return availableVersions.value if (!versionSearch.value) return versions
const s = versionSearch.value.toLowerCase() const s = versionSearch.value.toLowerCase()
return availableVersions.value.filter(v => v.toLowerCase().includes(s)) return versions.filter(v => v.toLowerCase().includes(s))
}) }
async function fetchInstalledLangs() { async function fetchInstalledLangs() {
loadingLangs.value = true loadingLangs.value = true
@@ -163,22 +161,32 @@ function getLangIcon(plugin: string) {
return '' return ''
} }
watch(() => form.value.language, (newVal) => { function updateAvailableVersions(lang: { name: string; version: string; availableVersions: string[] }) {
if (newVal) { if (lang.name) {
availableVersions.value = installedLangs.value lang.availableVersions = installedLangs.value
.filter(l => l.plugin === newVal) .filter(l => l.plugin === lang.name)
.map(l => l.version) .map(l => l.version)
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
// 如果当前选中的版本不在可用列表中,且列表不为空,则自动选中第一个(通常是最新版)
if (form.value.lang_version && !availableVersions.value.includes(form.value.lang_version)) {
// 保持原样,可能还没加载完
}
} else { } else {
availableVersions.value = [] lang.availableVersions = []
form.value.lang_version = ''
} }
}) }
function addLang() {
selectedLangs.value.push({ name: '', version: '', availableVersions: [] })
}
function removeLang(index: number) {
selectedLangs.value.splice(index, 1)
}
function updateLangName(index: number, name: string) {
const lang = selectedLangs.value[index]
if (!lang) return
lang.name = name
lang.version = '' // reset version
updateAvailableVersions(lang)
}
watch(() => props.open, async (val) => { watch(() => props.open, async (val) => {
if (val) { if (val) {
@@ -233,6 +241,16 @@ watch(() => props.open, async (val) => {
} else { } else {
selectedEnvIds.value = [] selectedEnvIds.value = []
} }
// 解析语言环境
selectedLangs.value = []
if (props.task?.languages && Array.isArray(props.task.languages)) {
selectedLangs.value = props.task.languages.map((l: any) => ({
name: l.name || '',
version: l.version || '',
availableVersions: []
}))
}
// 解析 Agent 和工作目录 // 解析 Agent 和工作目录
const agentId = props.task?.agent_id ? String(props.task.agent_id) : 'local' const agentId = props.task?.agent_id ? String(props.task.agent_id) : 'local'
selectedAgentId.value = agentId selectedAgentId.value = agentId
@@ -245,13 +263,10 @@ watch(() => props.open, async (val) => {
await loadData() await loadData()
if (selectedAgentId.value === 'local') { if (selectedAgentId.value === 'local') {
await fetchInstalledLangs() await fetchInstalledLangs()
// 如果已选择语言(编辑模式),手动触发一次版本更新 // 更新所有语言的可用版本
if (form.value.language) { selectedLangs.value.forEach(lang => {
availableVersions.value = installedLangs.value updateAvailableVersions(lang)
.filter(l => l.plugin === form.value.language) })
.map(l => l.version)
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
}
} }
} }
}) })
@@ -285,6 +300,12 @@ async function save() {
form.value.type = 'task' form.value.type = 'task'
form.value.agent_id = selectedAgentId.value === 'local' ? null : Number(selectedAgentId.value) form.value.agent_id = selectedAgentId.value === 'local' ? null : Number(selectedAgentId.value)
// 保存语言环境配置
form.value.languages = selectedLangs.value.map(l => ({
name: l.name,
version: l.version
}))
// 保存配置 - 确保 concurrency 字段被正确保存 // 保存配置 - 确保 concurrency 字段被正确保存
let config: Record<string, any> = {} let config: Record<string, any> = {}
@@ -365,93 +386,104 @@ async function save() {
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-2 sm:gap-3"> <div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-2 sm:gap-3">
<Label class="sm:text-right text-sm font-medium">语言环境</Label> <Label class="sm:text-right text-sm font-medium pt-2">语言环境</Label>
<div class="sm:col-span-3 flex gap-2"> <div class="sm:col-span-3 space-y-2">
<Popover v-model:open="openPluginPopover"> <div v-for="(clang, idx) in selectedLangs" :key="idx" class="flex gap-2">
<PopoverTrigger asChild> <Popover>
<Button variant="outline" role="combobox" :aria-expanded="openPluginPopover" <PopoverTrigger asChild>
class="justify-between flex-1 h-8 text-sm font-normal"> <Button variant="outline" role="combobox" class="justify-between flex-1 h-8 text-sm font-normal">
<div class="flex items-center gap-2 truncate"> <div class="flex items-center gap-2 truncate">
<div v-if="form.language && getLangIcon(form.language)" <div v-if="clang.name && getLangIcon(clang.name)"
class="w-4 h-4 shrink-0 rounded-sm bg-white p-0.5 border"> class="w-4 h-4 shrink-0 rounded-sm bg-white p-0.5 border">
<img :src="getLangIcon(form.language)" class="w-full h-full object-contain" /> <img :src="getLangIcon(clang.name)" class="w-full h-full object-contain" />
</div>
<span>{{ form.language || "选择环境..." }}</span>
</div>
<ChevronsUpDown class="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 w-[240px]" align="start">
<div class="p-2 border-b">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input v-model="pluginSearch" placeholder="搜索已安装语言..." class="h-7 pl-8 text-xs" />
</div>
</div>
<ScrollArea class="h-48">
<div class="p-1">
<div v-if="loadingLangs" class="flex items-center justify-center py-4">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="filteredPlugins.length === 0"
class="py-4 text-center text-xs text-muted-foreground">
未找到已安装语言
</div>
<button v-else v-for="p in filteredPlugins" :key="p"
@click="() => { form.language = p; openPluginPopover = false }"
class="w-full flex items-center px-2 py-1.5 text-xs rounded-sm hover:bg-muted text-left transition-colors group">
<div class="mr-2 h-4 w-4 shrink-0 flex items-center justify-center relative">
<div v-if="getLangIcon(p)"
class="w-full h-full rounded-sm bg-white overflow-hidden p-0.5 border">
<img :src="getLangIcon(p)" class="w-full h-full object-contain" />
</div>
<div v-else
class="w-full h-full flex items-center justify-center bg-primary/10 rounded-sm text-[8px] font-bold uppercase border">
{{ p.substring(0, 2) }}
</div>
<Check v-if="form.language === p"
class="absolute -right-2 -top-1 h-3 w-3 text-primary bg-background rounded-full border shadow-sm" />
</div> </div>
<span :class="{ 'font-bold text-primary': form.language === p }">{{ p }}</span> <span>{{ clang.name || "选择环境..." }}</span>
</button> </div>
</div> <ChevronsUpDown class="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</ScrollArea> </Button>
</PopoverContent> </PopoverTrigger>
</Popover> <PopoverContent class="p-0 w-[240px]" align="start">
<div class="p-2 border-b">
<Popover v-model:open="openVersionPopover"> <div class="relative">
<PopoverTrigger asChild :disabled="!form.language"> <Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Button variant="outline" role="combobox" :aria-expanded="openVersionPopover" <Input v-model="pluginSearch" placeholder="搜索已安装语言..." class="h-7 pl-8 text-xs" />
class="justify-between w-32 h-8 text-sm font-normal" :disabled="!form.language">
<span class="truncate">{{ form.lang_version || "选择版本..." }}</span>
<div class="flex items-center">
<ChevronsUpDown class="h-3.5 w-3.5 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 w-[140px]" align="start">
<div class="p-2 border-b">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input v-model="versionSearch" placeholder="搜索版本..." class="h-7 pl-8 text-xs" />
</div>
</div>
<ScrollArea class="h-48">
<div class="p-1">
<div v-if="filteredVersions.length === 0" class="py-4 text-center text-xs text-muted-foreground">
无可用版本
</div> </div>
<button v-else v-for="v in filteredVersions" :key="v"
@click="() => { form.lang_version = v; openVersionPopover = false }"
class="w-full flex items-center px-2 py-1.5 text-xs rounded-sm hover:bg-muted text-left transition-colors">
<Check :class="cn('mr-2 h-3 w-3', form.lang_version === v ? 'opacity-100' : 'opacity-0')" />
<span class="truncate">{{ v }}</span>
</button>
</div> </div>
</ScrollArea> <ScrollArea class="h-48">
</PopoverContent> <div class="p-1">
</Popover> <div v-if="loadingLangs" class="flex items-center justify-center py-4">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="filteredPlugins.length === 0"
class="py-4 text-center text-xs text-muted-foreground">
未找到已安装语言
</div>
<button v-else v-for="p in filteredPlugins" :key="p" @click="updateLangName(idx, p)"
class="w-full flex items-center px-2 py-1.5 text-xs rounded-sm hover:bg-muted text-left transition-colors group">
<div class="mr-2 h-4 w-4 shrink-0 flex items-center justify-center relative">
<div v-if="getLangIcon(p)"
class="w-full h-full rounded-sm bg-white overflow-hidden p-0.5 border">
<img :src="getLangIcon(p)" class="w-full h-full object-contain" />
</div>
<div v-else
class="w-full h-full flex items-center justify-center bg-primary/10 rounded-sm text-[8px] font-bold uppercase border">
{{ p.substring(0, 2) }}
</div>
<Check v-if="clang.name === p"
class="absolute -right-2 -top-1 h-3 w-3 text-primary bg-background rounded-full border shadow-sm" />
</div>
<span :class="{ 'font-bold text-primary': clang.name === p }">{{ p }}</span>
</button>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild :disabled="!clang.name">
<Button variant="outline" role="combobox" class="justify-between w-32 h-8 text-sm font-normal"
:disabled="!clang.name">
<span class="truncate">{{ clang.version || "选择版本..." }}</span>
<div class="flex items-center">
<ChevronsUpDown class="h-3.5 w-3.5 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 w-[140px]" align="start">
<div class="p-2 border-b">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input v-model="versionSearch" placeholder="搜索版本..." class="h-7 pl-8 text-xs" />
</div>
</div>
<ScrollArea class="h-48">
<div class="p-1">
<div v-if="getFilteredVersions(clang.availableVersions).length === 0"
class="py-4 text-center text-xs text-muted-foreground">
无可用版本
</div>
<button v-else v-for="v in getFilteredVersions(clang.availableVersions)" :key="v"
@click="clang.version = v"
class="w-full flex items-center px-2 py-1.5 text-xs rounded-sm hover:bg-muted text-left transition-colors">
<Check :class="cn('mr-2 h-3 w-3', clang.version === v ? 'opacity-100' : 'opacity-0')" />
<span class="truncate">{{ v }}</span>
</button>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-destructive"
@click="removeLang(idx)">
<X class="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" class="w-full h-8 text-xs border-dashed text-muted-foreground"
@click="addLang">
<Plus class="h-3.5 w-3.5 mr-1" /> 添加语言环境
</Button>
</div> </div>
</div> </div>
</template> </template>