diff --git a/.stignore b/.stignore index 4e8e3b7..3113b42 100644 --- a/.stignore +++ b/.stignore @@ -1,3 +1,4 @@ .git/ web/node_modules -web/dist \ No newline at end of file +web/dist +envs/ diff --git a/README.md b/README.md index 7dd8d98..81784d8 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,44 @@ +## 支持语言脚本和依赖 + +
+点击展开查看已支持的语言及依赖管理详情 + +### 脚本运行环境 +白虎面板原生支持以下脚本的定时执行: +- **Python3**, **Node.js**, **Bash** (内置环境) +- 通过 **Mise** 扩展:支持几乎所有主流编程语言的动态安装与切换。 + +### 依赖管理支持 +系统内置了高度集成的跨语言依赖管理器,支持自动化安装和管理以下语言的依赖项,并确保在容器内全局可用: + +| 语言 | 包管理器 | 功能说明 | +| :--- | :--- | :--- | +| **Python** | pip | 自动使用内置虚拟环境,支持清华源 | +| **Node.js** | npm | 全局安装模式,自动配置 npmmirror 镜像 | +| **Go** | go install | 通过 `go install` 安装二进制工具 | +| **Rust** | cargo | 通过 `cargo install` 安装 Rust 依赖 | +| **Ruby** | gem | 支持 `gem install` 本地安装 | +| **Bun** | bun | 支持 `bun add -g` 全局模式 | +| **PHP** | composer | 支持 `composer global require` | +| **Deno** | deno | 支持 `deno install -g` | +| **.NET** | dotnet | 支持 `dotnet tool install -g` | +| **Elixir/Erlang** | mix | 支持 `mix archive.install` | +| **Lua** | luarocks | 通过 `luarocks` 管理 Lua 包 | +| **Nim** | nimble | 支持 `nimble install` | +| **Dart/Flutter** | pub | 支持 `pub global activate` | +| **Perl** | cpanm | 简单的 `cpanm` 安装支持 | +| **Crystal** | shards | `shards` 项目级别或工具安装 | + +### 使用方法 +1. **安装环境**:进入「编程语言」页面,使用 `mise` 一键安装所需的语言及版本。 +2. **依赖管理**:在已安装列表点击「依赖管理」,输入名称(可选版本)即可自动在对应环境内完成安装。 +3. **隔离机制**:系统基于 `mise exec` 实现了完善的环境隔离,不同版本的依赖包互不冲突。 + +
+ ## 效果图 ![baihu-display.gif](https://raw.githubusercontent.com/engigu/resources/refs/heads/images/baihu-display.gif) diff --git a/agent/agent.go b/agent/agent.go index 0122b6a..579a743 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -43,15 +43,17 @@ type WSMessage struct { } type AgentTask struct { - ID uint `json:"id"` - Name string `json:"name"` - Command string `json:"command"` - Schedule string `json:"schedule"` - Cron string `json:"cron"` - Timeout int `json:"timeout"` - WorkDir string `json:"work_dir"` - Envs string `json:"envs"` - Enabled bool `json:"enabled"` + ID uint `json:"id"` + Name string `json:"name"` + Command string `json:"command"` + Schedule string `json:"schedule"` + Cron string `json:"cron"` + Timeout int `json:"timeout"` + WorkDir string `json:"work_dir"` + Envs string `json:"envs"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + Enabled bool `json:"enabled"` } func (t *AgentTask) GetID() string { @@ -78,6 +80,22 @@ func (t *AgentTask) GetEnvs() string { return t.Envs } +func (t *AgentTask) GetLanguage() string { + return t.Language +} + +func (t *AgentTask) GetLangVersion() string { + return t.LangVersion +} + +func (t *AgentTask) GetUseMise() bool { + return false +} + +func (t *AgentTask) UseMise() bool { + return false +} + func (t *AgentTask) GetSchedule() string { if t.Schedule != "" { return t.Schedule @@ -495,14 +513,17 @@ func (a *Agent) handleExecute(data json.RawMessage) { // 准备执行请求 execReq := &executor.ExecutionRequest{ - TaskID: fmt.Sprintf("%d", task.ID), - LogID: req.LogID, - Name: task.Name, - Command: task.Command, - WorkDir: task.WorkDir, - Envs: executor.ParseEnvVars(task.Envs), - Timeout: task.Timeout, - Type: executor.TaskTypeManual, + TaskID: fmt.Sprintf("%d", task.ID), + LogID: req.LogID, + Name: task.Name, + Command: task.Command, + WorkDir: task.WorkDir, + Envs: executor.ParseEnvVars(task.Envs), + Timeout: task.Timeout, + Language: task.Language, + LangVersion: task.LangVersion, + UseMise: false, + Type: executor.TaskTypeManual, } // 立即执行任务(加入队列) diff --git a/internal/constant/mise.go b/internal/constant/mise.go new file mode 100644 index 0000000..dba8297 --- /dev/null +++ b/internal/constant/mise.go @@ -0,0 +1,9 @@ +package constant + +// MainstreamMisePlugins 主流的 mise 插件列表 +var MainstreamMisePlugins = []string{ + "python", "node", "go", "rust", "ruby", "java", "php", + "deno", "bun", "zig", "dotnet", "elixir", "erlang", + "crystal", "lua", "julia", "nim", "perl", "scala", + "kotlin", "clojure", "dart", "flutter", "terraform", +} diff --git a/internal/controllers/dependency_controller.go b/internal/controllers/dependency_controller.go index 85b281b..644a733 100644 --- a/internal/controllers/dependency_controller.go +++ b/internal/controllers/dependency_controller.go @@ -24,8 +24,9 @@ func NewDependencyController() *DependencyController { // List 获取依赖列表 func (c *DependencyController) List(ctx *gin.Context) { - depType := ctx.Query("type") - deps, err := c.service.List(depType) + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + deps, err := c.service.List(language, langVersion) if err != nil { utils.ServerError(ctx, "获取依赖列表失败") return @@ -40,10 +41,11 @@ func (c *DependencyController) List(ctx *gin.Context) { // Create 添加依赖 func (c *DependencyController) Create(ctx *gin.Context) { var req struct { - Name string `json:"name" binding:"required"` - Version string `json:"version"` - Type string `json:"type" binding:"required"` - Remark string `json:"remark"` + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language" binding:"required"` + LangVersion string `json:"lang_version"` + Remark string `json:"remark"` } if err := ctx.ShouldBindJSON(&req); err != nil { @@ -51,16 +53,12 @@ func (c *DependencyController) Create(ctx *gin.Context) { return } - if req.Type != "py" && req.Type != "node" { - utils.BadRequest(ctx, "类型必须是 py 或 node") - return - } - dep := &models.Dependency{ - Name: req.Name, - Version: req.Version, - Type: req.Type, - Remark: req.Remark, + Name: req.Name, + Version: req.Version, + Language: req.Language, + LangVersion: req.LangVersion, + Remark: req.Remark, } if err := c.service.Create(dep); err != nil { @@ -87,13 +85,13 @@ func (c *DependencyController) Delete(ctx *gin.Context) { utils.SuccessMsg(ctx, "删除成功") } -// Install 安装依赖 func (c *DependencyController) Install(ctx *gin.Context) { var req struct { - Name string `json:"name" binding:"required"` - Version string `json:"version"` - Type string `json:"type" binding:"required"` - Remark string `json:"remark"` + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + Remark string `json:"remark"` } if err := ctx.ShouldBindJSON(&req); err != nil { @@ -101,11 +99,21 @@ func (c *DependencyController) Install(ctx *gin.Context) { return } + language := req.Language + if language == "" { + language = ctx.Query("language") + } + langVersion := req.LangVersion + if langVersion == "" { + langVersion = ctx.Query("lang_version") + } + dep := &models.Dependency{ - Name: req.Name, - Version: req.Version, - Type: req.Type, - Remark: req.Remark, + Name: req.Name, + Version: req.Version, + Language: language, + LangVersion: langVersion, + Remark: req.Remark, } if err := c.service.Install(dep); err != nil { @@ -119,6 +127,63 @@ func (c *DependencyController) Install(ctx *gin.Context) { utils.SuccessMsg(ctx, "安装成功") } +// GetInstallCommand 获取安装命令 +func (c *DependencyController) GetInstallCommand(ctx *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + language := req.Language + if language == "" { + language = ctx.Query("language") + } + langVersion := req.LangVersion + if langVersion == "" { + langVersion = ctx.Query("lang_version") + } + + dep := &models.Dependency{ + Name: req.Name, + Version: req.Version, + Language: language, + LangVersion: langVersion, + } + + cmd, err := c.service.GetInstallCommand(dep) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"command": cmd}) +} + +// GetReinstallAllCommand 获取全部重装命令 +func (c *DependencyController) GetReinstallAllCommand(ctx *gin.Context) { + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + if language == "" { + utils.BadRequest(ctx, "缺少 language 参数") + return + } + + cmd, err := c.service.GetReinstallAllCommand(language, langVersion) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"command": cmd}) +} + // Uninstall 卸载依赖 func (c *DependencyController) Uninstall(ctx *gin.Context) { id, err := strconv.Atoi(ctx.Param("id")) @@ -128,7 +193,7 @@ func (c *DependencyController) Uninstall(ctx *gin.Context) { } // 获取依赖信息 - deps, _ := c.service.List("") + deps, _ := c.service.List("", "") var dep *models.Dependency for _, d := range deps { if d.ID == id { @@ -162,7 +227,7 @@ func (c *DependencyController) Reinstall(ctx *gin.Context) { } // 获取依赖信息 - deps, _ := c.service.List("") + deps, _ := c.service.List("", "") var dep *models.Dependency for _, d := range deps { if d.ID == id { @@ -186,13 +251,14 @@ func (c *DependencyController) Reinstall(ctx *gin.Context) { // ReinstallAll 重新安装所有依赖 func (c *DependencyController) ReinstallAll(ctx *gin.Context) { - depType := ctx.Query("type") - if depType == "" { - utils.BadRequest(ctx, "缺少 type 参数") + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + if language == "" { + utils.BadRequest(ctx, "缺少 language 参数") return } - deps, err := c.service.List(depType) + deps, err := c.service.List(language, langVersion) if err != nil { utils.ServerError(ctx, "获取依赖列表失败") return @@ -215,13 +281,14 @@ func (c *DependencyController) ReinstallAll(ctx *gin.Context) { // GetInstalled 获取已安装的包 func (c *DependencyController) GetInstalled(ctx *gin.Context) { - depType := ctx.Query("type") - if depType == "" { - utils.BadRequest(ctx, "缺少 type 参数") + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + if language == "" { + utils.BadRequest(ctx, "缺少 language 参数") return } - packages, err := c.service.GetInstalledPackages(depType) + packages, err := c.service.GetInstalledPackages(language, langVersion) if err != nil { utils.ServerError(ctx, "获取已安装包失败: "+err.Error()) return diff --git a/internal/controllers/mise_controller.go b/internal/controllers/mise_controller.go new file mode 100644 index 0000000..0aa9c67 --- /dev/null +++ b/internal/controllers/mise_controller.go @@ -0,0 +1,62 @@ +package controllers + +import ( + "github.com/engigu/baihu-panel/internal/services" + "github.com/engigu/baihu-panel/internal/utils" + + "github.com/gin-gonic/gin" +) + +type MiseController struct { + service *services.MiseService +} + +func NewMiseController(service *services.MiseService) *MiseController { + return &MiseController{ + service: service, + } +} + +// List 获取语言列表 +func (c *MiseController) List(ctx *gin.Context) { + langs, err := c.service.List() + if err != nil { + utils.ServerError(ctx, "获取语言列表失败: "+err.Error()) + return + } + utils.Success(ctx, langs) +} + +// Sync 同步本地环境到数据库 +func (c *MiseController) Sync(ctx *gin.Context) { + if err := c.service.Sync(); err != nil { + utils.ServerError(ctx, "同步本地环境失败: "+err.Error()) + return + } + utils.Success(ctx, nil) +} + +// Plugins 获取可用插件列表 +func (c *MiseController) Plugins(ctx *gin.Context) { + plugins, err := c.service.Plugins() + if err != nil { + utils.ServerError(ctx, "获取插件列表失败: "+err.Error()) + return + } + utils.Success(ctx, plugins) +} + +// Versions 获取指定插件的可用版本列表 +func (c *MiseController) Versions(ctx *gin.Context) { + plugin := ctx.Query("plugin") + if plugin == "" { + utils.BadRequest(ctx, "参数 plugin 不能为空") + return + } + versions, err := c.service.Versions(plugin) + if err != nil { + utils.ServerError(ctx, "获取版本列表失败: "+err.Error()) + return + } + utils.Success(ctx, versions) +} diff --git a/internal/controllers/task_controller.go b/internal/controllers/task_controller.go index 18538fc..79d3e87 100644 --- a/internal/controllers/task_controller.go +++ b/internal/controllers/task_controller.go @@ -61,6 +61,8 @@ func (tc *TaskController) CreateTask(c *gin.Context) { WorkDir string `json:"work_dir"` CleanConfig string `json:"clean_config"` Envs string `json:"envs"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` AgentID *uint `json:"agent_id"` } @@ -86,7 +88,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) + 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) // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron if task.AgentID != nil && *task.AgentID > 0 { @@ -156,6 +158,8 @@ func (tc *TaskController) UpdateTask(c *gin.Context) { CleanConfig string `json:"clean_config"` Envs string `json:"envs"` Enabled bool `json:"enabled"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` AgentID *uint `json:"agent_id"` } @@ -177,7 +181,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) + 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) if task == nil { utils.NotFound(c, "任务不存在") return diff --git a/internal/database/migrate.go b/internal/database/migrate.go index a85a7d5..6da81d9 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -23,6 +23,7 @@ func Migrate() error { &models.Dependency{}, &models.Agent{}, &models.AgentToken{}, + &models.Language{}, ) } @@ -37,5 +38,14 @@ func customMigrations() error { } } } + // 移除 deps 表中的 type 字段(如果存在) + if DB.Migrator().HasColumn(&models.Dependency{}, "type") { + if err := DB.Migrator().DropColumn(&models.Dependency{}, "type"); err != nil { + logger.Debugf("[Database] 移除 deps.type 列失败: %v", err) + } else { + logger.Infof("[Database] 已成功移除 deps 表中的冗余 type 列") + } + } + return nil } diff --git a/internal/executor/cron.go b/internal/executor/cron.go index 63ae4af..9a3c2cb 100644 --- a/internal/executor/cron.go +++ b/internal/executor/cron.go @@ -78,6 +78,9 @@ func (m *CronManager) AddTask(task CronTask) error { timeout := task.GetTimeout() workDir := task.GetWorkDir() envs := task.GetEnvs() + language := task.GetLanguage() + langVersion := task.GetLangVersion() + useMise := task.UseMise() entryID, err := m.cron.AddFunc(task.GetSchedule(), func() { defer func() { @@ -88,13 +91,16 @@ func (m *CronManager) AddTask(task CronTask) error { m.logger.Infof("[CronManager] 触发计划任务 #%s (%s)", taskID, name) req := &ExecutionRequest{ - TaskID: taskID, - Name: name, - Command: cmd, - Type: TaskTypeCron, - Timeout: timeout, - WorkDir: workDir, - Envs: ParseEnvVars(envs), + TaskID: taskID, + Name: name, + Command: cmd, + Type: TaskTypeCron, + Timeout: timeout, + WorkDir: workDir, + Envs: ParseEnvVars(envs), + Language: language, + LangVersion: langVersion, + UseMise: useMise, } // 如果有关联的 Scheduler,加入队列执行 @@ -116,7 +122,12 @@ func (m *CronManager) AddTask(task CronTask) error { // 初始触发一次下次运行时间通知 go func() { - req := &ExecutionRequest{TaskID: taskID, Name: name, Type: TaskTypeCron} + req := &ExecutionRequest{ + TaskID: taskID, + Name: name, + Type: TaskTypeCron, + UseMise: task.UseMise(), + } m.triggerNextRunEvent(taskID, req) }() diff --git a/internal/executor/executor.go b/internal/executor/executor.go index b13a4d1..7632d8e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -23,20 +23,27 @@ type Task interface { GetTimeout() int GetWorkDir() string GetEnvs() string + GetLanguage() string + GetLangVersion() string + GetUseMise() bool } // CronTask 计划任务接口 type CronTask interface { Task GetSchedule() string + UseMise() bool } // Request 任务执行请求 type Request struct { - Command string - WorkDir string - Envs []string - Timeout int // 任务超时时间(分钟) + Command string + WorkDir string + Envs []string + Timeout int // 任务超时时间(分钟) + Language string + LangVersion string + UseMise bool } // Result 任务执行结果 @@ -96,6 +103,9 @@ func ExecuteWithHooks(ctx context.Context, req Request, stdout, stderr io.Writer defer cancel() finalCommand := req.Command + if req.UseMise { + finalCommand = BuildLanguageCommand(req.Command, req.Language, req.LangVersion) + } shell, args := utils.GetShellCommand(finalCommand) cmd := exec.CommandContext(execCtx, shell, args...) @@ -272,6 +282,18 @@ func ExecuteWithHooks(ctx context.Context, req Request, stdout, stderr io.Writer return result, err } +// BuildLanguageCommand 构建语言环境执行命令 (使用 mise) +func BuildLanguageCommand(command, language, version string) string { + if language == "" { + return command + } + v := version + if v == "" { + v = "latest" + } + return "mise exec " + language + "@" + v + " -- " + command +} + // ParseEnvVars 解析环境变量字符串 "KEY1=VALUE1,KEY2=VALUE2" func ParseEnvVars(envStr string) []string { if envStr == "" { diff --git a/internal/executor/scheduler.go b/internal/executor/scheduler.go index a3b3f20..90d7e66 100644 --- a/internal/executor/scheduler.go +++ b/internal/executor/scheduler.go @@ -61,15 +61,18 @@ const ( // ExecutionRequest 执行请求(标准接口) type ExecutionRequest struct { - TaskID string // 任务 ID - LogID uint // 日志 ID - Name string // 任务名称 - Type TaskType // 任务类型 - Command string // 命令 - WorkDir string // 工作目录 - Envs []string // 环境变量 - Timeout int // 超时时间(分钟) - Metadata map[string]interface{} // 额外元数据 + TaskID string // 任务 ID + LogID uint // 日志 ID + Name string // 任务名称 + Type TaskType // 任务类型 + Command string // 命令 + WorkDir string // 工作目录 + Envs []string // 环境变量 + Timeout int // 超时时间(分钟) + Language string // 语言环境 + LangVersion string // 语言版本 + UseMise bool // 是否使用 mise + Metadata map[string]interface{} // 额外元数据 } // ExecutionResult 执行结果(标准接口) @@ -194,10 +197,13 @@ func NewScheduler(config SchedulerConfig, handler SchedulerEventHandler) *Schedu executor: func(ctx context.Context, req *ExecutionRequest, stdout, stderr io.Writer) (*Result, error) { hooks := &schedulerHooksAdapter{handler: handler, req: req} return ExecuteWithHooks(ctx, Request{ - Command: req.Command, - WorkDir: req.WorkDir, - Envs: req.Envs, - Timeout: req.Timeout, + Command: req.Command, + WorkDir: req.WorkDir, + Envs: req.Envs, + Timeout: req.Timeout, + Language: req.Language, + LangVersion: req.LangVersion, + UseMise: req.UseMise, }, stdout, stderr, hooks) }, taskQueue: make(chan *ExecutionRequest, config.QueueSize), @@ -309,6 +315,12 @@ func (s *Scheduler) executeTask(req *ExecutionRequest) (*ExecutionResult, error) s.logger.Infof("[Scheduler] 执行任务 %s (名称: %s, 类型: %s)", req.TaskID, req.Name, req.Type) + finalCommand := req.Command + if req.UseMise { + finalCommand = BuildLanguageCommand(req.Command, req.Language, req.LangVersion) + } + s.logger.Infof("[Scheduler] 实际执行命令: %s", finalCommand) + if s.config.Verbose { workDir := req.WorkDir if workDir == "" { diff --git a/internal/models/agent.go b/internal/models/agent.go index a44a58f..23c6ef8 100644 --- a/internal/models/agent.go +++ b/internal/models/agent.go @@ -52,14 +52,16 @@ func (AgentToken) TableName() string { // AgentTask Agent 任务配置(用于下发给 Agent) type AgentTask struct { - ID uint `json:"id"` - Name string `json:"name"` - Command string `json:"command"` - Schedule string `json:"schedule"` - Timeout int `json:"timeout"` - WorkDir string `json:"work_dir"` - Envs string `json:"envs"` - Enabled bool `json:"enabled"` + ID uint `json:"id"` + Name string `json:"name"` + Command string `json:"command"` + Schedule string `json:"schedule"` + Timeout int `json:"timeout"` + WorkDir string `json:"work_dir"` + Envs string `json:"envs"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + Enabled bool `json:"enabled"` } // AgentTaskResult Agent 上报的任务执行结果 diff --git a/internal/models/dependency.go b/internal/models/dependency.go index 67f0313..17b2bbc 100644 --- a/internal/models/dependency.go +++ b/internal/models/dependency.go @@ -1,20 +1,22 @@ package models import ( - "github.com/engigu/baihu-panel/internal/constant" "time" + + "github.com/engigu/baihu-panel/internal/constant" ) // Dependency 依赖包模型 type Dependency struct { - ID int `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:100;not null"` - Version string `json:"version" gorm:"size:50"` - Type string `json:"type" gorm:"size:10;not null"` // py 或 node - Remark string `json:"remark" gorm:"size:255"` - Log string `json:"log" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"size:100;not null"` + Version string `json:"version" gorm:"size:50"` + Language string `json:"language" gorm:"size:100;index"` // 关联语言 (node, python...) + LangVersion string `json:"lang_version" gorm:"size:100;index"` // 关联语言版本 + Remark string `json:"remark" gorm:"size:255"` + Log string `json:"log" gorm:"type:text"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Dependency) TableName() string { diff --git a/internal/models/language.go b/internal/models/language.go new file mode 100644 index 0000000..614894b --- /dev/null +++ b/internal/models/language.go @@ -0,0 +1,22 @@ +package models + +import ( + "github.com/engigu/baihu-panel/internal/constant" + "gorm.io/gorm" +) + +type Language struct { + ID uint `json:"id" gorm:"primaryKey"` + Plugin string `json:"plugin" gorm:"size:100;not null;index"` + Version string `json:"version" gorm:"size:100;not null;index"` + InstallPath string `json:"install_path" gorm:"size:255"` + Source string `json:"source" gorm:"size:255"` + InstalledAt *LocalTime `json:"installed_at"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (Language) TableName() string { + return constant.TablePrefix + "languages" +} diff --git a/internal/models/task.go b/internal/models/task.go index ae3051c..ceff80e 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -44,6 +44,8 @@ type Task struct { WorkDir string `json:"work_dir" gorm:"size:255;default:''"` // 工作目录,为空则使用 scripts 目录 CleanConfig string `json:"clean_config" gorm:"size:255;default:''"` // 清理配置 JSON Envs string `json:"envs" gorm:"size:255;default:''"` // 环境变量ID列表,逗号分隔 + Language string `json:"language" gorm:"size:50;default:''"` // 针对本地任务的语言名称 (mise) + LangVersion string `json:"lang_version" gorm:"size:50;default:''"` // 针对本地任务的语言版本 (mise) AgentID *uint `json:"agent_id" gorm:"index"` // Agent ID,为空表示本地执行 Enabled bool `json:"enabled" gorm:"default:true"` RunningGo string `json:"running_go" gorm:"type:text"` // 正在运行的 go routine id 数组 (JSON) @@ -82,6 +84,22 @@ func (t *Task) GetEnvs() string { return t.Envs } +func (t *Task) GetLanguage() string { + return t.Language +} + +func (t *Task) GetLangVersion() string { + return t.LangVersion +} + +func (t *Task) GetUseMise() bool { + return t.AgentID == nil || *t.AgentID == 0 +} + +func (t *Task) UseMise() bool { + return t.GetUseMise() +} + func (t *Task) GetSchedule() string { return t.Schedule } diff --git a/internal/models/vo/dep_vo.go b/internal/models/vo/dep_vo.go index ab57cc4..b55b73e 100644 --- a/internal/models/vo/dep_vo.go +++ b/internal/models/vo/dep_vo.go @@ -8,14 +8,15 @@ import ( // DependencyVO 依赖包视图对象 type DependencyVO struct { - ID int `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Type string `json:"type"` - Remark string `json:"remark"` - Log string `json:"log,omitempty"` // 仅在需要时返回 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + Remark string `json:"remark"` + Log string `json:"log,omitempty"` // 仅在需要时返回 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ToDependencyVO 将 Dependency 模型转换为 DependencyVO @@ -24,14 +25,15 @@ func ToDependencyVO(dep *models.Dependency) *DependencyVO { return nil } return &DependencyVO{ - ID: dep.ID, - Name: dep.Name, - Version: dep.Version, - Type: dep.Type, - Remark: dep.Remark, - Log: dep.Log, - CreatedAt: dep.CreatedAt, - UpdatedAt: dep.UpdatedAt, + ID: dep.ID, + Name: dep.Name, + Version: dep.Version, + Language: dep.Language, + LangVersion: dep.LangVersion, + Remark: dep.Remark, + Log: dep.Log, + CreatedAt: dep.CreatedAt, + UpdatedAt: dep.UpdatedAt, } } diff --git a/internal/models/vo/task_vo.go b/internal/models/vo/task_vo.go index 5ad4f06..8ce0b35 100644 --- a/internal/models/vo/task_vo.go +++ b/internal/models/vo/task_vo.go @@ -17,6 +17,8 @@ type TaskVO struct { WorkDir string `json:"work_dir"` CleanConfig string `json:"clean_config"` Envs string `json:"envs"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` AgentID *uint `json:"agent_id"` Enabled bool `json:"enabled"` LastRun *models.LocalTime `json:"last_run"` @@ -41,6 +43,8 @@ func ToTaskVO(task *models.Task) *TaskVO { WorkDir: task.WorkDir, CleanConfig: task.CleanConfig, Envs: task.Envs, + Language: task.Language, + LangVersion: task.LangVersion, AgentID: task.AgentID, Enabled: task.Enabled, LastRun: task.LastRun, diff --git a/internal/router/register.go b/internal/router/register.go index c3068fb..edf617f 100644 --- a/internal/router/register.go +++ b/internal/router/register.go @@ -52,6 +52,7 @@ func RegisterControllers() *Controllers { Settings: controllers.NewSettingsController(userService, loginLogService, executorService), Dependency: controllers.NewDependencyController(), Agent: controllers.NewAgentController(settingsService), + Mise: controllers.NewMiseController(services.NewMiseService()), } } diff --git a/internal/router/router.go b/internal/router/router.go index 136d602..54fc59f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -27,6 +27,7 @@ type Controllers struct { Settings *controllers.SettingsController Dependency *controllers.DependencyController Agent *controllers.AgentController + Mise *controllers.MiseController } func mustSubFS(fsys fs.FS, dir string) fs.FS { @@ -202,9 +203,11 @@ func Setup(c *Controllers) *gin.Engine { deps.POST("", c.Dependency.Create) deps.DELETE("/:id", c.Dependency.Delete) deps.POST("/install", c.Dependency.Install) + deps.POST("/install-cmd", c.Dependency.GetInstallCommand) deps.POST("/uninstall/:id", c.Dependency.Uninstall) deps.POST("/reinstall/:id", c.Dependency.Reinstall) deps.POST("/reinstall-all", c.Dependency.ReinstallAll) + deps.POST("/reinstall-all-cmd", c.Dependency.GetReinstallAllCommand) deps.GET("/installed", c.Dependency.GetInstalled) } @@ -223,6 +226,15 @@ func Setup(c *Controllers) *gin.Engine { agents.DELETE("/tokens/:id", c.Agent.DeleteToken) } + // Mise routes (Mise 管理) + mise := authorized.Group("/mise") + { + mise.GET("/ls", c.Mise.List) + mise.POST("/sync", c.Mise.Sync) + mise.GET("/plugins", c.Mise.Plugins) + mise.GET("/versions", c.Mise.Versions) + } + // Agent API(供前端调用,保持在 v1 下) agentAPIv1 := authorized.Group("/agent") { diff --git a/internal/services/agent_service.go b/internal/services/agent_service.go index 87bc797..06a2e63 100644 --- a/internal/services/agent_service.go +++ b/internal/services/agent_service.go @@ -311,14 +311,16 @@ func (s *AgentService) GetTasks(agentID uint) []models.AgentTask { envVarsStr := s.buildEnvVarsString(task.Envs) 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" 格式 - Enabled: task.Enabled, + ID: task.ID, + Name: task.Name, + Command: task.Command, + Schedule: task.Schedule, + Timeout: task.Timeout, + WorkDir: task.WorkDir, + Envs: envVarsStr, // 传递 "KEY1=VALUE1,KEY2=VALUE2" 格式 + Language: task.Language, + LangVersion: task.LangVersion, + Enabled: task.Enabled, } } diff --git a/internal/services/backup_service.go b/internal/services/backup_service.go index d3b6bb0..28a282a 100644 --- a/internal/services/backup_service.go +++ b/internal/services/backup_service.go @@ -51,6 +51,8 @@ func (s *BackupService) getTableConfigs() []tableConfig { {"login_logs.json", s.exportTable(&[]models.LoginLog{}, false), s.restoreTable(&[]models.LoginLog{}, false)}, {"agents.json", s.exportTable(&[]models.Agent{}, true), s.restoreTable(&[]models.Agent{}, true)}, {"tokens.json", s.exportTable(&[]models.AgentToken{}, true), s.restoreTable(&[]models.AgentToken{}, true)}, + {"languages.json", s.exportTable(&[]models.Language{}, true), s.restoreTable(&[]models.Language{}, true)}, + {"deps.json", s.exportTable(&[]models.Dependency{}, true), s.restoreTable(&[]models.Dependency{}, true)}, } } @@ -153,6 +155,20 @@ func (s *BackupService) CreateBackup() (string, error) { } } + // 写入元数据信息 + sysInfo := map[string]interface{}{ + "version": "v2", + "ts": time.Now().Format("2006-01-02 15:04:05"), + } + sysFile, err := zipWriter.Create("__sys__.json") + if err != nil { + return "", err + } + sysData, _ := json.MarshalIndent(sysInfo, "", " ") + if _, err := sysFile.Write(sysData); err != nil { + return "", err + } + // 打包 scripts 文件夹 scriptsDir := constant.ScriptsWorkDir if _, err := os.Stat(scriptsDir); err == nil { @@ -192,6 +208,8 @@ func (s *BackupService) Restore(zipPath string) error { tx.Unscoped().Where("1=1").Delete(&models.LoginLog{}) tx.Unscoped().Where("1=1").Delete(&models.Agent{}) tx.Unscoped().Where("1=1").Delete(&models.AgentToken{}) + tx.Unscoped().Where("1=1").Delete(&models.Language{}) + tx.Unscoped().Where("1=1").Delete(&models.Dependency{}) // 2. 依次恢复每个表 for _, cfg := range configs { @@ -258,6 +276,10 @@ func (s *BackupService) restoreFromZipFile(tx *gorm.DB, f *zip.File, filename st return &models.Agent{} case "tokens.json": return &models.AgentToken{} + case "languages.json": + return &models.Language{} + case "deps.json": + return &models.Dependency{} default: return nil } diff --git a/internal/services/dependency_service.go b/internal/services/dependency_service.go index b162355..7186f85 100644 --- a/internal/services/dependency_service.go +++ b/internal/services/dependency_service.go @@ -2,12 +2,10 @@ package services import ( "errors" - "os/exec" - "strings" "github.com/engigu/baihu-panel/internal/database" - "github.com/engigu/baihu-panel/internal/logger" "github.com/engigu/baihu-panel/internal/models" + "github.com/engigu/baihu-panel/internal/services/deps" ) type DependencyService struct{} @@ -17,21 +15,25 @@ func NewDependencyService() *DependencyService { } // List 获取依赖列表 -func (s *DependencyService) List(depType string) ([]models.Dependency, error) { - var deps []models.Dependency +func (s *DependencyService) List(language, langVersion string) ([]models.Dependency, error) { + var results []models.Dependency query := database.DB - if depType != "" { - query = query.Where("type = ?", depType) + if language != "" { + query = query.Where("language = ?", language) } - err := query.Order("id desc").Find(&deps).Error - return deps, err + if langVersion != "" { + query = query.Where("lang_version = ?", langVersion) + } + err := query.Order("id desc").Find(&results).Error + return results, err } // Create 创建依赖记录 func (s *DependencyService) Create(dep *models.Dependency) error { - // 检查是否已存在 + // 检查是否已存在(名称、语言、版本 必须完全匹配) var existing models.Dependency - if err := database.DB.Where("name = ? AND type = ?", dep.Name, dep.Type).First(&existing).Error; err == nil { + err := database.DB.Where("name = ? AND language = ? AND lang_version = ?", dep.Name, dep.Language, dep.LangVersion).First(&existing).Error + if err == nil { return errors.New("依赖已存在") } return database.DB.Create(dep).Error @@ -44,134 +46,51 @@ func (s *DependencyService) Delete(id int) error { // Install 安装依赖 func (s *DependencyService) Install(dep *models.Dependency) error { - var cmd *exec.Cmd - var packageSpec string - - if dep.Version != "" { - if dep.Type == "py" { - packageSpec = dep.Name + "==" + dep.Version - } else { - packageSpec = dep.Name + "@" + dep.Version - } - } else { - packageSpec = dep.Name + m := deps.GetManager(dep.Language) + if m == nil { + return errors.New("不支持的依赖类型: " + dep.Language) } - - switch dep.Type { - case "py": - cmd = exec.Command("pip", "install", packageSpec) - case "node": - cmd = exec.Command("npm", "install", "-g", packageSpec) - default: - return errors.New("不支持的依赖类型") - } - - logger.Infof("Installing %s package: %s", dep.Type, packageSpec) - output, err := cmd.CombinedOutput() - dep.Log = string(output) - - if err != nil { - logger.Errorf("Install failed: %v, output: %s", err, string(output)) - return errors.New("安装失败: " + string(output)) - } - logger.Infof("Install success: %s", packageSpec) - - return nil + return m.Install(dep) } // Uninstall 卸载依赖 func (s *DependencyService) Uninstall(dep *models.Dependency) error { - var cmd *exec.Cmd - - switch dep.Type { - case "py": - cmd = exec.Command("pip", "uninstall", "-y", dep.Name) - case "node": - cmd = exec.Command("npm", "uninstall", "-g", dep.Name) - default: - return errors.New("不支持的依赖类型") + m := deps.GetManager(dep.Language) + if m == nil { + return errors.New("不支持的依赖类型: " + dep.Language) } - - logger.Infof("Uninstalling %s package: %s", dep.Type, dep.Name) - output, err := cmd.CombinedOutput() - if err != nil { - logger.Errorf("Uninstall failed: %v, output: %s", err, string(output)) - return errors.New("卸载失败: " + string(output)) - } - - return nil + return m.Uninstall(dep) } // GetInstalledPackages 获取已安装的包列表 -func (s *DependencyService) GetInstalledPackages(depType string) ([]models.Dependency, error) { - var packages []models.Dependency - - switch depType { - case "py": - return s.getPipPackages() - case "node": - return s.getNpmPackages() - default: - return packages, errors.New("不支持的依赖类型") +func (s *DependencyService) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + m := deps.GetManager(language) + if m == nil { + return nil, errors.New("不支持的依赖类型: " + language) } + return m.GetInstalledPackages(language, langVersion) } -// getPipPackages 获取 pip 已安装的包 -func (s *DependencyService) getPipPackages() ([]models.Dependency, error) { - cmd := exec.Command("pip", "list", "--format=freeze") - output, err := cmd.Output() +// GetInstallCommand 获取安装命令 +func (s *DependencyService) GetInstallCommand(dep *models.Dependency) (string, error) { + m := deps.GetManager(dep.Language) + if m == nil { + return "", errors.New("不支持的依赖类型: " + dep.Language) + } + return m.GetInstallCommand(dep) +} + +// GetReinstallAllCommand 获取全部重装命令 +func (s *DependencyService) GetReinstallAllCommand(language, langVersion string) (string, error) { + m := deps.GetManager(language) + if m == nil { + return "", errors.New("不支持的依赖类型: " + language) + } + + deps_list, err := s.List(language, langVersion) if err != nil { - return nil, err + return "", err } - var packages []models.Dependency - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - parts := strings.SplitN(line, "==", 2) - pkg := models.Dependency{ - Name: parts[0], - Type: "py", - } - if len(parts) > 1 { - pkg.Version = parts[1] - } - packages = append(packages, pkg) - } - - return packages, nil -} - -// getNpmPackages 获取 npm 全局安装的包 -func (s *DependencyService) getNpmPackages() ([]models.Dependency, error) { - cmd := exec.Command("npm", "list", "-g", "--depth=0", "--json") - output, err := cmd.Output() - if err != nil { - // npm list 在没有包时也会返回错误,忽略 - } - - var packages []models.Dependency - // 简单解析,不用 json 库 - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.Contains(line, `"version"`) { - continue - } - if strings.HasPrefix(line, `"`) && strings.Contains(line, ":") { - // 格式: "package-name": { - name := strings.Trim(strings.Split(line, ":")[0], `" `) - if name != "" && name != "dependencies" { - packages = append(packages, models.Dependency{ - Name: name, - Type: "node", - }) - } - } - } - - return packages, nil + return m.GetReinstallAllCommand(deps_list) } diff --git a/internal/services/deps/bun.go b/internal/services/deps/bun.go new file mode 100644 index 0000000..6238bd5 --- /dev/null +++ b/internal/services/deps/bun.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type BunManager struct { + BaseManager +} + +func NewBunManager(language string) *BunManager { + return &BunManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"bun", "add", "-g"}, + UninstallCmd: []string{"bun", "remove", "-g"}, + ListCmd: []string{"bun", "pm", "ls", "-g"}, + Separator: "@", + }, + } +} + +func (m *BunManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/crystal.go b/internal/services/deps/crystal.go new file mode 100644 index 0000000..3337f6f --- /dev/null +++ b/internal/services/deps/crystal.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type CrystalManager struct { + BaseManager +} + +func NewCrystalManager(language string) *CrystalManager { + return &CrystalManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"shards", "install"}, // 主要是针对当前目录,但 mise exec 下可以安装 tools + UninstallCmd: []string{"rm", "-rf"}, // 这里的逻辑可能不完全正确 + ListCmd: []string{"shards", "list"}, + Separator: " ", + }, + } +} + +func (m *CrystalManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Shards") { + continue + } + fields := strings.Fields(line) + if len(fields) > 1 { + packages = append(packages, models.Dependency{Name: fields[1], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/dart.go b/internal/services/deps/dart.go new file mode 100644 index 0000000..def95f7 --- /dev/null +++ b/internal/services/deps/dart.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type DartManager struct { + BaseManager +} + +func NewDartManager(language string) *DartManager { + return &DartManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"dart", "pub", "global", "activate"}, + UninstallCmd: []string{"dart", "pub", "global", "deactivate"}, + ListCmd: []string{"dart", "pub", "global", "list"}, + Separator: " ", + }, + } +} + +func (m *DartManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/deno.go b/internal/services/deps/deno.go new file mode 100644 index 0000000..9e99d29 --- /dev/null +++ b/internal/services/deps/deno.go @@ -0,0 +1,26 @@ +package deps + +import ( + "github.com/engigu/baihu-panel/internal/models" +) + +type DenoManager struct { + BaseManager +} + +func NewDenoManager(language string) *DenoManager { + return &DenoManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"deno", "install", "--global", "-A"}, + UninstallCmd: []string{"deno", "uninstall"}, + ListCmd: []string{"ls", "-1", "/root/.deno/bin"}, // 这是一个简单的 fallback 尝试 + Separator: "@", + }, + } +} + +func (m *DenoManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + // Deno 没有官方的 list 命令,暂时留空或通过文件系统猜测 + return []models.Dependency{}, nil +} diff --git a/internal/services/deps/dotnet.go b/internal/services/deps/dotnet.go new file mode 100644 index 0000000..2e131d0 --- /dev/null +++ b/internal/services/deps/dotnet.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type DotnetManager struct { + BaseManager +} + +func NewDotnetManager(language string) *DotnetManager { + return &DotnetManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"dotnet", "tool", "install", "-g"}, + UninstallCmd: []string{"dotnet", "tool", "uninstall", "-g"}, + ListCmd: []string{"dotnet", "tool", "list", "-g"}, + Separator: " ", + }, + } +} + +func (m *DotnetManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for i, line := range lines { + line = strings.TrimSpace(line) + if i < 2 || line == "" { // 跳过表头 + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language, Version: fields[1]}) + } + } + return packages, nil +} diff --git a/internal/services/deps/elixir.go b/internal/services/deps/elixir.go new file mode 100644 index 0000000..ede1cc8 --- /dev/null +++ b/internal/services/deps/elixir.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type ElixirManager struct { + BaseManager +} + +func NewElixirManager(language string) *ElixirManager { + return &ElixirManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"mix", "archive.install", "hex", "--force"}, + UninstallCmd: []string{"mix", "archive.uninstall"}, + ListCmd: []string{"mix", "archive"}, + Separator: " ", + }, + } +} + +func (m *ElixirManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "*") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/go.go b/internal/services/deps/go.go new file mode 100644 index 0000000..fdeecbf --- /dev/null +++ b/internal/services/deps/go.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type GoManager struct { + BaseManager +} + +func NewGoManager(language string) *GoManager { + return &GoManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"go", "install"}, + UninstallCmd: []string{"go", "clean", "-i"}, + ListCmd: []string{"go", "list", "..."}, + Separator: "@", + }, + } +} + +func (m *GoManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/lua.go b/internal/services/deps/lua.go new file mode 100644 index 0000000..39c7d11 --- /dev/null +++ b/internal/services/deps/lua.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type LuaManager struct { + BaseManager +} + +func NewLuaManager(language string) *LuaManager { + return &LuaManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"luarocks", "install"}, + UninstallCmd: []string{"luarocks", "remove"}, + ListCmd: []string{"luarocks", "list"}, + Separator: " ", + }, + } +} + +func (m *LuaManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Rock") || strings.HasPrefix(line, "--") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/manager.go b/internal/services/deps/manager.go new file mode 100644 index 0000000..139d097 --- /dev/null +++ b/internal/services/deps/manager.go @@ -0,0 +1,174 @@ +package deps + +import ( + "errors" + "os/exec" + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +// Manager 依赖管理器接口 +type Manager interface { + Install(dep *models.Dependency) error + Uninstall(dep *models.Dependency) error + GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) + GetInstallCommand(dep *models.Dependency) (string, error) + GetReinstallAllCommand(deps []models.Dependency) (string, error) +} + +// BaseManager 基础管理器,提供通用方法 +type BaseManager struct { + Language string + InstallCmd []string + UninstallCmd []string + ListCmd []string + Separator string +} + +func (m *BaseManager) runMiseCommand(langVersion string, cmdArgs []string) ([]byte, error) { + var args []string + if langVersion != "" { + args = []string{"mise", "exec", m.Language + "@" + langVersion, "--"} + } + args = append(args, cmdArgs...) + + cmd := exec.Command(args[0], args[1:]...) + return cmd.CombinedOutput() +} + +func (m *BaseManager) Install(dep *models.Dependency) error { + var packageSpec string + if dep.Version != "" { + packageSpec = dep.Name + m.Separator + dep.Version + } else { + packageSpec = dep.Name + } + + args := append([]string{}, m.InstallCmd...) + args = append(args, packageSpec) + + logger.Infof("Installing %s package: %s", m.Language, packageSpec) + output, err := m.runMiseCommand(dep.LangVersion, args) + dep.Log = string(output) + + if err != nil { + logger.Errorf("Install failed: %v, output: %s", err, string(output)) + return errors.New("安装失败: " + string(output)) + } + logger.Infof("Install success: %s", packageSpec) + return nil +} + +func (m *BaseManager) GetInstallCommand(dep *models.Dependency) (string, error) { + var packageSpec string + if dep.Version != "" { + packageSpec = dep.Name + m.Separator + dep.Version + } else { + packageSpec = dep.Name + } + + var args []string + if dep.LangVersion != "" { + args = []string{"mise", "exec", m.Language + "@" + dep.LangVersion, "--"} + } + args = append(args, m.InstallCmd...) + args = append(args, packageSpec) + + fullCmd := strings.Join(args, " ") + return fullCmd + " && echo \"__INSTALL_SUCCESS__\" || echo \"__INSTALL_FAILED__\"", nil +} + +func (m *BaseManager) GetReinstallAllCommand(deps []models.Dependency) (string, error) { + if len(deps) == 0 { + return "echo \"没有需要安装的依赖\"", nil + } + + var packageSpecs []string + var langVersion string + for _, dep := range deps { + if dep.LangVersion != "" { + langVersion = dep.LangVersion + } + if dep.Version != "" { + packageSpecs = append(packageSpecs, dep.Name+m.Separator+dep.Version) + } else { + packageSpecs = append(packageSpecs, dep.Name) + } + } + + var args []string + if langVersion != "" { + args = []string{"mise", "exec", m.Language + "@" + langVersion, "--"} + } + args = append(args, m.InstallCmd...) + args = append(args, packageSpecs...) + + fullCmd := strings.Join(args, " ") + return fullCmd + " && echo \"__INSTALL_SUCCESS__\" || echo \"__INSTALL_FAILED__\"", nil +} + +func (m *BaseManager) Uninstall(dep *models.Dependency) error { + args := append([]string{}, m.UninstallCmd...) + args = append(args, dep.Name) + + logger.Infof("Uninstalling %s package: %s", m.Language, dep.Name) + output, err := m.runMiseCommand(dep.LangVersion, args) + if err != nil { + logger.Errorf("Uninstall failed: %v, output: %s", err, string(output)) + return errors.New("卸载失败: " + string(output)) + } + return nil +} + +// GetManager 根据语言获取对应的管理器 +func GetManager(language string) Manager { + lang := strings.ToLower(language) + if strings.Contains(lang, "python") { + return NewPythonManager(language) + } + if strings.Contains(lang, "node") { + return NewNodeManager(language) + } + if strings.Contains(lang, "ruby") { + return NewRubyManager(language) + } + if strings.Contains(lang, "go") { + return NewGoManager(language) + } + if strings.Contains(lang, "rust") { + return NewRustManager(language) + } + if strings.Contains(lang, "bun") { + return NewBunManager(language) + } + if strings.Contains(lang, "php") { + return NewPhpManager(language) + } + if strings.Contains(lang, "deno") { + return NewDenoManager(language) + } + if strings.Contains(lang, "dotnet") { + return NewDotnetManager(language) + } + if strings.Contains(lang, "elixir") || strings.Contains(lang, "erlang") { + return NewElixirManager(language) + } + if strings.Contains(lang, "lua") { + return NewLuaManager(language) + } + if strings.Contains(lang, "nim") { + return NewNimManager(language) + } + if strings.Contains(lang, "dart") || strings.Contains(lang, "flutter") { + return NewDartManager(language) + } + if strings.Contains(lang, "perl") { + return NewPerlManager(language) + } + if strings.Contains(lang, "crystal") { + return NewCrystalManager(language) + } + return nil +} diff --git a/internal/services/deps/nim.go b/internal/services/deps/nim.go new file mode 100644 index 0000000..ce3dfa2 --- /dev/null +++ b/internal/services/deps/nim.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type NimManager struct { + BaseManager +} + +func NewNimManager(language string) *NimManager { + return &NimManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"nimble", "install", "-y"}, + UninstallCmd: []string{"nimble", "uninstall", "-y"}, + ListCmd: []string{"nimble", "list", "-i"}, + Separator: "@", + }, + } +} + +func (m *NimManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "[") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/node.go b/internal/services/deps/node.go new file mode 100644 index 0000000..fcf2bad --- /dev/null +++ b/internal/services/deps/node.go @@ -0,0 +1,47 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type NodeManager struct { + BaseManager +} + +func NewNodeManager(language string) *NodeManager { + return &NodeManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"npm", "install", "-g"}, + UninstallCmd: []string{"npm", "uninstall", "-g"}, + ListCmd: []string{"npm", "list", "-g", "--depth=0", "--json"}, + Separator: "@", + }, + } +} + +func (m *NodeManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, `"version"`) || strings.HasPrefix(line, "{") || strings.HasPrefix(line, "}") || strings.HasPrefix(line, "]") { + continue + } + if strings.HasPrefix(line, `"`) && strings.Contains(line, ":") { + name := strings.Trim(strings.Split(line, ":")[0], `" `) + if name != "" && name != "dependencies" && name != "name" { + packages = append(packages, models.Dependency{Name: name, Language: language}) + } + } + } + return packages, nil +} diff --git a/internal/services/deps/perl.go b/internal/services/deps/perl.go new file mode 100644 index 0000000..acb70e0 --- /dev/null +++ b/internal/services/deps/perl.go @@ -0,0 +1,26 @@ +package deps + +import ( + "github.com/engigu/baihu-panel/internal/models" +) + +type PerlManager struct { + BaseManager +} + +func NewPerlManager(language string) *PerlManager { + return &PerlManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"cpanm"}, // 需要系统预装 cpanm + UninstallCmd: []string{"cpanm", "--uninstall"}, // 部分 cpanm 版本不支持,这是一个占位 + ListCmd: []string{"perldoc", "-l"}, // 很难列出所有,暂时简单处理 + Separator: " ", + }, + } +} + +func (m *PerlManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + // Perl 依赖列出比较复杂,暂时返回空 + return []models.Dependency{}, nil +} diff --git a/internal/services/deps/php.go b/internal/services/deps/php.go new file mode 100644 index 0000000..3219e02 --- /dev/null +++ b/internal/services/deps/php.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type PhpManager struct { + BaseManager +} + +func NewPhpManager(language string) *PhpManager { + return &PhpManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"composer", "global", "require"}, + UninstallCmd: []string{"composer", "global", "remove"}, + ListCmd: []string{"composer", "global", "show", "--name-only"}, + Separator: ":", + }, + } +} + +func (m *PhpManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/python.go b/internal/services/deps/python.go new file mode 100644 index 0000000..91b78c7 --- /dev/null +++ b/internal/services/deps/python.go @@ -0,0 +1,47 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type PythonManager struct { + BaseManager +} + +func NewPythonManager(language string) *PythonManager { + return &PythonManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"pip", "install"}, + UninstallCmd: []string{"pip", "uninstall", "-y"}, + ListCmd: []string{"pip", "list", "--format=freeze"}, + Separator: "==", + }, + } +} + +func (m *PythonManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "==", 2) + pkg := models.Dependency{Name: parts[0], Language: language} + if len(parts) > 1 { + pkg.Version = parts[1] + } + packages = append(packages, pkg) + } + return packages, nil +} diff --git a/internal/services/deps/ruby.go b/internal/services/deps/ruby.go new file mode 100644 index 0000000..dd5c899 --- /dev/null +++ b/internal/services/deps/ruby.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type RubyManager struct { + BaseManager +} + +func NewRubyManager(language string) *RubyManager { + return &RubyManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"gem", "install"}, + UninstallCmd: []string{"gem", "uninstall", "-a", "-x"}, + ListCmd: []string{"gem", "list", "--local"}, + Separator: " ", + }, + } +} + +func (m *RubyManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") || strings.HasPrefix(line, "(") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/rust.go b/internal/services/deps/rust.go new file mode 100644 index 0000000..eae1c9e --- /dev/null +++ b/internal/services/deps/rust.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" +) + +type RustManager struct { + BaseManager +} + +func NewRustManager(language string) *RustManager { + return &RustManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"cargo", "install"}, + UninstallCmd: []string{"cargo", "uninstall"}, + ListCmd: []string{"cargo", "install", "--list"}, + Separator: " v", + }, + } +} + +func (m *RustManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, " v") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/mise_service.go b/internal/services/mise_service.go new file mode 100644 index 0000000..cfdf8cd --- /dev/null +++ b/internal/services/mise_service.go @@ -0,0 +1,322 @@ +package services + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + "time" + + "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/models" + "gorm.io/gorm" +) + +type MiseService struct{} + +func NewMiseService() *MiseService { + return &MiseService{} +} + +type MiseLanguage struct { + Plugin string `json:"plugin"` + Version string `json:"version"` + Source MiseSource `json:"source"` + Active bool `json:"active"` + InstallPath string `json:"install_path,omitempty"` + InstalledAt string `json:"installed_at,omitempty"` // 安装日期 +} + +type MiseSource struct { + Type string `json:"type"` + Path string `json:"path"` +} + +// List 从数据库获取已保存的语言列表 +func (s *MiseService) List() ([]MiseLanguage, error) { + db := database.GetDB() + var models []models.Language + if err := db.Order("installed_at DESC, plugin ASC").Find(&models).Error; err != nil { + return nil, err + } + + result := make([]MiseLanguage, 0, len(models)) + for _, m := range models { + lang := MiseLanguage{ + Plugin: m.Plugin, + Version: m.Version, + InstallPath: m.InstallPath, + Active: true, + } + lang.Source.Path = m.Source + if m.InstalledAt != nil { + lang.InstalledAt = time.Time(*m.InstalledAt).Format("2006-01-02 15:04:05") + } + result = append(result, lang) + } + return result, nil +} + +// Sync 实时检测本地 mise 环境并同步到数据库 +func (s *MiseService) Sync() error { + // 获取实时数据 + langs, err := s.fetchLiveLanguages() + if err != nil { + return err + } + + // 同步到数据库 + s.syncToDB(langs) + return nil +} + +// fetchLiveLanguages 实时从系统检测 mise 语言列表 +func (s *MiseService) fetchLiveLanguages() ([]MiseLanguage, error) { + // 使用 --json 获取格式化数据 + cmd := exec.Command("mise", "ls", "--json") + // 强制禁用颜色和交互 + cmd.Env = append(cmd.Env, "MISE_NO_COLOR=1", "TERM=dumb") + + output, err := cmd.Output() + if err != nil { + logger.Warnf("[Mise] mise ls --json failed: %v", err) + return s.listFallback() + } + + // 1. 尝试解析为数组格式 [{}, {}] + var languages []MiseLanguage + if err := json.Unmarshal(output, &languages); err == nil { + s.enrichInstallDates(languages) + s.enrichSourceInfo(languages) + s.sortByInstallDate(languages) + return languages, nil + } + + // 2. 如果失败,尝试解析为对象格式 {"node": [{}], "python": [{}]} + var langMap map[string][]MiseLanguage + if err := json.Unmarshal(output, &langMap); err == nil { + var result []MiseLanguage + for plugin, items := range langMap { + for _, item := range items { + // 填充插件名称(对象格式中插件名通常是 key) + if item.Plugin == "" { + item.Plugin = plugin + } + result = append(result, item) + } + } + s.enrichInstallDates(result) + s.enrichSourceInfo(result) + s.sortByInstallDate(result) + return result, nil + } + + return s.listFallback() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func (s *MiseService) listFallback() ([]MiseLanguage, error) { + cmd := exec.Command("mise", "ls") + cmd.Env = append(cmd.Env, "MISE_NO_COLOR=1", "TERM=dumb") + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("mise ls failed: %v, output: %s", err, string(output)) + } + + lines := strings.Split(string(output), "\n") + languages := []MiseLanguage{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(strings.ToLower(line), "tool") { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + lang := MiseLanguage{ + Plugin: parts[0], + Version: parts[1], + Active: true, + } + if len(parts) >= 3 { + lang.Source = MiseSource{Path: parts[2]} + } + languages = append(languages, lang) + } + + return languages, nil +} + +// Plugins 获取主流的 mise 插件列表 (固定返回以确保速度和稳定性) +func (s *MiseService) Plugins() ([]string, error) { + mainstream := constant.MainstreamMisePlugins + logger.Infof("[Mise] Returning fixed list of %d mainstream plugins", len(mainstream)) + return mainstream, nil +} + +// Versions 获取指定插件的所有可用版本 +func (s *MiseService) Versions(plugin string) ([]string, error) { + if plugin == "" { + return []string{}, nil + } + + // 只获取最新版本列表 + cmd := exec.Command("mise", "ls-remote", plugin) + cmd.Env = append(cmd.Env, "MISE_NO_COLOR=1", "TERM=dumb") + output, err := cmd.CombinedOutput() + + if err != nil && len(output) == 0 { + logger.Errorf("[Mise] Fetch versions for %s failed: %v", plugin, err) + return []string{}, nil + } + + lines := strings.Split(string(output), "\n") + var versions []string + // 倒序排列,优先显示新版本 + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.Contains(line, " ") || strings.Contains(line, "Usage:") || line == "latest" { + continue + } + versions = append(versions, line) + if len(versions) >= 300 { + break + } + } + + return versions, nil +} + +// enrichInstallDates 为语言列表添加安装日期信息 +func (s *MiseService) enrichInstallDates(languages []MiseLanguage) { + for i := range languages { + if languages[i].InstallPath != "" { + if installDate := s.getInstallDate(languages[i].InstallPath); installDate != "" { + languages[i].InstalledAt = installDate + } + } + } +} + +// enrichSourceInfo 为语言列表添加来源信息 +func (s *MiseService) enrichSourceInfo(languages []MiseLanguage) { + for i := range languages { + // 如果source为空,使用install_path作为来源 + if languages[i].Source.Type == "" && languages[i].Source.Path == "" { + if languages[i].InstallPath != "" { + languages[i].Source.Path = languages[i].InstallPath + } + } + } +} + +// getInstallDate 获取安装路径的创建时间 +func (s *MiseService) getInstallDate(installPath string) string { + if installPath == "" { + return "" + } + + fileInfo, err := os.Stat(installPath) + if err != nil { + logger.Debugf("[Mise] Failed to stat install path %s: %v", installPath, err) + return "" + } + + // 获取修改时间作为安装时间的近似值 + modTime := fileInfo.ModTime() + return modTime.Format("2006-01-02 15:04:05") +} + +// sortByInstallDate 按安装时间降序排序(最新的在前面) +func (s *MiseService) sortByInstallDate(languages []MiseLanguage) { + sort.Slice(languages, func(i, j int) bool { + // 如果都有安装时间,按时间降序排序 + if languages[i].InstalledAt != "" && languages[j].InstalledAt != "" { + return languages[i].InstalledAt > languages[j].InstalledAt + } + // 有安装时间的排在前面 + if languages[i].InstalledAt != "" { + return true + } + if languages[j].InstalledAt != "" { + return false + } + // 都没有安装时间,按插件名排序 + return languages[i].Plugin < languages[j].Plugin + }) +} + +// syncToDB 将实时检测到的语言信息同步到数据库表中 +func (s *MiseService) syncToDB(languages []MiseLanguage) { + db := database.GetDB() + if db == nil { + return + } + + var currentIds []uint + for _, lang := range languages { + var model models.Language + // 以 plugin 和 version 作为联合唯一标识(业务逻辑上) + err := db.Where("plugin = ? AND version = ?", lang.Plugin, lang.Version).First(&model).Error + + sourceStr := "" + if lang.Source.Path != "" { + sourceStr = lang.Source.Path + } else if lang.Source.Type != "" { + sourceStr = lang.Source.Type + } + + var installTime *models.LocalTime + if lang.InstalledAt != "" { + t, err := time.Parse("2006-01-02 15:04:05", lang.InstalledAt) + if err == nil { + lt := models.LocalTime(t) + installTime = < + } + } + + if err != nil { + // 如果不存在,则创建 + newLang := models.Language{ + Plugin: lang.Plugin, + Version: lang.Version, + InstallPath: lang.InstallPath, + Source: sourceStr, + InstalledAt: installTime, + } + if err := db.Create(&newLang).Error; err == nil { + currentIds = append(currentIds, newLang.ID) + } + } else { + // 如果已存在,更新可能变动的信息 + updates := map[string]interface{}{ + "install_path": lang.InstallPath, + "source": sourceStr, + "installed_at": installTime, + } + db.Model(&model).Updates(updates) + currentIds = append(currentIds, model.ID) + } + } + + // 清理数据库中存在但实际 mise 已经卸载的记录 + if len(currentIds) > 0 { + db.Where("id NOT IN ?", currentIds).Delete(&models.Language{}) + } else { + // 如果本地一个都没有,清空表 + db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&models.Language{}) + } +} diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go index 4aa3c97..847790f 100644 --- a/internal/services/tasks/executor_service.go +++ b/internal/services/tasks/executor_service.go @@ -326,6 +326,7 @@ func (es *ExecutorService) ExecuteDispatcher(ctx context.Context, req *executor. WorkDir: req.WorkDir, Envs: req.Envs, Timeout: req.Timeout, + UseMise: false, // 系统任务不使用 mise }, stdout, stderr) } @@ -351,10 +352,13 @@ func (es *ExecutorService) ExecuteDispatcher(ctx context.Context, req *executor. // 本地任务 hooks := &LocalTaskHooks{es: es, logID: req.LogID} return executor.ExecuteWithHooks(ctx, executor.Request{ - Command: req.Command, - WorkDir: req.WorkDir, - Envs: req.Envs, - Timeout: req.Timeout, + Command: req.Command, + WorkDir: req.WorkDir, + Envs: req.Envs, + Timeout: req.Timeout, + Language: task.Language, + LangVersion: task.LangVersion, + UseMise: true, // 本地业务任务启用 mise }, stdout, stderr, hooks) } diff --git a/internal/services/tasks/task_service.go b/internal/services/tasks/task_service.go index 9d55c53..1a0a29f 100644 --- a/internal/services/tasks/task_service.go +++ b/internal/services/tasks/task_service.go @@ -11,7 +11,7 @@ func NewTaskService() *TaskService { return &TaskService{} } -func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *uint) *models.Task { +func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *uint, language, langVersion string) *models.Task { if taskType == "" { taskType = "task" } @@ -25,6 +25,8 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w WorkDir: workDir, CleanConfig: cleanConfig, Envs: envs, + Language: language, + LangVersion: langVersion, AgentID: agentID, Enabled: true, } @@ -65,7 +67,7 @@ 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) *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, language, langVersion string) *models.Task { var task models.Task if err := database.DB.First(&task, id).Error; err != nil { return nil @@ -79,6 +81,8 @@ func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeou task.Envs = envs task.Enabled = enabled task.AgentID = agentID + task.Language = language + task.LangVersion = langVersion if taskType != "" { task.Type = taskType } diff --git a/web/package.json b/web/package.json index f87f039..ed22d0b 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host 0.0.0.0", "build": "vue-tsc -b && vite build", "preview": "vite preview" }, @@ -44,4 +44,4 @@ "vite": "^7.2.4", "vue-tsc": "^3.1.4" } -} +} \ No newline at end of file diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 032e061..c3f11c2 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -202,19 +202,34 @@ export const api = { } }, deps: { - list: (type?: string) => { - const query = type ? `?type=${type}` : '' - return request(`/deps${query}`) + list: (params?: { language?: string; lang_version?: string }) => { + const query = new URLSearchParams() + if (params?.language) query.set('language', params.language) + if (params?.lang_version) query.set('lang_version', params.lang_version) + return request(`/deps?${query}`) }, - create: (data: { name: string; version?: string; type: string; remark?: string }) => + create: (data: { name: string; version?: string; language: string; lang_version?: string; remark?: string }) => request('/deps', { method: 'POST', body: JSON.stringify(data) }), delete: (id: number) => request(`/deps/${id}`, { method: 'DELETE' }), - install: (data: { name: string; version?: string; type: string; remark?: string }) => - request('/deps/install', { method: 'POST', body: JSON.stringify(data) }), - uninstall: (id: number) => request(`/deps/uninstall/${id}`, { method: 'POST' }), + install: (data: any) => request('/deps/install', { method: 'POST', body: JSON.stringify(data) }), + getInstallCmd: (data: any) => request<{ command: string }>('/deps/install-cmd', { method: 'POST', body: JSON.stringify(data) }), + uninstall: (id: number) => request(`/deps/uninstall/${id}`, { method: 'POST' }), reinstall: (id: number) => request(`/deps/reinstall/${id}`, { method: 'POST' }), - reinstallAll: (type: string) => request(`/deps/reinstall-all?type=${type}`, { method: 'POST' }), - getInstalled: (type: string) => request(`/deps/installed?type=${type}`) + reinstallAll: (language: string, lang_version?: string) => { + const query = new URLSearchParams({ language }) + if (lang_version) query.set('lang_version', lang_version) + return request(`/deps/reinstall-all?${query}`, { method: 'POST' }) + }, + getReinstallAllCmd: (language: string, lang_version?: string) => { + const query = new URLSearchParams({ language }) + if (lang_version) query.set('lang_version', lang_version) + return request<{ command: string }>(`/deps/reinstall-all-cmd?${query}`, { method: 'POST' }) + }, + getInstalled: (language: string, lang_version?: string) => { + const query = new URLSearchParams({ language }) + if (lang_version) query.set('lang_version', lang_version) + return request(`/deps/installed?${query}`) + } }, agents: { list: () => request('/agents'), @@ -229,6 +244,12 @@ export const api = { createToken: (data: { remark?: string; max_uses?: number; expires_at?: string }) => request('/agents/tokens', { method: 'POST', body: JSON.stringify(data) }), deleteToken: (id: number) => request('/agents/tokens/' + id, { method: 'DELETE' }) + }, + mise: { + list: () => request('/mise/ls'), + sync: () => request('/mise/sync', { method: 'POST' }), + plugins: () => request('/mise/plugins'), + versions: (plugin: string) => request(`/mise/versions?plugin=${plugin}`) } } @@ -250,6 +271,8 @@ export interface Task { work_dir: string clean_config: string envs: string + language: string + lang_version: string agent_id: number | null enabled: boolean last_run: string @@ -410,7 +433,8 @@ export interface Dependency { id: number name: string version: string - type: string + language: string + lang_version: string remark: string log: string created_at: string @@ -446,3 +470,12 @@ export interface AgentToken { enabled: boolean created_at: string } + +export interface MiseLanguage { + plugin: string + version: string + source: { type?: string; path?: string } | string + active: boolean + install_path?: string + installed_at?: string // 安装日期 +} diff --git a/web/src/components/XTerminal.vue b/web/src/components/XTerminal.vue index b913e72..19fe5ff 100644 --- a/web/src/components/XTerminal.vue +++ b/web/src/components/XTerminal.vue @@ -20,6 +20,8 @@ const props = withDefaults( const emit = defineEmits<{ connected: [] disconnected: [] + success: [] + failed: [] }>() const terminalRef = ref(null) @@ -39,7 +41,7 @@ function initTerminal(forceConnect = false) { terminal.dispose() terminal = null } - + // 确保旧的 WebSocket 完全关闭 if (ws) { if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { @@ -47,7 +49,7 @@ function initTerminal(forceConnect = false) { } ws = null } - + inputBuffer = '' isPtyMode = false @@ -65,7 +67,7 @@ function initTerminal(forceConnect = false) { fitAddon = new FitAddon() terminal.loadAddon(fitAddon) terminal.open(terminalRef.value) - + // 延迟调用 fit,确保 DOM 已渲染 setTimeout(() => { try { @@ -74,7 +76,7 @@ function initTerminal(forceConnect = false) { // 忽略 fit 错误 } }, 50) - + terminal.focus() // autoConnect 或者强制连接时才连接 @@ -187,6 +189,15 @@ function connectWebSocket() { return } terminal?.write(event.data) + + // 检测安装结果标识 + if (typeof event.data === 'string') { + if (event.data.includes('__INSTALL_SUCCESS__')) { + emit('success') + } else if (event.data.includes('__INSTALL_FAILED__')) { + emit('failed') + } + } } ws.onclose = () => { diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index 619273a..5bba3e9 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -2,7 +2,7 @@ import { ref, onMounted, computed } from 'vue' import { RouterLink, RouterView, useRoute } from 'vue-router' import { resetAuthCache } from '@/router' -import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Package, Menu, X, Server } from 'lucide-vue-next' +import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import ThemeToggle from '@/components/ThemeToggle.vue' import { api } from '@/api' @@ -58,7 +58,7 @@ const navItems = [ { to: '/editor', icon: FileCode, label: '脚本编辑', exact: false }, { to: '/history', icon: ScrollText, label: '执行历史', exact: true }, { to: '/environments', icon: Variable, label: '环境变量', exact: true }, - { to: '/dependencies', icon: Package, label: '依赖管理', exact: true }, + { to: '/languages', icon: Globe, label: '语言依赖', exact: true }, { to: '/terminal', icon: Terminal, label: '终端命令', exact: true }, { to: '/loginlogs', icon: KeyRound, label: '登录日志', exact: true }, { to: '/settings', icon: Settings, label: '系统设置', exact: true }, diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 08fae73..a7900cc 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -42,6 +42,7 @@ const router = createRouter({ { path: 'editor', name: 'editor', component: () => import('@/views/editor/Editor.vue') }, { path: 'environments', name: 'environments', component: () => import('@/views/environments/Environments.vue') }, { path: 'dependencies', name: 'dependencies', component: () => import('@/views/dependencies/Dependencies.vue') }, + { path: 'languages', name: 'languages', component: () => import('@/views/languages/Languages.vue') }, { path: 'agents', name: 'agents', component: () => import('@/views/agents/Agents.vue') }, { path: 'history', name: 'history', component: () => import('@/views/history/History.vue') }, { path: 'loginlogs', name: 'loginlogs', component: () => import('@/views/loginlogs/LoginLogs.vue') }, @@ -57,7 +58,7 @@ router.beforeEach(async (to, _from, next) => { // 首次访问或访问登录页时强制检查 const forceCheck = !authChecked || to.path === '/login' const isAuthenticated = await getAuthStatus(forceCheck) - + // 检查是否需要认证 if (to.matched.some(record => record.meta.requiresAuth)) { if (isAuthenticated) { diff --git a/web/src/views/dependencies/Dependencies.vue b/web/src/views/dependencies/Dependencies.vue index f7ccf73..eb5f5bc 100644 --- a/web/src/views/dependencies/Dependencies.vue +++ b/web/src/views/dependencies/Dependencies.vue @@ -1,23 +1,29 @@ diff --git a/web/src/views/languages/Languages.vue b/web/src/views/languages/Languages.vue new file mode 100644 index 0000000..75e7ff9 --- /dev/null +++ b/web/src/views/languages/Languages.vue @@ -0,0 +1,484 @@ + + + diff --git a/web/src/views/tasks/TaskDialog.vue b/web/src/views/tasks/TaskDialog.vue index 5f541d9..0ee821f 100644 --- a/web/src/views/tasks/TaskDialog.vue +++ b/web/src/views/tasks/TaskDialog.vue @@ -7,9 +7,11 @@ import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' import DirTreeSelect from '@/components/DirTreeSelect.vue' -import { Plus, ChevronDown, X } from 'lucide-vue-next' -import { api, type Task, type EnvVar, type Agent } from '@/api' +import { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, Loader2, AlertCircle } from 'lucide-vue-next' +import { cn } from '@/lib/utils' +import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api' import { toast } from 'vue-sonner' const props = defineProps<{ @@ -84,6 +86,100 @@ const onlineAgents = computed(() => { return allAgents.value.filter(a => a.enabled) }) +// 语言环境相关 +const installedLangs = ref([]) +const loadingLangs = ref(false) +const availablePlugins = ref([]) +const pluginSearch = ref('') +const openPluginPopover = ref(false) + +const availableVersions = ref([]) +const versionSearch = ref('') +const openVersionPopover = ref(false) + +const filteredPlugins = computed(() => { + if (!pluginSearch.value) return availablePlugins.value + const s = pluginSearch.value.toLowerCase() + return availablePlugins.value.filter(p => p.toLowerCase().includes(s)) +}) + +const filteredVersions = computed(() => { + if (!versionSearch.value) return availableVersions.value + const s = versionSearch.value.toLowerCase() + return availableVersions.value.filter(v => v.toLowerCase().includes(s)) +}) + +async function fetchInstalledLangs() { + loadingLangs.value = true + try { + installedLangs.value = await api.mise.list() + const plugins = new Set() + installedLangs.value.forEach(l => plugins.add(l.plugin)) + availablePlugins.value = Array.from(plugins).sort() + } catch (e) { + console.error('Fetch installed langs failed', e) + } finally { + loadingLangs.value = false + } +} + +function getLangIcon(plugin: string) { + const name = plugin?.toLowerCase().trim() + const mapping: Record = { + 'python': 'python/python-original.svg', + 'node': 'nodejs/nodejs-original.svg', + 'nodejs': 'nodejs/nodejs-original.svg', + 'go': 'go/go-original.svg', + 'rust': 'rust/rust-original.svg', + 'ruby': 'ruby/ruby-plain.svg', + 'php': 'php/php-plain.svg', + 'java': 'java/java-plain.svg', + 'deno': 'deno/deno-plain.svg', + 'bun': 'bun/bun-plain.svg', + 'zig': 'zig/zig-original.svg', + 'dotnet': 'dot-net/dot-net-original.svg', + '.net': 'dot-net/dot-net-original.svg', + 'elixir': 'elixir/elixir-original.svg', + 'erlang': 'erlang/erlang-original.svg', + 'crystal': 'crystal/crystal-original.svg', + 'lua': 'lua/lua-original.svg', + 'julia': 'julia/julia-original.svg', + 'nim': 'nim/nim-original.svg', + 'perl': 'perl/perl-original.svg', + 'scala': 'scala/scala-original.svg', + 'kotlin': 'kotlin/kotlin-original.svg', + 'clojure': 'clojure/clojure-line.svg', + 'dart': 'dart/dart-original.svg', + 'flutter': 'flutter/flutter-original.svg', + 'terraform': 'terraform/terraform-original.svg', + 'docker': 'docker/docker-original.svg', + 'kubernetes': 'kubernetes/kubernetes-plain.svg', + 'ansible': 'ansible/ansible-original.svg', + } + + if (mapping[name]) { + return `https://cdn.jsdelivr.net/gh/devicons/devicon/icons/${mapping[name]}` + } + return '' +} + +watch(() => form.value.language, (newVal) => { + if (newVal) { + availableVersions.value = installedLangs.value + .filter(l => l.plugin === newVal) + .map(l => l.version) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + + // 如果当前选中的版本不在可用列表中,且列表不为空,则自动选中第一个(通常是最新版) + if (form.value.lang_version && !availableVersions.value.includes(form.value.lang_version)) { + // 保持原样,可能还没加载完 + } + } else { + availableVersions.value = [] + form.value.lang_version = '' + } +}) + watch(() => props.open, async (val) => { if (val) { form.value = { ...props.task } @@ -147,6 +243,16 @@ watch(() => props.open, async (val) => { envSearchQuery.value = '' // 加载数据 await loadData() + if (selectedAgentId.value === 'local') { + await fetchInstalledLangs() + // 如果已选择语言(编辑模式),手动触发一次版本更新 + if (form.value.language) { + availableVersions.value = installedLangs.value + .filter(l => l.plugin === form.value.language) + .map(l => l.version) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + } + } } }) @@ -229,17 +335,7 @@ async function save() { -
- - -
-
- -
- - -
-
+
@@ -256,6 +352,120 @@ async function save() {
+ + + +
+ + +
+
+ +
+ + +
+