From 81e921fa73f80cd20f22ae90f1deac673683d6f9 Mon Sep 17 00:00:00 2001 From: duorameng <2997944583@qq.com> Date: Sun, 28 Jun 2026 20:46:00 +0800 Subject: [PATCH] feat: implement data import/export and optimize terminal UX --- internal/controllers/data_controller.go | 81 ++++ internal/controllers/env_controller.go | 6 +- .../controllers/interconnect_controller.go | 73 ++-- internal/models/export.go | 25 ++ internal/router/api_routes.go | 9 + internal/router/register.go | 8 +- internal/router/router.go | 1 + internal/services/data_service.go | 350 +++++++++++++++++ internal/utils/pagination.go | 2 +- web/src/api/index.ts | 4 + web/src/components/LogContent.vue | 20 + web/src/components/XTerminal.vue | 14 + .../interconnect/components/SyncPanel.vue | 365 +++++++++++++++--- 13 files changed, 856 insertions(+), 102 deletions(-) create mode 100644 internal/controllers/data_controller.go create mode 100644 internal/models/export.go create mode 100644 internal/services/data_service.go diff --git a/internal/controllers/data_controller.go b/internal/controllers/data_controller.go new file mode 100644 index 0000000..1018218 --- /dev/null +++ b/internal/controllers/data_controller.go @@ -0,0 +1,81 @@ +package controllers + +import ( + "github.com/engigu/baihu-panel/internal/models" + "github.com/engigu/baihu-panel/internal/services" + "github.com/engigu/baihu-panel/internal/utils" + "github.com/gin-gonic/gin" +) + +type DataController struct { + dataService *services.DataService + taskController *TaskController + envController *EnvController +} + +func NewDataController(tc *TaskController, ec *EnvController) *DataController { + return &DataController{ + dataService: services.NewDataService(), + taskController: tc, + envController: ec, + } +} + +// ExportBusinessData 导出业务数据 +func (dc *DataController) ExportBusinessData(c *gin.Context) { + var req struct { + TaskIDs []string `json:"task_ids"` + EnvIDs []string `json:"env_ids"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + exportData := dc.dataService.ExportBusinessData(req.TaskIDs, req.EnvIDs) + utils.Success(c, exportData) +} + +// ImportBusinessData 导入业务数据 +func (dc *DataController) ImportBusinessData(c *gin.Context) { + var req models.ExportData + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + if req.Version == "" { + utils.BadRequest(c, "无效的导入数据格式") + return + } + + // 停止相关的定时任务 + if len(req.Tasks) > 0 { + for _, task := range req.Tasks { + dc.taskController.executorService.RemoveCronTask(task.ID) + dc.taskController.executorService.GetScheduler().StopTask(task.ID) + } + } + + // 导入数据 + if err := dc.dataService.ImportBusinessData(&req); err != nil { + utils.ServerError(c, "导入失败: "+err.Error()) + return + } + + // 重新启动任务和通知相关的代理 + if len(req.Tasks) > 0 { + for i := range req.Tasks { + task := &req.Tasks[i] + if utils.DerefBool(task.Enabled, true) && (task.AgentID == nil || *task.AgentID == "") { + dc.taskController.executorService.AddCronTask(task) + } + if task.AgentID != nil && *task.AgentID != "" { + dc.taskController.agentWSManager.BroadcastTasks(*task.AgentID) + } + } + } + + utils.SuccessMsg(c, "导入成功") +} diff --git a/internal/controllers/env_controller.go b/internal/controllers/env_controller.go index ff7fb97..d4448e0 100644 --- a/internal/controllers/env_controller.go +++ b/internal/controllers/env_controller.go @@ -61,7 +61,7 @@ func (ec *EnvController) CreateEnvVar(c *gin.Context) { } if req.Type == "" { - req.Type = "normal" + req.Type = constant.EnvTypeNormal } hidden := true @@ -187,7 +187,7 @@ func (ec *EnvController) UpdateEnvVar(c *gin.Context) { } if req.Type == "" { - req.Type = "normal" + req.Type = constant.EnvTypeNormal } // 对于更新,获取现有数据 @@ -323,7 +323,7 @@ func (ec *EnvController) BulkSaveEnv(c *gin.Context) { userID := c.GetString("userID") for _, req := range reqs { - if req.Type == "secret" { + if req.Type == constant.EnvTypeSecret { continue // 二次严苛拦截,机密变量不应下发/保存 } diff --git a/internal/controllers/interconnect_controller.go b/internal/controllers/interconnect_controller.go index 916ec26..5f16731 100644 --- a/internal/controllers/interconnect_controller.go +++ b/internal/controllers/interconnect_controller.go @@ -11,9 +11,7 @@ import ( "time" "github.com/engigu/baihu-panel/internal/constant" - "github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/models" - "github.com/engigu/baihu-panel/internal/models/vo" "github.com/engigu/baihu-panel/internal/services" "github.com/engigu/baihu-panel/internal/tunnel" "github.com/engigu/baihu-panel/internal/utils" @@ -271,15 +269,7 @@ func (ic *InterconnectController) SyncScript(c *gin.Context) { func (ic *InterconnectController) SyncEnv(c *gin.Context) { var req struct { NodeIDs []string `json:"node_ids" binding:"required"` - Envs []struct { - ID string `json:"id"` - Name string `json:"name"` - Value string `json:"value"` - Remark string `json:"remark"` - Type string `json:"type"` - Hidden *bool `json:"hidden"` - Enabled *bool `json:"enabled"` - } `json:"envs" binding:"required"` + Envs []struct{ ID string `json:"id"` } `json:"envs" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -287,27 +277,14 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) { return } - // Filter out secret variables securely using the database - var safeEnvs []interface{} - for _, envReq := range req.Envs { - var env models.EnvironmentVariable - query := database.DB - if envReq.ID != "" { - query = query.Where("id = ? OR name = ?", envReq.ID, envReq.Name) - } else { - query = query.Where("name = ?", envReq.Name) - } - if err := query.First(&env).Error; err == nil { - if env.Type == "secret" { - continue // 坚决阻断下发机密数据 - } - } - if envReq.Type == "secret" { - continue // 坚决阻断下发机密数据 - } - safeEnvs = append(safeEnvs, envReq) + var envIDs []string + for _, e := range req.Envs { + envIDs = append(envIDs, e.ID) } + dataService := services.NewDataService() + exportData := dataService.ExportBusinessData(nil, envIDs) + results := make([]map[string]interface{}, 0) for _, nodeID := range req.NodeIDs { @@ -317,13 +294,13 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) { continue } - client, apiURL, err := ic.getClientAndURL(node, "/api/v1/env/bulk_save") + client, apiURL, err := ic.getClientAndURL(node, "/api/v1/system/import") if err != nil { results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"}) continue } - - payloadBytes, _ := json.Marshal(safeEnvs) + + payloadBytes, _ := json.Marshal(exportData) httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) if err != nil { @@ -335,7 +312,11 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) { resp, err := client.Do(httpReq) if err != nil || resp.StatusCode != 200 { - results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"}) + msg := "同步失败" + if err != nil { + msg = err.Error() + } + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": msg}) } else { results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"}) } @@ -350,8 +331,8 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) { // SyncTask 将任务同步到指定的节点列表 func (ic *InterconnectController) SyncTask(c *gin.Context) { var req struct { - NodeIDs []string `json:"node_ids" binding:"required"` - Tasks []vo.TaskVO `json:"tasks" binding:"required"` + NodeIDs []string `json:"node_ids" binding:"required"` + Tasks []struct{ ID string `json:"id"` } `json:"tasks" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -359,6 +340,14 @@ func (ic *InterconnectController) SyncTask(c *gin.Context) { return } + var taskIDs []string + for _, t := range req.Tasks { + taskIDs = append(taskIDs, t.ID) + } + + dataService := services.NewDataService() + exportData := dataService.ExportBusinessData(taskIDs, nil) + results := make([]map[string]interface{}, 0) for _, nodeID := range req.NodeIDs { @@ -368,13 +357,13 @@ func (ic *InterconnectController) SyncTask(c *gin.Context) { continue } - client, apiURL, err := ic.getClientAndURL(node, "/api/v1/tasks/bulk_save") + client, apiURL, err := ic.getClientAndURL(node, "/api/v1/system/import") if err != nil { results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"}) continue } - - payloadBytes, _ := json.Marshal(req.Tasks) + + payloadBytes, _ := json.Marshal(exportData) httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) if err != nil { @@ -386,7 +375,11 @@ func (ic *InterconnectController) SyncTask(c *gin.Context) { resp, err := client.Do(httpReq) if err != nil || resp.StatusCode != 200 { - results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"}) + msg := "同步失败" + if err != nil { + msg = err.Error() + } + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": msg}) } else { results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"}) } diff --git a/internal/models/export.go b/internal/models/export.go new file mode 100644 index 0000000..6376b8c --- /dev/null +++ b/internal/models/export.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// ExportData 全量或部分业务数据的导出/导入结构 +type ExportData struct { + Version string `json:"version"` + ExportAt LocalTime `json:"export_at"` + Tasks []Task `json:"tasks"` + Envs []EnvironmentVariable `json:"envs"` + Tags []DataStorage `json:"tags"` + Bindings []NotifyBinding `json:"bindings"` +} + +// NewExportData 创建一个导出数据对象 +func NewExportData() *ExportData { + return &ExportData{ + Version: "1.0", + ExportAt: LocalTime(time.Now()), + Tasks: make([]Task, 0), + Envs: make([]EnvironmentVariable, 0), + Tags: make([]DataStorage, 0), + Bindings: make([]NotifyBinding, 0), + } +} diff --git a/internal/router/api_routes.go b/internal/router/api_routes.go index b4def0c..001e308 100644 --- a/internal/router/api_routes.go +++ b/internal/router/api_routes.go @@ -76,6 +76,7 @@ func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) { registerWebUIRoutes(adminOnly, c) registerMonitorRoutes(adminOnly, c) registerInterconnectRoutes(adminOnly, c) + registerSystemRoutes(adminOnly, c) } } @@ -329,3 +330,11 @@ func registerInterconnectRoutes(g *gin.RouterGroup, c *Controllers) { interconnect.Any("/proxy/:node_id/*path", c.Interconnect.ProxyRequest) } } + +func registerSystemRoutes(g *gin.RouterGroup, c *Controllers) { + systemAPI := g.Group("/system") + { + systemAPI.POST("/export", c.Data.ExportBusinessData) + systemAPI.POST("/import", c.Data.ImportBusinessData) + } +} diff --git a/internal/router/register.go b/internal/router/register.go index f2a1c9f..a30ae1c 100644 --- a/internal/router/register.go +++ b/internal/router/register.go @@ -45,11 +45,14 @@ func RegisterControllers() *Controllers { setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager) startAppLogCleanup(appLogService) + taskController := controllers.NewTaskController(taskService, executorService) + envController := controllers.NewEnvController(envService) + // 初始化并返回控制器 return &Controllers{ - Task: controllers.NewTaskController(taskService, executorService), + Task: taskController, Auth: controllers.NewAuthController(userService, settingsService, loginLogService), - Env: controllers.NewEnvController(envService), + Env: envController, Script: controllers.NewScriptController(scriptService), Executor: controllers.NewExecutorController(executorService), File: controllers.NewFileController(constant.ScriptsWorkDir), @@ -67,6 +70,7 @@ func RegisterControllers() *Controllers { WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)), Monitor: controllers.NewMonitorController(executorService), Interconnect: controllers.NewInterconnectController(interconnectService), + Data: controllers.NewDataController(taskController, envController), } } diff --git a/internal/router/router.go b/internal/router/router.go index eaf562d..9116dff 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -33,6 +33,7 @@ type Controllers struct { WebUI *controllers.WebUIController Monitor *controllers.MonitorController Interconnect *controllers.InterconnectController + Data *controllers.DataController } func Setup(c *Controllers) *gin.Engine { diff --git a/internal/services/data_service.go b/internal/services/data_service.go new file mode 100644 index 0000000..c846385 --- /dev/null +++ b/internal/services/data_service.go @@ -0,0 +1,350 @@ +package services + +import ( + "strings" + + "github.com/engigu/baihu-panel/internal/constant" + "github.com/engigu/baihu-panel/internal/database" + "github.com/engigu/baihu-panel/internal/models" + "github.com/engigu/baihu-panel/internal/services/relation" + "github.com/rs/xid" + "gorm.io/gorm" +) + +type DataService struct{} + +func NewDataService() *DataService { + return &DataService{} +} + +// ExportBusinessData 智能解析依赖并导出业务数据 +func (s *DataService) ExportBusinessData(taskIDs []string, envIDs []string) *models.ExportData { + export := models.NewExportData() + + export.Tasks = s.collectTasksAndRelations(taskIDs) + export.Envs = s.collectEnvironmentVariables(export.Tasks, envIDs) + export.Bindings = s.collectNotifyBindings(export.Tasks) + export.Tags = s.collectTagStorages(export.Tasks) + + return export +} + +// collectTasksAndRelations 收集任务及其子任务,并加载关联关系 (Envs, Tags) +func (s *DataService) collectTasksAndRelations(taskIDs []string) []models.Task { + if len(taskIDs) == 0 { + return nil + } + + var tasks []models.Task + targetTaskIDs := make(map[string]bool) + for _, id := range taskIDs { + targetTaskIDs[id] = true + } + + var initialTasks []models.Task + database.DB.Where("id IN ?", taskIDs).Find(&initialTasks) + tasks = append(tasks, initialTasks...) + + // 检查哪些是仓库任务,并递归查询其子任务 + var parentIDs []string + for _, t := range initialTasks { + if t.Type == "repo" { + parentIDs = append(parentIDs, t.ID) + } + } + + if len(parentIDs) > 0 { + var childTasks []models.Task + database.DB.Where("repo_task_id IN ?", parentIDs).Find(&childTasks) + for _, ct := range childTasks { + if !targetTaskIDs[ct.ID] { + tasks = append(tasks, ct) + targetTaskIDs[ct.ID] = true + } + } + } + + // 填充任务的 Envs 与 Tags 关系数据,以保证依赖解析和后续导入成功 + if len(tasks) > 0 { + var exportTaskIDs []string + for _, t := range tasks { + exportTaskIDs = append(exportTaskIDs, t.ID) + } + + envsMap := relation.DataRelation.LoadRelations(exportTaskIDs, constant.RelationTypeTaskEnv) + tagsMap := relation.DataRelation.LoadTags(exportTaskIDs, constant.RelationTypeTaskTag) + + for i, t := range tasks { + if envs, ok := envsMap[t.ID]; ok { + tasks[i].Envs = models.BigText(strings.Join(envs, ",")) + } + if tags, ok := tagsMap[t.ID]; ok { + tasks[i].Tags = strings.Join(tags, ",") + } + } + } + + return tasks +} + +// collectEnvironmentVariables 收集所需和任务所依赖的环境变量 +func (s *DataService) collectEnvironmentVariables(tasks []models.Task, envIDs []string) []models.EnvironmentVariable { + targetEnvIDs := make(map[string]bool) + for _, id := range envIDs { + targetEnvIDs[id] = true + } + + // 从任务中解析依赖的环境变量 + for _, t := range tasks { + if t.Envs != "" { + envArray := strings.Split(string(t.Envs), ",") + for _, eID := range envArray { + eID = strings.TrimSpace(eID) + if eID != "" { + targetEnvIDs[eID] = true + } + } + } + } + + if len(targetEnvIDs) == 0 { + return nil + } + + var finalEnvIDs []string + for id := range targetEnvIDs { + finalEnvIDs = append(finalEnvIDs, id) + } + + var envs []models.EnvironmentVariable + database.DB.Where("id IN ?", finalEnvIDs).Find(&envs) + return envs +} + +// collectNotifyBindings 收集相关的通知规则 (NotifyBindings) +func (s *DataService) collectNotifyBindings(tasks []models.Task) []models.NotifyBinding { + if len(tasks) == 0 { + return nil + } + + var taskIDList []string + for _, t := range tasks { + taskIDList = append(taskIDList, t.ID) + } + + var bindings []models.NotifyBinding + database.DB.Where("type = ? AND data_id IN ?", "task", taskIDList).Find(&bindings) + return bindings +} + +// collectTagStorages 收集标签定义 (DataStorage) +func (s *DataService) collectTagStorages(tasks []models.Task) []models.DataStorage { + targetTagNames := make(map[string]bool) + for _, t := range tasks { + if t.Tags != "" { + tagArray := strings.Split(t.Tags, ",") + for _, tagName := range tagArray { + tagName = strings.TrimSpace(tagName) + if tagName != "" { + targetTagNames[tagName] = true + } + } + } + } + + if len(targetTagNames) == 0 { + return nil + } + + var finalTagNames []string + for name := range targetTagNames { + finalTagNames = append(finalTagNames, name) + } + + var tagStorages []models.DataStorage + database.DB.Where("type = ? AND name IN ?", constant.RelationTypeTaskTag, finalTagNames).Find(&tagStorages) + return tagStorages +} + +// ImportBusinessData 导入业务数据 +func (s *DataService) ImportBusinessData(data *models.ExportData) error { + tx := database.DB.Begin() + if tx.Error != nil { + return tx.Error + } + + var adminUser models.User + if err := tx.Where("role = ?", constant.AdminRole).First(&adminUser).Error; err != nil { + tx.Rollback() + return err + } + + importer := &businessImporter{ + tx: tx, + adminID: adminUser.ID, + } + + // 1. 导入环境变量 + if err := importer.importEnvs(data.Envs); err != nil { + tx.Rollback() + return err + } + + // 2. 导入标签定义 (DataStorage) + if err := importer.importTags(data.Tags); err != nil { + tx.Rollback() + return err + } + + // 3. 导入任务及关联映射关系 + if err := importer.importTasks(data.Tasks); err != nil { + tx.Rollback() + return err + } + + // 4. 导入通知规则 + if err := importer.importBindings(data.Bindings); err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +type businessImporter struct { + tx *gorm.DB + adminID string +} + +func (importer *businessImporter) importEnvs(envs []models.EnvironmentVariable) error { + if len(envs) == 0 { + return nil + } + for _, env := range envs { + env.UserID = importer.adminID + if err := importer.tx.Save(&env).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTags(tags []models.DataStorage) error { + if len(tags) == 0 { + return nil + } + for _, tagStorage := range tags { + if err := importer.tx.Save(&tagStorage).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTasks(tasks []models.Task) error { + if len(tasks) == 0 { + return nil + } + for _, task := range tasks { + if err := importer.tx.Save(&task).Error; err != nil { + return err + } + + if err := importer.importTaskRelations(task); err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTaskRelations(task models.Task) error { + if err := importer.importTaskTagRelations(task); err != nil { + return err + } + return importer.importTaskEnvRelations(task) +} + +func (importer *businessImporter) importTaskTagRelations(task models.Task) error { + if task.Tags == "" { + return nil + } + if err := importer.tx.Where("data_id = ? AND type = ?", task.ID, constant.RelationTypeTaskTag).Delete(&models.DataRelation{}).Error; err != nil { + return err + } + tags := strings.Split(task.Tags, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + var storage models.DataStorage + res := importer.tx.Where("type = ? AND name = ?", constant.RelationTypeTaskTag, tag).Limit(1).Find(&storage) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + storage = models.DataStorage{ + ID: xid.New().String(), + Type: constant.RelationTypeTaskTag, + Name: tag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if err := importer.tx.Create(&storage).Error; err != nil { + return err + } + } + rel := models.DataRelation{ + ID: xid.New().String(), + DataID: task.ID, + RelateID: storage.ID, + Type: constant.RelationTypeTaskTag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if err := importer.tx.Create(&rel).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTaskEnvRelations(task models.Task) error { + if string(task.Envs) == "" { + return nil + } + if err := importer.tx.Where("data_id = ? AND type = ?", task.ID, constant.RelationTypeTaskEnv).Delete(&models.DataRelation{}).Error; err != nil { + return err + } + ids := strings.Split(string(task.Envs), ",") + for _, relateID := range ids { + relateID = strings.TrimSpace(relateID) + if relateID == "" { + continue + } + rel := models.DataRelation{ + ID: xid.New().String(), + DataID: task.ID, + RelateID: relateID, + Type: constant.RelationTypeTaskEnv, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if err := importer.tx.Create(&rel).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importBindings(bindings []models.NotifyBinding) error { + if len(bindings) == 0 { + return nil + } + for _, binding := range bindings { + if err := importer.tx.Save(&binding).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/utils/pagination.go b/internal/utils/pagination.go index d85d39a..dc81f38 100644 --- a/internal/utils/pagination.go +++ b/internal/utils/pagination.go @@ -48,7 +48,7 @@ func ParsePagination(c *gin.Context) Pagination { if page < 1 { page = 1 } - if pageSize < 1 || pageSize > 100 { + if pageSize < 1 || pageSize > 10000 { pageSize = defaultPageSize } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 459edec..3e2f098 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -438,6 +438,9 @@ export const api = { }, setActive: (name: string) => request<{ message: string }>('/webui/active', { method: 'PUT', body: JSON.stringify({ name }) }), delete: (name: string) => request<{ message: string }>(`/webui/${name}`, { method: 'DELETE' }) + }, + system: { + export: (data: { task_ids?: string[], env_ids?: string[] }) => request('/system/export', { method: 'POST', body: JSON.stringify(data) }) } } @@ -483,6 +486,7 @@ export interface Task { last_run: string next_run: string running_status?: string + repo_task_id?: string created_at?: string updated_at?: string } diff --git a/web/src/components/LogContent.vue b/web/src/components/LogContent.vue index 9377da9..bf71876 100644 --- a/web/src/components/LogContent.vue +++ b/web/src/components/LogContent.vue @@ -59,6 +59,24 @@ function initTerminal() { terminal.loadAddon(fitAddon) terminal.open(terminalRef.value) + // 禁用手机端点击弹出键盘 + const textarea = terminalRef.value.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement + if (textarea) { + textarea.readOnly = true + } + + // 支持 Ctrl+C 复制选中内容 + terminal.attachCustomKeyEventHandler((e) => { + if (e.ctrlKey && e.code === 'KeyC' && e.type === 'keydown') { + const selection = terminal?.getSelection() + if (selection) { + navigator.clipboard.writeText(selection) + return false + } + } + return true + }) + requestAnimationFrame(() => { try { fitAddon?.fit() @@ -183,6 +201,8 @@ watch( .log-terminal :deep(.xterm-viewport) { scrollbar-width: thin; scrollbar-color: rgba(150, 150, 150, 0.3) transparent; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; } .log-terminal :deep(.xterm-viewport::-webkit-scrollbar) { diff --git a/web/src/components/XTerminal.vue b/web/src/components/XTerminal.vue index aa733bf..eb93b76 100644 --- a/web/src/components/XTerminal.vue +++ b/web/src/components/XTerminal.vue @@ -94,6 +94,18 @@ function initTerminal(forceConnect = false) { } }, 50) + // 支持 Ctrl+C 复制选中内容 + terminal.attachCustomKeyEventHandler((e) => { + if (e.ctrlKey && e.code === 'KeyC' && e.type === 'keydown') { + const selection = terminal?.getSelection() + if (selection) { + navigator.clipboard.writeText(selection) + return false // 阻止默认的终端 Ctrl+C 行为 + } + } + return true + }) + terminal.focus() // autoConnect 或者强制连接时才连接 @@ -302,6 +314,8 @@ onUnmounted(() => { scrollbar-width: thin; scrollbar-color: #4a4a4a #1e1e1e; background: #1e1e1e !important; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; } .terminal-container :deep(.xterm-screen) { diff --git a/web/src/views/interconnect/components/SyncPanel.vue b/web/src/views/interconnect/components/SyncPanel.vue index 67dcf6d..b27ea4d 100644 --- a/web/src/views/interconnect/components/SyncPanel.vue +++ b/web/src/views/interconnect/components/SyncPanel.vue @@ -5,7 +5,15 @@ import { Input } from '@/components/ui/input' import { toast } from 'vue-sonner' import * as interconnectApi from '@/api/interconnect' import { api, type EnvVar, type Task } from '@/api' -import { Network, Search, HardDrive, Check } from 'lucide-vue-next' +import { Network, Search, HardDrive, Check, AlertCircle, RefreshCw, ChevronDown, ChevronRight } from 'lucide-vue-next' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' const props = defineProps<{ nodes: interconnectApi.InterconnectNode[] @@ -49,10 +57,69 @@ onMounted(() => { loadLocalData() }) -const filteredTasks = computed(() => { - if (!taskSearch.value) return localTasks.value - const kw = taskSearch.value.toLowerCase() - return localTasks.value.filter((t: Task) => t.name.toLowerCase().includes(kw) || t.command.toLowerCase().includes(kw)) +const expandedRepos = ref>({}) + +function toggleRepoExpand(repoId: string) { + const idStr = String(repoId) + if (expandedRepos.value[idStr] === false) { + expandedRepos.value[idStr] = true + } else { + expandedRepos.value[idStr] = false + } +} + +const taskTree = computed(() => { + const kw = taskSearch.value.toLowerCase().trim() + + const childrenMap: Record = {} + const rootTasks: Task[] = [] + + localTasks.value.forEach(t => { + if (t.type === 'repo') { + rootTasks.push(t) + } else if (t.repo_task_id) { + const repoId = t.repo_task_id + if (!childrenMap[repoId]) { + childrenMap[repoId] = [] + } + childrenMap[repoId]!.push(t) + } else { + rootTasks.push(t) + } + }) + + const result: { parent: Task; children: Task[] }[] = [] + + rootTasks.forEach(parent => { + const children = childrenMap[parent.id] || [] + const parentMatches = !kw || parent.name.toLowerCase().includes(kw) || parent.command.toLowerCase().includes(kw) + const matchingChildren = children.filter(c => !kw || c.name.toLowerCase().includes(kw) || c.command.toLowerCase().includes(kw)) + + if (parentMatches || matchingChildren.length > 0) { + const childrenToDisplay = parentMatches ? children : matchingChildren + result.push({ + parent, + children: childrenToDisplay + }) + // If parent didn't match but child did, auto-expand + if (kw && !parentMatches && matchingChildren.length > 0) { + expandedRepos.value[String(parent.id)] = true + } + } + }) + + return result +}) + +const totalVisibleTasks = computed(() => { + const list: Task[] = [] + taskTree.value.forEach(item => { + list.push(item.parent) + item.children.forEach(c => { + list.push(c) + }) + }) + return list }) const filteredEnvs = computed(() => { @@ -71,6 +138,13 @@ function toggleNode(nodeId: any) { } } +// Check if all visible tasks are selected +const isAllTasksSelected = computed(() => { + const visible = totalVisibleTasks.value + if (visible.length === 0) return false + return visible.every(t => selectedTasks.value.includes(String(t.id))) +}) + function toggleAllNodes() { if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) { selectedNodes.value = [] @@ -81,19 +155,59 @@ function toggleAllNodes() { function toggleTask(id: any) { const idStr = String(id) - const index = selectedTasks.value.indexOf(idStr) - if (index > -1) { - selectedTasks.value.splice(index, 1) + const task = localTasks.value.find(t => String(t.id) === idStr) + if (!task) return + + const isCurrentlySelected = selectedTasks.value.includes(idStr) + + if (task.type === 'repo') { + if (isCurrentlySelected) { + selectedTasks.value = selectedTasks.value.filter(x => x !== idStr) + const children = localTasks.value.filter(t => t.repo_task_id === task.id) + const childIds = children.map(c => String(c.id)) + selectedTasks.value = selectedTasks.value.filter(x => !childIds.includes(x)) + } else { + selectedTasks.value.push(idStr) + const children = localTasks.value.filter(t => t.repo_task_id === task.id) + children.forEach(c => { + const cid = String(c.id) + if (!selectedTasks.value.includes(cid)) { + selectedTasks.value.push(cid) + } + }) + } } else { - selectedTasks.value.push(idStr) + if (isCurrentlySelected) { + selectedTasks.value = selectedTasks.value.filter(x => x !== idStr) + if (task.repo_task_id) { + selectedTasks.value = selectedTasks.value.filter(x => x !== String(task.repo_task_id)) + } + } else { + selectedTasks.value.push(idStr) + if (task.repo_task_id) { + const parentIdStr = String(task.repo_task_id) + const siblings = localTasks.value.filter(t => t.repo_task_id === task.repo_task_id) + const allSiblingsChecked = siblings.every(s => selectedTasks.value.includes(String(s.id))) + if (allSiblingsChecked && !selectedTasks.value.includes(parentIdStr)) { + selectedTasks.value.push(parentIdStr) + } + } + } } } function toggleAllTasks() { - if (selectedTasks.value.length === filteredTasks.value.length && filteredTasks.value.length > 0) { - selectedTasks.value = [] + const visible = totalVisibleTasks.value + if (isAllTasksSelected.value) { + const visibleIds = visible.map(t => String(t.id)) + selectedTasks.value = selectedTasks.value.filter(id => !visibleIds.includes(id)) } else { - selectedTasks.value = filteredTasks.value.map((t: Task) => String(t.id)) + visible.forEach(t => { + const idStr = String(t.id) + if (!selectedTasks.value.includes(idStr)) { + selectedTasks.value.push(idStr) + } + }) } } @@ -115,30 +229,54 @@ function toggleAllEnvs() { } } +const showConfirmDialog = ref(false) +const previewLoading = ref(false) +const previewData = ref(null) + async function handleSync() { if (selectedNodes.value.length === 0) { toast.warning('请先选择目标节点') return } - const targetNodeIds = selectedNodes.value + if (activeSyncType.value === 'task' && selectedTasks.value.length === 0) { + toast.warning('请先选择要同步的任务') + return + } + + if (activeSyncType.value === 'env' && selectedEnvsIds.value.length === 0) { + toast.warning('请先选择要同步的变量') + return + } + previewLoading.value = true + showConfirmDialog.value = true + previewData.value = null + + try { + const reqData = activeSyncType.value === 'task' + ? { task_ids: selectedTasks.value } + : { env_ids: selectedEnvsIds.value } + const res = await api.system.export(reqData) + previewData.value = res + } catch (error) { + toast.error('获取预览数据失败') + showConfirmDialog.value = false + } finally { + previewLoading.value = false + } +} + +async function confirmSync() { + const targetNodeIds = selectedNodes.value syncing.value = true + try { if (activeSyncType.value === 'task') { - if (selectedTasks.value.length === 0) { - toast.warning('请先选择要同步的任务') - return - } const tasksToSync = localTasks.value.filter((t: Task) => selectedTasks.value.includes(t.id)) const res = await interconnectApi.syncTask(targetNodeIds, tasksToSync) showSyncResult(res) } else { - if (selectedEnvsIds.value.length === 0) { - toast.warning('请先选择要同步的变量') - return - } - const allSelectedEnvs = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id)) const envsToSync = allSelectedEnvs.filter((e: EnvVar) => e.type !== 'secret') @@ -146,18 +284,16 @@ async function handleSync() { toast.info('已自动过滤机密变量,因下发无意义') } - if (envsToSync.length === 0) { - syncing.value = false - return + if (envsToSync.length > 0) { + const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync) + showSyncResult(res) } - - const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync) - showSyncResult(res) } } catch (error) { toast.error('下发请求失败') } finally { syncing.value = false + showConfirmDialog.value = false } } @@ -190,16 +326,16 @@ function showSyncResult(res: any[]) { 暂无子节点
-
+
- +
-
- {{ node.name }} - {{ node.url }} +
+ {{ node.name }} + {{ node.url }}
@@ -245,24 +381,53 @@ function showSyncResult(res: any[]) {
已选 {{ selectedTasks.length }} 项
加载中...
-
暂无任务
-
-
- -
-
-
- {{ task.name }} - {{ task.type === 'task' ? '任务' : (task.type === 'repo' ? '仓库' : task.type) }} +
暂无任务
+
+ +
+ +
+ + +
+
+ +
+ +
+
+
+ {{ item.parent.name }} + {{ item.parent.type === 'task' ? '任务' : '仓库' }} +
+ {{ item.parent.command }} +
+
+ + +
+
+
+ +
+
+
+ {{ child.name }} + 任务 +
+ {{ child.command }} +
- {{ task.command }}
@@ -278,28 +443,116 @@ function showSyncResult(res: any[]) {
加载中...
暂无变量
- +
-
-
- {{ env.name }} - {{ env.type === 'secret' ? '机密' : (env.type === 'normal' ? '常量' : env.type) }} +
+
+ {{ env.name }} + {{ env.type === 'secret' ? '机密' : '常量' }}
- {{ env.type === 'secret' ? '********' : env.value }} + {{ env.type === 'secret' ? '********' : env.value }}
+ + + + + + 确认同步数据 + + 以下是系统自动解析后即将全量同步到目标节点的所有关联数据。 + + + +
+
+ +

正在智能解析关联数据...

+
+
+
+
+ + 即将同步以下数据: +
+ +
+ +
+
任务 ({{ previewData.tasks.length }})
+
+
+
+ {{ t.name }} +
+ {{ tag }} +
+
+ {{ t.command }} +
+
+
+ + +
+
环境变量 ({{ previewData.envs.length }})
+
+
+ {{ e.name }} + {{ e.type === 'secret' ? '********' : e.value }} +
+
+
+ + +
+
标签 ({{ previewData.tags.length }})
+
+ + {{ tag.name }} + +
+
+ + +
+
通知规则 ({{ previewData.bindings.length }})
+
+
+ 事件: {{ b.event_type }} + 通道: {{ b.channel_id }} +
+
+
+
+
+

+ * 同步将会覆盖目标节点上的同名数据,并且机密变量的实际值不会被下发。 +

+
+
+ + + + + +
+