diff --git a/agent/agent.go b/agent/agent.go index 3e96a12..9fed1ed 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -489,6 +489,7 @@ func (a *Agent) handleExecute(data json.RawMessage) { var req struct { TaskID string `json:"task_id"` LogID string `json:"log_id"` + Envs string `json:"envs"` } if err := json.Unmarshal(data, &req); err != nil { logger.Errorf("解析立即执行请求失败: %v", err) @@ -506,13 +507,19 @@ func (a *Agent) handleExecute(data json.RawMessage) { } // 准备执行请求 + // 如果消息中携带了环境变量,则优先使用(通常由服务端解析好后推过来) + envs := task.Envs + if req.Envs != "" { + envs = req.Envs + } + execReq := &executor.ExecutionRequest{ TaskID: task.ID, LogID: req.LogID, Name: task.Name, Command: task.Command, WorkDir: task.WorkDir, - Envs: executor.ParseEnvVars(task.Envs), + Envs: executor.ParseEnvVars(envs), Timeout: task.Timeout, Languages: task.Languages, UseMise: task.UseMise(), diff --git a/internal/executor/cron.go b/internal/executor/cron.go index b79baf3..8bdb97a 100644 --- a/internal/executor/cron.go +++ b/internal/executor/cron.go @@ -99,7 +99,12 @@ func (m *CronManager) AddTask(task CronTask) error { Type: TaskTypeCron, Timeout: timeout, WorkDir: workDir, - Envs: ParseEnvVars(envs), + Envs: func() []string { + if vars := task.GetEnvVars(); len(vars) > 0 { + return vars + } + return ParseEnvVars(envs) + }(), Languages: languages, UseMise: useMise, } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 7bb8184..d40b9b6 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2,6 +2,7 @@ package executor import ( "context" + "fmt" "io" "os" "os/exec" @@ -23,6 +24,7 @@ type Task interface { GetTimeout() int GetWorkDir() string GetEnvs() string + GetEnvVars() []string GetLanguages() []map[string]string GetUseMise() bool } @@ -330,3 +332,29 @@ func ParseEnvVars(envStr string) []string { return result } + +// FormatEnvVars 将环境变量列表格式化为逗号分隔的字符串 "KEY1=VALUE1,KEY2=VALUE2" +// 会对 , 和 = 进行转义 +func FormatEnvVars(envs []string) string { + if len(envs) == 0 { + return "" + } + + pairs := make([]string, 0, len(envs)) + for _, pair := range envs { + // 寻找第一个等号 + idx := strings.Index(pair, "=") + if idx == -1 { + continue + } + name := pair[:idx] + value := pair[idx+1:] + + // 转义特殊字符 + encodedValue := strings.ReplaceAll(value, ",", "{{COMMA}}") + encodedValue = strings.ReplaceAll(encodedValue, "=", "{{EQUAL}}") + pairs = append(pairs, fmt.Sprintf("%s=%s", name, encodedValue)) + } + + return strings.Join(pairs, ",") +} diff --git a/internal/models/task.go b/internal/models/task.go index 90ef102..f1d86ea 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -27,7 +27,8 @@ type RepoConfig struct { // TaskConfig 任务配置 RepoConfig+TaskConfig=task.config type TaskConfig struct { - Concurrency int `json:"$task_concurrency"` // 0: disable concurrency, 1: enable concurrency + Concurrency int `json:"$task_concurrency"` // 0: disable concurrency, 1: enable concurrency + AllEnvs bool `json:"$task_all_envs"` // 开启则注入全部环境变量 } // Task 代表一个计划任务 @@ -51,6 +52,7 @@ type Task struct { RandomRange int `json:"random_range" gorm:"default:0"` // 随机延迟范围(秒) Enabled bool `json:"enabled" gorm:"default:true"` RunningGo string `json:"running_go" gorm:"type:text"` // 正在运行的 go routine id 数组 (JSON) + RuntimeEnvs []string `json:"-" gorm:"-"` // 运行时环境变量(非持久化) LastRun *LocalTime `json:"last_run"` NextRun *LocalTime `json:"next_run"` CreatedAt LocalTime `json:"created_at"` @@ -90,6 +92,10 @@ func (t *Task) GetLanguages() []map[string]string { return t.Languages } +func (t *Task) GetEnvVars() []string { + return t.RuntimeEnvs +} + func (t *Task) GetUseMise() bool { return t.AgentID == nil || *t.AgentID == "" } diff --git a/internal/services/agent_service.go b/internal/services/agent_service.go index f6a6799..adc72cf 100644 --- a/internal/services/agent_service.go +++ b/internal/services/agent_service.go @@ -3,6 +3,7 @@ package services import ( "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/executor" "github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/services/tasks" "github.com/engigu/baihu-panel/internal/utils" @@ -310,18 +312,39 @@ func (s *AgentService) GetTasks(agentID string) []models.AgentTask { database.DB.Where("agent_id = ? AND enabled = ?", agentID, true).Find(&tasks) result := make([]models.AgentTask, len(tasks)) + envService := NewEnvService() + for i, task := range tasks { - // 将环境变量 ID 转换为实际的环境变量键值对 - envVarsStr := s.buildEnvVarsString(task.Envs) + // 加载环境配置 + var envVars []string + + // 检查全量注入模式 + allEnvs := false + if task.Config != "" { + var config models.TaskConfig + if err := json.Unmarshal([]byte(task.Config), &config); err == nil { + if config.AllEnvs { + allEnvs = true + } + } + } + + if allEnvs { + envVars = envService.GetAllEnvVars() + } else if task.Envs != "" { + envVars = envService.GetEnvVarsByIDs(task.Envs) + } + + envVarsStr := executor.FormatEnvVars(envVars) result[i] = models.AgentTask{ - ID: task.ID, - Name: task.Name, - Command: task.Command, - Schedule: task.Schedule, - Timeout: task.Timeout, - WorkDir: task.WorkDir, - Envs: envVarsStr, // 传递 "KEY1=VALUE1,KEY2=VALUE2" 格式 + ID: task.ID, + Name: task.Name, + Command: task.Command, + Schedule: task.Schedule, + Timeout: task.Timeout, + WorkDir: task.WorkDir, + Envs: envVarsStr, Languages: task.Languages, RandomRange: task.RandomRange, Enabled: task.Enabled, @@ -331,30 +354,6 @@ func (s *AgentService) GetTasks(agentID string) []models.AgentTask { return result } -// buildEnvVarsString 将环境变量 ID 列表转换为键值对字符串 -func (s *AgentService) buildEnvVarsString(envIDs string) string { - if envIDs == "" { - return "" - } - - var envVars []models.EnvironmentVariable - ids := strings.Split(envIDs, ",") - database.DB.Where("id IN ?", ids).Find(&envVars) - - if len(envVars) == 0 { - return "" - } - - // 构建 "KEY1=VALUE1,KEY2=VALUE2" 格式 - pairs := make([]string, 0, len(envVars)) - for _, env := range envVars { - // 对值进行转义,避免特殊字符问题 - encodedValue := strings.ReplaceAll(env.Value, ",", "{{COMMA}}") - encodedValue = strings.ReplaceAll(encodedValue, "=", "{{EQUAL}}") - pairs = append(pairs, fmt.Sprintf("%s=%s", env.Name, encodedValue)) - } - return strings.Join(pairs, ",") -} // ReportResult Agent 上报执行结果 func (s *AgentService) ReportResult(result *models.AgentTaskResult) error { diff --git a/internal/services/env_service.go b/internal/services/env_service.go index f10b5b2..ddaf0bf 100644 --- a/internal/services/env_service.go +++ b/internal/services/env_service.go @@ -125,6 +125,32 @@ func (es *EnvService) GetEnvVarsByIDs(envIDs string) []string { } ids := splitEnvIDs(envIDs) + var envs []models.EnvironmentVariable + for _, id := range ids { + env := es.GetEnvVarByID(id) + if env != nil { + envs = append(envs, *env) + } + } + + return es.formatEnvVars(envs) +} + +// GetAllEnvVars 获取系统中所有的环境变量,并按 NAME=VALUE 格式返回(支持重名合并) +func (es *EnvService) GetAllEnvVars() []string { + var envs []models.EnvironmentVariable + if err := database.DB.Find(&envs).Error; err != nil { + return nil + } + return es.formatEnvVars(envs) +} + +// formatEnvVars 将环境变量列表格式化为 NAME=VALUE 数组,并处理重名合并 +func (es *EnvService) formatEnvVars(envs []models.EnvironmentVariable) []string { + if len(envs) == 0 { + return nil + } + type mergedEnv struct { name string values []string @@ -132,12 +158,7 @@ func (es *EnvService) GetEnvVarsByIDs(envIDs string) []string { var mergedList []mergedEnv nameToIndex := make(map[string]int) - for _, id := range ids { - env := es.GetEnvVarByID(id) - if env == nil { - continue - } - + for _, env := range envs { if idx, ok := nameToIndex[env.Name]; ok { mergedList[idx].values = append(mergedList[idx].values, env.Value) } else { diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go index 78de8a8..d39d4cb 100644 --- a/internal/services/tasks/executor_service.go +++ b/internal/services/tasks/executor_service.go @@ -37,6 +37,7 @@ type SettingsService interface { // EnvService 接口定义(避免循环依赖) type EnvService interface { GetEnvVarsByIDs(ids string) []string + GetAllEnvVars() []string } // Notifier 通知服务接口定义(避免循环依赖) @@ -372,7 +373,7 @@ func (es *ExecutorService) HandleTaskRetry(task *models.Task, req *executor.Exec return nil } - newEnvs := es.loadEnvVars(latestTask.Envs) + newEnvs := es.loadEnvVars(latestTask.ID, latestTask.Envs) return &executor.ExecutionRequest{ TaskID: req.TaskID, Name: latestTask.Name, @@ -444,14 +445,10 @@ func (es *ExecutorService) ExecuteDispatcher(ctx context.Context, req *executor. } } - // 加载环境变量 - if task.Envs != "" { - req.Envs = append(req.Envs, es.loadEnvVars(task.Envs)...) - } - // 远程任务 if task.AgentID != nil && *task.AgentID != "" { - return es.ExecuteRemoteForScheduler(task, req.LogID) + // 将请求中已包含的环境变量(已合并)传递给 Agent + return es.ExecuteRemoteForScheduler(task, req.LogID, executor.FormatEnvVars(req.Envs)) } // 本地任务 @@ -504,6 +501,9 @@ func (es *ExecutorService) AddCronTask(task *models.Task) error { es.RemoveCronTask(task.ID) // 如果不是cron类型,确保从调度器移除 return nil } + // 在加入调度器前,预先加载好环境信息 + task.RuntimeEnvs = es.loadEnvVars(task.ID, task.Envs) + return es.cronManager.AddTask(task) } @@ -540,7 +540,7 @@ func (es *ExecutorService) loadCronTasks() { }(task) } else if task.TriggerType == constant.TriggerTypeCron && task.Schedule != "" && (task.AgentID == nil || *task.AgentID == "") { // 只调度本地任务(agent_id 为空或 0)的定时任务 - err := es.cronManager.AddTask(&task) + err := es.AddCronTask(&task) if err != nil { continue } @@ -583,7 +583,7 @@ func (es *ExecutorService) ExecuteTask(taskID string, extraEnvs []string) *execu } } - envs := es.loadEnvVars(task.Envs) + envs := es.loadEnvVars(task.ID, task.Envs) if len(extraEnvs) > 0 { envs = append(envs, extraEnvs...) } @@ -613,7 +613,7 @@ func (es *ExecutorService) ExecuteTask(taskID string, extraEnvs []string) *execu // StopTaskExecution stops a running task execution by LogID func (es *ExecutorService) StopTaskExecution(logID string) error { var taskLog models.TaskLog - if err := database.DB.First(&taskLog, logID).Error; err != nil { + if err := database.DB.Where("id = ?", logID).First(&taskLog).Error; err != nil { return fmt.Errorf("日志不存在") } @@ -819,7 +819,7 @@ func (es *ExecutorService) RemoveRunningGo(taskID string, goid int64) { } // ExecuteRemoteForScheduler 供 Scheduler 调用,执行远程任务并等待结果 -func (es *ExecutorService) ExecuteRemoteForScheduler(task *models.Task, logID string) (*executor.Result, error) { +func (es *ExecutorService) ExecuteRemoteForScheduler(task *models.Task, logID string, envs string) (*executor.Result, error) { agentID := *task.AgentID logger.Infof("[Executor] 远程执行任务 #%s: %s (Agent #%s, LogID: %s)", task.ID, task.Name, agentID, logID) @@ -843,6 +843,7 @@ func (es *ExecutorService) ExecuteRemoteForScheduler(task *models.Task, logID st err := es.agentWSManager.SendToAgent(agentID, constant.WSTypeExecute, map[string]interface{}{ "task_id": task.ID, "log_id": logID, + "envs": envs, }) if err != nil { return nil, fmt.Errorf("发送执行命令失败: %v", err) @@ -936,8 +937,24 @@ func (es *ExecutorService) BuildRepoCommand(task *models.Task) (string, string) return exePath + " " + strings.Join(args, " "), filepath.Dir(exePath) } -// loadEnvVars 加载环境变量(支持重名合并) -func (es *ExecutorService) loadEnvVars(envIDs string) []string { +// loadEnvVars 加载环境变量,支持全局注入及重名合并 +func (es *ExecutorService) loadEnvVars(taskID string, envIDs string) []string { + // 1. 检查是否开启了注入全部环境变量 + if taskID != "" && es.taskService != nil { + task := es.taskService.GetTaskByID(taskID) + if task != nil && task.Config != "" { + var config models.TaskConfig + if err := json.Unmarshal([]byte(task.Config), &config); err == nil { + if config.AllEnvs { + if es.envService != nil { + return es.envService.GetAllEnvVars() + } + } + } + } + } + + // 2. 否则按 ID 列表进行加载(支持合并逻辑在 envService 中处理) if envIDs == "" { return nil } diff --git a/internal/services/tasks/task_service.go b/internal/services/tasks/task_service.go index 15e49ee..3b2cdf6 100644 --- a/internal/services/tasks/task_service.go +++ b/internal/services/tasks/task_service.go @@ -104,6 +104,7 @@ func (ts *TaskService) UpdateTask(id string, name, command, schedule string, tim task.Enabled = enabled task.AgentID = agentID task.Languages = languages + task.Config = config task.RetryCount = retryCount task.RetryInterval = retryInterval task.RandomRange = randomRange diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 5b1fa50..3f34672 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -319,6 +319,8 @@ export interface Task { enabled: boolean last_run: string next_run: string + created_at?: string + updated_at?: string } export interface RepoConfig { diff --git a/web/src/assets/index.css b/web/src/assets/index.css index 6b1bb0a..9243ce0 100644 --- a/web/src/assets/index.css +++ b/web/src/assets/index.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); /* Sonner toast styles */ [data-sonner-toaster] { @@ -135,28 +136,36 @@ @layer base { * { @apply outline-ring/50; + font-family: inherit; } + *, ::before, ::after { border-color: transparent; } + .border, [class*="border-"] { border-color: var(--border); } + body { @apply bg-background text-foreground antialiased; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; + letter-spacing: -0.01em; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; } - .font-code { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + .font-code, + .font-mono, + code, + pre { + font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important; } } @@ -189,16 +198,20 @@ body { } /* 按钮增强 - 更流畅的交互 */ -button, [role="button"] { +button, +[role="button"] { transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } -button:active, [role="button"]:active { +button:active, +[role="button"]:active { transform: scale(0.98); } /* 输入框增强 - 聚焦效果 */ -input:focus, textarea:focus, select:focus { +input:focus, +textarea:focus, +select:focus { box-shadow: 0 0 0 3px oklch(from var(--ring) l c h / 0.1); transition: box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1); } @@ -218,7 +231,8 @@ table tbody tr:hover { } /* 模态框和弹出层增强 - 背景模糊 */ -[role="dialog"]::backdrop, [data-state="open"]::backdrop { +[role="dialog"]::backdrop, +[data-state="open"]::backdrop { backdrop-filter: blur(4px); animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); } @@ -227,13 +241,15 @@ table tbody tr:hover { from { opacity: 0; } + to { opacity: 1; } } /* 代码块增强 */ -code, pre { +code, +pre { background: oklch(from var(--muted) l c h / 0.5); border-radius: calc(var(--radius) - 2px); padding: 0.125rem 0.375rem; @@ -247,7 +263,10 @@ pre code { } /* Windows 小字体优化 */ -.text-xs, .text-sm, [class*="text-xs"], [class*="text-sm"] { +.text-xs, +.text-sm, +[class*="text-xs"], +[class*="text-sm"] { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-weight: 400; @@ -255,25 +274,30 @@ pre code { } /* 表格和列表中的小字体优化 */ -table, [role="table"] { +table, +[role="table"] { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* 命令和路径文本优化 */ -code, pre, [class*="font-mono"] { +code, +pre, +[class*="font-mono"] { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; font-weight: 400; } /* 徽章和标签增强 */ -[class*="badge"], [class*="tag"] { +[class*="badge"], +[class*="tag"] { transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); } -[class*="badge"]:hover, [class*="tag"]:hover { +[class*="badge"]:hover, +[class*="tag"]:hover { box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.1); } @@ -282,18 +306,17 @@ code, pre, [class*="font-mono"] { 0% { background-position: -1000px 0; } + 100% { background-position: 1000px 0; } } .skeleton { - background: linear-gradient( - 90deg, - oklch(from var(--muted) l c h / 0.3) 0%, - oklch(from var(--muted) l c h / 0.5) 50%, - oklch(from var(--muted) l c h / 0.3) 100% - ); + background: linear-gradient(90deg, + oklch(from var(--muted) l c h / 0.3) 0%, + oklch(from var(--muted) l c h / 0.5) 50%, + oklch(from var(--muted) l c h / 0.3) 100%); background-size: 1000px 100%; animation: shimmer 2s infinite linear; } @@ -341,25 +364,33 @@ code, pre, [class*="font-mono"] { background: oklch(from var(--primary) l c h / 0.3); } -/* Number input spinner buttons - dark mode */ +/* Number input spinner - Refined SVG version with more spacing */ input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { - opacity: 0.6; + -webkit-appearance: none; + appearance: none; + width: 22px; + /* Increased width for better hit area and visibility */ + height: 20px; + cursor: pointer; + opacity: 0.5; + /* Premium SVG Arrows: Increased gap between up and down arrows */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 17 5 5 5-5'/%3E%3Cpath d='m7 7 5-5 5 5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 14px; + transition: opacity 0.2s ease; + margin-right: 2px; } -input[type="number"]:hover::-webkit-inner-spin-button, -input[type="number"]:hover::-webkit-outer-spin-button { - opacity: 1; +/* Hover effect: Smoothly light up the arrows */ +input[type="number"]:hover::-webkit-inner-spin-button { + opacity: 0.9; } -.dark input[type="number"]::-webkit-inner-spin-button, -.dark input[type="number"]::-webkit-outer-spin-button { - filter: invert(0.7); -} - -.dark input[type="number"]:hover::-webkit-inner-spin-button, -.dark input[type="number"]:hover::-webkit-outer-spin-button { - filter: invert(0.85); +/* Light mode support */ +:root:not(.dark) input[type="number"]::-webkit-inner-spin-button { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 17 5 5 5-5'/%3E%3Cpath d='m7 7 5-5 5 5'/%3E%3C/svg%3E"); } /* Custom scrollbar styles */ @@ -421,4 +452,4 @@ input[type="number"]:hover::-webkit-outer-spin-button { .apexcharts-tooltip-title { display: none !important; -} +} \ No newline at end of file diff --git a/web/src/components/DirTreeSelect.vue b/web/src/components/DirTreeSelect.vue index ac6087c..82e604b 100644 --- a/web/src/components/DirTreeSelect.vue +++ b/web/src/components/DirTreeSelect.vue @@ -114,7 +114,7 @@ const displayValue = computed(() => {