Browse Source

Merge pull request #1078 from 0xJacky/v2.1

V2.1
Jacky 1 month ago
parent
commit
2fad018d7e
100 changed files with 5403 additions and 5789 deletions
  1. 1 1
      .vscode/settings.json
  2. 196 0
      api/backup/auto_backup.go
  3. 1 1
      api/backup/backup.go
  4. 1 1
      api/backup/backup_test.go
  5. 1 1
      api/backup/restore.go
  6. 20 0
      api/backup/router.go
  7. 3 1
      api/notification/notification.go
  8. 19 8
      api/sites/list.go
  9. 27 12
      api/streams/streams.go
  10. 175 0
      api/system/port_scan.go
  11. 2 6
      api/system/router.go
  12. 149 14
      api/upstream/upstream.go
  13. 74 0
      api/user/current_user.go
  14. 4 0
      api/user/router.go
  15. 4 18
      app/components.d.ts
  16. 2 0
      app/eslint.config.mjs
  17. 11 10
      app/package.json
  18. 206 237
      app/pnpm-lock.yaml
  19. 1 1
      app/src/api/2fa.ts
  20. 5 12
      app/src/api/acme_user.ts
  21. 1 1
      app/src/api/analytic.ts
  22. 1 1
      app/src/api/auth.ts
  23. 1 1
      app/src/api/auto_cert.ts
  24. 36 3
      app/src/api/backup.ts
  25. 2 2
      app/src/api/cert.ts
  26. 12 30
      app/src/api/config.ts
  27. 4 92
      app/src/api/curd.ts
  28. 4 2
      app/src/api/dns_credential.ts
  29. 11 4
      app/src/api/env_group.ts
  30. 5 12
      app/src/api/environment.ts
  31. 3 7
      app/src/api/external_notify.ts
  32. 1 1
      app/src/api/install.ts
  33. 2 2
      app/src/api/nginx_log.ts
  34. 1 1
      app/src/api/ngx.ts
  35. 1 1
      app/src/api/node.ts
  36. 5 8
      app/src/api/notification.ts
  37. 1 1
      app/src/api/openai.ts
  38. 1 1
      app/src/api/otp.ts
  39. 2 2
      app/src/api/passkey.ts
  40. 27 0
      app/src/api/port_scan.ts
  41. 1 1
      app/src/api/public.ts
  42. 1 1
      app/src/api/recovery.ts
  43. 1 1
      app/src/api/self_check.ts
  44. 1 1
      app/src/api/settings.ts
  45. 21 42
      app/src/api/site.ts
  46. 12 26
      app/src/api/stream.ts
  47. 9 26
      app/src/api/template.ts
  48. 1 1
      app/src/api/upgrade.ts
  49. 14 2
      app/src/api/user.ts
  50. 2 2
      app/src/components/AutoCertForm/DNSChallenge.vue
  51. 8 7
      app/src/components/ConfigHistory/ConfigHistory.vue
  52. 2 2
      app/src/components/NgxConfigEditor/directive/DirectiveEditorItem.vue
  53. 3 4
      app/src/components/Notification/Notification.vue
  54. 2 2
      app/src/components/Notification/detailRender.tsx
  55. 282 0
      app/src/components/PortScanner/PortScannerCompact.vue
  56. 1 0
      app/src/components/PortScanner/index.ts
  57. 109 0
      app/src/components/ProxyTargets/ProxyTargets.vue
  58. 3 0
      app/src/components/ProxyTargets/index.ts
  59. 0 90
      app/src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue
  60. 0 108
      app/src/components/StdDesign/StdDataDisplay/StdBulkActions.vue
  61. 0 309
      app/src/components/StdDesign/StdDataDisplay/StdCurd.vue
  62. 0 36
      app/src/components/StdDesign/StdDataDisplay/StdCurdDetail.vue
  63. 0 65
      app/src/components/StdDesign/StdDataDisplay/StdPagination.vue
  64. 0 623
      app/src/components/StdDesign/StdDataDisplay/StdTable.vue
  65. 0 155
      app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx
  66. 0 9
      app/src/components/StdDesign/StdDataDisplay/components/CustomRender.tsx
  67. 0 5
      app/src/components/StdDesign/StdDataDisplay/index.ts
  68. 0 7
      app/src/components/StdDesign/StdDataDisplay/methods/columns.ts
  69. 0 67
      app/src/components/StdDesign/StdDataDisplay/methods/exportCsv.ts
  70. 0 127
      app/src/components/StdDesign/StdDataDisplay/methods/sortable.ts
  71. 0 50
      app/src/components/StdDesign/StdDataDisplay/types.d.ts
  72. 0 119
      app/src/components/StdDesign/StdDataEntry/StdDataEntry.vue
  73. 0 63
      app/src/components/StdDesign/StdDataEntry/StdFormItem.vue
  74. 0 65
      app/src/components/StdDesign/StdDataEntry/components/StdPassword.vue
  75. 0 74
      app/src/components/StdDesign/StdDataEntry/components/StdSelect.vue
  76. 0 259
      app/src/components/StdDesign/StdDataEntry/components/StdSelector.vue
  77. 0 169
      app/src/components/StdDesign/StdDataEntry/index.tsx
  78. 0 7
      app/src/components/StdDesign/StdDataEntry/style.less
  79. 0 25
      app/src/components/StdDesign/StdDataEntry/types.d.ts
  80. 0 141
      app/src/components/StdDesign/StdDetail/StdDetail.vue
  81. 0 154
      app/src/components/StdDesign/types.d.ts
  82. 1 1
      app/src/components/TwoFA/Authorization.vue
  83. 20 0
      app/src/constants/errors/backup.ts
  84. 251 162
      app/src/language/ar/app.po
  85. 62 0
      app/src/language/curd.ts
  86. 252 161
      app/src/language/de_DE/app.po
  87. 256 163
      app/src/language/en/app.po
  88. 253 162
      app/src/language/es/app.po
  89. 253 162
      app/src/language/fr_FR/app.po
  90. 258 165
      app/src/language/ja_JP/app.po
  91. 258 165
      app/src/language/ko_KR/app.po
  92. 264 164
      app/src/language/messages.pot
  93. 252 163
      app/src/language/pt_PT/app.po
  94. 254 163
      app/src/language/ru_RU/app.po
  95. 252 163
      app/src/language/tr_TR/app.po
  96. 253 162
      app/src/language/uk_UA/app.po
  97. 251 162
      app/src/language/vi_VN/app.po
  98. 258 165
      app/src/language/zh_CN/app.po
  99. 258 165
      app/src/language/zh_TW/app.po
  100. 1 1
      app/src/layouts/BaseLayout.vue

+ 1 - 1
.vscode/settings.json

@@ -10,7 +10,7 @@
     "i18n-gettext.translatorConfig": {
     "onlyTranslateUntranslatedAndFuzzy": true,
     "batch": {
-      "pageSize": 20
+      "pageSize": 100
     }
   }
 }

+ 196 - 0
api/backup/auto_backup.go

@@ -0,0 +1,196 @@
+package backup
+
+import (
+	"net/http"
+
+	"github.com/0xJacky/Nginx-UI/internal/backup"
+	"github.com/0xJacky/Nginx-UI/internal/cron"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
+	"github.com/uozi-tech/cosy/logger"
+)
+
+// GetAutoBackupList retrieves a paginated list of auto backup configurations.
+// This endpoint supports fuzzy search by backup name and filtering by backup type and enabled status.
+//
+// Query Parameters:
+//   - page: Page number for pagination
+//   - page_size: Number of items per page
+//   - name: Fuzzy search filter for backup name
+//   - backup_type: Filter by backup type (nginx_config/nginx_ui_config/both_config/custom_dir)
+//   - enabled: Filter by enabled status (true/false)
+//
+// Response: Paginated list of auto backup configurations
+func GetAutoBackupList(c *gin.Context) {
+	cosy.Core[model.AutoBackup](c).
+		SetFussy("name").
+		SetEqual("backup_type", "enabled", "storage_type", "last_backup_status").
+		PagingList()
+}
+
+// CreateAutoBackup creates a new auto backup configuration with comprehensive validation.
+// This endpoint validates all required fields, path permissions, and S3 configuration.
+//
+// Request Body: AutoBackup model with required fields
+// Response: Created auto backup configuration
+func CreateAutoBackup(c *gin.Context) {
+	cosy.Core[model.AutoBackup](c).SetValidRules(gin.H{
+		"name":                 "required",
+		"backup_type":          "required",
+		"storage_type":         "required",
+		"storage_path":         "required",
+		"cron_expression":      "required",
+		"enabled":              "omitempty",
+		"backup_path":          "omitempty",
+		"s3_endpoint":          "omitempty",
+		"s3_access_key_id":     "omitempty",
+		"s3_secret_access_key": "omitempty",
+		"s3_bucket":            "omitempty",
+		"s3_region":            "omitempty",
+	}).BeforeExecuteHook(func(ctx *cosy.Ctx[model.AutoBackup]) {
+		// Validate backup configuration before creation
+		if err := backup.ValidateAutoBackupConfig(&ctx.Model); err != nil {
+			ctx.AbortWithError(err)
+			return
+		}
+	}).ExecutedHook(func(ctx *cosy.Ctx[model.AutoBackup]) {
+		// Register cron job only if the backup is enabled
+		if ctx.Model.Enabled {
+			if err := cron.AddAutoBackupJob(ctx.Model.ID, ctx.Model.CronExpression); err != nil {
+				ctx.AbortWithError(err)
+				return
+			}
+		}
+	}).Create()
+}
+
+// GetAutoBackup retrieves a single auto backup configuration by ID.
+//
+// Path Parameters:
+//   - id: Auto backup configuration ID
+//
+// Response: Auto backup configuration details
+func GetAutoBackup(c *gin.Context) {
+	cosy.Core[model.AutoBackup](c).Get()
+}
+
+// ModifyAutoBackup updates an existing auto backup configuration with validation.
+// This endpoint performs the same validation as creation for modified fields.
+//
+// Path Parameters:
+//   - id: Auto backup configuration ID
+//
+// Request Body: Partial AutoBackup model with fields to update
+// Response: Updated auto backup configuration
+func ModifyAutoBackup(c *gin.Context) {
+	cosy.Core[model.AutoBackup](c).SetValidRules(gin.H{
+		"name":                 "omitempty",
+		"backup_type":          "omitempty",
+		"storage_type":         "omitempty",
+		"storage_path":         "omitempty",
+		"cron_expression":      "omitempty",
+		"backup_path":          "omitempty",
+		"enabled":              "omitempty",
+		"s3_endpoint":          "omitempty",
+		"s3_access_key_id":     "omitempty",
+		"s3_secret_access_key": "omitempty",
+		"s3_bucket":            "omitempty",
+		"s3_region":            "omitempty",
+	}).BeforeExecuteHook(func(ctx *cosy.Ctx[model.AutoBackup]) {
+		// Validate backup configuration before modification
+		if err := backup.ValidateAutoBackupConfig(&ctx.Model); err != nil {
+			ctx.AbortWithError(err)
+			return
+		}
+	}).ExecutedHook(func(ctx *cosy.Ctx[model.AutoBackup]) {
+		// Update cron job based on enabled status
+		if ctx.Model.Enabled {
+			if err := cron.UpdateAutoBackupJob(ctx.Model.ID, ctx.Model.CronExpression); err != nil {
+				ctx.AbortWithError(err)
+				return
+			}
+		} else {
+			if err := cron.RemoveAutoBackupJob(ctx.Model.ID); err != nil {
+				ctx.AbortWithError(err)
+				return
+			}
+		}
+	}).Modify()
+}
+
+// DestroyAutoBackup deletes an auto backup configuration and removes its cron job.
+// This endpoint ensures proper cleanup of both database records and scheduled tasks.
+//
+// Path Parameters:
+//   - id: Auto backup configuration ID
+//
+// Response: Success confirmation
+func DestroyAutoBackup(c *gin.Context) {
+	cosy.Core[model.AutoBackup](c).BeforeExecuteHook(func(ctx *cosy.Ctx[model.AutoBackup]) {
+		// Remove cron job before deleting the backup task
+		if err := cron.RemoveAutoBackupJob(ctx.Model.ID); err != nil {
+			logger.Errorf("Failed to remove auto backup job %d: %v", ctx.Model.ID, err)
+		}
+	}).Destroy()
+}
+
+// TestS3Connection tests the S3 connection for auto backup configuration.
+// This endpoint allows users to verify their S3 settings before saving the configuration.
+//
+// Request Body: AutoBackup model with S3 configuration
+// Response: Success confirmation or error details
+func TestS3Connection(c *gin.Context) {
+	var autoBackup model.AutoBackup
+	if !cosy.BindAndValid(c, &autoBackup) {
+		return
+	}
+
+	// Validate S3 configuration
+	if err := backup.ValidateS3Config(&autoBackup); err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	// Test S3 connection
+	if err := backup.TestS3ConnectionForConfig(&autoBackup); err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "S3 connection test successful"})
+}
+
+// RestoreAutoBackup restores a soft-deleted auto backup configuration.
+// This endpoint restores the backup configuration and re-registers the cron job if enabled.
+//
+// Path Parameters:
+//   - id: Auto backup configuration ID to restore
+//
+// Response: Success confirmation
+func RestoreAutoBackup(c *gin.Context) {
+	var autoBackup model.AutoBackup
+	if err := c.ShouldBindUri(&autoBackup); err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	// Restore the backup configuration
+	if err := backup.RestoreAutoBackup(autoBackup.ID); err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	// Get the restored backup configuration to check if it's enabled
+	restoredBackup, err := backup.GetAutoBackupByID(autoBackup.ID)
+	if err != nil {
+		logger.Errorf("Failed to get restored auto backup %d: %v", autoBackup.ID, err)
+	} else if restoredBackup.Enabled {
+		// Register cron job if the backup is enabled
+		if err := cron.AddAutoBackupJob(restoredBackup.ID, restoredBackup.CronExpression); err != nil {
+			logger.Errorf("Failed to add auto backup job %d after restore: %v", restoredBackup.ID, err)
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "Auto backup restored successfully"})
+}

+ 1 - 1
api/system/backup.go → api/backup/backup.go

@@ -1,4 +1,4 @@
-package system
+package backup
 
 import (
 	"bytes"

+ 1 - 1
api/system/backup_test.go → api/backup/backup_test.go

@@ -1,4 +1,4 @@
-package system
+package backup
 
 import (
 	"bytes"

+ 1 - 1
api/system/restore.go → api/backup/restore.go

@@ -1,4 +1,4 @@
-package system
+package backup
 
 import (
 	"encoding/base64"

+ 20 - 0
api/backup/router.go

@@ -0,0 +1,20 @@
+package backup
+
+import (
+	"github.com/gin-gonic/gin"
+)
+
+func InitRouter(r *gin.RouterGroup) {
+	r.POST("/backup", CreateBackup)
+	r.POST("/restore", RestoreBackup)
+}
+
+func InitAutoBackupRouter(r *gin.RouterGroup) {
+	r.GET("/auto_backup", GetAutoBackupList)
+	r.POST("/auto_backup", CreateAutoBackup)
+	r.GET("/auto_backup/:id", GetAutoBackup)
+	r.PUT("/auto_backup/:id", ModifyAutoBackup)
+	r.DELETE("/auto_backup/:id", DestroyAutoBackup)
+	r.POST("/auto_backup/:id/restore", RestoreAutoBackup)
+	r.POST("/auto_backup/test_s3", TestS3Connection)
+}

+ 3 - 1
api/notification/notification.go

@@ -26,7 +26,9 @@ func Get(c *gin.Context) {
 }
 
 func GetList(c *gin.Context) {
-	cosy.Core[model.Notification](c).PagingList()
+	cosy.Core[model.Notification](c).
+		SetEqual("type").
+		PagingList()
 }
 
 func Destroy(c *gin.Context) {

+ 19 - 8
api/sites/list.go

@@ -103,15 +103,26 @@ func GetSiteList(c *gin.Context) {
 
 		indexedSite := site.GetIndexedSite(file.Name())
 
+		// Convert site.ProxyTarget to config.ProxyTarget
+		var proxyTargets []config.ProxyTarget
+		for _, target := range indexedSite.ProxyTargets {
+			proxyTargets = append(proxyTargets, config.ProxyTarget{
+				Host: target.Host,
+				Port: target.Port,
+				Type: target.Type,
+			})
+		}
+
 		configs = append(configs, config.Config{
-			Name:       file.Name(),
-			ModifiedAt: fileInfo.ModTime(),
-			Size:       fileInfo.Size(),
-			IsDir:      fileInfo.IsDir(),
-			Status:     configStatusMap[file.Name()],
-			EnvGroupID: envGroupId,
-			EnvGroup:   envGroup,
-			Urls:       indexedSite.Urls,
+			Name:         file.Name(),
+			ModifiedAt:   fileInfo.ModTime(),
+			Size:         fileInfo.Size(),
+			IsDir:        fileInfo.IsDir(),
+			Status:       configStatusMap[file.Name()],
+			EnvGroupID:   envGroupId,
+			EnvGroup:     envGroup,
+			Urls:         indexedSite.Urls,
+			ProxyTargets: proxyTargets,
 		})
 	}
 

+ 27 - 12
api/streams/streams.go

@@ -23,7 +23,7 @@ import (
 type Stream struct {
 	ModifiedAt      time.Time                      `json:"modified_at"`
 	Advanced        bool                           `json:"advanced"`
-	Enabled         bool                           `json:"enabled"`
+	Status          config.ConfigStatus            `json:"status"`
 	Name            string                         `json:"name"`
 	Config          string                         `json:"config"`
 	ChatGPTMessages []openai.ChatCompletionMessage `json:"chatgpt_messages,omitempty"`
@@ -32,6 +32,7 @@ type Stream struct {
 	EnvGroupID      uint64                         `json:"env_group_id"`
 	EnvGroup        *model.EnvGroup                `json:"env_group,omitempty"`
 	SyncNodeIDs     []uint64                       `json:"sync_node_ids" gorm:"serializer:json"`
+	ProxyTargets    []config.ProxyTarget           `json:"proxy_targets,omitempty"`
 }
 
 func GetStreams(c *gin.Context) {
@@ -130,15 +131,29 @@ func GetStreams(c *gin.Context) {
 			continue
 		}
 
+		// Get indexed stream for proxy targets
+		indexedStream := stream.GetIndexedStream(file.Name())
+
+		// Convert stream.ProxyTarget to config.ProxyTarget
+		var proxyTargets []config.ProxyTarget
+		for _, target := range indexedStream.ProxyTargets {
+			proxyTargets = append(proxyTargets, config.ProxyTarget{
+				Host: target.Host,
+				Port: target.Port,
+				Type: target.Type,
+			})
+		}
+
 		// Add the config to the result list after passing all filters
 		configs = append(configs, config.Config{
-			Name:       file.Name(),
-			ModifiedAt: fileInfo.ModTime(),
-			Size:       fileInfo.Size(),
-			IsDir:      fileInfo.IsDir(),
-			Status:     enabledConfigMap[file.Name()],
-			EnvGroupID: envGroupId,
-			EnvGroup:   envGroup,
+			Name:         file.Name(),
+			ModifiedAt:   fileInfo.ModTime(),
+			Size:         fileInfo.Size(),
+			IsDir:        fileInfo.IsDir(),
+			Status:       enabledConfigMap[file.Name()],
+			EnvGroupID:   envGroupId,
+			EnvGroup:     envGroup,
+			ProxyTargets: proxyTargets,
 		})
 	}
 
@@ -164,9 +179,9 @@ func GetStream(c *gin.Context) {
 	}
 
 	// Check if the stream is enabled
-	enabled := true
+	status := config.StatusEnabled
 	if _, err := os.Stat(nginx.GetConfPath("streams-enabled", name)); os.IsNotExist(err) {
-		enabled = false
+		status = config.StatusDisabled
 	}
 
 	// Retrieve or create ChatGPT log for this stream
@@ -201,7 +216,7 @@ func GetStream(c *gin.Context) {
 		c.JSON(http.StatusOK, Stream{
 			ModifiedAt:      file.ModTime(),
 			Advanced:        streamModel.Advanced,
-			Enabled:         enabled,
+			Status:          status,
 			Name:            name,
 			Config:          string(origContent),
 			ChatGPTMessages: chatgpt.Content,
@@ -223,7 +238,7 @@ func GetStream(c *gin.Context) {
 	c.JSON(http.StatusOK, Stream{
 		ModifiedAt:      file.ModTime(),
 		Advanced:        streamModel.Advanced,
-		Enabled:         enabled,
+		Status:          status,
 		Name:            name,
 		Config:          nginxConfig.FmtCode(),
 		Tokenized:       nginxConfig,

+ 175 - 0
api/system/port_scan.go

@@ -0,0 +1,175 @@
+package system
+
+import (
+	"fmt"
+	"net"
+	"os/exec"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy/logger"
+)
+
+type PortScanRequest struct {
+	StartPort int `json:"start_port" binding:"required,min=1,max=65535"`
+	EndPort   int `json:"end_port" binding:"required,min=1,max=65535"`
+	Page      int `json:"page" binding:"required,min=1"`
+	PageSize  int `json:"page_size" binding:"required,min=1,max=1000"`
+}
+
+type PortInfo struct {
+	Port    int    `json:"port"`
+	Status  string `json:"status"`
+	Process string `json:"process"`
+}
+
+type PortScanResponse struct {
+	Data     []PortInfo `json:"data"`
+	Total    int        `json:"total"`
+	Page     int        `json:"page"`
+	PageSize int        `json:"page_size"`
+}
+
+func PortScan(c *gin.Context) {
+	var req PortScanRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"message": err.Error()})
+		return
+	}
+
+	if req.StartPort > req.EndPort {
+		c.JSON(400, gin.H{"message": "Start port must be less than or equal to end port"})
+		return
+	}
+
+	// Calculate pagination
+	totalPorts := req.EndPort - req.StartPort + 1
+	startIndex := (req.Page - 1) * req.PageSize
+	endIndex := startIndex + req.PageSize
+
+	if startIndex >= totalPorts {
+		c.JSON(200, PortScanResponse{
+			Data:     []PortInfo{},
+			Total:    totalPorts,
+			Page:     req.Page,
+			PageSize: req.PageSize,
+		})
+		return
+	}
+
+	if endIndex > totalPorts {
+		endIndex = totalPorts
+	}
+
+	// Calculate actual port range for this page
+	actualStartPort := req.StartPort + startIndex
+	actualEndPort := req.StartPort + endIndex - 1
+
+	var ports []PortInfo
+
+	// Get listening ports info
+	listeningPorts := getListeningPorts()
+
+	// Scan ports in the current page range
+	for port := actualStartPort; port <= actualEndPort; port++ {
+		portInfo := PortInfo{
+			Port:    port,
+			Status:  "closed",
+			Process: "",
+		}
+
+		// Check if port is listening
+		if processInfo, exists := listeningPorts[port]; exists {
+			portInfo.Status = "listening"
+			portInfo.Process = processInfo
+		} else {
+			// Quick check if port is open but not in listening list
+			if isPortOpen(port) {
+				portInfo.Status = "open"
+			}
+		}
+
+		ports = append(ports, portInfo)
+	}
+
+	c.JSON(200, PortScanResponse{
+		Data:     ports,
+		Total:    totalPorts,
+		Page:     req.Page,
+		PageSize: req.PageSize,
+	})
+}
+
+func isPortOpen(port int) bool {
+	timeout := time.Millisecond * 100
+	conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), timeout)
+	if err != nil {
+		return false
+	}
+	defer conn.Close()
+	return true
+}
+
+func getListeningPorts() map[int]string {
+	ports := make(map[int]string)
+
+	// Try netstat first
+	if cmd := exec.Command("netstat", "-tlnp"); cmd.Err == nil {
+		if output, err := cmd.Output(); err == nil {
+			lines := strings.Split(string(output), "\n")
+			for _, line := range lines {
+				if strings.Contains(line, "LISTEN") {
+					fields := strings.Fields(line)
+					if len(fields) >= 4 {
+						address := fields[3]
+						process := ""
+						if len(fields) >= 7 {
+							process = fields[6]
+						}
+
+						// Extract port from address (format: 0.0.0.0:port or :::port)
+						if colonIndex := strings.LastIndex(address, ":"); colonIndex != -1 {
+							portStr := address[colonIndex+1:]
+							if port, err := strconv.Atoi(portStr); err == nil {
+								ports[port] = process
+							}
+						}
+					}
+				}
+			}
+			return ports
+		}
+	}
+
+	// Fallback to ss command
+	if cmd := exec.Command("ss", "-tlnp"); cmd.Err == nil {
+		if output, err := cmd.Output(); err == nil {
+			lines := strings.Split(string(output), "\n")
+			for _, line := range lines {
+				if strings.Contains(line, "LISTEN") {
+					fields := strings.Fields(line)
+					if len(fields) >= 4 {
+						address := fields[3]
+						process := ""
+						if len(fields) >= 6 {
+							process = fields[5]
+						}
+
+						// Extract port from address
+						if colonIndex := strings.LastIndex(address, ":"); colonIndex != -1 {
+							portStr := address[colonIndex+1:]
+							if port, err := strconv.Atoi(portStr); err == nil {
+								ports[port] = process
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	logger.Debug("Found listening ports: %v", ports)
+	return ports
+}

+ 2 - 6
api/system/router.go

@@ -23,8 +23,8 @@ func InitPrivateRouter(r *gin.RouterGroup) {
 	r.GET("upgrade/release", GetRelease)
 	r.GET("upgrade/current", GetCurrentVersion)
 
-	r.GET("system/backup", CreateBackup)
 	r.GET("system/processing", GetProcessingStatus)
+	r.POST("system/port_scan", PortScan)
 }
 
 func InitSelfCheckRouter(r *gin.RouterGroup) {
@@ -36,11 +36,7 @@ func InitSelfCheckRouter(r *gin.RouterGroup) {
 }
 
 func InitBackupRestoreRouter(r *gin.RouterGroup) {
-	r.POST("system/backup/restore",
-		authIfInstalled,
-		middleware.Proxy(),
-		middleware.EncryptedForm(),
-		RestoreBackup)
+	// Backup and restore routes moved to api/backup package
 }
 
 func InitWebSocketRouter(r *gin.RouterGroup) {

+ 149 - 14
api/upstream/upstream.go

@@ -1,15 +1,23 @@
 package upstream
 
 import (
+	"context"
+	"net/http"
+	"sync"
+	"time"
+
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/upstream"
 	"github.com/gin-gonic/gin"
 	"github.com/gorilla/websocket"
 	"github.com/uozi-tech/cosy/logger"
-	"net/http"
-	"time"
 )
 
+type wsMessage struct {
+	data interface{}
+	done chan error
+}
+
 func AvailabilityTest(c *gin.Context) {
 	var upGrader = websocket.Upgrader{
 		CheckOrigin: func(r *http.Request) bool {
@@ -25,24 +33,151 @@ func AvailabilityTest(c *gin.Context) {
 
 	defer ws.Close()
 
-	var body []string
+	var currentTargets []string
+	var targetsMutex sync.RWMutex
 
-	err = ws.ReadJSON(&body)
+	// Use context to manage goroutine lifecycle
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
 
-	if err != nil {
-		logger.Error(err)
-		return
+	// Use channel to serialize WebSocket write operations, avoiding concurrent conflicts
+	writeChan := make(chan wsMessage, 10)
+	testChan := make(chan bool, 1) // Immediate test signal
+
+	// Create debouncer for test execution
+	testDebouncer := helper.NewDebouncer(300 * time.Millisecond)
+
+	// WebSocket writer goroutine - serialize all write operations
+	go func() {
+		defer logger.Debug("WebSocket writer goroutine stopped")
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case msg := <-writeChan:
+				err := ws.WriteJSON(msg.data)
+				if msg.done != nil {
+					msg.done <- err
+					close(msg.done)
+				}
+				if err != nil {
+					logger.Error("Failed to send WebSocket message:", err)
+					if helper.IsUnexpectedWebsocketError(err) {
+						cancel() // Cancel all goroutines
+					}
+				}
+			}
+		}
+	}()
+
+	// Safe WebSocket write function
+	writeJSON := func(data interface{}) error {
+		done := make(chan error, 1)
+		msg := wsMessage{data: data, done: done}
+
+		select {
+		case writeChan <- msg:
+			return <-done
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(5 * time.Second): // Prevent write blocking
+			return context.DeadlineExceeded
+		}
 	}
 
-	for {
-		err = ws.WriteJSON(upstream.AvailabilityTest(body))
-		if err != nil {
-			if helper.IsUnexpectedWebsocketError(err) {
-				logger.Error(err)
+	// Function to perform availability test
+	performTest := func() {
+		targetsMutex.RLock()
+		targets := make([]string, len(currentTargets))
+		copy(targets, currentTargets)
+		targetsMutex.RUnlock()
+
+		logger.Debug("Performing availability test for targets:", targets)
+
+		if len(targets) > 0 {
+			logger.Debug("Starting upstream.AvailabilityTest...")
+			result := upstream.AvailabilityTest(targets)
+			logger.Debug("Test completed, results:", result)
+
+			logger.Debug("Sending results via WebSocket...")
+			if err := writeJSON(result); err != nil {
+				logger.Error("Failed to send WebSocket message:", err)
+				if helper.IsUnexpectedWebsocketError(err) {
+					cancel() // Cancel all goroutines
+				}
+			} else {
+				logger.Debug("Results sent successfully")
+			}
+		} else {
+			logger.Debug("No targets to test")
+			// Send empty result even if no targets
+			emptyResult := make(map[string]interface{})
+			if err := writeJSON(emptyResult); err != nil {
+				logger.Error("Failed to send empty result:", err)
+			} else {
+				logger.Debug("Empty result sent successfully")
+			}
+		}
+	}
+
+	// Goroutine to handle incoming messages (target updates)
+	go func() {
+		defer logger.Debug("WebSocket reader goroutine stopped")
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			default:
+			}
+
+			var newTargets []string
+			// Set read timeout to avoid blocking
+			ws.SetReadDeadline(time.Now().Add(30 * time.Second))
+			err := ws.ReadJSON(&newTargets)
+			ws.SetReadDeadline(time.Time{}) // Clear deadline
+
+			if err != nil {
+				if helper.IsUnexpectedWebsocketError(err) {
+					logger.Error(err)
+				}
+				cancel() // Cancel all goroutines
+				return
 			}
-			break
+
+			logger.Debug("Received targets from frontend:", newTargets)
+
+			targetsMutex.Lock()
+			currentTargets = newTargets
+			targetsMutex.Unlock()
+
+			// Use debouncer to trigger test execution
+			testDebouncer.Trigger(func() {
+				select {
+				case testChan <- true:
+				default:
+				}
+			})
 		}
+	}()
 
-		time.Sleep(10 * time.Second)
+	// Main testing loop
+	ticker := time.NewTicker(10 * time.Second)
+	defer ticker.Stop()
+
+	logger.Debug("WebSocket connection established, waiting for messages...")
+
+	for {
+		select {
+		case <-ctx.Done():
+			testDebouncer.Stop()
+			logger.Debug("WebSocket connection closed")
+			return
+		case <-testChan:
+			// Debounce triggered test or first test
+			go performTest() // Execute asynchronously to avoid blocking main loop
+		case <-ticker.C:
+			// Periodic test execution
+			go performTest() // Execute asynchronously to avoid blocking main loop
+		}
 	}
 }

+ 74 - 0
api/user/current_user.go

@@ -0,0 +1,74 @@
+package user
+
+import (
+	"net/http"
+
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
+	"golang.org/x/crypto/bcrypt"
+)
+
+func GetCurrentUser(c *gin.Context) {
+	user := api.CurrentUser(c)
+	c.JSON(http.StatusOK, user)
+}
+
+func UpdateCurrentUser(c *gin.Context) {
+	cosy.Core[model.User](c).
+		SetValidRules(gin.H{
+			"name": "required",
+		}).
+		Custom(func(c *cosy.Ctx[model.User]) {
+			user := api.CurrentUser(c.Context)
+			user.Name = c.Model.Name
+
+			db := cosy.UseDB()
+			err := db.Where("id = ?", user.ID).Updates(user).Error
+			if err != nil {
+				cosy.ErrHandler(c.Context, err)
+				return
+			}
+
+			c.JSON(http.StatusOK, user)
+		})
+}
+
+func UpdateCurrentUserPassword(c *gin.Context) {
+	var json struct {
+		OldPassword string `json:"old_password" binding:"required"`
+		NewPassword string `json:"new_password" binding:"required"`
+	}
+
+	if !cosy.BindAndValid(c, &json) {
+		return
+	}
+
+	user := api.CurrentUser(c)
+	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(json.OldPassword)); err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	user.Password = json.NewPassword
+
+	pwdBytes, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
+	if err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	db := cosy.UseDB()
+	err = db.Where("id = ?", user.ID).Updates(&model.User{
+		Password: string(pwdBytes),
+	}).Error
+	if err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}

+ 4 - 0
api/user/router.go

@@ -42,4 +42,8 @@ func InitUserRouter(r *gin.RouterGroup) {
 		o.GET("/recovery_codes", ViewRecoveryCodes)
 		o.GET("/recovery_codes_generate", GenerateRecoveryCodes)
 	}
+
+	r.GET("/user", GetCurrentUser)
+	r.POST("/user", middleware.RequireSecureSession(), UpdateCurrentUser)
+	r.POST("/user/password", middleware.RequireSecureSession(), UpdateCurrentUserPassword)
 }

+ 4 - 18
app/components.d.ts

@@ -23,8 +23,6 @@ declare module 'vue' {
     ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
     AComment: typeof import('ant-design-vue/es')['Comment']
     AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
-    ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
-    ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
     ADivider: typeof import('ant-design-vue/es')['Divider']
     ADrawer: typeof import('ant-design-vue/es')['Drawer']
     ADropdown: typeof import('ant-design-vue/es')['Dropdown']
@@ -46,16 +44,12 @@ declare module 'vue' {
     AMenu: typeof import('ant-design-vue/es')['Menu']
     AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
     AModal: typeof import('ant-design-vue/es')['Modal']
-    APagination: typeof import('ant-design-vue/es')['Pagination']
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
     APopover: typeof import('ant-design-vue/es')['Popover']
     AProgress: typeof import('ant-design-vue/es')['Progress']
     AQrcode: typeof import('ant-design-vue/es')['QRCode']
-    ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
-    ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     AResult: typeof import('ant-design-vue/es')['Result']
     ARow: typeof import('ant-design-vue/es')['Row']
-    ASegmented: typeof import('ant-design-vue/es')['Segmented']
     ASelect: typeof import('ant-design-vue/es')['Select']
     ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
     ASpace: typeof import('ant-design-vue/es')['Space']
@@ -103,7 +97,11 @@ declare module 'vue' {
     NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
     OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
     PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
+    PortScannerPortScanner: typeof import('./src/components/PortScanner/PortScanner.vue')['default']
+    PortScannerPortScannerCompact: typeof import('./src/components/PortScanner/PortScannerCompact.vue')['default']
     ProcessingStatusProcessingStatus: typeof import('./src/components/ProcessingStatus/ProcessingStatus.vue')['default']
+    ProxyTargets: typeof import('./src/components/ProxyTargets.vue')['default']
+    ProxyTargetsProxyTargets: typeof import('./src/components/ProxyTargets/ProxyTargets.vue')['default']
     ReactiveFromNowReactiveFromNow: typeof import('./src/components/ReactiveFromNow/ReactiveFromNow.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
@@ -111,18 +109,6 @@ declare module 'vue' {
     SelfCheckSelfCheckHeaderBanner: typeof import('./src/components/SelfCheck/SelfCheckHeaderBanner.vue')['default']
     SensitiveStringSensitiveString: typeof import('./src/components/SensitiveString/SensitiveString.vue')['default']
     SetLanguageSetLanguage: typeof import('./src/components/SetLanguage/SetLanguage.vue')['default']
-    StdDesignStdDataDisplayStdBatchEdit: typeof import('./src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue')['default']
-    StdDesignStdDataDisplayStdBulkActions: typeof import('./src/components/StdDesign/StdDataDisplay/StdBulkActions.vue')['default']
-    StdDesignStdDataDisplayStdCurd: typeof import('./src/components/StdDesign/StdDataDisplay/StdCurd.vue')['default']
-    StdDesignStdDataDisplayStdCurdDetail: typeof import('./src/components/StdDesign/StdDataDisplay/StdCurdDetail.vue')['default']
-    StdDesignStdDataDisplayStdPagination: typeof import('./src/components/StdDesign/StdDataDisplay/StdPagination.vue')['default']
-    StdDesignStdDataDisplayStdTable: typeof import('./src/components/StdDesign/StdDataDisplay/StdTable.vue')['default']
-    StdDesignStdDataEntryComponentsStdPassword: typeof import('./src/components/StdDesign/StdDataEntry/components/StdPassword.vue')['default']
-    StdDesignStdDataEntryComponentsStdSelect: typeof import('./src/components/StdDesign/StdDataEntry/components/StdSelect.vue')['default']
-    StdDesignStdDataEntryComponentsStdSelector: typeof import('./src/components/StdDesign/StdDataEntry/components/StdSelector.vue')['default']
-    StdDesignStdDataEntryStdDataEntry: typeof import('./src/components/StdDesign/StdDataEntry/StdDataEntry.vue')['default']
-    StdDesignStdDataEntryStdFormItem: typeof import('./src/components/StdDesign/StdDataEntry/StdFormItem.vue')['default']
-    StdDesignStdDetailStdDetail: typeof import('./src/components/StdDesign/StdDetail/StdDetail.vue')['default']
     SwitchAppearanceIconsVPIconMoon: typeof import('./src/components/SwitchAppearance/icons/VPIconMoon.vue')['default']
     SwitchAppearanceIconsVPIconSun: typeof import('./src/components/SwitchAppearance/icons/VPIconSun.vue')['default']
     SwitchAppearanceSwitchAppearance: typeof import('./src/components/SwitchAppearance/SwitchAppearance.vue')['default']

+ 2 - 0
app/eslint.config.mjs

@@ -51,6 +51,8 @@ export default createConfig(
       'sonarjs/no-nested-template-literals': 'off',
       'sonarjs/pseudo-random': 'warn',
       'sonarjs/no-nested-functions': 'off',
+
+      'eslint-comments/no-unlimited-disable': 'off',
     },
   },
 )

+ 11 - 10
app/package.json

@@ -1,8 +1,8 @@
 {
   "name": "nginx-ui-app-next",
   "type": "module",
-  "version": "2.0.2",
-  "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
+  "version": "2.1.0-beta.1",
+  "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
   "scripts": {
     "dev": "vite --host",
     "typecheck": "vue-tsc --noEmit",
@@ -10,14 +10,15 @@
     "lint:fix": "eslint --fix .",
     "build": "vite build",
     "preview": "vite preview",
-    "gettext:extract": "vue-gettext-extract"
+    "gettext:extract": "generate-curd-translations --output src/language/curd.ts && vue-gettext-extract"
   },
   "dependencies": {
     "@0xjacky/vue-github-button": "^3.1.1",
     "@ant-design/icons-vue": "^7.0.1",
     "@formkit/auto-animate": "^0.8.2",
     "@simplewebauthn/browser": "^13.1.0",
-    "@uozi-admin/curd": "^4.1.4",
+    "@uozi-admin/curd": "^4.3.2",
+    "@uozi-admin/request": "^2.7.1",
     "@vue/reactivity": "^3.5.14",
     "@vue/shared": "^3.5.14",
     "@vueuse/components": "^13.2.0",
@@ -33,14 +34,14 @@
     "highlight.js": "^11.11.1",
     "jsencrypt": "^3.3.2",
     "lodash": "^4.17.21",
-    "marked": "^15.0.11",
+    "marked": "^15.0.12",
     "marked-highlight": "^2.2.1",
     "nprogress": "^0.2.0",
     "pinia": "^3.0.2",
     "pinia-plugin-persistedstate": "^4.3.0",
     "reconnecting-websocket": "^4.4.0",
     "sortablejs": "^1.15.6",
-    "splitpanes": "^4.0.3",
+    "splitpanes": "^4.0.4",
     "sse.js": "^2.6.0",
     "universal-cookie": "^8.0.1",
     "unocss": "^66.1.2",
@@ -56,18 +57,18 @@
     "vuedraggable": "^4.1.0"
   },
   "devDependencies": {
-    "@antfu/eslint-config": "^4.13.1",
+    "@antfu/eslint-config": "^4.13.2",
     "@iconify-json/fa": "1.2.1",
     "@iconify-json/tabler": "^1.2.18",
     "@iconify/tools": "^4.1.2",
     "@iconify/types": "^2.0.0",
     "@iconify/utils": "^2.3.0",
     "@iconify/vue": "^5.0.0",
-    "@types/lodash": "^4.17.16",
+    "@types/lodash": "^4.17.17",
     "@types/nprogress": "^0.2.3",
     "@types/sortablejs": "^1.15.8",
     "@vitejs/plugin-vue": "^5.2.4",
-    "@vitejs/plugin-vue-jsx": "^4.1.2",
+    "@vitejs/plugin-vue-jsx": "^4.2.0",
     "@vue/compiler-sfc": "^3.5.14",
     "@vue/tsconfig": "^0.7.0",
     "ace-builds": "^1.41.0",
@@ -81,7 +82,7 @@
     "unplugin-vue-components": "^28.5.0",
     "unplugin-vue-define-options": "^1.5.5",
     "vite": "^6.3.5",
-    "vite-plugin-inspect": "^11.0.1",
+    "vite-plugin-inspect": "^11.1.0",
     "vite-svg-loader": "^5.1.0",
     "vue-tsc": "^2.2.10"
   }

File diff suppressed because it is too large
+ 206 - 237
app/pnpm-lock.yaml


+ 1 - 1
app/src/api/2fa.ts

@@ -1,5 +1,5 @@
 import type { AuthenticationResponseJSON } from '@simplewebauthn/browser'
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface TwoFAStatus {
   enabled: boolean

+ 5 - 12
app/src/api/acme_user.ts

@@ -1,6 +1,5 @@
 import type { ModelBase } from '@/api/curd'
-import Curd from '@/api/curd'
-import http from '@/lib/http'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export interface AcmeUser extends ModelBase {
   name: string
@@ -9,16 +8,10 @@ export interface AcmeUser extends ModelBase {
   registration: { body?: { status: string } }
 }
 
-class ACMEUserCurd extends Curd<AcmeUser> {
-  constructor() {
-    super('acme_users')
-  }
+const baseUrl = '/acme_users'
 
-  public async register(id: number) {
-    return http.post(`${this.baseUrl}/${id}/register`)
-  }
-}
-
-const acme_user = new ACMEUserCurd()
+const acme_user = extendCurdApi(useCurdApi<AcmeUser>(baseUrl), {
+  register: (id: number) => http.post(`${baseUrl}/${id}/register`),
+})
 
 export default acme_user

+ 1 - 1
app/src/api/analytic.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 import ws from '@/lib/websocket'
 
 export interface CPUInfoStat {

+ 1 - 1
app/src/api/auth.ts

@@ -1,5 +1,5 @@
 import type { AuthenticationResponseJSON } from '@simplewebauthn/browser'
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 import { useUserStore } from '@/pinia'
 
 const { login, logout } = useUserStore()

+ 1 - 1
app/src/api/auto_cert.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export const AutoCertChallengeMethod = {
   http01: 'http01',

+ 36 - 3
app/src/api/backup.ts

@@ -1,4 +1,5 @@
-import http from '@/lib/http'
+import type { ModelBase } from '@/api/curd'
+import { http, useCurdApi } from '@uozi-admin/request'
 
 /**
  * Interface for restore backup response
@@ -21,13 +22,34 @@ export interface RestoreOptions {
   verify_hash: boolean
 }
 
+/**
+ * Interface for auto backup configuration
+ */
+export interface AutoBackup extends ModelBase {
+  name: string
+  backup_type: 'nginx_config' | 'nginx_ui_config' | 'both_config' | 'custom_dir'
+  storage_type: 'local' | 's3'
+  backup_path?: string
+  storage_path: string
+  cron_expression: string
+  enabled: boolean
+  last_backup_time?: string
+  last_backup_status: 'pending' | 'success' | 'failed'
+  last_backup_error?: string
+  s3_endpoint?: string
+  s3_access_key_id?: string
+  s3_secret_access_key?: string
+  s3_bucket?: string
+  s3_region?: string
+}
+
 const backup = {
   /**
    * Create and download a backup of nginx-ui and nginx configurations
    * Use http module with returnFullResponse option to access headers
    */
   createBackup() {
-    return http.get('/system/backup', {
+    return http.get('/backup', {
       responseType: 'blob',
       returnFullResponse: true,
     })
@@ -45,7 +67,7 @@ const backup = {
     formData.append('restore_nginx_ui', options.restore_nginx_ui.toString())
     formData.append('verify_hash', options.verify_hash.toString())
 
-    return http.post('/system/backup/restore', formData, {
+    return http.post('/restore', formData, {
       headers: {
         'Content-Type': 'multipart/form-data;charset=UTF-8',
       },
@@ -54,4 +76,15 @@ const backup = {
   },
 }
 
+/**
+ * Test S3 connection for auto backup configuration
+ * @param config AutoBackup configuration with S3 settings
+ */
+export function testS3Connection(config: AutoBackup) {
+  return http.post('/auto_backup/test_s3', config)
+}
+
+// Auto backup CRUD API
+export const autoBackup = useCurdApi<AutoBackup>('/auto_backup')
+
 export default backup

+ 2 - 2
app/src/api/cert.ts

@@ -3,7 +3,7 @@ import type { AcmeUser } from '@/api/acme_user'
 import type { ModelBase } from '@/api/curd'
 import type { DnsCredential } from '@/api/dns_credential'
 import type { PrivateKeyType } from '@/constants'
-import Curd from '@/api/curd'
+import { useCurdApi } from '@uozi-admin/request'
 
 export interface Cert extends ModelBase {
   name: string
@@ -39,6 +39,6 @@ export interface CertificateResult {
   key_type: PrivateKeyType
 }
 
-const cert: Curd<Cert> = new Curd('/certs')
+const cert = useCurdApi<Cert>('/certs')
 
 export default cert

+ 12 - 30
app/src/api/config.ts

@@ -1,7 +1,6 @@
 import type { GetListResponse } from '@/api/curd'
 import type { ChatComplicationMessage } from '@/api/openai'
-import Curd from '@/api/curd'
-import http from '@/lib/http'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export interface ModelBase {
   id: number
@@ -26,33 +25,16 @@ export interface ConfigBackup extends ModelBase {
   content: string
 }
 
-class ConfigCurd extends Curd<Config> {
-  constructor() {
-    super('/configs')
-  }
-
-  get_base_path() {
-    return http.get('/config_base_path')
-  }
-
-  mkdir(basePath: string, name: string) {
-    return http.post('/config_mkdir', { base_path: basePath, folder_name: name })
-  }
-
-  rename(basePath: string, origName: string, newName: string, syncNodeIds?: number[]) {
-    return http.post('/config_rename', {
-      base_path: basePath,
-      orig_name: origName,
-      new_name: newName,
-      sync_node_ids: syncNodeIds,
-    })
-  }
-
-  get_history(filepath: string) {
-    return http.get<GetListResponse<ConfigBackup>>('/config_histories', { params: { filepath } })
-  }
-}
-
-const config: ConfigCurd = new ConfigCurd()
+const config = extendCurdApi(useCurdApi<Config>('/configs'), {
+  get_base_path: () => http.get('/config_base_path'),
+  mkdir: (basePath: string, name: string) => http.post('/config_mkdir', { base_path: basePath, folder_name: name }),
+  rename: (basePath: string, origName: string, newName: string, syncNodeIds?: number[]) => http.post('/config_rename', {
+    base_path: basePath,
+    orig_name: origName,
+    new_name: newName,
+    sync_node_ids: syncNodeIds,
+  }),
+  get_history: (filepath: string) => http.get<GetListResponse<ConfigBackup>>('/config_histories', { params: { filepath } }),
+})
 
 export default config

+ 4 - 92
app/src/api/curd.ts

@@ -1,5 +1,3 @@
-import http from '@/lib/http'
-
 export interface ModelBase {
   id: number
   created_at: string
@@ -18,94 +16,8 @@ export interface GetListResponse<T> {
   pagination?: Pagination
 }
 
-class Curd<T> {
-  protected readonly baseUrl: string
-
-  get_list = this._get_list.bind(this)
-  get = this._get.bind(this)
-  save = this._save.bind(this)
-  import = this._import.bind(this)
-  import_check = this._import_check.bind(this)
-  destroy = this._destroy.bind(this)
-  recover = this._recover.bind(this)
-  update_order = this._update_order.bind(this)
-  batch_save = this._batch_save.bind(this)
-  batch_destroy = this._batch_destroy.bind(this)
-  batch_recover = this._batch_recover.bind(this)
-
-  constructor(baseUrl: string) {
-    this.baseUrl = baseUrl
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _get_list(params: any = null): Promise<GetListResponse<T>> {
-    return http.get(this.baseUrl, { params })
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _get(id: any = null, params: any = {}): Promise<T> {
-    return http.get(this.baseUrl + (id ? `/${encodeURIComponent(id)}` : ''), { params })
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _save(id: any = null, data: any = {}, config: any = undefined): Promise<T> {
-    return http.post(this.baseUrl + (id ? `/${encodeURIComponent(id)}` : ''), data, config)
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _import_check(formData: FormData, config: any = {}): Promise<T> {
-    return http.post(`${this.baseUrl}/import_check`, formData, {
-      headers: {
-        'Content-Type': 'multipart/form-data;charset=UTF-8',
-      },
-      ...config,
-    })
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _import(data: any, config: any = {}): Promise<T> {
-    return http.post(`${this.baseUrl}/import`, data, config)
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _destroy(id: any = null, params: any = {}) {
-    return http.delete(`${this.baseUrl}/${encodeURIComponent(id)}`, { params })
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _recover(id: any = null) {
-    return http.patch(`${this.baseUrl}/${encodeURIComponent(id)}`)
-  }
-
-  _update_order(data: { target_id: number, direction: number, affected_ids: number[] }) {
-    return http.post(`${this.baseUrl}/order`, data)
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _batch_save(ids: any, data: any) {
-    return http.put(this.baseUrl, {
-      ids,
-      data,
-    })
-  }
-
-  // eslint-disable-next-line ts/no-explicit-any
-  _batch_destroy(ids?: (string | number)[], params: any = {}) {
-    return http.delete(this.baseUrl, {
-      params,
-      data: {
-        ids,
-      },
-    })
-  }
-
-  _batch_recover(ids?: (string | number)[]) {
-    return http.patch(this.baseUrl, {
-      data: {
-        ids,
-      },
-    })
-  }
+export interface UpdateOrderRequest {
+  target_id: number
+  direction: number
+  affected_ids: number[]
 }
-
-export default Curd

+ 4 - 2
app/src/api/dns_credential.ts

@@ -1,6 +1,6 @@
 import type { DNSProvider } from '@/api/auto_cert'
 import type { ModelBase } from '@/api/curd'
-import Curd from '@/api/curd'
+import { useCurdApi } from '@uozi-admin/request'
 
 export interface DnsCredential extends ModelBase {
   name: string
@@ -17,6 +17,8 @@ export interface DnsCredential extends ModelBase {
   }
 }
 
-const dns_credential: Curd<DnsCredential> = new Curd('/dns_credentials')
+const baseUrl = '/dns_credentials'
+
+const dns_credential = useCurdApi<DnsCredential>(baseUrl)
 
 export default dns_credential

+ 11 - 4
app/src/api/env_group.ts

@@ -1,6 +1,5 @@
-import type { ModelBase } from '@/api/curd'
-import Curd from '@/api/curd'
-
+import type { ModelBase, UpdateOrderRequest } from '@/api/curd'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 // Post-sync action types
 export const PostSyncAction = {
   None: 'none',
@@ -13,4 +12,12 @@ export interface EnvGroup extends ModelBase {
   post_sync_action?: string
 }
 
-export default new Curd<EnvGroup>('/env_groups')
+const baseUrl = '/env_groups'
+
+const env_group = extendCurdApi(useCurdApi<EnvGroup>(baseUrl), {
+  updateOrder(data: UpdateOrderRequest) {
+    return http.post('/env_groups/order', data)
+  },
+})
+
+export default env_group

+ 5 - 12
app/src/api/environment.ts

@@ -1,6 +1,5 @@
 import type { ModelBase } from '@/api/curd'
-import Curd from '@/api/curd'
-import http from '@/lib/http'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export interface Environment extends ModelBase {
   name: string
@@ -16,16 +15,10 @@ export interface Node {
   response_at?: Date
 }
 
-class EnvironmentCurd extends Curd<Environment> {
-  constructor() {
-    super('/environments')
-  }
+const baseUrl = '/environments'
 
-  load_from_settings() {
-    return http.post(`${this.baseUrl}/load_from_settings`)
-  }
-}
-
-const environment: EnvironmentCurd = new EnvironmentCurd()
+const environment = extendCurdApi(useCurdApi<Environment>(baseUrl), {
+  load_from_settings: () => http.post(`${baseUrl}/load_from_settings`),
+})
 
 export default environment

+ 3 - 7
app/src/api/external_notify.ts

@@ -1,17 +1,13 @@
 import type { ModelBase } from '@/api/curd'
-import Curd from '@/api/curd'
+import { useCurdApi } from '@uozi-admin/request'
 
 export interface ExternalNotify extends ModelBase {
   type: string
   config: Record<string, string>
 }
 
-class ExternalNotifyCurd extends Curd<ExternalNotify> {
-  constructor() {
-    super('/external_notifies')
-  }
-}
+const baseUrl = '/external_notifies'
 
-const externalNotify: ExternalNotifyCurd = new ExternalNotifyCurd()
+const externalNotify = useCurdApi<ExternalNotify>(baseUrl)
 
 export default externalNotify

+ 1 - 1
app/src/api/install.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface InstallRequest {
   email: string

+ 2 - 2
app/src/api/nginx_log.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface INginxLogData {
   type: string
@@ -10,7 +10,7 @@ const nginx_log = {
     return http.post(`/nginx_log?page=${page}`, data)
   },
 
-  get_list(params: {
+  getList(params: {
     type?: string
     name?: string
     path?: string

+ 1 - 1
app/src/api/ngx.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface NgxConfig {
   file_name?: string

+ 1 - 1
app/src/api/node.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 function reloadNginx(nodeIds: number[]) {
   return http.post('/environments/reload_nginx', { node_ids: nodeIds })

+ 5 - 8
app/src/api/notification.ts

@@ -1,6 +1,5 @@
 import type { ModelBase } from '@/api/curd'
-import Curd from '@/api/curd'
-import http from '@/lib/http'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export interface Notification extends ModelBase {
   type: string
@@ -8,12 +7,10 @@ export interface Notification extends ModelBase {
   details: string
 }
 
-class NotificationCurd extends Curd<Notification> {
-  public clear() {
-    return http.delete(this.baseUrl)
-  }
-}
+const baseUrl = '/notifications'
 
-const notification = new NotificationCurd('/notifications')
+const notification = extendCurdApi(useCurdApi<Notification>(baseUrl), {
+  clear: () => http.delete(baseUrl),
+})
 
 export default notification

+ 1 - 1
app/src/api/openai.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 import ws from '@/lib/websocket'
 
 export interface ChatComplicationMessage {

+ 1 - 1
app/src/api/otp.ts

@@ -1,5 +1,5 @@
 import type { RecoveryCodesResponse } from '@/api/recovery'
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface OTPGenerateSecretResponse {
   secret: string

+ 2 - 2
app/src/api/passkey.ts

@@ -1,6 +1,6 @@
 import type { RegistrationResponseJSON } from '@simplewebauthn/browser'
 import type { ModelBase } from '@/api/curd'
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface Passkey extends ModelBase {
   name: string
@@ -19,7 +19,7 @@ const passkey = {
       },
     })
   },
-  get_list() {
+  getList() {
     return http.get('/passkeys')
   },
   update(passkeyId: number, data: Passkey) {

+ 27 - 0
app/src/api/port_scan.ts

@@ -0,0 +1,27 @@
+import { http } from '@uozi-admin/request'
+
+export interface PortScanRequest {
+  start_port: number
+  end_port: number
+  page: number
+  page_size: number
+}
+
+export interface PortInfo {
+  port: number
+  status: string
+  process: string
+}
+
+export interface PortScanResponse {
+  data: PortInfo[]
+  total: number
+  page: number
+  page_size: number
+}
+
+const portScan = {
+  scan: (data: PortScanRequest) => http.post<PortScanResponse>('/system/port_scan', data),
+}
+
+export default portScan

+ 1 - 1
app/src/api/public.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface ICP {
   icp_number: string

+ 1 - 1
app/src/api/recovery.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface RecoveryCode {
   code: string

+ 1 - 1
app/src/api/self_check.ts

@@ -1,6 +1,6 @@
 import type { Container } from '@/language'
 import type { CosyError } from '@/lib/http'
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 import ws from '@/lib/websocket'
 
 export const ReportStatus = {

+ 1 - 1
app/src/api/settings.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface AppSettings {
   page_size: number

+ 21 - 42
app/src/api/site.ts

@@ -4,11 +4,16 @@ import type { EnvGroup } from '@/api/env_group'
 import type { NgxConfig } from '@/api/ngx'
 import type { ChatComplicationMessage } from '@/api/openai'
 import type { ConfigStatus, PrivateKeyType } from '@/constants'
-import Curd from '@/api/curd'
-import http from '@/lib/http'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export type SiteStatus = ConfigStatus.Enabled | ConfigStatus.Disabled | ConfigStatus.Maintenance
 
+export interface ProxyTarget {
+  host: string
+  port: string
+  type: string // "proxy_pass" or "upstream"
+}
+
 export interface Site extends ModelBase {
   modified_at: string
   path: string
@@ -24,6 +29,7 @@ export interface Site extends ModelBase {
   env_group?: EnvGroup
   sync_node_ids: number[]
   urls?: string[]
+  proxy_targets?: ProxyTarget[]
   status: SiteStatus
 }
 
@@ -34,45 +40,18 @@ export interface AutoCertRequest {
   key_type: PrivateKeyType
 }
 
-class SiteCurd extends Curd<Site> {
-  // eslint-disable-next-line ts/no-explicit-any
-  enable(name: string, config?: any) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/enable`, undefined, config)
-  }
-
-  disable(name: string) {
-    return http.post(`${this.baseUrl}/${name}/disable`)
-  }
-
-  rename(oldName: string, newName: string) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(oldName)}/rename`, { new_name: newName })
-  }
-
-  get_default_template() {
-    return http.get('default_site_template')
-  }
-
-  add_auto_cert(domain: string, data: AutoCertRequest) {
-    return http.post(`auto_cert/${encodeURIComponent(domain)}`, data)
-  }
-
-  remove_auto_cert(domain: string) {
-    return http.delete(`auto_cert/${encodeURIComponent(domain)}`)
-  }
-
-  duplicate(name: string, data: { name: string }): Promise<{ dst: string }> {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/duplicate`, data)
-  }
-
-  advance_mode(name: string, data: { advanced: boolean }) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/advance`, data)
-  }
-
-  enableMaintenance(name: string) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/maintenance`)
-  }
-}
-
-const site = new SiteCurd('/sites')
+const baseUrl = '/sites'
+
+const site = extendCurdApi(useCurdApi<Site>(baseUrl), {
+  enable: (name: string) => http.post(`${baseUrl}/${encodeURIComponent(name)}/enable`),
+  disable: (name: string) => http.post(`${baseUrl}/${name}/disable`),
+  rename: (oldName: string, newName: string) => http.post(`${baseUrl}/${encodeURIComponent(oldName)}/rename`, { new_name: newName }),
+  get_default_template: () => http.get('default_site_template'),
+  add_auto_cert: (domain: string, data: AutoCertRequest) => http.post(`auto_cert/${encodeURIComponent(domain)}`, data),
+  remove_auto_cert: (domain: string) => http.delete(`auto_cert/${encodeURIComponent(domain)}`),
+  duplicate: (name: string, data: { name: string }) => http.post(`${baseUrl}/${encodeURIComponent(name)}/duplicate`, data),
+  advance_mode: (name: string, data: { advanced: boolean }) => http.post(`${baseUrl}/${encodeURIComponent(name)}/advance`, data),
+  enableMaintenance: (name: string) => http.post(`${baseUrl}/${encodeURIComponent(name)}/maintenance`),
+})
 
 export default site

+ 12 - 26
app/src/api/stream.ts

@@ -1,13 +1,13 @@
 import type { EnvGroup } from './env_group'
 import type { NgxConfig } from '@/api/ngx'
 import type { ChatComplicationMessage } from '@/api/openai'
-import Curd from '@/api/curd'
-import http from '@/lib/http'
+import type { ProxyTarget, SiteStatus } from '@/api/site'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export interface Stream {
   modified_at: string
   advanced: boolean
-  enabled: boolean
+  status: SiteStatus
   name: string
   filepath: string
   config: string
@@ -16,31 +16,17 @@ export interface Stream {
   env_group_id: number
   env_group?: EnvGroup
   sync_node_ids: number[]
+  proxy_targets?: ProxyTarget[]
 }
 
-class StreamCurd extends Curd<Stream> {
-  // eslint-disable-next-line ts/no-explicit-any
-  enable(name: string, config?: any) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/enable`, undefined, config)
-  }
+const baseUrl = '/streams'
 
-  disable(name: string) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/disable`)
-  }
-
-  duplicate(name: string, data: { name: string }): Promise<{ dst: string }> {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/duplicate`, data)
-  }
-
-  advance_mode(name: string, data: { advanced: boolean }) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/advance`, data)
-  }
-
-  rename(name: string, newName: string) {
-    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/rename`, { new_name: newName })
-  }
-}
-
-const stream = new StreamCurd('/streams')
+const stream = extendCurdApi(useCurdApi<Stream>(baseUrl), {
+  enable: (name: string) => http.post(`${baseUrl}/${encodeURIComponent(name)}/enable`),
+  disable: (name: string) => http.post(`${baseUrl}/${encodeURIComponent(name)}/disable`),
+  duplicate: (name: string, data: { name: string }) => http.post(`${baseUrl}/${encodeURIComponent(name)}/duplicate`, data),
+  advance_mode: (name: string, data: { advanced: boolean }) => http.post(`${baseUrl}/${encodeURIComponent(name)}/advance`, data),
+  rename: (name: string, newName: string) => http.post(`${baseUrl}/${encodeURIComponent(name)}/rename`, { new_name: newName }),
+})
 
 export default stream

+ 9 - 26
app/src/api/template.ts

@@ -1,6 +1,5 @@
 import type { NgxDirective, NgxLocation, NgxServer } from '@/api/ngx'
-import Curd from '@/api/curd'
-import http from '@/lib/http'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export interface Variable {
   type?: string
@@ -21,30 +20,14 @@ export interface Template extends NgxServer {
   directives?: NgxDirective[]
 }
 
-class TemplateApi extends Curd<Template> {
-  get_config_list() {
-    return http.get('templates/configs')
-  }
+const baseUrl = '/templates'
 
-  get_block_list() {
-    return http.get<{
-      data: Template[]
-    }>('templates/blocks')
-  }
-
-  get_config(name: string) {
-    return http.get(`templates/config/${name}`)
-  }
-
-  get_block(name: string) {
-    return http.get<Template>(`templates/block/${name}`)
-  }
-
-  build_block(name: string, data: Variable) {
-    return http.post(`templates/block/${name}`, data)
-  }
-}
-
-const template = new TemplateApi('/templates')
+const template = extendCurdApi(useCurdApi<Template>(baseUrl), {
+  get_config_list: () => http.get(`${baseUrl}/configs`),
+  get_block_list: () => http.get(`${baseUrl}/blocks`),
+  get_config: (name: string) => http.get(`${baseUrl}/config/${name}`),
+  get_block: (name: string) => http.get(`${baseUrl}/block/${name}`),
+  build_block: (name: string, data: Variable) => http.post(`${baseUrl}/block/${name}`, data),
+})
 
 export default template

+ 1 - 1
app/src/api/upgrade.ts

@@ -1,4 +1,4 @@
-import http from '@/lib/http'
+import { http } from '@uozi-admin/request'
 
 export interface RuntimeInfo {
   name: string

+ 14 - 2
app/src/api/user.ts

@@ -1,11 +1,23 @@
 import type { ModelBase } from '@/api/curd'
-import Curd from '@/api/curd'
+import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
 
 export interface User extends ModelBase {
   name: string
   password: string
+  enabled_2fa: boolean
+  status: boolean
 }
 
-const user: Curd<User> = new Curd('users')
+const user = extendCurdApi(useCurdApi<User>('/users'), {
+  getCurrentUser: () => {
+    return http.get('/user')
+  },
+  updateCurrentUser: (data: User) => {
+    return http.post('/user', data)
+  },
+  updateCurrentUserPassword: (data: { old_password: string, new_password: string }) => {
+    return http.post('/user/password', data)
+  },
+})
 
 export default user

+ 2 - 2
app/src/components/AutoCertForm/DNSChallenge.vue

@@ -42,7 +42,7 @@ watch(current, () => {
   if (mounted.value)
     data.value.dns_credential_id = undefined
 
-  dns_credential.get_list({ provider: data.value.provider }).then(r => {
+  dns_credential.getList({ provider: data.value.provider }).then(r => {
     r.data.forEach(v => {
       credentials.value?.push({
         value: v.id,
@@ -60,7 +60,7 @@ onMounted(async () => {
   })
 
   if (data.value.dns_credential_id) {
-    await dns_credential.get(data.value.dns_credential_id).then(r => {
+    await dns_credential.getItem(data.value.dns_credential_id).then(r => {
       data.value.code = r.code
       data.value.provider = r.provider
       providerIdx.value = providers.value.findIndex(v => v.code === r.code)

+ 8 - 7
app/src/components/ConfigHistory/ConfigHistory.vue

@@ -1,12 +1,10 @@
-<script setup lang="ts">
+<script setup lang="tsx">
 import type { Key } from 'ant-design-vue/es/_util/type'
 import type { ConfigBackup } from '@/api/config'
 import type { GetListResponse } from '@/api/curd'
+import { datetimeRender, StdPagination } from '@uozi-admin/curd'
 import { message } from 'ant-design-vue'
-import { defineAsyncComponent } from 'vue'
 import config from '@/api/config'
-import StdPagination from '@/components/StdDesign/StdDataDisplay/StdPagination.vue'
-import { datetime } from '../StdDesign/StdDataDisplay/StdTableTransformer'
 
 // Define props for the component
 const props = defineProps<{
@@ -54,7 +52,9 @@ const columns = [
   {
     title: () => $gettext('Modified At'),
     dataIndex: 'created_at',
-    customRender: datetime,
+    customRender: args => {
+      return <span>{datetimeRender(args)}</span>
+    },
   },
 ]
 
@@ -152,8 +152,9 @@ const compareButtonText = computed(() => {
 
         <div class="history-footer">
           <StdPagination
-            :pagination="pagination"
-            :loading="loading"
+            size="small"
+            :pagination
+            :loading
             @change="changePage"
           />
 

+ 2 - 2
app/src/components/NgxConfigEditor/directive/DirectiveEditorItem.vue

@@ -27,7 +27,7 @@ const content = ref('')
 
 function init() {
   if (directive.value.directive === Include) {
-    config.get(directive.value.params).then(r => {
+    config.getItem(directive.value.params).then(r => {
       content.value = r.content
     })
   }
@@ -38,7 +38,7 @@ init()
 watch(props, init)
 
 function save() {
-  config.save(directive.value.params, { content: content.value }).then(r => {
+  config.updateItem(directive.value.params, { content: content.value }).then(r => {
     content.value = r.content
     message.success($gettext('Saved successfully'))
   }).catch(r => {

+ 3 - 4
app/src/components/Notification/Notification.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import type { Ref } from 'vue'
 import type { Notification } from '@/api/notification'
-import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { BellOutlined, CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, InfoCircleOutlined, WarningOutlined } from '@ant-design/icons-vue'
 import { message, notification } from 'ant-design-vue'
 import dayjs from 'dayjs'
@@ -39,14 +38,14 @@ connect({
 
     notification[typeTrans[data.type]]({
       message: $gettext(data.title),
-      description: detailRender({ text: data.details, record: data } as CustomRender),
+      description: detailRender({ text: data.details, record: data }),
     })
   },
 })
 
 function init() {
   loading.value = true
-  notificationApi.get_list({ sort: 'desc', order_by: 'created_at' }).then(r => {
+  notificationApi.getList({ sort: 'desc', order_by: 'created_at' }).then(r => {
     data.value = r.data
     unreadCount.value = r.pagination?.total || 0
   }).finally(() => {
@@ -75,7 +74,7 @@ function clear() {
 }
 
 function remove(id: number) {
-  notificationApi.destroy(id).then(() => {
+  notificationApi.deleteItem(id).then(() => {
     message.success($gettext('Removed successfully'))
     init()
   })

+ 2 - 2
app/src/components/Notification/detailRender.tsx

@@ -1,8 +1,8 @@
-import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRenderArgs } from '@uozi-admin/curd'
 import { NotificationTypeT } from '@/constants'
 import notifications from './notifications'
 
-export function detailRender(args: CustomRender) {
+export function detailRender(args: Pick<CustomRenderArgs, 'record' | 'text'>) {
   try {
     return (
       <div>

+ 282 - 0
app/src/components/PortScanner/PortScannerCompact.vue

@@ -0,0 +1,282 @@
+<script setup lang="ts">
+import type { PortInfo, PortScanRequest } from '@/api/port_scan'
+import { Badge, message } from 'ant-design-vue'
+import { computed, reactive, ref } from 'vue'
+import portScan from '@/api/port_scan'
+
+interface FormData {
+  startPort: number
+  endPort: number
+}
+
+const loading = ref(false)
+const formData = reactive<FormData>({
+  startPort: 80,
+  endPort: 8080,
+})
+
+const tableData = ref<PortInfo[]>([])
+const pagination = reactive({
+  current: 1,
+  pageSize: 20,
+  total: 0,
+  showSizeChanger: false,
+  showQuickJumper: false,
+  simple: true,
+  size: 'small' as const,
+})
+
+const columns = [
+  {
+    title: $gettext('Port'),
+    dataIndex: 'port',
+    key: 'port',
+    width: 60,
+  },
+  {
+    title: $gettext('Status'),
+    dataIndex: 'status',
+    key: 'status',
+    width: 80,
+    customRender: ({ text }: { text: string }) => {
+      const statusMap = {
+        listening: { color: 'orange', text: $gettext('Listening') },
+        open: { color: 'blue', text: $gettext('Open') },
+        closed: { color: 'green', text: $gettext('Closed') },
+      }
+      const status = statusMap[text as keyof typeof statusMap] || { status: 'error', text: $gettext('Unknown') }
+      return h(Badge, {
+        color: status.color,
+        text: h('span', { style: 'font-size: 11px;' }, status.text),
+      })
+    },
+  },
+  {
+    title: $gettext('Process'),
+    dataIndex: 'process',
+    key: 'process',
+    ellipsis: true,
+    customRender: ({ text }: { text: string }) => {
+      if (!text)
+        return '-'
+
+      // Extract process name from format like "1234/nginx: master process" or "1234/nginx"
+      // Use a more specific regex to avoid backtracking issues
+      const match = text.match(/^\d+\/([^:]+)/)
+      if (match) {
+        const processName = match[1].trim()
+        return h('span', { title: text, style: 'font-size: 12px;' }, processName)
+      }
+
+      // Fallback: if no match, show first 10 characters
+      return h('span', { title: text, style: 'font-size: 12px;' }, text.substring(0, 10))
+    },
+  },
+]
+
+const isFormValid = computed(() => {
+  return formData.startPort >= 1
+    && formData.endPort <= 65535
+    && formData.startPort <= formData.endPort
+})
+
+async function scanPorts() {
+  if (!isFormValid.value) {
+    message.error($gettext('Please enter a valid port range'))
+    return
+  }
+
+  loading.value = true
+  pagination.current = 1
+
+  try {
+    await loadData()
+    message.success($gettext('Scan completed'))
+  }
+  catch (error) {
+    console.error('Port scan failed:', error)
+    message.error($gettext('Scan failed'))
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+async function loadData() {
+  const request: PortScanRequest = {
+    start_port: formData.startPort,
+    end_port: formData.endPort,
+    page: pagination.current,
+    page_size: pagination.pageSize,
+  }
+
+  const response = await portScan.scan(request)
+  tableData.value = response.data
+  pagination.total = response.total
+}
+
+async function handleTableChange(pag: { current?: number, pageSize?: number }) {
+  if (pag.current) {
+    pagination.current = pag.current
+  }
+
+  if (pagination.total > 0) {
+    loading.value = true
+    try {
+      await loadData()
+    }
+    finally {
+      loading.value = false
+    }
+  }
+}
+
+function quickScan(start: number, end: number) {
+  formData.startPort = start
+  formData.endPort = end
+  scanPorts()
+}
+</script>
+
+<template>
+  <div class="port-scanner-compact">
+    <div class="scan-form">
+      <ASpace direction="vertical" size="small" style="width: 100%">
+        <ARow :gutter="8">
+          <ACol :span="11">
+            <AInputNumber
+              v-model:value="formData.startPort"
+              :min="1"
+              :max="65535"
+              :placeholder="$gettext('Start')"
+              size="small"
+              style="width: 100%"
+            />
+          </ACol>
+          <ACol :span="2" class="text-center">
+            <span style="line-height: 24px;">-</span>
+          </ACol>
+          <ACol :span="11">
+            <AInputNumber
+              v-model:value="formData.endPort"
+              :min="1"
+              :max="65535"
+              :placeholder="$gettext('End')"
+              size="small"
+              style="width: 100%"
+            />
+          </ACol>
+        </ARow>
+
+        <AButton
+          type="primary"
+          size="small"
+          :loading="loading"
+          :disabled="!isFormValid"
+          block
+          @click="scanPorts"
+        >
+          {{ $gettext('Scan Ports') }}
+        </AButton>
+
+        <div class="quick-actions">
+          <ASpace size="small" wrap>
+            <AButton size="small" @click="quickScan(80, 443)">
+              Web
+            </AButton>
+            <AButton size="small" @click="quickScan(20, 22)">
+              SSH/FTP
+            </AButton>
+            <AButton size="small" @click="quickScan(3306, 5432)">
+              DB
+            </AButton>
+            <AButton size="small" @click="quickScan(1, 1024)">
+              {{ $gettext('System') }}
+            </AButton>
+          </ASpace>
+        </div>
+      </ASpace>
+    </div>
+
+    <div v-if="pagination.total > 0" class="scan-results">
+      <ADivider style="margin: 12px 0;">
+        <span style="font-size: 12px; color: #666;">
+          {{ $gettext('Scan Results') }} ({{ pagination.total }})
+        </span>
+      </ADivider>
+
+      <ATable
+        :columns="columns"
+        :data-source="tableData"
+        :pagination="pagination"
+        :loading="loading"
+        size="small"
+        :scroll="{ y: 300 }"
+        @change="handleTableChange"
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+.port-scanner-compact {
+  .scan-form {
+    padding: 8px 0;
+  }
+
+  .quick-actions {
+    :deep(.ant-btn) {
+      font-size: 11px;
+      height: 20px;
+      padding: 0 6px;
+    }
+  }
+
+  .scan-results {
+    :deep(.ant-table) {
+      font-size: 12px;
+
+      .ant-table-thead > tr > th {
+        padding: 4px 8px;
+        font-size: 11px;
+        background-color: var(--ant-color-fill-alter);
+      }
+
+      .ant-table-tbody > tr > td {
+        padding: 4px 8px;
+      }
+
+      .ant-pagination {
+        margin: 8px 0 0 0;
+        text-align: center;
+
+        .ant-pagination-item,
+        .ant-pagination-prev,
+        .ant-pagination-next {
+          min-width: 24px;
+          height: 24px;
+          line-height: 22px;
+          font-size: 12px;
+        }
+      }
+    }
+
+    // Custom styling for badge status indicators
+    :deep(.ant-badge) {
+      .ant-badge-status-dot {
+        width: 8px;
+        height: 8px;
+      }
+
+      .ant-badge-status-text {
+        margin-left: 6px;
+        font-size: 11px;
+      }
+    }
+  }
+
+  .text-center {
+    text-align: center;
+  }
+}
+</style>

+ 1 - 0
app/src/components/PortScanner/index.ts

@@ -0,0 +1 @@
+export { default as PortScannerCompact } from './PortScannerCompact.vue'

+ 109 - 0
app/src/components/ProxyTargets/ProxyTargets.vue

@@ -0,0 +1,109 @@
+<script setup lang="ts">
+import type { ProxyTarget } from '@/api/site'
+import { useProxyAvailabilityStore } from '@/pinia/moudule/proxyAvailability'
+
+interface Props {
+  targets: ProxyTarget[]
+}
+
+const props = defineProps<Props>()
+
+const proxyStore = useProxyAvailabilityStore()
+let componentId = ''
+
+// Register component and watch for target changes
+watch(() => props.targets, newTargets => {
+  componentId = proxyStore.registerComponent(newTargets)
+}, {
+  immediate: true,
+  deep: true,
+})
+
+// Cleanup when component unmounts
+onBeforeUnmount(() => {
+  proxyStore.unregisterComponent(componentId)
+})
+
+function getTargetColor(target: ProxyTarget): string {
+  const result = proxyStore.getAvailabilityResult(target)
+  if (!result)
+    return 'default'
+  return result.online ? 'green' : 'red'
+}
+
+function getTargetText(target: ProxyTarget): string {
+  const result = proxyStore.getAvailabilityResult(target)
+  if (!result)
+    return `${target.host}:${target.port}`
+
+  if (result.online) {
+    return `${target.host}:${target.port} (${result.latency.toFixed(2)}ms)`
+  }
+  else {
+    return `${target.host}:${target.port} (${$gettext('Offline')})`
+  }
+}
+
+function getTargetTitle(target: ProxyTarget): string {
+  return `${$gettext('Type')}: ${target.type === 'upstream' ? $gettext('Upstream') : $gettext('Proxy Pass')}`
+}
+</script>
+
+<template>
+  <div v-if="targets.length > 0" class="proxy-targets">
+    <ATag
+      v-for="target in targets"
+      :key="proxyStore.getTargetKey(target)"
+      :color="getTargetColor(target)"
+      class="proxy-target-tag"
+      :bordered="false"
+    >
+      <template #icon>
+        <ATooltip
+          :title="getTargetTitle(target)"
+          placement="bottom"
+          class="cursor-pointer"
+        >
+          <span v-if="target.type === 'upstream'" class="target-type-icon">U</span>
+          <span v-else class="target-type-icon">P</span>
+        </ATooltip>
+      </template>
+      {{ getTargetText(target) }}
+    </ATag>
+  </div>
+</template>
+
+<style scoped lang="less">
+.proxy-targets {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  max-width: 100%;
+  overflow: hidden;
+}
+
+.proxy-target-tag {
+  margin-right: 4px;
+  margin-bottom: 4px;
+  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+  font-size: 12px;
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+
+  .target-type-icon {
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    line-height: 12px;
+    text-align: center;
+    background: rgba(255, 255, 255, 0.2);
+    border-radius: 2px;
+    margin-right: 4px;
+    font-weight: bold;
+    font-size: 10px;
+    flex-shrink: 0;
+  }
+}
+</style>

+ 3 - 0
app/src/components/ProxyTargets/index.ts

@@ -0,0 +1,3 @@
+import ProxyTargets from './ProxyTargets.vue'
+
+export default ProxyTargets

+ 0 - 90
app/src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue

@@ -1,90 +0,0 @@
-<script setup lang="ts">
-import type Curd from '@/api/curd'
-import type { Column } from '@/components/StdDesign/types'
-import { message } from 'ant-design-vue'
-import { getPithyColumns } from '@/components/StdDesign/StdDataDisplay/methods/columns'
-import StdDataEntry from '@/components/StdDesign/StdDataEntry'
-
-const props = defineProps<{
-  // eslint-disable-next-line ts/no-explicit-any
-  api: Curd<any>
-  beforeSave?: () => Promise<void>
-  columns: Column[]
-}>()
-
-const emit = defineEmits(['save'])
-
-const batchColumns = ref<Column[]>([])
-const selectedRowKeys = ref<(number | string)[]>([])
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRows = ref<any[]>([])
-
-const visible = ref(false)
-const data = ref({})
-const error = ref({})
-const loading = ref(false)
-
-// eslint-disable-next-line ts/no-explicit-any
-function showModal(c: Column[], rowKeys: (number | string)[], rows: any[]) {
-  data.value = {}
-  visible.value = true
-  selectedRowKeys.value = rowKeys
-  batchColumns.value = c
-  selectedRows.value = rows
-}
-
-defineExpose({
-  showModal,
-})
-
-async function ok() {
-  loading.value = true
-
-  await props.beforeSave?.()
-
-  await props.api.batch_save(selectedRowKeys.value, data.value)
-    .then(async () => {
-      message.success($gettext('Save successfully'))
-      emit('save')
-      visible.value = false
-    })
-    .finally(() => {
-      loading.value = false
-    })
-}
-</script>
-
-<template>
-  <AModal
-    v-model:open="visible"
-    class="std-curd-edit-modal"
-    :mask="false"
-    :title="$gettext('Batch Modify')"
-    :cancel-text="$gettext('No')"
-    :ok-text="$gettext('Save')"
-    :confirm-loading="loading"
-    :width="600"
-    destroy-on-close
-    @ok="ok"
-  >
-    <p>{{ $gettext('Belows are selected items that you want to batch modify') }}</p>
-    <ATable
-      class="mb-4"
-      size="small"
-      :columns="getPithyColumns(columns)"
-      :data-source="selectedRows"
-      :pagination="{ showSizeChanger: false, pageSize: 5, size: 'small' }"
-    />
-
-    <p>{{ $gettext('Leave blank if do not want to modify') }}</p>
-    <StdDataEntry
-      :data-list="batchColumns"
-      :data-source="data"
-      :errors="error"
-    />
-
-    <slot name="extra" />
-  </AModal>
-</template>
-
-<style scoped></style>

+ 0 - 108
app/src/components/StdDesign/StdDataDisplay/StdBulkActions.vue

@@ -1,108 +0,0 @@
-<script setup lang="ts" generic="T=any">
-import type Curd from '@/api/curd'
-import type { BulkActionOptions, BulkActions } from '@/components/StdDesign/types'
-import { message } from 'ant-design-vue'
-
-const props = defineProps<{
-  api: Curd<T>
-  actions: BulkActions
-  selectedRowKeys: Array<number | string>
-  inTrash?: boolean
-}>()
-
-const emit = defineEmits(['onSuccess'])
-
-const computedActions = computed(() => {
-  if (!props.inTrash) {
-    const result = { ...props.actions }
-
-    if (result.delete) {
-      result.delete = {
-        text: () => $gettext('Delete'),
-        action: ids => {
-          return props.api.batch_destroy(ids)
-        },
-      }
-    }
-    if (result.recover)
-      delete result.recover
-    return result
-  }
-  else {
-    const result = {} as { [key: string]: BulkActionOptions }
-    if (props.actions.delete) {
-      result.delete = {
-        text: () => $gettext('Delete Permanently'),
-        action: ids => {
-          return props.api.batch_destroy(ids, { permanent: true })
-        },
-      }
-    }
-    if (props.actions.recover) {
-      result.recover = {
-        text: () => $gettext('Recover'),
-        action: ids => {
-          return props.api.batch_recover(ids)
-        },
-      }
-    }
-    return result
-  }
-}) as ComputedRef<Record<string, BulkActionOptions>>
-
-const actionValue = ref('')
-
-watch(() => props.inTrash, () => {
-  actionValue.value = ''
-})
-
-function onClickApply() {
-  return new Promise(resolve => {
-    if (actionValue.value === '')
-      return resolve(false)
-
-    // call action
-    return resolve(
-      computedActions.value[actionValue.value]?.action(props.selectedRowKeys).then(async () => {
-        message.success($gettext('Apply bulk action successfully'))
-        emit('onSuccess')
-      }),
-    )
-  })
-}
-</script>
-
-<template>
-  <AFormItem>
-    <ASpace>
-      <ASelect
-        v-model:value="actionValue"
-        style="min-width: 150px"
-      >
-        <ASelectOption value="">
-          {{ $gettext('Batch Actions') }}
-        </ASelectOption>
-        <ASelectOption
-          v-for="(action, key) in computedActions"
-          :key
-          :value="key"
-        >
-          {{ action.text() }}
-        </ASelectOption>
-      </ASelect>
-      <APopconfirm
-        :cancel-text="$gettext('No')"
-        :ok-text="$gettext('OK')"
-        :title="$gettext('Are you sure you want to apply to all selected?')"
-        @confirm="onClickApply"
-      >
-        <AButton
-          danger
-          :disabled="!actionValue || !selectedRowKeys?.length"
-        >
-          {{ $gettext('Apply') }}
-        </AButton>
-      </APopconfirm>
-    </ASpace>
-  </AFormItem>
-</template>

+ 0 - 309
app/src/components/StdDesign/StdDataDisplay/StdCurd.vue

@@ -1,309 +0,0 @@
-<script setup lang="ts" generic="T=any">
-import type { ComputedRef } from 'vue'
-import type { StdCurdProps, StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
-import type { Column } from '@/components/StdDesign/types'
-import { message } from 'ant-design-vue'
-import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
-import StdCurdDetail from '@/components/StdDesign/StdDataDisplay/StdCurdDetail.vue'
-import StdDataEntry from '@/components/StdDesign/StdDataEntry'
-import StdTable from './StdTable.vue'
-
-const props = defineProps<StdTableProps<T> & StdCurdProps<T>>()
-
-const selectedRowKeys = defineModel<(number | string)[]>('selectedRowKeys', {
-  default: () => reactive([]),
-})
-
-const selectedRows = defineModel<T[]>('selectedRows', {
-  default: () => reactive([]),
-})
-
-const visible = ref(false)
-// eslint-disable-next-line ts/no-explicit-any
-const data: any = reactive({ id: null })
-const modifyMode = ref(true)
-const editMode = ref<string>()
-const shouldRefetchList = ref(false)
-
-provide('data', data)
-provide('editMode', editMode)
-provide('shouldRefetchList', shouldRefetchList)
-
-// eslint-disable-next-line ts/no-explicit-any
-const error: any = reactive({})
-const selected = ref([])
-
-// eslint-disable-next-line ts/no-explicit-any
-function onSelect(keys: any) {
-  selected.value = keys
-}
-
-const editableColumns = computed(() => {
-  return props.columns!.filter(c => {
-    return c.edit
-  })
-}) as ComputedRef<Column[]>
-
-// eslint-disable-next-line ts/no-explicit-any
-function add(preset: any = undefined) {
-  if (props.onClickAdd)
-    return
-  Object.keys(data).forEach(v => {
-    delete data[v]
-  })
-
-  if (preset)
-    Object.assign(data, preset)
-
-  clearError()
-  visible.value = true
-  editMode.value = 'create'
-  modifyMode.value = true
-}
-
-const table = useTemplateRef('table')
-const inTrash = ref(false)
-const getParams = reactive(props.getParams ?? {})
-
-function get_list() {
-  table.value?.get_list()
-}
-
-defineExpose({
-  add,
-  get_list,
-  data,
-  inTrash,
-})
-
-function clearError() {
-  Object.keys(error).forEach(v => {
-    delete error[v]
-  })
-}
-
-// eslint-disable-next-line vue/require-typed-ref
-const stdEntryRef = ref()
-
-async function ok() {
-  const { formRef } = stdEntryRef.value
-
-  clearError()
-  try {
-    await formRef.validateFields()
-    props?.beforeSave?.(data)
-    props
-      .api!.save(data.id, { ...data, ...props.overwriteParams }, { params: { ...props.overwriteParams } }).then(r => {
-      message.success($gettext('Save successfully'))
-      Object.assign(data, r)
-      get_list()
-      visible.value = false
-    }).catch(e => {
-      Object.assign(error, e.errors)
-    })
-  }
-  catch {
-    message.error($gettext('Please fill in the required fields'))
-  }
-}
-
-function cancel() {
-  visible.value = false
-
-  clearError()
-
-  if (shouldRefetchList.value) {
-    get_list()
-    shouldRefetchList.value = false
-  }
-}
-
-function edit(id: number | string) {
-  if (props.onClickEdit)
-    return
-  get(id).then(() => {
-    visible.value = true
-    modifyMode.value = true
-    editMode.value = 'modify'
-  })
-}
-
-function view(id: number | string) {
-  get(id).then(() => {
-    visible.value = true
-    modifyMode.value = false
-  })
-}
-
-async function get(id: number | string) {
-  return props
-    .api!.get(id, { ...props.overwriteParams }).then(async r => {
-    Object.keys(data).forEach(k => {
-      delete data[k]
-    })
-    data.id = null
-    Object.assign(data, r)
-  })
-}
-
-const modalTitle = computed(() => {
-  // eslint-disable-next-line sonarjs/no-nested-conditional
-  return data.id ? modifyMode.value ? $gettext('Modify') : $gettext('View Details') : $gettext('Add')
-})
-
-const localOverwriteParams = reactive(props.overwriteParams ?? {})
-
-const stdBatchEditRef = useTemplateRef('stdBatchEditRef')
-
-async function handleClickBatchEdit(batchColumns: Column[]) {
-  stdBatchEditRef.value?.showModal(batchColumns, selectedRowKeys.value, selectedRows.value)
-}
-
-function handleBatchUpdated() {
-  table.value?.get_list()
-  table.value?.resetSelection()
-}
-</script>
-
-<template>
-  <div class="std-curd">
-    <ACard>
-      <template #title>
-        <div class="flex items-center">
-          {{ title || $gettext('List') }}
-          <slot name="title-slot" />
-        </div>
-      </template>
-      <template #extra>
-        <ASpace>
-          <slot name="beforeAdd" />
-          <AButton
-            v-if="!disableAdd && !inTrash"
-            type="link"
-            size="small"
-            @click="add()"
-          >
-            {{ $gettext('Add') }}
-          </AButton>
-          <slot name="extra" />
-          <template v-if="!disableDelete">
-            <AButton
-              v-if="!inTrash"
-              type="link"
-              size="small"
-              :loading="table?.loading"
-              @click="inTrash = true"
-            >
-              {{ $gettext('Trash') }}
-            </AButton>
-            <AButton
-              v-else
-              type="link"
-              size="small"
-              :loading="table?.loading"
-              @click="inTrash = false"
-            >
-              {{ $gettext('Back to list') }}
-            </AButton>
-          </template>
-        </ASpace>
-      </template>
-
-      <slot name="beforeTable" />
-      <StdTable
-        ref="table"
-        v-bind="{
-          ...props,
-          getParams,
-          overwriteParams: localOverwriteParams,
-        }"
-        v-model:selected-row-keys="selectedRowKeys"
-        v-model:selected-rows="selectedRows"
-        :in-trash="inTrash"
-        @click-edit="edit"
-        @click-view="view"
-        @selected="onSelect"
-        @click-batch-modify="handleClickBatchEdit"
-      >
-        <template
-          v-for="(_, key) in $slots"
-          :key="key"
-          #[key]="slotProps"
-        >
-          <slot
-            :name="key"
-            v-bind="slotProps"
-          />
-        </template>
-      </StdTable>
-    </ACard>
-
-    <AModal
-      class="std-curd-edit-modal"
-      :mask="modalMask"
-      :title="modalTitle"
-      :open="visible"
-      :cancel-text="$gettext('Cancel')"
-      :ok-text="$gettext('Ok')"
-      :width="modalMaxWidth"
-      :footer="modifyMode ? undefined : null"
-      destroy-on-close
-      @cancel="cancel"
-      @ok="ok"
-    >
-      <div
-        v-if="!disableModify && !disableView && editMode === 'modify'"
-        class="m-2 flex justify-end"
-      >
-        <ASwitch
-          v-model:checked="modifyMode"
-          class="mr-2"
-        />
-        {{ modifyMode ? $gettext('Modify Mode') : $gettext('View Mode') }}
-      </div>
-
-      <template v-if="modifyMode">
-        <div
-          v-if="$slots.beforeEdit"
-          class="before-edit"
-        >
-          <slot
-            name="beforeEdit"
-            :data="data"
-          />
-        </div>
-
-        <StdDataEntry
-          ref="stdEntryRef"
-          :data-list="editableColumns"
-          :data-source="data"
-          :errors="error"
-        />
-
-        <slot
-          name="edit"
-          :data="data"
-        />
-      </template>
-
-      <StdCurdDetail
-        v-else
-        :columns
-        :data
-      />
-    </AModal>
-
-    <StdBatchEdit
-      ref="stdBatchEditRef"
-      :api
-      :columns
-      @save="handleBatchUpdated"
-    />
-  </div>
-</template>
-
-<style lang="less" scoped>
-:deep(.before-edit:last-child) {
-  margin-bottom: 20px;
-}
-</style>

+ 0 - 36
app/src/components/StdDesign/StdDataDisplay/StdCurdDetail.vue

@@ -1,36 +0,0 @@
-<script setup lang="ts">
-import type { ComputedRef } from 'vue'
-import type { Column } from '@/components/StdDesign/types'
-import { get } from 'lodash'
-import { CustomRender } from '@/components/StdDesign/StdDataDisplay/components/CustomRender'
-import { labelRender } from '@/components/StdDesign/StdDataEntry'
-
-const props = defineProps<{
-  columns: Column[]
-  // eslint-disable-next-line ts/no-explicit-any
-  data: any
-}>()
-
-const displayColumns: ComputedRef<Column[]> = computed(() => {
-  return props.columns.filter(c => !c.hiddenInDetail && c.dataIndex !== 'action')
-})
-</script>
-
-<template>
-  <ADescriptions
-    :column="1"
-    bordered
-  >
-    <ADescriptionsItem
-      v-for="(c, index) in displayColumns"
-      :key="index"
-      :label="labelRender(c.title)"
-    >
-      <CustomRender v-bind="{ column: c, record: data, index, text: get(data, c.dataIndex!), isDetail: true }" />
-    </ADescriptionsItem>
-  </ADescriptions>
-</template>
-
-<style scoped lang="less">
-
-</style>

+ 0 - 65
app/src/components/StdDesign/StdDataDisplay/StdPagination.vue

@@ -1,65 +0,0 @@
-<script setup lang="ts">
-import type { Pagination } from '@/api/curd'
-
-const props = withDefaults(defineProps<{
-  pagination: Pagination
-  size?: 'default' | 'small'
-  loading?: boolean
-  showSizeChanger?: boolean
-}>(), {
-  showSizeChanger: true,
-})
-
-const emit = defineEmits(['change', 'changePageSize', 'update:pagination'])
-
-function change(num: number, pageSize: number) {
-  emit('change', num, pageSize)
-}
-
-const pageSize = computed({
-  get() {
-    return props.pagination.per_page
-  },
-  set(v) {
-    emit('changePageSize', v)
-    emit('update:pagination', { ...props.pagination, per_page: v })
-  },
-})
-</script>
-
-<template>
-  <div
-    v-if="pagination.total > pagination.per_page"
-    class="pagination-container"
-  >
-    <APagination
-      v-model:page-size="pageSize"
-      :disabled="loading"
-      :current="pagination.current_page"
-      :show-size-changer="showSizeChanger"
-      :show-total="(total:number) => $ngettext('Total %{total} item', 'Total %{total} items', total, { total: total.toString() })"
-      :size="size"
-      :total="pagination.total"
-      @change="change"
-    />
-  </div>
-</template>
-
-<style lang="less">
-.ant-pagination-total-text {
-  @media (max-width: 450px) {
-    display: block;
-  }
-}
-</style>
-
-<style lang="less" scoped>
-.pagination-container {
-  padding: 10px 0 0 0;
-  display: flex;
-  justify-content: right;
-  @media (max-width: 450px) {
-    justify-content: center;
-  }
-}
-</style>

+ 0 - 623
app/src/components/StdDesign/StdDataDisplay/StdTable.vue

@@ -1,623 +0,0 @@
-<script setup lang="ts" generic="T=any">
-import type { TableProps } from 'ant-design-vue'
-import type { Key } from 'ant-design-vue/es/_util/type'
-import type { FilterValue } from 'ant-design-vue/es/table/interface'
-import type { SorterResult, TablePaginationConfig } from 'ant-design-vue/lib/table/interface'
-import type { ComputedRef, Ref } from 'vue'
-import type { RouteParams } from 'vue-router'
-import type { GetListResponse, Pagination } from '@/api/curd'
-import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
-import type { Column } from '@/components/StdDesign/types'
-import { HolderOutlined } from '@ant-design/icons-vue'
-import { message } from 'ant-design-vue'
-import { debounce } from 'lodash'
-import { getPithyColumns } from '@/components/StdDesign/StdDataDisplay/methods/columns'
-import useSortable from '@/components/StdDesign/StdDataDisplay/methods/sortable'
-import StdBulkActions from '@/components/StdDesign/StdDataDisplay/StdBulkActions.vue'
-import StdDataEntry, { labelRender } from '@/components/StdDesign/StdDataEntry'
-import StdPagination from './StdPagination.vue'
-
-const props = withDefaults(defineProps<StdTableProps<T>>(), {
-  rowKey: 'id',
-})
-
-const emit = defineEmits([
-  'clickEdit',
-  'clickView',
-  'clickBatchModify',
-])
-
-const selectedRowKeys = defineModel<(number | string)[]>('selectedRowKeys', {
-  default: () => reactive([]),
-})
-
-const selectedRows = defineModel<T[]>('selectedRows', {
-  default: () => reactive([]),
-})
-
-const route = useRoute()
-
-const dataSource: Ref<T[]> = ref([])
-const expandKeysList: Ref<Key[]> = ref([])
-
-watch(dataSource, () => {
-  if (!props.expandAll)
-    return
-
-  const res: Key[] = []
-
-  function buildKeysList(record) {
-    record.children?.forEach(v => {
-      buildKeysList(v)
-    })
-    res.push(record[props.rowKey])
-  }
-
-  dataSource.value.forEach(v => {
-    buildKeysList(v)
-  })
-
-  expandKeysList.value = res
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-const rowsKeyIndexMap: Ref<Record<number, any>> = ref({})
-const loading = ref(true)
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRecords: Ref<Record<any, any>> = ref({})
-
-// This can be useful if there are more than one StdTable in the same page.
-// eslint-disable-next-line sonarjs/pseudo-random
-const randomId = ref(Math.random().toString(36).substring(2, 8))
-const updateFilter = ref(0)
-const init = ref(false)
-
-const pagination: Pagination = reactive({
-  total: 1,
-  per_page: 10,
-  current_page: 1,
-  total_pages: 1,
-})
-
-const filterParams = ref({})
-
-const paginationParams = ref({
-  page: 1,
-  page_size: 20,
-})
-
-const sortParams = ref({
-  order: 'desc' as 'desc' | 'asc' | undefined,
-  sort_by: '' as Key | readonly Key[] | undefined,
-})
-
-const params = computed(() => {
-  return {
-    ...filterParams.value,
-    ...sortParams.value,
-    ...props.getParams,
-    ...props.overwriteParams,
-    trash: props.inTrash,
-  }
-})
-
-onMounted(() => {
-  selectedRows.value.forEach(v => {
-    selectedRecords.value[v[props.rowKey]] = v
-  })
-})
-
-const searchColumns = computed(() => {
-  const _searchColumns: Column[] = []
-
-  props.columns.forEach((column: Column) => {
-    if (column.search) {
-      if (typeof column.search === 'object') {
-        _searchColumns.push({
-          ...column,
-          edit: column.search,
-        })
-      }
-
-      else {
-        _searchColumns.push({ ...column })
-      }
-    }
-  })
-
-  return _searchColumns
-})
-
-const pithyColumns = computed<Column[]>(() => {
-  if (props.pithy)
-    return getPithyColumns(props.columns)
-
-  return props.columns?.filter(c => {
-    return !c.hiddenInTable
-  })
-})
-
-const batchColumns = computed(() => {
-  return props.columns?.filter(column => column.batch) || []
-})
-
-const radioColumns = computed(() => {
-  return props.columns?.filter(column => column.radio) || []
-})
-
-const get_list = debounce(_get_list, 100, {
-  leading: false,
-  trailing: true,
-})
-
-onMounted(async () => {
-  if (!props.disableQueryParams) {
-    filterParams.value = {
-      ...route.query,
-      ...props.getParams,
-    }
-    paginationParams.value.page = Number(route.query.page) || 1
-    paginationParams.value.page_size = Number(route.query.page_size) || 20
-  }
-
-  await nextTick()
-
-  get_list()
-
-  if (props.sortable)
-    initSortable()
-
-  init.value = true
-})
-
-defineExpose({
-  get_list,
-  pagination,
-  resetSelection,
-  loading,
-})
-
-function destroy(id: number | string) {
-  props.api!.destroy(id, { permanent: props.inTrash }).then(() => {
-    get_list()
-    message.success($gettext('Deleted successfully'))
-  })
-}
-
-function recover(id: number | string) {
-  props.api.recover(id).then(() => {
-    message.success($gettext('Recovered Successfully'))
-    get_list()
-  })
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-function buildIndexMap(data: any, level: number = 0, index: number = 0, total: number[] = []) {
-  if (data && data.length > 0) {
-  // eslint-disable-next-line ts/no-explicit-any
-    data.forEach((v: any) => {
-      v.level = level
-
-      const current_indexes = [...total, index++]
-
-      rowsKeyIndexMap.value[v.id] = current_indexes
-      if (v.children)
-        buildIndexMap(v.children, level + 1, 0, current_indexes)
-    })
-  }
-}
-
-async function _get_list() {
-  dataSource.value = []
-  loading.value = true
-
-  // eslint-disable-next-line ts/no-explicit-any
-  await props.api?.get_list({ ...params.value, ...paginationParams.value }).then(async (r: GetListResponse<any>) => {
-    dataSource.value = r.data
-    rowsKeyIndexMap.value = {}
-    if (props.sortable)
-      buildIndexMap(r.data)
-
-    if (r.pagination)
-      Object.assign(pagination, r.pagination)
-  })
-
-  loading.value = false
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-function onTableChange(_pagination: TablePaginationConfig, filters: Record<string, FilterValue>, sorter: SorterResult | SorterResult<any>[]) {
-  if (sorter) {
-    sorter = sorter as SorterResult
-    selectedRowKeys.value = []
-    sortParams.value.sort_by = sorter.field
-    switch (sorter.order) {
-      case 'ascend':
-        sortParams.value.order = 'asc'
-        break
-      case 'descend':
-        sortParams.value.order = 'desc'
-        break
-      default:
-        sortParams.value.order = undefined
-        break
-    }
-  }
-  if (filters) {
-    Object.keys(filters).forEach((v: string) => {
-      params[v] = filters[v]
-    })
-  }
-
-  if (_pagination)
-    selectedRowKeys.value = []
-}
-
-function expandedTable(keys: Key[]) {
-  expandKeysList.value = keys
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
-  // console.log('onSelect', record, selected, _selectedRows)
-  if (props.selectionType === 'checkbox' || props.exportExcel || batchColumns.value.length > 0 || props.bulkActions) {
-    if (selected) {
-      _selectedRows.forEach(v => {
-        if (v) {
-          if (selectedRecords.value[v[props.rowKey]] === undefined)
-            selectedRowKeys.value.push(v[props.rowKey])
-
-          selectedRecords.value[v[props.rowKey]] = v
-        }
-      })
-    }
-    else {
-      selectedRowKeys.value.splice(selectedRowKeys.value.indexOf(record[props.rowKey]), 1)
-      delete selectedRecords.value[record[props.rowKey]]
-    }
-    await nextTick()
-    selectedRows.value = [...selectedRowKeys.value.map(v => selectedRecords.value[v])]
-  }
-  else if (selected) {
-    selectedRowKeys.value = record[props.rowKey]
-    selectedRows.value = [record]
-  }
-  else {
-    selectedRowKeys.value = []
-    selectedRows.value = []
-  }
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-async function onSelectAll(selected: boolean, _selectedRows: any[], changeRows: any[]) {
-  // console.log('onSelectAll', selected, selectedRows, changeRows)
-  // eslint-disable-next-line ts/no-explicit-any
-  changeRows.forEach((v: any) => {
-    if (v) {
-      if (selected) {
-        selectedRowKeys.value.push(v[props.rowKey])
-        selectedRecords.value[v[props.rowKey]] = v
-      }
-      else {
-        delete selectedRecords.value[v[props.rowKey]]
-      }
-    }
-  })
-
-  if (!selected) {
-    selectedRowKeys.value.splice(0, selectedRowKeys.value.length, ...selectedRowKeys.value.filter(v => selectedRecords.value[v]))
-  }
-
-  // console.log(selectedRowKeysBuffer.value, selectedRecords.value)
-
-  await nextTick()
-  selectedRows.value.splice(0, selectedRows.value.length, ...selectedRowKeys.value.map(v => selectedRecords.value[v]))
-}
-
-function resetSelection() {
-  selectedRowKeys.value = reactive([])
-  selectedRows.value = reactive([])
-  selectedRecords.value = reactive({})
-}
-
-const router = useRouter()
-
-async function resetSearch() {
-  filterParams.value = {}
-  updateFilter.value++
-}
-
-watch(params, async v => {
-  if (!init.value)
-    return
-
-  paginationParams.value = {
-    page: 1,
-    page_size: paginationParams.value.page_size,
-  }
-
-  await nextTick()
-
-  if (!props.disableQueryParams)
-    await router.push({ query: { ...v as unknown as RouteParams, ...paginationParams.value } })
-  else
-    get_list()
-}, { deep: true })
-
-watch(() => route.query, () => {
-  if (init.value)
-    get_list()
-})
-
-const rowSelection = computed(() => {
-  if (batchColumns.value.length > 0 || props.selectionType || props.exportExcel || props.bulkActions) {
-    return {
-      selectedRowKeys: unref(selectedRowKeys),
-      onSelect,
-      onSelectAll,
-      getCheckboxProps: props?.getCheckboxProps,
-      type: (batchColumns.value.length > 0 || props.exportExcel || props.bulkActions) ? 'checkbox' : props.selectionType,
-    }
-  }
-  else {
-    return null
-  }
-}) as ComputedRef<TableProps['rowSelection']>
-
-const hasSelectedRow = computed(() => {
-  return batchColumns.value.length > 0 && selectedRowKeys.value.length > 0
-})
-
-function clickBatchEdit() {
-  emit('clickBatchModify', batchColumns.value, selectedRowKeys.value, selectedRows.value)
-}
-
-function initSortable() {
-  useSortable(props, randomId, dataSource, rowsKeyIndexMap, expandKeysList)
-}
-
-async function changePage(page: number, page_size: number) {
-  if (page) {
-    paginationParams.value = {
-      page,
-      page_size,
-    }
-  }
-  else {
-    paginationParams.value = {
-      page: 1,
-      page_size,
-    }
-  }
-
-  await nextTick()
-
-  if (!props.disableQueryParams)
-    await router.push({ query: { ...route.query, ...paginationParams.value } })
-
-  get_list()
-}
-
-const paginationSize = computed(() => {
-  if (props.size === 'small')
-    return 'small'
-  else
-    return 'default'
-})
-</script>
-
-<template>
-  <div class="std-table">
-    <div v-if="radioColumns.length">
-      <AFormItem
-        v-for="column in radioColumns"
-        :key="column.dataIndex as PropertyKey"
-        :label="labelRender(column.title)"
-      >
-        <ARadioGroup v-model:value="params[column.dataIndex as string]">
-          <ARadioButton :value="undefined">
-            {{ $gettext('All') }}
-          </ARadioButton>
-          <ARadioButton
-            v-for="(value, key) in column.mask"
-            :key
-            :value="key"
-          >
-            {{ labelRender(value) }}
-          </ARadioButton>
-        </ARadioGroup>
-      </AFormItem>
-    </div>
-    <StdDataEntry
-      v-if="!disableSearch && searchColumns.length"
-      :key="updateFilter"
-      :data-list="searchColumns"
-      :data-source="filterParams"
-      type="search"
-      layout="inline"
-    >
-      <template #action>
-        <ASpace class="action-btn">
-          <AButton @click="resetSearch">
-            {{ $gettext('Reset') }}
-          </AButton>
-          <AButton
-            v-if="hasSelectedRow"
-            @click="clickBatchEdit"
-          >
-            {{ $gettext('Batch Modify') }}
-          </AButton>
-          <slot name="append-search" />
-        </ASpace>
-      </template>
-    </StdDataEntry>
-    <StdBulkActions
-      v-if="bulkActions"
-      v-model:selected-row-keys="selectedRowKeys"
-      :api
-      :in-trash="inTrash"
-      :actions="bulkActions"
-      @on-success="() => { resetSelection(); get_list() }"
-    />
-    <ATable
-      :id="`std-table-${randomId}`"
-      :columns="pithyColumns"
-      :data-source="dataSource"
-      :loading="loading"
-      :pagination="false"
-      :row-key="rowKey"
-      :row-selection="rowSelection"
-      :scroll="{ x: scrollX ?? true }"
-      :size="size as any"
-      :expanded-row-keys="expandKeysList"
-      @change="onTableChange"
-      @expanded-rows-change="expandedTable"
-    >
-      <template #bodyCell="{ text, record, column }: {text: any, record: Record<string, any>, column: any}">
-        <template v-if="column.handle === true">
-          <span class="ant-table-drag-icon"><HolderOutlined /></span>
-          {{ text }}
-        </template>
-        <div v-if="column.dataIndex === 'action'" class="action">
-          <template v-if="!props.disableView && !inTrash">
-            <AButton
-              type="link"
-              size="small"
-              @click="$emit('clickView', record[props.rowKey], record)"
-            >
-              {{ $gettext('View') }}
-            </AButton>
-          </template>
-
-          <template v-if="!props.disableModify && !inTrash">
-            <AButton
-              type="link"
-              size="small"
-              @click="$emit('clickEdit', record[props.rowKey], record)"
-            >
-              {{ $gettext('Modify') }}
-            </AButton>
-          </template>
-
-          <slot
-            name="actions"
-            :record="record"
-          />
-
-          <template v-if="!props.disableDelete">
-            <APopconfirm
-              v-if="!inTrash"
-              :cancel-text="$gettext('No')"
-              :ok-text="$gettext('Ok')"
-              :title="$gettext('Are you sure you want to delete this item?')"
-              @confirm="destroy(record[rowKey])"
-            >
-              <AButton
-                type="link"
-                size="small"
-              >
-                {{ $gettext('Delete') }}
-              </AButton>
-            </APopconfirm>
-            <APopconfirm
-              v-else
-              :cancel-text="$gettext('No')"
-              :ok-text="$gettext('Ok')"
-              :title="$gettext('Are you sure you want to recover this item?')"
-              @confirm="recover(record[rowKey])"
-            >
-              <AButton
-                type="link"
-                size="small"
-              >
-                {{ $gettext('Recover') }}
-              </AButton>
-            </APopconfirm>
-            <APopconfirm
-              v-if="inTrash"
-              :cancel-text="$gettext('No')"
-              :ok-text="$gettext('Ok')"
-              :title="$gettext('Are you sure you want to delete this item permanently?')"
-              @confirm="destroy(record[rowKey])"
-            >
-              <AButton
-                type="link"
-                size="small"
-              >
-                {{ $gettext('Delete Permanently') }}
-              </AButton>
-            </APopconfirm>
-          </template>
-        </div>
-      </template>
-    </ATable>
-    <StdPagination
-      :size="paginationSize"
-      :loading="loading"
-      :pagination="pagination"
-      @change="changePage"
-      @change-page-size="onTableChange"
-    />
-  </div>
-</template>
-
-<style lang="less">
-.ant-table-scroll {
-  .ant-table-body {
-    overflow-x: auto !important;
-    overflow-y: hidden !important;
-  }
-}
-
-.std-table {
-  overflow-x: hidden !important;
-  overflow-y: hidden !important;
-}
-</style>
-
-<style lang="less" scoped>
-.ant-form {
-  margin: 10px 0 20px 0;
-}
-
-.ant-slider {
-  min-width: 90px;
-}
-
-.action-btn {
-  // min-height: 50px;
-  height: 100%;
-  display: flex;
-  align-items: flex-start;
-}
-
-:deep(.ant-form-inline .ant-form-item) {
-  margin-bottom: 10px;
-}
-
-.ant-divider {
-  &:last-child {
-    display: none;
-  }
-}
-
-.action {
-  @media (max-width: 768px) {
-    .ant-divider-vertical {
-      display: none;
-    }
-  }
-}
-</style>
-
-<style lang="less">
-.ant-table-drag-icon {
-  float: left;
-  margin-right: 16px;
-  cursor: grab;
-}
-
-.sortable-ghost *, .sortable-chosen * {
-  cursor: grabbing !important;
-}
-</style>

+ 0 - 155
app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx

@@ -1,155 +0,0 @@
-import type { VNode } from 'vue'
-import type { JSX } from 'vue/jsx-runtime'
-import { CopyOutlined } from '@ant-design/icons-vue'
-import { message, Tag } from 'ant-design-vue'
-// text, record, index, column
-import dayjs from 'dayjs'
-import { get } from 'lodash'
-
-// eslint-disable-next-line ts/no-explicit-any
-export interface CustomRender<T = any, R = any> {
-  text: T
-  record: R
-  // eslint-disable-next-line ts/no-explicit-any
-  index: any
-  // eslint-disable-next-line ts/no-explicit-any
-  column: any
-  isExport?: boolean
-  isDetail?: boolean
-}
-
-export function datetime(args: CustomRender) {
-  if (!args.text)
-    return '/'
-
-  return dayjs(args.text).format('YYYY-MM-DD HH:mm:ss')
-}
-
-export function date(args: CustomRender) {
-  return args.text ? dayjs(args.text).format('YYYY-MM-DD') : '-'
-}
-
-// Used in Export
-date.isDate = true
-datetime.isDatetime = true
-
-// eslint-disable-next-line ts/no-explicit-any
-export function mask(maskObj: any): (args: CustomRender) => JSX.Element {
-  return (args: CustomRender) => {
-    // eslint-disable-next-line ts/no-explicit-any
-    let v: any
-    if (typeof maskObj?.[args.text] === 'function')
-      v = maskObj[args.text]()
-    else if (typeof maskObj?.[args.text] === 'string')
-      v = maskObj[args.text]
-    else v = args.text
-
-    return v ?? '-'
-  }
-}
-
-export function arrayToTextRender(args: CustomRender) {
-  return args.text?.join(', ')
-}
-export function actualValueRender(actualDataIndex: string | string[]) {
-  return (args: CustomRender) => {
-    return get(args.record, actualDataIndex) || ''
-  }
-}
-
-export function longTextWithEllipsis(len: number): (args: CustomRender) => JSX.Element {
-  return (args: CustomRender) => {
-    if (args.isExport || args.isDetail)
-      return args.text
-
-    return args.text.length > len ? `${args.text.substring(0, len)}...` : args.text
-  }
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function maskRenderWithColor(maskObj: any, customColors?: Record<string | number, string> | string) {
-  return (args: CustomRender) => {
-    let label: string
-    if (typeof maskObj[args.text] === 'function')
-      label = maskObj[args.text]()
-    else if (typeof maskObj[args.text] === 'string')
-      label = maskObj[args.text]
-    else label = args.text
-
-    if (args.isExport)
-      return label
-
-    let colorMap: Record<string | number, string> = {
-      0: '',
-      1: 'blue',
-      2: 'green',
-      3: 'purple',
-      4: 'cyan',
-    }
-
-    if (typeof customColors === 'object')
-      colorMap = customColors
-
-    let color = colorMap[args.text]
-
-    if (typeof customColors === 'string')
-      color = customColors
-
-    return args.text ? h(Tag, { color }, () => label) : '/'
-  }
-}
-
-interface MultiFieldRenderProps {
-  key: string | number | string[] | number[]
-  label?: () => string
-  prefix?: string
-  suffix?: string
-  render?: ((args: CustomRender) => string | number | VNode) | (() => ((args: CustomRender) => string | VNode))
-  direction?: 'vertical' | 'horizontal'
-}
-
-export function multiFieldsRender(fields: MultiFieldRenderProps[]) {
-  return (args: CustomRender) => {
-    const list = fields.map(field => {
-      let label = field.label?.()
-      let value = get(args.record, field.key)
-
-      if (field.prefix)
-        value = field.prefix + value
-      if (field.suffix)
-        value += field.suffix
-
-      if (label)
-        label += ':'
-
-      const valueNode = field.render?.({ ...args, text: value }) ?? value
-      const direction = field.direction ?? 'vertical'
-
-      const labelNode = label
-        // eslint-disable-next-line sonarjs/no-nested-conditional
-        ? h(direction === 'vertical' ? 'div' : 'span', { class: 'text-gray-500 my-1 mr-1' }, label)
-        : null
-
-      return h('div', { class: 'my-4' }, [labelNode, valueNode])
-    })
-
-    return h('div', null, list)
-  }
-}
-
-export function copiableFieldRender(args: CustomRender) {
-  return h('div', null, [
-    h('span', null, args.text),
-    h(CopyOutlined, {
-      style: {
-        marginLeft: '10px',
-        cursor: 'pointer',
-      },
-      onClick: () => {
-        navigator.clipboard.writeText(args.text).then(() => {
-          message.success($gettext('Copied'))
-        })
-      },
-    }),
-  ])
-}

+ 0 - 9
app/src/components/StdDesign/StdDataDisplay/components/CustomRender.tsx

@@ -1,9 +0,0 @@
-import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
-import { get } from 'lodash'
-
-// eslint-disable-next-line ts/no-redeclare
-export function CustomRender(props: CustomRender) {
-  return props.column.customRender
-    ? props.column.customRender(props)
-    : get(props.record, props.column.dataIndex!)
-}

+ 0 - 5
app/src/components/StdDesign/StdDataDisplay/index.ts

@@ -1,5 +0,0 @@
-import StdBatchEdit from './StdBatchEdit.vue'
-import StdCurd from './StdCurd.vue'
-import StdTable from './StdTable.vue'
-
-export { StdBatchEdit, StdCurd, StdTable }

+ 0 - 7
app/src/components/StdDesign/StdDataDisplay/methods/columns.ts

@@ -1,7 +0,0 @@
-import type { Column } from '@/components/StdDesign/types'
-
-export function getPithyColumns(columns: Column[]) {
-  return columns.filter(c => {
-    return c.pithy === true && !c.hiddenInTable
-  })
-}

+ 0 - 67
app/src/components/StdDesign/StdDataDisplay/methods/exportCsv.ts

@@ -1,67 +0,0 @@
-import type { ComputedRef } from 'vue'
-import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
-import type { Column, StdTableResponse } from '@/components/StdDesign/types'
-import dayjs from 'dayjs'
-import { get, set } from 'lodash'
-import { downloadCsv } from '@/lib/helper'
-
-async function exportCsv(props: StdTableProps, pithyColumns: ComputedRef<Column[]>) {
-  const header: { title?: string, key: Column['dataIndex'] }[] = []
-  // eslint-disable-next-line ts/no-explicit-any
-  const headerKeys: any[] = []
-  const showColumnsMap: Record<string, Column> = {}
-
-  pithyColumns.value.forEach((column: Column) => {
-    if (column.dataIndex === 'action')
-      return
-    let t = column.title
-    if (typeof t === 'function')
-      t = t()
-    header.push({
-      title: t,
-      key: column.dataIndex,
-    })
-    headerKeys.push(column?.dataIndex?.toString())
-    showColumnsMap[column?.dataIndex?.toString() as string] = column
-  })
-
-  // eslint-disable-next-line ts/no-explicit-any
-  const dataSource: any[] = []
-  let hasMore = true
-  let page = 1
-  while (hasMore) {
-    // prepare dataSource
-    await props
-      .api!.get_list({ page }).then((r: StdTableResponse) => {
-      if (r.data.length === 0) {
-        hasMore = false
-
-        return
-      }
-      dataSource.push(...r.data)
-    }).catch(() => {
-      hasMore = false
-    })
-    page += 1
-  }
-  // eslint-disable-next-line ts/no-explicit-any
-  const data: any[] = []
-
-  dataSource.forEach(row => {
-    // eslint-disable-next-line ts/no-explicit-any
-    const obj: Record<string, any> = {}
-
-    headerKeys.forEach(key => {
-      let _data = get(row, key)
-      const c = showColumnsMap[key]
-
-      _data = c?.customRender?.({ text: _data }) ?? _data
-      set(obj, c.dataIndex as string, _data)
-    })
-    data.push(obj)
-  })
-
-  downloadCsv(header, data, `${$gettext('Export')}-${props.title}-${dayjs().format('YYYYMMDDHHmmss')}.csv`)
-}
-
-export default exportCsv

+ 0 - 127
app/src/components/StdDesign/StdDataDisplay/methods/sortable.ts

@@ -1,127 +0,0 @@
-import type { Key } from 'ant-design-vue/es/_util/type'
-import type { Ref } from 'vue'
-import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
-import { message } from 'ant-design-vue'
-import sortable from 'sortablejs'
-
-// eslint-disable-next-line ts/no-explicit-any
-function getRowKey(item: any) {
-  return item.dataset.rowKey
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-function getTargetData(data: any, indexList: number[]): any {
-  // eslint-disable-next-line ts/no-explicit-any
-  let target: any = { children: data }
-  indexList.forEach((index: number) => {
-    target.children[index].parent = target
-    target = target.children[index]
-  })
-
-  return target
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-function useSortable(props: StdTableProps, randomId: Ref<string>, dataSource: Ref<any[]>, rowsKeyIndexMap: Ref<Record<number, number[]>>, expandKeysList: Ref<Key[]>) {
-  // eslint-disable-next-line ts/no-explicit-any
-  const table: any = document.querySelector(`#std-table-${randomId.value} tbody`)
-
-  // eslint-disable-next-line no-new,new-cap,sonarjs/constructor-for-side-effects
-  new sortable(table, {
-    handle: '.ant-table-drag-icon',
-    animation: 150,
-    sort: true,
-    forceFallback: true,
-    setData(dataTransfer) {
-      dataTransfer.setData('Text', '')
-    },
-    onStart({ item }) {
-      const targetRowKey = Number(getRowKey(item))
-      if (targetRowKey)
-        expandKeysList.value = expandKeysList.value.filter((_item: Key) => _item !== targetRowKey)
-    },
-    onMove({
-      dragged,
-      related,
-    }) {
-      const oldRow: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(dragged))]
-      const newRow: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(related))]
-
-      if (oldRow.length !== newRow.length || oldRow[oldRow.length - 2] !== newRow[newRow.length - 2])
-        return false
-
-      if (props.sortableMoveHook)
-        return props.sortableMoveHook(oldRow, newRow)
-    },
-    async onEnd({
-      item,
-      newIndex,
-      oldIndex,
-    }) {
-      if (newIndex === oldIndex)
-        return
-
-      const indexDelta: number = Number(oldIndex) - Number(newIndex)
-      const direction: number = indexDelta > 0 ? +1 : -1
-
-      const rowIndex: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(item))]
-      const newRow = getTargetData(dataSource.value, rowIndex)
-      const newRowParent = newRow.parent
-      const level: number = newRow.level
-
-      const currentRowIndex: number[] = [...rowsKeyIndexMap.value![Number(getRowKey(table?.children?.[Number(newIndex) + direction]))]]
-
-      // eslint-disable-next-line ts/no-explicit-any
-      const currentRow: any = getTargetData(dataSource.value, currentRowIndex)
-
-      // Reset parent
-      currentRow.parent = newRow.parent = null
-      newRowParent.children.splice(rowIndex[level], 1)
-      newRowParent.children.splice(currentRowIndex[level], 0, toRaw(newRow))
-
-      const changeIds: number[] = []
-
-      // eslint-disable-next-line ts/no-explicit-any
-      function processChanges(row: any, children = false, _newIndex: number | undefined = undefined) {
-        // Build changes ID list expect new row
-        if (children || _newIndex === undefined)
-          changeIds.push(row.id)
-
-        if (_newIndex !== undefined)
-          rowsKeyIndexMap.value[row.id][level] = _newIndex
-        else if (children)
-          rowsKeyIndexMap.value[row.id][level] += direction
-
-        row.parent = null
-        if (row.children)
-        // eslint-disable-next-line ts/no-explicit-any
-          row.children.forEach((v: any) => processChanges(v, true, _newIndex))
-      }
-
-      // Replace row index for new row
-      processChanges(newRow, false, currentRowIndex[level])
-
-      // Rebuild row index maps for changes row
-      // eslint-disable-next-line sonarjs/no-equals-in-for-termination
-      for (let i = Number(oldIndex); i !== newIndex; i -= direction) {
-        const _rowIndex: number[] = rowsKeyIndexMap.value?.[getRowKey(table.children[i])]
-
-        _rowIndex[level] += direction
-        processChanges(getTargetData(dataSource.value, _rowIndex))
-      }
-
-      // console.log('Change row id', newRow.id, 'order', newRow.id, '=>', currentRow.id, ', direction: ', direction,
-      //   ', changes IDs:', changeIds
-
-      props.api.update_order({
-        target_id: newRow.id,
-        direction,
-        affected_ids: changeIds,
-      }).then(() => {
-        message.success($gettext('Updated successfully'))
-      })
-    },
-  })
-}
-
-export default useSortable

+ 0 - 50
app/src/components/StdDesign/StdDataDisplay/types.d.ts

@@ -1,50 +0,0 @@
-import type { ImportConfig } from '@/components/StdDesign/StdDataImport/types'
-
-export interface StdCurdProps<T> extends StdTableProps<T> {
-  cardTitleKey?: string
-  modalMaxWidth?: string | number
-  modalMask?: boolean
-  exportExcel?: boolean
-  importExcel?: boolean
-
-  disableAdd?: boolean
-  onClickAdd?: () => void
-
-  onClickEdit?: (id: number | string, record: T, index: number) => void
-  // eslint-disable-next-line ts/no-explicit-any
-  beforeSave?: (data: any) => Promise<void>
-  importConfig?: ImportConfig
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export interface StdTableProps<T = any> {
-  title?: string
-  mode?: string
-  rowKey?: string
-
-  api: Curd<T>
-  columns: Column[]
-  // eslint-disable-next-line ts/no-explicit-any
-  getParams?: Record<string, any>
-  size?: string
-  disableQueryParams?: boolean
-  disableSearch?: boolean
-  pithy?: boolean
-  exportExcel?: boolean
-  exportMaterial?: boolean
-  // eslint-disable-next-line ts/no-explicit-any
-  overwriteParams?: Record<string, any>
-  disableView?: boolean
-  disableModify?: boolean
-  selectionType?: string
-  sortable?: boolean
-  disableDelete?: boolean
-  disablePagination?: boolean
-  sortableMoveHook?: (oldRow: number[], newRow: number[]) => boolean
-  scrollX?: string | number
-  // eslint-disable-next-line ts/no-explicit-any
-  getCheckboxProps?: (record: any) => any
-  bulkActions?: BulkActions
-  inTrash?: boolean
-  expandAll?: boolean
-}

+ 0 - 119
app/src/components/StdDesign/StdDataEntry/StdDataEntry.vue

@@ -1,119 +0,0 @@
-<script setup lang="tsx">
-import type { FormInstance } from 'ant-design-vue'
-import type { Ref } from 'vue'
-import type { Column, JSXElements, StdDesignEdit } from '@/components/StdDesign/types'
-import { Form } from 'ant-design-vue'
-import { labelRender } from '@/components/StdDesign/StdDataEntry'
-import StdFormItem from '@/components/StdDesign/StdDataEntry/StdFormItem.vue'
-
-const props = defineProps<{
-  dataList: Column[]
-  errors?: Record<string, string>
-  type?: 'search' | 'edit'
-  layout?: 'horizontal' | 'vertical' | 'inline'
-}>()
-
-defineSlots<{
-  // eslint-disable-next-line ts/no-explicit-any
-  action: () => any
-}>()
-
-// eslint-disable-next-line ts/no-explicit-any
-const dataSource = defineModel<Record<string, any>>('dataSource')
-
-const slots = useSlots()
-
-function extraRender(extra?: string | (() => string)) {
-  if (typeof extra === 'function')
-    return extra()
-
-  return extra
-}
-
-const formRef = ref<FormInstance>()
-
-defineExpose({
-  formRef,
-})
-
-function Render() {
-  const template: JSXElements = []
-  const isCreate = inject<Ref<string>>('editMode', ref(''))?.value === 'create'
-
-  props.dataList.forEach((v: Column) => {
-    const dataIndex = (v.edit?.actualDataIndex ?? v.dataIndex) as string
-
-    dataSource.value![dataIndex] = dataSource.value![dataIndex]
-    if (props.type === 'search') {
-      if (v.search) {
-        const type = (v.search as StdDesignEdit)?.type || v.edit?.type
-
-        template.push(
-          <StdFormItem
-            label={labelRender(v.title)}
-            extra={extraRender(v.extra)}
-            error={props.errors}
-          >
-            {type?.(v.edit!, dataSource.value, v.dataIndex)}
-          </StdFormItem>,
-        )
-      }
-
-      return
-    }
-
-    // console.log(isCreate && v.hiddenInCreate, !isCreate && v.hiddenInModify)
-    if ((isCreate && v.hiddenInCreate) || (!isCreate && v.hiddenInModify))
-      return
-
-    let show = true
-    if (v.edit?.show && typeof v.edit.show === 'function')
-      show = v.edit.show(dataSource.value)
-
-    if (v.edit?.type && show) {
-      template.push(
-        <StdFormItem
-          key={dataIndex}
-          dataIndex={dataIndex}
-          label={labelRender(v.title)}
-          extra={extraRender(v.extra)}
-          error={props.errors}
-          required={v.edit?.config?.required}
-          hint={v.edit?.hint}
-          noValidate={v.edit?.config?.noValidate}
-        >
-          {v.edit.type(v.edit, dataSource.value, dataIndex)}
-        </StdFormItem>,
-      )
-    }
-  })
-
-  if (slots.action)
-    template.push(<div class="std-data-entry-action">{slots.action()}</div>)
-
-  return (
-    <Form
-      class="my-10px!"
-      ref={formRef}
-      model={dataSource.value}
-      layout={props.layout || 'vertical'}
-    >
-      {template}
-    </Form>
-  )
-}
-</script>
-
-<template>
-  <Render />
-</template>
-
-<style scoped lang="less">
-.std-data-entry-action {
-  @media (max-width: 375px) {
-    display: block;
-    width: 100%;
-    margin: 10px 0;
-  }
-}
-</style>

+ 0 - 63
app/src/components/StdDesign/StdDataEntry/StdFormItem.vue

@@ -1,63 +0,0 @@
-<script setup lang="ts">
-import type { Rule } from 'ant-design-vue/es/form'
-import type { Column } from '@/components/StdDesign/types'
-import FormErrors from '@/constants/form_errors'
-
-const props = defineProps<Props>()
-
-export interface Props {
-  dataIndex?: Column['dataIndex']
-  label?: string
-  extra?: string
-  hint?: string | (() => string)
-  error?: {
-    [key: string]: string
-  }
-  required?: boolean
-  noValidate?: boolean
-}
-
-const tag = computed(() => {
-  return props.error?.[props.dataIndex!.toString()] ?? ''
-})
-
-const help = computed(() => {
-  const rules = tag.value.split(',')
-
-  for (const rule of rules) {
-    if (FormErrors[rule])
-      return FormErrors[rule]()
-  }
-
-  return props.hint
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-async function validator(_: Rule, value: any): Promise<any> {
-  return new Promise((resolve, reject) => {
-    if (props.required && !props.noValidate && (!value && value !== 0)) {
-      reject(help.value ?? $gettext('This field should not be empty'))
-
-      return
-    }
-
-    resolve(true)
-  })
-}
-</script>
-
-<template>
-  <AFormItem
-    :name="dataIndex as string"
-    :label="label"
-    :help="help"
-    :rules="{ required, validator }"
-    :validate-status="tag ? 'error' : undefined"
-    :auto-link="false"
-  >
-    <slot />
-  </AFormItem>
-</template>
-
-<style scoped lang="less">
-</style>

+ 0 - 65
app/src/components/StdDesign/StdDataEntry/components/StdPassword.vue

@@ -1,65 +0,0 @@
-<script setup lang="ts">
-defineProps<{
-  generate?: boolean
-  placeholder?: string
-}>()
-
-const modelValue = defineModel<string>('value', {
-  default: () => {
-    return ''
-  },
-})
-
-const visibility = ref(false)
-
-function handleGenerate() {
-  visibility.value = true
-  modelValue.value = 'xxxx'
-
-  const chars = '0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*()ABCDEFGHIJKLMNOPQRSTUVWXYZ'
-  const passwordLength = 12
-  let password = ''
-  for (let i = 0; i <= passwordLength; i++) {
-    // eslint-disable-next-line sonarjs/pseudo-random
-    const randomNumber = Math.floor(Math.random() * chars.length)
-
-    password += chars.substring(randomNumber, randomNumber + 1)
-  }
-
-  modelValue.value = password
-}
-</script>
-
-<template>
-  <div>
-    <AInputGroup compact>
-      <AInputPassword
-        v-if="!visibility"
-        v-model:value="modelValue"
-        :class="{ compact: generate }"
-        :placeholoder="placeholder"
-        :maxlength="20"
-      />
-      <AInput
-        v-else
-        v-model:value="modelValue"
-        :class="{ compact: generate }"
-        :placeholoder="placeholder"
-        :maxlength="20"
-      />
-      <AButton
-        v-if="generate"
-        type="primary"
-        @click="handleGenerate"
-      >
-        {{ $gettext('Generate') }}
-      </AButton>
-    </AInputGroup>
-  </div>
-</template>
-
-<style lang="less" scoped>
-:deep(.ant-input-group.ant-input-group-compact) {
-  display: flex;
-}
-</style>

+ 0 - 74
app/src/components/StdDesign/StdDataEntry/components/StdSelect.vue

@@ -1,74 +0,0 @@
-<script setup lang="ts">
-import type { SelectProps } from 'ant-design-vue'
-
-const props = defineProps<{
-  mask?: Record<string | number, string | (() => string)> | (() => Promise<Record<string | number, string>>)
-  placeholder?: string
-  multiple?: boolean
-  // eslint-disable-next-line ts/no-explicit-any
-  defaultValue?: any
-}>()
-
-const selectedValue = defineModel<string | number | string[] | number[]>('value')
-const options = ref<SelectProps['options']>([])
-
-async function loadOptions() {
-  options.value = []
-  let actualValue: number | string
-  if (typeof props.mask === 'function') {
-    const getOptions = props.mask as (() => Promise<Record<string | number, string>>)
-
-    const r = await getOptions()
-    for (const [value, label] of Object.entries(r)) {
-      actualValue = value
-      if (typeof selectedValue.value === 'number')
-        actualValue = Number(value)
-      options.value?.push({ label, value: actualValue })
-    }
-
-    return
-  }
-  for (const [value, label] of Object.entries(props.mask as Record<string | number, string | (() => string)>)) {
-    let actualLabel = label
-
-    if (typeof label === 'function')
-      actualLabel = label()
-
-    actualValue = value
-    if (typeof selectedValue.value === 'number')
-      actualValue = Number(value)
-
-    options.value?.push({ label: actualLabel, value: actualValue })
-    if (actualValue === selectedValue.value)
-      selectedValue.value = actualValue
-  }
-}
-
-function init() {
-  loadOptions()
-}
-
-watch(props, init)
-
-onMounted(() => {
-  if (!selectedValue.value && props.defaultValue)
-    selectedValue.value = props.defaultValue
-
-  init()
-})
-</script>
-
-<template>
-  <ASelect
-    v-model:value="selectedValue"
-    allow-clear
-    :options="options"
-    :placeholder="props.placeholder"
-    :default-active-first-option="false"
-    :mode="props.multiple ? 'multiple' : undefined"
-    class="min-w-180px w-auto!"
-    :get-popup-container="triggerNode => triggerNode.parentNode"
-  />
-</template>
-
-<style lang="less" scoped></style>

+ 0 - 259
app/src/components/StdDesign/StdDataEntry/components/StdSelector.vue

@@ -1,259 +0,0 @@
-<script setup lang="ts">
-import type Curd from '@/api/curd'
-import type { Column } from '@/components/StdDesign/types'
-import { CloseCircleFilled } from '@ant-design/icons-vue'
-import { watchOnce } from '@vueuse/core'
-import { clone } from 'lodash'
-import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
-
-const props = defineProps<{
-  placeholder?: string
-  label?: string
-  selectionType: 'radio' | 'checkbox'
-  recordValueIndex: string // to index the value of the record
-  // eslint-disable-next-line ts/no-explicit-any
-  api: Curd<any>
-  columns: Column[]
-  disableSearch?: boolean
-  // eslint-disable-next-line ts/no-explicit-any
-  getParams?: any
-  description?: string
-  errorMessages?: string
-  itemKey?: string // default: id
-  // eslint-disable-next-line ts/no-explicit-any
-  value?: any | any[]
-  disabled?: boolean
-  // eslint-disable-next-line ts/no-explicit-any
-  valueApi?: Curd<any>
-  // eslint-disable-next-line ts/no-explicit-any
-  getCheckboxProps?: (record: any) => any
-  hideInputContainer?: boolean
-  expandAll?: boolean
-}>()
-
-const selectedKey = defineModel<number | number[] | undefined | null | string | string[]>('selectedKey')
-
-onMounted(() => {
-  if (!selectedKey.value)
-    watchOnce(selectedKey, _init)
-  else
-    _init()
-})
-
-const visible = ref(false)
-// eslint-disable-next-line ts/no-explicit-any
-const M_values = ref([]) as Ref<any[]>
-
-const ComputedMValue = computed(() => {
-  return M_values.value.filter(v => v && Object.keys(v).length > 0)
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-const records = defineModel<any[]>('selectedRecords', {
-  default: () => [],
-})
-
-watch(() => props.value, () => {
-  if (props.selectionType === 'radio')
-    M_values.value = [props.value]
-  else if (typeof selectedKey.value === 'object')
-    M_values.value = props.value || []
-})
-
-async function _init() {
-  // valueApi is used to fetch items that are using itemKey as index value
-  const api = props.valueApi || props.api
-
-  M_values.value = []
-
-  if (props.selectionType === 'radio') {
-    // M_values.value = [props.value]
-    // not init value, we need to fetch them from api
-    if (!props.value && selectedKey.value && selectedKey.value !== '0') {
-      api.get(selectedKey.value, props.getParams).then(r => {
-        M_values.value = [r]
-        records.value = [r]
-      })
-    }
-  }
-  else if (typeof selectedKey.value === 'object') {
-    // M_values.value = props.value || []
-    // not init value, we need to fetch them from api
-    if (!props.value && (selectedKey.value?.length || 0) > 0) {
-      api.get_list({
-        ...props.getParams,
-        id: selectedKey.value,
-      }).then(r => {
-        M_values.value = r.data
-        records.value = r.data
-      })
-    }
-  }
-}
-
-function show() {
-  if (!props.disabled)
-    visible.value = true
-}
-
-// eslint-disable-next-line vue/require-typed-ref
-const selectedKeyBuffer = ref()
-// eslint-disable-next-line ts/no-explicit-any
-const selectedBuffer: Ref<any[]> = ref([])
-
-watch(selectedKey, () => {
-  selectedKeyBuffer.value = clone(selectedKey.value)
-})
-
-watch(records, v => {
-  selectedBuffer.value = [...v]
-  M_values.value = [...v]
-})
-
-onMounted(() => {
-  selectedKeyBuffer.value = clone(selectedKey.value)
-  selectedBuffer.value = clone(records.value)
-})
-
-const computedSelectedKeys = computed({
-  get() {
-    if (props.selectionType === 'radio')
-      return [selectedKeyBuffer.value]
-    else
-      return selectedKeyBuffer.value
-  },
-  set(v) {
-    selectedKeyBuffer.value = v
-  },
-})
-
-async function ok() {
-  visible.value = false
-  selectedKey.value = selectedKeyBuffer.value
-  records.value = selectedBuffer.value
-  await nextTick()
-  M_values.value = clone(records.value)
-}
-
-function clear() {
-  M_values.value = []
-  if (props.selectionType === 'radio')
-    selectedKey.value = null
-  else
-    selectedKey.value = []
-}
-
-defineExpose({ show })
-</script>
-
-<template>
-  <div>
-    <div
-      v-if="!hideInputContainer"
-      class="std-selector-container"
-    >
-      <div
-        class="std-selector"
-      >
-        <div class="chips-container w-full" @click="show">
-          <div v-if="props.recordValueIndex">
-            <ATag
-              v-for="(chipText, index) in ComputedMValue"
-              :key="index"
-              class="mr-1"
-              color="orange"
-              :bordered="false"
-              @click="show"
-            >
-              {{ chipText?.[recordValueIndex] }}
-            </ATag>
-          </div>
-          <div
-            v-else
-            class="text-gray-400"
-          >
-            {{ placeholder }}
-          </div>
-        </div>
-
-        <div class="close-btn flex text-trueGray-3" @click="clear">
-          <CloseCircleFilled />
-        </div>
-      </div>
-    </div>
-    <AModal
-      :mask="false"
-      :open="visible"
-      :cancel-text="$gettext('Cancel')"
-      :ok-text="$gettext('Ok')"
-      :title="$gettext('Selector')"
-      :width="800"
-      destroy-on-close
-      @cancel="visible = false"
-      @ok="ok"
-    >
-      {{ description }}
-      <StdTable
-        v-model:selected-row-keys="computedSelectedKeys"
-        v-model:selected-rows="selectedBuffer"
-        :api
-        :columns
-        :disable-search
-        :row-key="itemKey"
-        :expand-all
-        :get-params
-        :selection-type
-        :get-checkbox-props
-        pithy
-        disable-query-params
-      />
-    </AModal>
-  </div>
-</template>
-
-<style lang="less" scoped>
-.std-selector-container {
-  min-height: 39.9px;
-  display: flex;
-  align-items: self-start;
-
-  .std-selector {
-    display: flex;
-    justify-content: space-between;
-    overflow-y: auto;
-    box-sizing: border-box;
-    font-variant: tabular-nums;
-    list-style: none;
-    font-feature-settings: 'tnum';
-    min-height: 32px;
-    max-height: 100px;
-    padding: 4px 11px;
-    font-size: 14px;
-    line-height: 1.5;
-    background-image: none;
-    border: 1px solid #d9d9d9;
-    border-radius: 6px;
-    transition: all 0.3s;
-    //margin: 0 10px 0 0;
-    cursor: pointer;
-    min-width: 180px;
-
-    .close-btn {
-      opacity: 0;
-      transition: opacity 0.3s;
-    }
-    &:hover {
-      .close-btn {
-        opacity: 1;
-      }
-    }
-  }
-}
-
-.dark {
-  .std-selector {
-    border: 1px solid #424242;
-    background-color: #141414;
-  }
-}
-</style>

+ 0 - 169
app/src/components/StdDesign/StdDataEntry/index.tsx

@@ -1,169 +0,0 @@
-import type { Dayjs } from 'dayjs'
-import type { StdDesignEdit } from '@/components/StdDesign/types'
-import {
-  DatePicker,
-  Input,
-  InputNumber,
-  RangePicker,
-  Switch,
-} from 'ant-design-vue'
-import dayjs from 'dayjs'
-import { h } from 'vue'
-import { DATE_FORMAT } from '@/constants'
-import StdPassword from './components/StdPassword.vue'
-import StdSelect from './components/StdSelect.vue'
-import StdSelector from './components/StdSelector.vue'
-import StdDataEntry from './StdDataEntry.vue'
-
-// eslint-disable-next-line ts/no-explicit-any
-export function readonly(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  return h('p', dataSource?.[dataIndex] ?? edit?.config?.defaultValue)
-}
-
-export function labelRender(title?: string | (() => string)) {
-  if (typeof title === 'function')
-    return title()
-
-  return title
-}
-
-export function placeholderHelper(edit: StdDesignEdit) {
-  return typeof edit.config?.placeholder === 'function' ? edit.config?.placeholder() : edit.config?.placeholder
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function input(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  return h(Input, {
-    'autocomplete': 'off',
-    'placeholder': placeholderHelper(edit),
-    'value': dataSource?.[dataIndex] ?? edit?.config?.defaultValue,
-    'onUpdate:value': value => {
-      dataSource[dataIndex] = value
-    },
-  })
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function inputNumber(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  if (edit.config?.defaultValue !== undefined)
-    dataSource[dataIndex] = edit.config.defaultValue
-
-  return h(InputNumber, {
-    'placeholder': placeholderHelper(edit),
-    'min': edit.config?.min,
-    'max': edit.config?.max,
-    'value': dataSource?.[dataIndex] ?? edit?.config?.defaultValue,
-    'onUpdate:value': value => {
-      dataSource[dataIndex] = value
-    },
-    'addon-before': edit.config?.addonBefore,
-    'addon-after': edit.config?.addonAfter,
-    'prefix': edit.config?.prefix,
-    'suffix': edit.config?.suffix,
-  })
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function textarea(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  if (!dataSource[dataIndex])
-    dataSource[dataIndex] = edit.config?.defaultValue
-
-  return (
-    <Input
-      v-model:value={dataSource[dataIndex]}
-      placeholder={placeholderHelper(edit)}
-    />
-  )
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function password(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  return (
-    <StdPassword
-      v-model:value={dataSource[dataIndex]}
-      value={dataSource[dataIndex] ?? edit?.config?.defaultValue}
-      generate={edit.config?.generate}
-      placeholder={placeholderHelper(edit)}
-    />
-  )
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function select(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  const actualDataIndex = edit?.actualDataIndex ?? dataIndex
-
-  return (
-    <StdSelect
-      v-model:value={dataSource[actualDataIndex]}
-      mask={edit.mask}
-      placeholder={placeholderHelper(edit)}
-      multiple={edit.select?.multiple}
-      defaultValue={edit.config?.defaultValue}
-    />
-  )
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function selector(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  return (
-    <StdSelector
-      v-model:selectedKey={dataSource[dataIndex]}
-      selectedKey={dataSource[dataIndex] || edit?.config?.defaultValue}
-      recordValueIndex={edit.selector?.recordValueIndex}
-      selectionType={edit.selector?.selectionType ?? 'radio'}
-      api={edit.selector?.api}
-      columns={edit.selector?.columns}
-      disableSearch={edit.selector?.disableSearch}
-      getParams={edit.selector?.getParams}
-      description={edit.selector?.description}
-      getCheckboxProps={edit.selector?.getCheckboxProps}
-      expandAll={edit.selector?.expandAll}
-    />
-  )
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function switcher(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  return h(Switch, {
-    'checked': dataSource?.[dataIndex] ?? edit?.config?.defaultValue,
-    // eslint-disable-next-line ts/no-explicit-any
-    'onUpdate:checked': (value: any) => {
-      dataSource[dataIndex] = value
-    },
-  })
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function datePicker(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  const date: Dayjs | undefined = dataSource?.[dataIndex] ? dayjs.unix(dataSource?.[dataIndex]) : undefined
-
-  return (
-    <DatePicker
-      allowClear
-      format={edit?.datePicker?.format ?? DATE_FORMAT}
-      picker={edit?.datePicker?.picker}
-      value={date}
-      onChange={(_, dataString) => dataSource[dataIndex] = dayjs(dataString).unix() ?? undefined}
-    />
-  )
-}
-
-// eslint-disable-next-line ts/no-explicit-any
-export function dateRangePicker(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  const dates: [Dayjs, Dayjs] = dataSource
-    ?.[dataIndex]
-    ?.filter((item: string) => !!item)
-    ?.map((item: string) => dayjs(item))
-
-  return (
-    <RangePicker
-      allowClear
-      format={edit?.datePicker?.format ?? DATE_FORMAT}
-      picker={edit?.datePicker?.picker}
-      value={dates}
-      onChange={(_, dateStrings: [string, string]) => dataSource[dataIndex] = dateStrings}
-    />
-  )
-}
-
-export default StdDataEntry

+ 0 - 7
app/src/components/StdDesign/StdDataEntry/style.less

@@ -1,7 +0,0 @@
-.std-data-entry-action {
-  @media (max-width: 375px) {
-    display: block;
-    width: 100%;
-    margin: 10px 0;
-  }
-}

+ 0 - 25
app/src/components/StdDesign/StdDataEntry/types.d.ts

@@ -1,25 +0,0 @@
-import type { DefaultOptionType } from 'ant-design-vue/es/vc-cascader'
-
-export interface Author {
-  id?: number
-  name: string
-  checked?: boolean
-  sort?: number
-  affiliated_unit?: string
-}
-
-export interface AuthorSelector {
-  input?: {
-    title?: () => string
-    placeholder?: () => string
-  }
-  checkbox?: {
-    title?: () => string
-    placeholder?: () => string
-  }
-  select?: {
-    title?: () => string
-    placeholder?: () => string
-    options?: DefaultOptionType[]
-  }
-}

+ 0 - 141
app/src/components/StdDesign/StdDetail/StdDetail.vue

@@ -1,141 +0,0 @@
-<script setup lang="ts" generic="T extends ModelBase">
-import type { ButtonProps, FormInstance } from 'ant-design-vue'
-import type { DataIndex } from 'ant-design-vue/es/vc-table/interface'
-import type { ModelBase } from '@/api/curd'
-import type Curd from '@/api/curd'
-import type { Column, StdDesignEdit } from '@/components/StdDesign/types'
-import { message } from 'ant-design-vue'
-import { cloneDeep, get } from 'lodash'
-
-import { labelRender } from '@/components/StdDesign/StdDataEntry'
-
-const props = defineProps<{
-  title?: string
-  dataSource?: T
-  api: Curd<T>
-  columns: Column[]
-  actionButtonProps?: ButtonProps
-  useOutsideData?: boolean
-}>()
-
-const detail = ref(props.dataSource) as Ref<T | undefined>
-const editModel = ref({}) as Ref<T | undefined>
-const editStatus = ref(false)
-const loading = ref(false)
-
-const formRef = ref<FormInstance>()
-
-watch(() => props.dataSource, val => detail.value = val)
-
-async function save() {
-  try {
-    await formRef.value?.validate()
-    loading.value = true
-    props.api.save(editModel.value?.id, editModel.value).then(res => {
-      detail.value = res
-      editStatus.value = false
-    }).catch(() => {
-      message.error('Save failed')
-    }).finally(() => loading.value = false)
-  }
-  catch {
-    message.error('Validation failed')
-  }
-}
-
-function FormController(p: { editConfig: StdDesignEdit, dataIndex?: DataIndex }) {
-  return p?.editConfig?.type?.(p.editConfig, editModel.value, p.dataIndex)
-}
-
-function CustomRender(p: { column?: Column, text: unknown, record?: T }) {
-  const { column, text, record } = p
-  return column?.customRender?.({ text, record }) ?? text ?? '/'
-}
-
-const route = useRoute()
-
-onMounted(() => {
-  if (props?.useOutsideData) {
-    editModel.value = cloneDeep(props.dataSource)
-    return
-  }
-
-  props.api.get(route.params.id).then(res => {
-    detail.value = res
-  })
-})
-
-function clickEdit() {
-  editModel.value = cloneDeep(detail.value)
-  editStatus.value = true
-}
-</script>
-
-<template>
-  <AForm
-    ref="formRef"
-    :model="editModel"
-  >
-    <ADescriptions
-      bordered
-      :title="props.title ?? $gettext('Info')"
-      :column="2"
-    >
-      <template #extra>
-        <ASpace v-if="editStatus">
-          <AButton
-            type="primary"
-            :disabled="loading"
-            :loading="loading"
-            v-bind="props.actionButtonProps"
-            @click="save"
-          >
-            {{ $gettext('Save') }}
-          </AButton>
-          <AButton
-            :disabled="loading"
-            :loading="loading"
-            v-bind="props.actionButtonProps"
-            @click="editStatus = false"
-          >
-            {{ $gettext('Cancel') }}
-          </AButton>
-        </ASpace>
-        <div v-else>
-          <AButton
-            type="primary"
-            v-bind="props.actionButtonProps"
-            @click="clickEdit"
-          >
-            {{ $gettext('Edit') }}
-          </AButton>
-          <slot name="extra" />
-        </div>
-      </template>
-      <ADescriptionsItem
-        v-for="c in props.columns.filter(c => c.dataIndex !== 'action')"
-        :key="c.dataIndex?.toString()"
-        :label="$gettext(labelRender(c.title) ?? '')"
-      >
-        <AFormItem
-          v-if="editStatus && c.edit"
-          class="mb-0"
-          :name="c.dataIndex?.toString()"
-          :required="c?.edit?.config?.required"
-        >
-          <FormController
-            :edit-config="c.edit"
-            :data-index="c.dataIndex"
-          />
-        </AFormItem>
-        <span v-else>
-          <CustomRender
-            :column="c"
-            :text="get(detail, c.dataIndex as any)"
-            :record="detail"
-          />
-        </span>
-      </ADescriptionsItem>
-    </ADescriptions>
-  </AForm>
-</template>

+ 0 - 154
app/src/components/StdDesign/types.d.ts

@@ -1,154 +0,0 @@
-/* eslint-disable ts/no-explicit-any */
-
-import type { TableColumnType } from 'ant-design-vue'
-import type { RuleObject } from 'ant-design-vue/es/form'
-
-import type { JSX } from 'vue/jsx'
-import type { Pagination } from '@/api/curd'
-import type Curd from '@/api/curd'
-
-export type JSXElements = JSX.Element[]
-
-// use for select-option
-export type StdDesignMask =
-  Record<string | number, string | (() => string)>
-  | (() => Promise<Record<string | number, string>>)
-
-export interface StdDesignEdit {
-
-  type?: (edit: StdDesignEdit, dataSource: any, dataIndex: any) => JSX.Element // component type
-
-  show?: (dataSource: any) => boolean // show component or not
-
-  batch?: boolean // batch edit
-
-  mask?: StdDesignMask
-
-  rules?: RuleObject[] // validator rules
-
-  hint?: string | (() => string) // hint form item
-
-  actualDataIndex?: string
-
-  datePicker?: {
-    picker?: 'date' | 'week' | 'month' | 'year' | 'quarter'
-    format?: string
-  }
-
-  cascader?: {
-    api: () => Promise<any>
-    fieldNames: Record<string, string>
-  }
-
-  select?: {
-    multiple?: boolean
-  }
-
-  selector?: {
-    getParams?: Record<string | number, any>
-    selectionType?: 'radio' | 'checkbox'
-    api: Curd
-    valueApi?: Curd
-    columns: any
-    disableSearch?: boolean
-    description?: string
-    bind?: any
-    itemKey?: any // default is id
-    dataSourceValueIndex?: any // relative to dataSource
-    recordValueIndex?: any // relative to dataSource
-    getCheckboxProps?: (record: any) => any
-    expandAll?: boolean
-  } // StdSelector Config
-
-  upload?: {
-    limit?: number // upload file limitation
-    action: string // upload url
-  }
-
-  config?: {
-    label?: string | (() => string) // label for form item
-    recordValueIndex?: any // relative to api return
-    placeholder?: string | (() => string) // placeholder for input
-    generate?: boolean // generate btn for StdPassword
-    selectionType?: any
-    api?: Curd
-    valueApi?: Curd
-    columns?: any
-    disableSearch?: boolean
-    description?: string
-    bind?: any
-    itemKey?: any // default is id
-    dataSourceValueIndex?: any // relative to dataSource
-    defaultValue?: any
-    required?: boolean
-    noValidate?: boolean
-    min?: number // min value for input number
-    max?: number // max value for input number
-    addonBefore?: string // for inputNumber
-    addonAfter?: string // for inputNumber
-    prefix?: string // for inputNumber
-    suffix?: string // for inputNumber
-    size?: string // class size of Std image upload
-    error_messages?: Ref
-  }
-
-  flex?: Flex
-}
-
-export interface Flex {
-  // eslint-disable-next-line sonarjs/use-type-alias
-  sm?: string | number | boolean
-  md?: string | number | boolean
-  lg?: string | number | boolean
-  xl?: string | number | boolean
-  xxl?: string | number | boolean
-}
-
-export interface Column extends TableColumnType {
-  title?: string | (() => string)
-  edit?: StdDesignEdit
-  extra?: string | (() => string)
-  pithy?: boolean
-  search?: boolean | StdDesignEdit
-  handle?: boolean
-  hiddenInTable?: boolean
-  hiddenInTrash?: boolean
-  hiddenInCreate?: boolean
-  hiddenInModify?: boolean
-  hiddenInDetail?: boolean
-  hiddenInExport?: boolean
-  import?: boolean
-  batch?: boolean
-  radio?: boolean
-  mask?: StdDesignMask
-  customRender?: function
-  selector?: {
-    getParams?: Record<string | number, any>
-    recordValueIndex: any // relative to api return
-    selectionType: any
-    api: Curd
-    valueApi?: Curd
-    columns: any
-    disableSearch?: boolean
-    description?: string
-    bind?: any
-    itemKey?: any // default is id
-    dataSourceValueIndex?: any // relative to dataSource
-    getCheckboxProps?: (record: any) => any
-  }
-}
-
-export interface StdTableResponse {
-  data: any[]
-  pagination: Pagination
-}
-
-export interface BulkActionOptions {
-  text: () => string
-  action: (rows: (number | string)[] | undefined) => Promise<boolean>
-}
-
-export type BulkActions = Record<string, BulkActionOptions | boolean> & {
-  delete?: boolean | BulkActionOptions
-  recover?: boolean | BulkActionOptions
-}

+ 1 - 1
app/src/components/TwoFA/Authorization.vue

@@ -3,7 +3,7 @@ import type { TwoFAStatus } from '@/api/2fa'
 import { KeyOutlined } from '@ant-design/icons-vue'
 import { startAuthentication } from '@simplewebauthn/browser'
 import twoFA from '@/api/2fa'
-import OTPInput from '@/components/OTPInput/OTPInput.vue'
+import OTPInput from '@/components/OTPInput'
 import { useUserStore } from '@/pinia'
 
 defineProps<{

+ 20 - 0
app/src/constants/errors/backup.ts

@@ -57,4 +57,24 @@ export default {
   4802: () => $gettext('Failed to calculate Nginx UI hash: {0}'),
   4803: () => $gettext('Failed to calculate Nginx hash: {0}'),
   4804: () => $gettext('Hash verification failed: file integrity compromised'),
+  4901: () => $gettext('Backup path not in granted access paths: {0}'),
+  4902: () => $gettext('Storage path not in granted access paths: {0}'),
+  4903: () => $gettext('Backup path is required for custom directory backup'),
+  4904: () => $gettext('S3 configuration is incomplete: missing {0}'),
+  4905: () => $gettext('Unsupported backup type: {0}'),
+  4906: () => $gettext('Failed to create backup directory: {0}'),
+  4907: () => $gettext('Failed to write backup file: {0}'),
+  4908: () => $gettext('Failed to write security key file: {0}'),
+  4909: () => $gettext('S3 upload failed: {0}'),
+  4920: () => $gettext('S3 connection test failed: {0}'),
+  4921: () => $gettext('S3 bucket access denied: {0}'),
+  4922: () => $gettext('S3 credentials are invalid: {0}'),
+  4923: () => $gettext('S3 endpoint is invalid: {0}'),
+  4910: () => $gettext('Invalid path: {0}'),
+  4911: () => $gettext('Path not in granted access paths: {0}'),
+  4912: () => $gettext('Backup path does not exist: {0}'),
+  4913: () => $gettext('Cannot access backup path {0}: {1}'),
+  4914: () => $gettext('Backup path is not a directory: {0}'),
+  4915: () => $gettext('Failed to create storage directory {0}: {1}'),
+  4916: () => $gettext('Cannot access storage path {0}: {1}'),
 }

File diff suppressed because it is too large
+ 251 - 162
app/src/language/ar/app.po


+ 62 - 0
app/src/language/curd.ts

@@ -0,0 +1,62 @@
+/* eslint-disable */
+// This file is auto-generated. Do not edit manually.
+
+export const translations = {
+  total: $gettext('Total'),
+  'item(s)': $gettext('item(s)'),
+  view: $gettext('View'),
+  edit: $gettext('Edit'),
+  delete: $gettext('Delete'),
+  restore: $gettext('Restore'),
+  deletePermanently: $gettext('Delete Permanently'),
+  search: $gettext('Search'),
+  reset: $gettext('Reset'),
+  close: $gettext('Close'),
+  ok: $gettext('OK'),
+  selectorTitle: $gettext('Selector'),
+  generate: $gettext('Generate'),
+  save: $gettext('Save'),
+  add: $gettext('Add'),
+  trash: $gettext('Trash'),
+  backToList: $gettext('Back to List'),
+  exportExcel: $gettext('Export Excel'),
+  list: $gettext('List'),
+  formValidateError: $gettext('Please fill all fields correctly'),
+  deleteConfirm: $gettext('Are you sure you want to delete?'),
+  restoreConfirm: $gettext('Are you sure you want to restore?'),
+  deletePermanentlyConfirm: $gettext('Are you sure you want to delete permanently?'),
+  savedSuccessfully: $gettext('Saved successfully'),
+  deletedSuccessfully: $gettext('Deleted successfully'),
+  restoredSuccessfully: $gettext('Restored successfully'),
+  selectAll: $gettext('Select all'),
+  batchEdit: $gettext('Batch Edit'),
+  pleaseSelectAtLeastOneItem: $gettext('Please select at least one item'),
+  batchModify: $gettext('Batch Modify'),
+  saveSuccessfully: $gettext('Save successfully'),
+  belowsAreSelectedItems: $gettext('Belows are selected items that you want to batch modify'),
+  leaveBlankIfDoNotWantToModify: $gettext('Leave blank if do not want to modify'),
+  no: $gettext('No'),
+  validate: {
+    required: $gettext('This field should not be empty'),
+    email: $gettext('This field should be a valid email address'),
+    db_unique: $gettext('This value is already taken'),
+    hostname: $gettext('This field should be a valid hostname'),
+    safety_text: $gettext('This field should only contain letters, unicode characters, numbers, and -_./:')
+  },
+  upload: {
+    uploadFiles: $gettext('Upload Files'),
+    uploadFolders: $gettext('Upload Folders'),
+    clickOrDragFilesToThisAreaToUpload: $gettext('Click or drag files to this area to upload'),
+    clickOrDragFoldersToThisAreaToUpload: $gettext('Click or drag folders to this area to upload'),
+    supportForSingleOrBatchUploadOfFiles: $gettext('Support single or batch upload of files'),
+    supportForUploadingEntireFolders: $gettext('Support uploading entire folders'),
+    mainTipsForFiles: $gettext('Click or drag files to this area to upload'),
+    mainTipsForFolders: $gettext('Click or drag folders to this area to upload'),
+    subTipsForFiles: $gettext('Support single or batch upload of files'),
+    subTipsForFolders: $gettext('Support uploading entire folders'),
+    selectedFiles: $gettext('Selected {count} files'),
+    processing: $gettext('Processing {count}/{total}'),
+    path: $gettext('Path'),
+    size: $gettext('Size')
+  }
+}

File diff suppressed because it is too large
+ 252 - 161
app/src/language/de_DE/app.po


File diff suppressed because it is too large
+ 256 - 163
app/src/language/en/app.po


File diff suppressed because it is too large
+ 253 - 162
app/src/language/es/app.po


File diff suppressed because it is too large
+ 253 - 162
app/src/language/fr_FR/app.po


File diff suppressed because it is too large
+ 258 - 165
app/src/language/ja_JP/app.po


File diff suppressed because it is too large
+ 258 - 165
app/src/language/ko_KR/app.po


File diff suppressed because it is too large
+ 264 - 164
app/src/language/messages.pot


File diff suppressed because it is too large
+ 252 - 163
app/src/language/pt_PT/app.po


File diff suppressed because it is too large
+ 254 - 163
app/src/language/ru_RU/app.po


File diff suppressed because it is too large
+ 252 - 163
app/src/language/tr_TR/app.po


File diff suppressed because it is too large
+ 253 - 162
app/src/language/uk_UA/app.po


File diff suppressed because it is too large
+ 251 - 162
app/src/language/vi_VN/app.po


File diff suppressed because it is too large
+ 258 - 165
app/src/language/zh_CN/app.po


File diff suppressed because it is too large
+ 258 - 165
app/src/language/zh_TW/app.po


+ 1 - 1
app/src/layouts/BaseLayout.vue

@@ -2,7 +2,7 @@
 import { throttle } from 'lodash'
 import { storeToRefs } from 'pinia'
 import settings from '@/api/settings'
-import PageHeader from '@/components/PageHeader/PageHeader.vue'
+import PageHeader from '@/components/PageHeader'
 import { useSettingsStore, useUserStore } from '@/pinia'
 import FooterLayout from './FooterLayout.vue'
 import HeaderLayout from './HeaderLayout.vue'

Some files were not shown because too many files changed in this diff