feat: implement data import/export and optimize terminal UX
This commit is contained in:
@@ -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, "导入成功")
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func (ec *EnvController) CreateEnvVar(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Type == "" {
|
if req.Type == "" {
|
||||||
req.Type = "normal"
|
req.Type = constant.EnvTypeNormal
|
||||||
}
|
}
|
||||||
|
|
||||||
hidden := true
|
hidden := true
|
||||||
@@ -187,7 +187,7 @@ func (ec *EnvController) UpdateEnvVar(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Type == "" {
|
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")
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
for _, req := range reqs {
|
for _, req := range reqs {
|
||||||
if req.Type == "secret" {
|
if req.Type == constant.EnvTypeSecret {
|
||||||
continue // 二次严苛拦截,机密变量不应下发/保存
|
continue // 二次严苛拦截,机密变量不应下发/保存
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"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"
|
||||||
"github.com/engigu/baihu-panel/internal/models/vo"
|
|
||||||
"github.com/engigu/baihu-panel/internal/services"
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
"github.com/engigu/baihu-panel/internal/tunnel"
|
"github.com/engigu/baihu-panel/internal/tunnel"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"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) {
|
func (ic *InterconnectController) SyncEnv(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
NodeIDs []string `json:"node_ids" binding:"required"`
|
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||||
Envs []struct {
|
Envs []struct{ ID string `json:"id"` } `json:"envs" binding:"required"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -287,27 +277,14 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out secret variables securely using the database
|
var envIDs []string
|
||||||
var safeEnvs []interface{}
|
for _, e := range req.Envs {
|
||||||
for _, envReq := range req.Envs {
|
envIDs = append(envIDs, e.ID)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataService := services.NewDataService()
|
||||||
|
exportData := dataService.ExportBusinessData(nil, envIDs)
|
||||||
|
|
||||||
results := make([]map[string]interface{}, 0)
|
results := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
for _, nodeID := range req.NodeIDs {
|
for _, nodeID := range req.NodeIDs {
|
||||||
@@ -317,13 +294,13 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) {
|
|||||||
continue
|
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 {
|
if err != nil {
|
||||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
payloadBytes, _ := json.Marshal(safeEnvs)
|
payloadBytes, _ := json.Marshal(exportData)
|
||||||
|
|
||||||
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -335,7 +312,11 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
if err != nil || resp.StatusCode != 200 {
|
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 {
|
} else {
|
||||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
||||||
}
|
}
|
||||||
@@ -350,8 +331,8 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) {
|
|||||||
// SyncTask 将任务同步到指定的节点列表
|
// SyncTask 将任务同步到指定的节点列表
|
||||||
func (ic *InterconnectController) SyncTask(c *gin.Context) {
|
func (ic *InterconnectController) SyncTask(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
NodeIDs []string `json:"node_ids" binding:"required"`
|
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||||
Tasks []vo.TaskVO `json:"tasks" binding:"required"`
|
Tasks []struct{ ID string `json:"id"` } `json:"tasks" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -359,6 +340,14 @@ func (ic *InterconnectController) SyncTask(c *gin.Context) {
|
|||||||
return
|
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)
|
results := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
for _, nodeID := range req.NodeIDs {
|
for _, nodeID := range req.NodeIDs {
|
||||||
@@ -368,13 +357,13 @@ func (ic *InterconnectController) SyncTask(c *gin.Context) {
|
|||||||
continue
|
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 {
|
if err != nil {
|
||||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
payloadBytes, _ := json.Marshal(req.Tasks)
|
payloadBytes, _ := json.Marshal(exportData)
|
||||||
|
|
||||||
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -386,7 +375,11 @@ func (ic *InterconnectController) SyncTask(c *gin.Context) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
if err != nil || resp.StatusCode != 200 {
|
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 {
|
} else {
|
||||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) {
|
|||||||
registerWebUIRoutes(adminOnly, c)
|
registerWebUIRoutes(adminOnly, c)
|
||||||
registerMonitorRoutes(adminOnly, c)
|
registerMonitorRoutes(adminOnly, c)
|
||||||
registerInterconnectRoutes(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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,11 +45,14 @@ func RegisterControllers() *Controllers {
|
|||||||
setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager)
|
setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager)
|
||||||
startAppLogCleanup(appLogService)
|
startAppLogCleanup(appLogService)
|
||||||
|
|
||||||
|
taskController := controllers.NewTaskController(taskService, executorService)
|
||||||
|
envController := controllers.NewEnvController(envService)
|
||||||
|
|
||||||
// 初始化并返回控制器
|
// 初始化并返回控制器
|
||||||
return &Controllers{
|
return &Controllers{
|
||||||
Task: controllers.NewTaskController(taskService, executorService),
|
Task: taskController,
|
||||||
Auth: controllers.NewAuthController(userService, settingsService, loginLogService),
|
Auth: controllers.NewAuthController(userService, settingsService, loginLogService),
|
||||||
Env: controllers.NewEnvController(envService),
|
Env: envController,
|
||||||
Script: controllers.NewScriptController(scriptService),
|
Script: controllers.NewScriptController(scriptService),
|
||||||
Executor: controllers.NewExecutorController(executorService),
|
Executor: controllers.NewExecutorController(executorService),
|
||||||
File: controllers.NewFileController(constant.ScriptsWorkDir),
|
File: controllers.NewFileController(constant.ScriptsWorkDir),
|
||||||
@@ -67,6 +70,7 @@ func RegisterControllers() *Controllers {
|
|||||||
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
|
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
|
||||||
Monitor: controllers.NewMonitorController(executorService),
|
Monitor: controllers.NewMonitorController(executorService),
|
||||||
Interconnect: controllers.NewInterconnectController(interconnectService),
|
Interconnect: controllers.NewInterconnectController(interconnectService),
|
||||||
|
Data: controllers.NewDataController(taskController, envController),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Controllers struct {
|
|||||||
WebUI *controllers.WebUIController
|
WebUI *controllers.WebUIController
|
||||||
Monitor *controllers.MonitorController
|
Monitor *controllers.MonitorController
|
||||||
Interconnect *controllers.InterconnectController
|
Interconnect *controllers.InterconnectController
|
||||||
|
Data *controllers.DataController
|
||||||
}
|
}
|
||||||
|
|
||||||
func Setup(c *Controllers) *gin.Engine {
|
func Setup(c *Controllers) *gin.Engine {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ func ParsePagination(c *gin.Context) Pagination {
|
|||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
if pageSize < 1 || pageSize > 100 {
|
if pageSize < 1 || pageSize > 10000 {
|
||||||
pageSize = defaultPageSize
|
pageSize = defaultPageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
setActive: (name: string) => request<{ message: string }>('/webui/active', { method: 'PUT', body: JSON.stringify({ name }) }),
|
setActive: (name: string) => request<{ message: string }>('/webui/active', { method: 'PUT', body: JSON.stringify({ name }) }),
|
||||||
delete: (name: string) => request<{ message: string }>(`/webui/${name}`, { method: 'DELETE' })
|
delete: (name: string) => request<{ message: string }>(`/webui/${name}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
export: (data: { task_ids?: string[], env_ids?: string[] }) => request<any>('/system/export', { method: 'POST', body: JSON.stringify(data) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +486,7 @@ export interface Task {
|
|||||||
last_run: string
|
last_run: string
|
||||||
next_run: string
|
next_run: string
|
||||||
running_status?: string
|
running_status?: string
|
||||||
|
repo_task_id?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ function initTerminal() {
|
|||||||
terminal.loadAddon(fitAddon)
|
terminal.loadAddon(fitAddon)
|
||||||
terminal.open(terminalRef.value)
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
fitAddon?.fit()
|
fitAddon?.fit()
|
||||||
@@ -183,6 +201,8 @@ watch(
|
|||||||
.log-terminal :deep(.xterm-viewport) {
|
.log-terminal :deep(.xterm-viewport) {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(150, 150, 150, 0.3) transparent;
|
scrollbar-color: rgba(150, 150, 150, 0.3) transparent;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar) {
|
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ function initTerminal(forceConnect = false) {
|
|||||||
}
|
}
|
||||||
}, 50)
|
}, 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()
|
terminal.focus()
|
||||||
|
|
||||||
// autoConnect 或者强制连接时才连接
|
// autoConnect 或者强制连接时才连接
|
||||||
@@ -302,6 +314,8 @@ onUnmounted(() => {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #4a4a4a #1e1e1e;
|
scrollbar-color: #4a4a4a #1e1e1e;
|
||||||
background: #1e1e1e !important;
|
background: #1e1e1e !important;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container :deep(.xterm-screen) {
|
.terminal-container :deep(.xterm-screen) {
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import * as interconnectApi from '@/api/interconnect'
|
import * as interconnectApi from '@/api/interconnect'
|
||||||
import { api, type EnvVar, type Task } from '@/api'
|
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<{
|
const props = defineProps<{
|
||||||
nodes: interconnectApi.InterconnectNode[]
|
nodes: interconnectApi.InterconnectNode[]
|
||||||
@@ -49,10 +57,69 @@ onMounted(() => {
|
|||||||
loadLocalData()
|
loadLocalData()
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredTasks = computed(() => {
|
const expandedRepos = ref<Record<string, boolean>>({})
|
||||||
if (!taskSearch.value) return localTasks.value
|
|
||||||
const kw = taskSearch.value.toLowerCase()
|
function toggleRepoExpand(repoId: string) {
|
||||||
return localTasks.value.filter((t: Task) => t.name.toLowerCase().includes(kw) || t.command.toLowerCase().includes(kw))
|
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<string, Task[]> = {}
|
||||||
|
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(() => {
|
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() {
|
function toggleAllNodes() {
|
||||||
if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) {
|
if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) {
|
||||||
selectedNodes.value = []
|
selectedNodes.value = []
|
||||||
@@ -81,19 +155,59 @@ function toggleAllNodes() {
|
|||||||
|
|
||||||
function toggleTask(id: any) {
|
function toggleTask(id: any) {
|
||||||
const idStr = String(id)
|
const idStr = String(id)
|
||||||
const index = selectedTasks.value.indexOf(idStr)
|
const task = localTasks.value.find(t => String(t.id) === idStr)
|
||||||
if (index > -1) {
|
if (!task) return
|
||||||
selectedTasks.value.splice(index, 1)
|
|
||||||
|
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 {
|
} 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() {
|
function toggleAllTasks() {
|
||||||
if (selectedTasks.value.length === filteredTasks.value.length && filteredTasks.value.length > 0) {
|
const visible = totalVisibleTasks.value
|
||||||
selectedTasks.value = []
|
if (isAllTasksSelected.value) {
|
||||||
|
const visibleIds = visible.map(t => String(t.id))
|
||||||
|
selectedTasks.value = selectedTasks.value.filter(id => !visibleIds.includes(id))
|
||||||
} else {
|
} 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<any>(null)
|
||||||
|
|
||||||
async function handleSync() {
|
async function handleSync() {
|
||||||
if (selectedNodes.value.length === 0) {
|
if (selectedNodes.value.length === 0) {
|
||||||
toast.warning('请先选择目标节点')
|
toast.warning('请先选择目标节点')
|
||||||
return
|
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
|
syncing.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (activeSyncType.value === 'task') {
|
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 tasksToSync = localTasks.value.filter((t: Task) => selectedTasks.value.includes(t.id))
|
||||||
const res = await interconnectApi.syncTask(targetNodeIds, tasksToSync)
|
const res = await interconnectApi.syncTask(targetNodeIds, tasksToSync)
|
||||||
showSyncResult(res)
|
showSyncResult(res)
|
||||||
} else {
|
} else {
|
||||||
if (selectedEnvsIds.value.length === 0) {
|
|
||||||
toast.warning('请先选择要同步的变量')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const allSelectedEnvs = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id))
|
const allSelectedEnvs = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id))
|
||||||
const envsToSync = allSelectedEnvs.filter((e: EnvVar) => e.type !== 'secret')
|
const envsToSync = allSelectedEnvs.filter((e: EnvVar) => e.type !== 'secret')
|
||||||
|
|
||||||
@@ -146,18 +284,16 @@ async function handleSync() {
|
|||||||
toast.info('已自动过滤机密变量,因下发无意义')
|
toast.info('已自动过滤机密变量,因下发无意义')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envsToSync.length === 0) {
|
if (envsToSync.length > 0) {
|
||||||
syncing.value = false
|
const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync)
|
||||||
return
|
showSyncResult(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync)
|
|
||||||
showSyncResult(res)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('下发请求失败')
|
toast.error('下发请求失败')
|
||||||
} finally {
|
} finally {
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
|
showConfirmDialog.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,16 +326,16 @@ function showSyncResult(res: any[]) {
|
|||||||
暂无子节点
|
暂无子节点
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-1">
|
<div v-else class="space-y-1">
|
||||||
<div v-for="node in nodes" :key="node.id" class="flex items-center gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer select-none group/node" @click="toggleNode(node.id)">
|
<div v-for="node in nodes" :key="node.id" class="flex items-center gap-2 py-1 px-1.5 hover:bg-muted/50 rounded-md cursor-pointer select-none group/node" @click="toggleNode(node.id)">
|
||||||
<div
|
<div
|
||||||
class="size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
|
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
|
||||||
:class="selectedNodes.includes(String(node.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/node:border-muted-foreground/50 scale-95'"
|
:class="selectedNodes.includes(String(node.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/node:border-muted-foreground/50 scale-95'"
|
||||||
>
|
>
|
||||||
<Check v-if="selectedNodes.includes(String(node.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
|
<Check v-if="selectedNodes.includes(String(node.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-1 min-w-0">
|
<div class="flex items-center flex-1 min-w-0 justify-between gap-3">
|
||||||
<span class="text-sm font-medium truncate">{{ node.name }}</span>
|
<span class="text-xs font-medium truncate">{{ node.name }}</span>
|
||||||
<span class="text-xs text-muted-foreground truncate">{{ node.url }}</span>
|
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[50%]">{{ node.url }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,24 +381,53 @@ function showSyncResult(res: any[]) {
|
|||||||
<div class="flex items-center justify-between px-2 py-1 mb-2">
|
<div class="flex items-center justify-between px-2 py-1 mb-2">
|
||||||
<span class="text-xs text-muted-foreground">已选 {{ selectedTasks.length }} 项</span>
|
<span class="text-xs text-muted-foreground">已选 {{ selectedTasks.length }} 项</span>
|
||||||
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllTasks">
|
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllTasks">
|
||||||
{{ selectedTasks.length === filteredTasks.length && filteredTasks.length > 0 ? '全不选' : '全选' }}
|
{{ isAllTasksSelected && totalVisibleTasks.length > 0 ? '全不选' : '全选' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
|
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
|
||||||
<div v-else-if="filteredTasks.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div>
|
<div v-else-if="taskTree.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div>
|
||||||
<div v-for="task in filteredTasks" :key="task.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer select-none group/task" @click="toggleTask(task.id)">
|
<div v-for="item in taskTree" :key="item.parent.id" class="space-y-1">
|
||||||
<div
|
<!-- 父节点 (仓库或独立任务) -->
|
||||||
class="mt-1 size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
|
<div class="flex items-center gap-2 py-1 px-1.5 hover:bg-muted/50 rounded-md cursor-pointer select-none group/task" @click="toggleTask(item.parent.id)">
|
||||||
:class="selectedTasks.includes(String(task.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/task:border-muted-foreground/50 scale-95'"
|
<!-- 展开/折叠按钮 -->
|
||||||
>
|
<div v-if="item.parent.type === 'repo'" class="w-3.5 h-3.5 flex items-center justify-center shrink-0" @click.stop="toggleRepoExpand(item.parent.id)">
|
||||||
<Check v-if="selectedTasks.includes(String(task.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
|
<ChevronDown v-if="expandedRepos[item.parent.id] !== false" class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||||
</div>
|
<ChevronRight v-else class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||||
<div class="flex flex-col flex-1 min-w-0">
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div v-else class="w-3.5 h-3.5 shrink-0"></div>
|
||||||
<span class="text-sm font-medium truncate">{{ task.name }}</span>
|
|
||||||
<span class="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{{ task.type === 'task' ? '任务' : (task.type === 'repo' ? '仓库' : task.type) }}</span>
|
<div
|
||||||
|
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
|
||||||
|
:class="selectedTasks.includes(String(item.parent.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/task:border-muted-foreground/50 scale-95'"
|
||||||
|
>
|
||||||
|
<Check v-if="selectedTasks.includes(String(item.parent.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center flex-1 min-w-0 justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span class="text-xs font-medium truncate">{{ item.parent.name }}</span>
|
||||||
|
<span class="text-[9px] bg-muted px-1.5 py-0.2 rounded text-muted-foreground shrink-0">{{ item.parent.type === 'task' ? '任务' : '仓库' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[60%]">{{ item.parent.command }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子节点 (归属于该仓库的任务) -->
|
||||||
|
<div v-if="item.parent.type === 'repo' && expandedRepos[item.parent.id] !== false && item.children.length > 0" class="pl-5 space-y-1 border-l ml-3.5 pb-0.5">
|
||||||
|
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-0.5 px-1.5 hover:bg-muted/50 rounded-md cursor-pointer select-none group/subtask" @click="toggleTask(child.id)">
|
||||||
|
<div
|
||||||
|
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
|
||||||
|
:class="selectedTasks.includes(String(child.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/subtask:border-muted-foreground/50 scale-95'"
|
||||||
|
>
|
||||||
|
<Check v-if="selectedTasks.includes(String(child.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center flex-1 min-w-0 justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span class="text-xs font-medium truncate">{{ child.name }}</span>
|
||||||
|
<span class="text-[9px] bg-muted px-1.5 py-0.2 rounded text-muted-foreground shrink-0">任务</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[60%]">{{ child.command }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ task.command }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,28 +443,116 @@ function showSyncResult(res: any[]) {
|
|||||||
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
|
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
|
||||||
<div v-else-if="filteredEnvs.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无变量</div>
|
<div v-else-if="filteredEnvs.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无变量</div>
|
||||||
<div v-for="env in filteredEnvs" :key="env.id"
|
<div v-for="env in filteredEnvs" :key="env.id"
|
||||||
class="flex items-start gap-3 p-2 rounded-md select-none group/env"
|
class="flex items-center gap-2 py-1 px-1.5 rounded-md select-none group/env"
|
||||||
:class="env.type === 'secret' ? 'opacity-50 cursor-not-allowed bg-muted/20' : 'hover:bg-muted/50 cursor-pointer'"
|
:class="env.type === 'secret' ? 'opacity-50 cursor-not-allowed bg-muted/20' : 'hover:bg-muted/50 cursor-pointer'"
|
||||||
@click="env.type === 'secret' ? null : toggleEnv(env.id)">
|
@click="env.type === 'secret' ? null : toggleEnv(env.id)">
|
||||||
<div
|
<div
|
||||||
class="mt-1 size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
|
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
|
||||||
:class="[
|
:class="[
|
||||||
selectedEnvsIds.includes(String(env.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/env:border-muted-foreground/50 scale-95',
|
selectedEnvsIds.includes(String(env.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/env:border-muted-foreground/50 scale-95',
|
||||||
env.type === 'secret' ? 'opacity-50 bg-muted border-muted-foreground/30' : ''
|
env.type === 'secret' ? 'opacity-50 bg-muted border-muted-foreground/30' : ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Check v-if="selectedEnvsIds.includes(String(env.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
|
<Check v-if="selectedEnvsIds.includes(String(env.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-1 min-w-0">
|
<div class="flex items-center flex-1 min-w-0 justify-between gap-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<span class="text-sm font-medium truncate">{{ env.name }}</span>
|
<span class="text-xs font-medium truncate">{{ env.name }}</span>
|
||||||
<span v-if="env.type" class="text-[10px] bg-muted px-1.5 py-0.5 rounded" :class="env.type === 'secret' ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground'">{{ env.type === 'secret' ? '机密' : (env.type === 'normal' ? '常量' : env.type) }}</span>
|
<span v-if="env.type" class="text-[9px] bg-muted px-1.5 py-0.2 rounded shrink-0" :class="env.type === 'secret' ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground'">{{ env.type === 'secret' ? '机密' : '常量' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ env.type === 'secret' ? '********' : env.value }}</span>
|
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[60%]">{{ env.type === 'secret' ? '********' : env.value }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 确认同步弹窗 -->
|
||||||
|
<Dialog v-model:open="showConfirmDialog">
|
||||||
|
<DialogContent class="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认同步数据</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
以下是系统自动解析后即将全量同步到目标节点的所有关联数据。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="py-4">
|
||||||
|
<div v-if="previewLoading" class="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<RefreshCw class="w-8 h-8 animate-spin mb-4" />
|
||||||
|
<p>正在智能解析关联数据...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="previewData" class="space-y-4">
|
||||||
|
<div class="rounded-lg border bg-card text-card-foreground p-3">
|
||||||
|
<div class="flex items-center gap-2 font-medium tracking-tight mb-2 text-xs">
|
||||||
|
<AlertCircle class="h-4 w-4 text-primary" />
|
||||||
|
即将同步以下数据:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[220px] overflow-y-auto space-y-3 pr-1 text-xs">
|
||||||
|
<!-- 任务列表 -->
|
||||||
|
<div v-if="previewData.tasks && previewData.tasks.length > 0">
|
||||||
|
<div class="font-semibold text-muted-foreground mb-1">任务 ({{ previewData.tasks.length }})</div>
|
||||||
|
<div class="space-y-1 pl-2 border-l-2 border-primary/30">
|
||||||
|
<div v-for="t in previewData.tasks" :key="t.id" class="flex justify-between items-center py-1 border-b border-muted/30 last:border-0 pb-1 mb-1 last:pb-0 last:mb-0">
|
||||||
|
<div class="flex flex-col min-w-0 max-w-[60%]">
|
||||||
|
<span class="font-medium truncate">{{ t.name }}</span>
|
||||||
|
<div v-if="t.tags" class="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
<span v-for="tag in t.tags.split(',')" :key="tag" class="text-[9px] bg-primary/10 text-primary px-1 py-0.2 rounded">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-muted-foreground font-mono truncate max-w-[38%]">{{ t.command }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 变量列表 -->
|
||||||
|
<div v-if="previewData.envs && previewData.envs.length > 0">
|
||||||
|
<div class="font-semibold text-muted-foreground mb-1">环境变量 ({{ previewData.envs.length }})</div>
|
||||||
|
<div class="space-y-1 pl-2 border-l-2 border-amber-500/30">
|
||||||
|
<div v-for="e in previewData.envs" :key="e.id" class="flex justify-between items-center py-0.5">
|
||||||
|
<span class="font-medium truncate max-w-[50%]">{{ e.name }}</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground font-mono truncate max-w-[45%]">{{ e.type === 'secret' ? '********' : e.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签列表 -->
|
||||||
|
<div v-if="previewData.tags && previewData.tags.length > 0">
|
||||||
|
<div class="font-semibold text-muted-foreground mb-1">标签 ({{ previewData.tags.length }})</div>
|
||||||
|
<div class="space-y-1 pl-2 border-l-2 border-teal-500/30 flex flex-wrap gap-1.5 py-0.5">
|
||||||
|
<span v-for="tag in previewData.tags" :key="tag.id" class="text-[10px] bg-teal-500/10 text-teal-600 dark:text-teal-400 px-1.5 py-0.5 rounded font-medium">
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通知绑定 -->
|
||||||
|
<div v-if="previewData.bindings && previewData.bindings.length > 0">
|
||||||
|
<div class="font-semibold text-muted-foreground mb-1">通知规则 ({{ previewData.bindings.length }})</div>
|
||||||
|
<div class="space-y-1 pl-2 border-l-2 border-emerald-500/30">
|
||||||
|
<div v-for="b in previewData.bindings" :key="b.id" class="flex justify-between items-center py-0.5">
|
||||||
|
<span class="font-medium truncate max-w-[50%]">事件: {{ b.event_type }}</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground truncate max-w-[45%]">通道: {{ b.channel_id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-muted-foreground mt-4 leading-normal">
|
||||||
|
* 同步将会覆盖目标节点上的同名数据,并且机密变量的实际值不会被下发。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showConfirmDialog = false" :disabled="syncing">取消</Button>
|
||||||
|
<Button @click="confirmSync" :disabled="previewLoading || syncing">
|
||||||
|
<RefreshCw v-if="syncing" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
确认下发
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user