浏览代码

Merge pull request #657 from 0xJacky/feat/site-category

feat: site category
Jacky 6 月之前
父节点
当前提交
6dc6092bfc
共有 100 个文件被更改,包括 4469 次插入2896 次删除
  1. 2 2
      api/certificate/acme_user.go
  2. 5 5
      api/certificate/certificate.go
  3. 2 2
      api/certificate/dns_credential.go
  4. 2 2
      api/cluster/environment.go
  5. 1 1
      api/cluster/router.go
  6. 5 5
      api/config/add.go
  7. 2 2
      api/config/get.go
  8. 6 6
      api/config/modify.go
  9. 4 4
      api/config/rename.go
  10. 3 3
      api/config/router.go
  11. 1 1
      api/notification/notification.go
  12. 2 2
      api/notification/router.go
  13. 1 1
      api/sites/auto_cert.go
  14. 1 1
      api/sites/category.go
  15. 0 322
      api/sites/domain.go
  16. 4 16
      api/sites/duplicate.go
  17. 56 18
      api/sites/list.go
  18. 22 13
      api/sites/router.go
  19. 216 0
      api/sites/site.go
  20. 3 2
      api/sites/type.go
  21. 7 7
      api/streams/router.go
  22. 5 5
      api/template/router.go
  23. 2 2
      api/user/passkey.go
  24. 5 5
      api/user/router.go
  25. 2 2
      api/user/user.go
  26. 95 0
      app/.eslint-auto-import.mjs
  27. 2 0
      app/components.d.ts
  28. 5 1
      app/eslint.config.mjs
  29. 7 0
      app/package.json
  30. 514 0
      app/pnpm-lock.yaml
  31. 0 6
      app/postcss.config.cjs
  32. 1 1
      app/src/api/acme_user.ts
  33. 1 1
      app/src/api/cert.ts
  34. 1 1
      app/src/api/config.ts
  35. 52 12
      app/src/api/curd.ts
  36. 1 1
      app/src/api/dns_credential.ts
  37. 2 2
      app/src/api/environment.ts
  38. 2 2
      app/src/api/notification.ts
  39. 11 3
      app/src/api/site.ts
  40. 11 0
      app/src/api/site_category.ts
  41. 1 1
      app/src/api/stream.ts
  42. 6 6
      app/src/api/template.ts
  43. 1 1
      app/src/api/user.ts
  44. 3 4
      app/src/components/NodeSelector/NodeSelector.vue
  45. 1 1
      app/src/components/Notification/Notification.vue
  46. 67 2
      app/src/components/Notification/config.ts
  47. 32 0
      app/src/components/Notification/detailRender.ts
  48. 43 23
      app/src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue
  49. 43 28
      app/src/components/StdDesign/StdDataDisplay/StdCurd.vue
  50. 70 70
      app/src/components/StdDesign/StdDataDisplay/StdTable.vue
  51. 4 2
      app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx
  52. 7 0
      app/src/components/StdDesign/StdDataDisplay/methods/columns.ts
  53. 4 2
      app/src/components/StdDesign/StdDataDisplay/methods/exportCsv.ts
  54. 17 19
      app/src/components/StdDesign/StdDataEntry/StdDataEntry.vue
  55. 31 4
      app/src/components/StdDesign/StdDataEntry/StdFormItem.vue
  56. 112 82
      app/src/components/StdDesign/StdDataEntry/components/StdSelector.vue
  57. 15 11
      app/src/components/StdDesign/types.d.ts
  58. 7 0
      app/src/constants/form_errors.ts
  59. 11 0
      app/src/language/constants.ts
  60. 225 160
      app/src/language/en/app.po
  61. 228 163
      app/src/language/es/app.po
  62. 225 160
      app/src/language/fr_FR/app.po
  63. 228 163
      app/src/language/ko_KR/app.po
  64. 211 168
      app/src/language/messages.pot
  65. 228 163
      app/src/language/ru_RU/app.po
  66. 237 169
      app/src/language/tr_TR/app.po
  67. 225 160
      app/src/language/vi_VN/app.po
  68. 二进制
      app/src/language/zh_CN/app.mo
  69. 222 168
      app/src/language/zh_CN/app.po
  70. 228 163
      app/src/language/zh_TW/app.po
  71. 6 4
      app/src/lib/http/index.ts
  72. 16 0
      app/src/lib/nprogress/nprogress.ts
  73. 1 1
      app/src/main.ts
  74. 14 8
      app/src/routes/index.ts
  75. 0 3
      app/src/style.css
  76. 1 1
      app/src/views/certificate/ACMEUserSelector.vue
  77. 1 1
      app/src/views/dashboard/Environments.vue
  78. 3 3
      app/src/views/notification/Notification.vue
  79. 4 4
      app/src/views/site/SiteAdd.vue
  80. 3 3
      app/src/views/site/cert/components/ObtainCert.vue
  81. 0 122
      app/src/views/site/components/Deploy.vue
  82. 0 128
      app/src/views/site/components/RightSettings.vue
  83. 0 149
      app/src/views/site/components/SiteDuplicate.vue
  84. 28 0
      app/src/views/site/site_category/SiteCategory.vue
  85. 30 0
      app/src/views/site/site_category/columns.ts
  86. 168 0
      app/src/views/site/site_edit/RightSettings.vue
  87. 11 15
      app/src/views/site/site_edit/SiteEdit.vue
  88. 63 0
      app/src/views/site/site_edit/components/ConfigName.vue
  89. 92 0
      app/src/views/site/site_list/SiteDuplicate.vue
  90. 65 63
      app/src/views/site/site_list/SiteList.vue
  91. 74 0
      app/src/views/site/site_list/columns.tsx
  92. 0 1
      app/src/views/stream/StreamList.vue
  93. 0 14
      app/tailwind.config.js
  94. 66 0
      app/uno.config.ts
  95. 6 2
      app/vite.config.ts
  96. 4 3
      cmd/generate/generate.go
  97. 5 5
      go.mod
  98. 8 10
      go.sum
  99. 1 1
      internal/analytic/node.go
  100. 3 3
      internal/cert/payload.go

+ 2 - 2
api/certificate/acme_user.go

@@ -13,7 +13,7 @@ import (
 
 func GetAcmeUser(c *gin.Context) {
 	u := query.AcmeUser
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 	user, err := u.FirstByID(id)
 	if err != nil {
 		api.ErrHandler(c, err)
@@ -79,7 +79,7 @@ func RecoverAcmeUser(c *gin.Context) {
 }
 
 func RegisterAcmeUser(c *gin.Context) {
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 	u := query.AcmeUser
 	user, err := u.FirstByID(id)
 	if err != nil {

+ 5 - 5
api/certificate/certificate.go

@@ -71,7 +71,7 @@ func GetCertList(c *gin.Context) {
 func GetCert(c *gin.Context) {
 	q := query.Cert
 
-	certModel, err := q.FirstByID(cast.ToInt(c.Param("id")))
+	certModel, err := q.FirstByID(cast.ToUint64(c.Param("id")))
 
 	if err != nil {
 		api.ErrHandler(c, err)
@@ -89,9 +89,9 @@ type certJson struct {
 	SSLCertificateKey     string             `json:"ssl_certificate_key" binding:"omitempty,privatekey"`
 	KeyType               certcrypto.KeyType `json:"key_type" binding:"omitempty,auto_cert_key_type"`
 	ChallengeMethod       string             `json:"challenge_method"`
-	DnsCredentialID       int                `json:"dns_credential_id"`
-	ACMEUserID            int                `json:"acme_user_id"`
-	SyncNodeIds           []int              `json:"sync_node_ids"`
+	DnsCredentialID       uint64             `json:"dns_credential_id"`
+	ACMEUserID            uint64             `json:"acme_user_id"`
+	SyncNodeIds           []uint64           `json:"sync_node_ids"`
 }
 
 func AddCert(c *gin.Context) {
@@ -141,7 +141,7 @@ func AddCert(c *gin.Context) {
 }
 
 func ModifyCert(c *gin.Context) {
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 
 	var json certJson
 

+ 2 - 2
api/certificate/dns_credential.go

@@ -12,7 +12,7 @@ import (
 )
 
 func GetDnsCredential(c *gin.Context) {
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 
 	d := query.DnsCredential
 
@@ -70,7 +70,7 @@ func AddDnsCredential(c *gin.Context) {
 }
 
 func EditDnsCredential(c *gin.Context) {
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 
 	var json DnsCredentialManageJson
 	if !api.BindAndValid(c, &json) {

+ 2 - 2
api/cluster/environment.go

@@ -15,7 +15,7 @@ import (
 )
 
 func GetEnvironment(c *gin.Context) {
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 
 	envQuery := query.Environment
 
@@ -67,7 +67,7 @@ func EditEnvironment(c *gin.Context) {
 }
 
 func DeleteEnvironment(c *gin.Context) {
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 	envQuery := query.Environment
 
 	env, err := envQuery.FirstByID(id)

+ 1 - 1
api/cluster/router.go

@@ -6,7 +6,7 @@ func InitRouter(r *gin.RouterGroup) {
 	// Environment
 	r.GET("environments", GetEnvironmentList)
 	r.POST("environments/load_from_settings", LoadEnvironmentFromSettings)
-	envGroup := r.Group("environment")
+	envGroup := r.Group("environments")
 	{
 		envGroup.GET("/:id", GetEnvironment)
 		envGroup.POST("", AddEnvironment)

+ 5 - 5
api/config/add.go

@@ -17,11 +17,11 @@ import (
 
 func AddConfig(c *gin.Context) {
 	var json struct {
-		Name        string `json:"name" binding:"required"`
-		NewFilepath string `json:"new_filepath" binding:"required"`
-		Content     string `json:"content"`
-		Overwrite   bool   `json:"overwrite"`
-		SyncNodeIds []int  `json:"sync_node_ids"`
+		Name        string   `json:"name" binding:"required"`
+		NewFilepath string   `json:"new_filepath" binding:"required"`
+		Content     string   `json:"content"`
+		Overwrite   bool     `json:"overwrite"`
+		SyncNodeIds []uint64 `json:"sync_node_ids"`
 	}
 
 	if !api.BindAndValid(c, &json) {

+ 2 - 2
api/config/get.go

@@ -14,8 +14,8 @@ import (
 
 type APIConfigResp struct {
 	config.Config
-	SyncNodeIds   []int `json:"sync_node_ids" gorm:"serializer:json"`
-	SyncOverwrite bool  `json:"sync_overwrite"`
+	SyncNodeIds   []uint64 `json:"sync_node_ids" gorm:"serializer:json"`
+	SyncOverwrite bool     `json:"sync_overwrite"`
 }
 
 func GetConfig(c *gin.Context) {

+ 6 - 6
api/config/modify.go

@@ -21,12 +21,12 @@ type EditConfigJson struct {
 func EditConfig(c *gin.Context) {
 	name := c.Param("name")
 	var json struct {
-		Name          string `json:"name" binding:"required"`
-		Filepath      string `json:"filepath" binding:"required"`
-		NewFilepath   string `json:"new_filepath" binding:"required"`
-		Content       string `json:"content"`
-		SyncOverwrite bool   `json:"sync_overwrite"`
-		SyncNodeIds   []int  `json:"sync_node_ids"`
+		Name          string   `json:"name" binding:"required"`
+		Filepath      string   `json:"filepath" binding:"required"`
+		NewFilepath   string   `json:"new_filepath" binding:"required"`
+		Content       string   `json:"content"`
+		SyncOverwrite bool     `json:"sync_overwrite"`
+		SyncNodeIds   []uint64 `json:"sync_node_ids"`
 	}
 	if !api.BindAndValid(c, &json) {
 		return

+ 4 - 4
api/config/rename.go

@@ -14,10 +14,10 @@ import (
 
 func Rename(c *gin.Context) {
 	var json struct {
-		BasePath    string `json:"base_path"`
-		OrigName    string `json:"orig_name"`
-		NewName     string `json:"new_name"`
-		SyncNodeIds []int  `json:"sync_node_ids" gorm:"serializer:json"`
+		BasePath    string   `json:"base_path"`
+		OrigName    string   `json:"orig_name"`
+		NewName     string   `json:"new_name"`
+		SyncNodeIds []uint64 `json:"sync_node_ids" gorm:"serializer:json"`
 	}
 	if !api.BindAndValid(c, &json) {
 		return

+ 3 - 3
api/config/router.go

@@ -9,9 +9,9 @@ func InitRouter(r *gin.RouterGroup) {
 	r.GET("config_base_path", GetBasePath)
 
 	r.GET("configs", GetConfigs)
-	r.GET("config/*name", GetConfig)
-	r.POST("config", AddConfig)
-	r.POST("config/*name", EditConfig)
+	r.GET("configs/*name", GetConfig)
+	r.POST("configs", AddConfig)
+	r.POST("configs/*name", EditConfig)
 
 	o := r.Group("", middleware.RequireSecureSession())
 	{

+ 1 - 1
api/notification/notification.go

@@ -13,7 +13,7 @@ import (
 func Get(c *gin.Context) {
 	n := query.Notification
 
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 
 	data, err := n.FirstByID(id)
 

+ 2 - 2
api/notification/router.go

@@ -4,7 +4,7 @@ import "github.com/gin-gonic/gin"
 
 func InitRouter(r *gin.RouterGroup) {
 	r.GET("notifications", GetList)
-	r.GET("notification/:id", Get)
-	r.DELETE("notification/:id", Destroy)
+	r.GET("notifications/:id", Get)
+	r.DELETE("notifications/:id", Destroy)
 	r.DELETE("notifications", DestroyAll)
 }

+ 1 - 1
api/sites/auto_cert.go

@@ -13,7 +13,7 @@ func AddDomainToAutoCert(c *gin.Context) {
 	name := c.Param("name")
 
 	var json struct {
-		DnsCredentialID int                `json:"dns_credential_id"`
+		DnsCredentialID uint64             `json:"dns_credential_id"`
 		ChallengeMethod string             `json:"challenge_method"`
 		Domains         []string           `json:"domains"`
 		KeyType         certcrypto.KeyType `json:"key_type"`

+ 1 - 1
api/sites/category.go

@@ -7,7 +7,7 @@ import (
 )
 
 func GetCategory(c *gin.Context) {
-
+	cosy.Core[model.SiteCategory](c).Get()
 }
 
 func GetCategoryList(c *gin.Context) {

+ 0 - 322
api/sites/domain.go

@@ -1,322 +0,0 @@
-package sites
-
-import (
-	"github.com/0xJacky/Nginx-UI/api"
-	"github.com/0xJacky/Nginx-UI/internal/cert"
-	"github.com/0xJacky/Nginx-UI/internal/helper"
-	"github.com/0xJacky/Nginx-UI/internal/nginx"
-	"github.com/0xJacky/Nginx-UI/model"
-	"github.com/0xJacky/Nginx-UI/query"
-	"github.com/gin-gonic/gin"
-	"github.com/sashabaranov/go-openai"
-	"github.com/uozi-tech/cosy/logger"
-	"net/http"
-	"os"
-)
-
-func GetSite(c *gin.Context) {
-	rewriteName, ok := c.Get("rewriteConfigFileName")
-	name := c.Param("name")
-
-	// for modify filename
-	if ok {
-		name = rewriteName.(string)
-	}
-
-	path := nginx.GetConfPath("sites-available", name)
-	file, err := os.Stat(path)
-	if os.IsNotExist(err) {
-		c.JSON(http.StatusNotFound, gin.H{
-			"message": "file not found",
-		})
-		return
-	}
-
-	enabled := true
-	if _, err := os.Stat(nginx.GetConfPath("sites-enabled", name)); os.IsNotExist(err) {
-		enabled = false
-	}
-
-	g := query.ChatGPTLog
-	chatgpt, err := g.Where(g.Name.Eq(path)).FirstOrCreate()
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	if chatgpt.Content == nil {
-		chatgpt.Content = make([]openai.ChatCompletionMessage, 0)
-	}
-
-	s := query.Site
-	site, err := s.Where(s.Path.Eq(path)).FirstOrInit()
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	certModel, err := model.FirstCert(name)
-	if err != nil {
-		logger.Warn(err)
-	}
-
-	if site.Advanced {
-		origContent, err := os.ReadFile(path)
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
-
-		c.JSON(http.StatusOK, Site{
-			ModifiedAt:      file.ModTime(),
-			Advanced:        site.Advanced,
-			Enabled:         enabled,
-			Name:            name,
-			Config:          string(origContent),
-			AutoCert:        certModel.AutoCert == model.AutoCertEnabled,
-			ChatGPTMessages: chatgpt.Content,
-			Filepath:        path,
-		})
-		return
-	}
-
-	nginxConfig, err := nginx.ParseNgxConfig(path)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	certInfoMap := make(map[int][]*cert.Info)
-	for serverIdx, server := range nginxConfig.Servers {
-		for _, directive := range server.Directives {
-			if directive.Directive == "ssl_certificate" {
-				pubKey, err := cert.GetCertInfo(directive.Params)
-				if err != nil {
-					logger.Error("Failed to get certificate information", err)
-					continue
-				}
-				certInfoMap[serverIdx] = append(certInfoMap[serverIdx], pubKey)
-			}
-		}
-	}
-
-	c.JSON(http.StatusOK, Site{
-		ModifiedAt:      file.ModTime(),
-		Advanced:        site.Advanced,
-		Enabled:         enabled,
-		Name:            name,
-		Config:          nginxConfig.FmtCode(),
-		Tokenized:       nginxConfig,
-		AutoCert:        certModel.AutoCert == model.AutoCertEnabled,
-		CertInfo:        certInfoMap,
-		ChatGPTMessages: chatgpt.Content,
-		Filepath:        path,
-	})
-}
-
-func SaveSite(c *gin.Context) {
-	name := c.Param("name")
-
-	if name == "" {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "param name is empty",
-		})
-		return
-	}
-
-	var json struct {
-		Name      string `json:"name" binding:"required"`
-		Content   string `json:"content" binding:"required"`
-		Overwrite bool   `json:"overwrite"`
-	}
-
-	if !api.BindAndValid(c, &json) {
-		return
-	}
-
-	path := nginx.GetConfPath("sites-available", name)
-
-	if !json.Overwrite && helper.FileExists(path) {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "File exists",
-		})
-		return
-	}
-
-	err := os.WriteFile(path, []byte(json.Content), 0644)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
-	// rename the config file if needed
-	if name != json.Name {
-		newPath := nginx.GetConfPath("sites-available", json.Name)
-		s := query.Site
-		_, err = s.Where(s.Path.Eq(path)).Update(s.Path, newPath)
-
-		// check if dst file exists, do not rename
-		if helper.FileExists(newPath) {
-			c.JSON(http.StatusNotAcceptable, gin.H{
-				"message": "File exists",
-			})
-			return
-		}
-		// recreate a soft link
-		if helper.FileExists(enabledConfigFilePath) {
-			_ = os.Remove(enabledConfigFilePath)
-			enabledConfigFilePath = nginx.GetConfPath("sites-enabled", json.Name)
-			err = os.Symlink(newPath, enabledConfigFilePath)
-
-			if err != nil {
-				api.ErrHandler(c, err)
-				return
-			}
-		}
-
-		err = os.Rename(path, newPath)
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
-
-		name = json.Name
-		c.Set("rewriteConfigFileName", name)
-	}
-
-	enabledConfigFilePath = nginx.GetConfPath("sites-enabled", name)
-	if helper.FileExists(enabledConfigFilePath) {
-		// Test nginx configuration
-		output := nginx.TestConf()
-
-		if nginx.GetLogLevel(output) > nginx.Warn {
-			c.JSON(http.StatusInternalServerError, gin.H{
-				"message": output,
-			})
-			return
-		}
-
-		output = nginx.Reload()
-
-		if nginx.GetLogLevel(output) > nginx.Warn {
-			c.JSON(http.StatusInternalServerError, gin.H{
-				"message": output,
-			})
-			return
-		}
-	}
-
-	GetSite(c)
-}
-
-func EnableSite(c *gin.Context) {
-	configFilePath := nginx.GetConfPath("sites-available", c.Param("name"))
-	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", c.Param("name"))
-
-	_, err := os.Stat(configFilePath)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	if _, err = os.Stat(enabledConfigFilePath); os.IsNotExist(err) {
-		err = os.Symlink(configFilePath, enabledConfigFilePath)
-
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
-	}
-
-	// Test nginx config, if not pass, then disable the site.
-	output := nginx.TestConf()
-	if nginx.GetLogLevel(output) > nginx.Warn {
-		_ = os.Remove(enabledConfigFilePath)
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
-		return
-	}
-
-	output = nginx.Reload()
-
-	if nginx.GetLogLevel(output) > nginx.Warn {
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"message": "ok",
-	})
-}
-
-func DisableSite(c *gin.Context) {
-	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", c.Param("name"))
-	_, err := os.Stat(enabledConfigFilePath)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	err = os.Remove(enabledConfigFilePath)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	// delete auto cert record
-	certModel := model.Cert{Filename: c.Param("name")}
-	err = certModel.Remove()
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	output := nginx.Reload()
-	if nginx.GetLogLevel(output) > nginx.Warn {
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"message": "ok",
-	})
-}
-
-func DeleteSite(c *gin.Context) {
-	var err error
-	name := c.Param("name")
-	availablePath := nginx.GetConfPath("sites-available", name)
-	enabledPath := nginx.GetConfPath("sites-enabled", name)
-	if _, err = os.Stat(availablePath); os.IsNotExist(err) {
-		c.JSON(http.StatusNotFound, gin.H{
-			"message": "site not found",
-		})
-		return
-	}
-
-	if _, err = os.Stat(enabledPath); err == nil {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "site is enabled",
-		})
-		return
-	}
-
-	certModel := model.Cert{Filename: name}
-	_ = certModel.Remove()
-
-	err = os.Remove(availablePath)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"message": "ok",
-	})
-}

+ 4 - 16
api/sites/duplicate.go

@@ -2,15 +2,14 @@ package sites
 
 import (
 	"github.com/0xJacky/Nginx-UI/api"
-	"github.com/0xJacky/Nginx-UI/internal/helper"
-	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/site"
 	"github.com/gin-gonic/gin"
 	"net/http"
 )
 
 func DuplicateSite(c *gin.Context) {
 	// Source name
-	name := c.Param("name")
+	src := c.Param("name")
 
 	// Destination name
 	var json struct {
@@ -21,24 +20,13 @@ func DuplicateSite(c *gin.Context) {
 		return
 	}
 
-	src := nginx.GetConfPath("sites-available", name)
-	dst := nginx.GetConfPath("sites-available", json.Name)
-
-	if helper.FileExists(dst) {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "File exists",
-		})
-		return
-	}
-
-	_, err := helper.CopyFile(src, dst)
-
+	err := site.Duplicate(src, json.Name)
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
 	}
 
 	c.JSON(http.StatusOK, gin.H{
-		"dst": dst,
+		"message": "ok",
 	})
 }

+ 56 - 18
api/sites/list.go

@@ -4,9 +4,14 @@ import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/config"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
+	"github.com/samber/lo"
+	"github.com/spf13/cast"
 	"net/http"
 	"os"
+	"path/filepath"
 	"strings"
 )
 
@@ -15,6 +20,7 @@ func GetSiteList(c *gin.Context) {
 	enabled := c.Query("enabled")
 	orderBy := c.Query("order_by")
 	sort := c.DefaultQuery("sort", "desc")
+	querySiteCategoryId := cast.ToUint64(c.Query("site_category_id"))
 
 	configFiles, err := os.ReadDir(nginx.GetConfPath("sites-available"))
 	if err != nil {
@@ -28,6 +34,20 @@ func GetSiteList(c *gin.Context) {
 		return
 	}
 
+	s := query.Site
+	sTx := s.Preload(s.SiteCategory)
+	if querySiteCategoryId != 0 {
+		sTx.Where(s.SiteCategoryID.Eq(querySiteCategoryId))
+	}
+	sites, err := sTx.Find()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	sitesMap := lo.SliceToMap(sites, func(item *model.Site) (string, *model.Site) {
+		return filepath.Base(item.Path), item
+	})
+
 	enabledConfigMap := make(map[string]bool)
 	for i := range enabledConfig {
 		enabledConfigMap[enabledConfig[i].Name()] = true
@@ -38,28 +58,46 @@ func GetSiteList(c *gin.Context) {
 	for i := range configFiles {
 		file := configFiles[i]
 		fileInfo, _ := file.Info()
-		if !file.IsDir() {
-			// name filter
-			if name != "" && !strings.Contains(file.Name(), name) {
+		if file.IsDir() {
+			continue
+		}
+		// name filter
+		if name != "" && !strings.Contains(file.Name(), name) {
+			continue
+		}
+		// status filter
+		if enabled != "" {
+			if enabled == "true" && !enabledConfigMap[file.Name()] {
 				continue
 			}
-			// status filter
-			if enabled != "" {
-				if enabled == "true" && !enabledConfigMap[file.Name()] {
-					continue
-				}
-				if enabled == "false" && enabledConfigMap[file.Name()] {
-					continue
-				}
+			if enabled == "false" && enabledConfigMap[file.Name()] {
+				continue
 			}
-			configs = append(configs, config.Config{
-				Name:       file.Name(),
-				ModifiedAt: fileInfo.ModTime(),
-				Size:       fileInfo.Size(),
-				IsDir:      fileInfo.IsDir(),
-				Enabled:    enabledConfigMap[file.Name()],
-			})
 		}
+		var (
+			siteCategoryId uint64
+			siteCategory   *model.SiteCategory
+		)
+
+		if site, ok := sitesMap[file.Name()]; ok {
+			siteCategoryId = site.SiteCategoryID
+			siteCategory = site.SiteCategory
+		}
+
+		// site category filter
+		if querySiteCategoryId != 0 && siteCategoryId != querySiteCategoryId {
+			continue
+		}
+
+		configs = append(configs, config.Config{
+			Name:           file.Name(),
+			ModifiedAt:     fileInfo.ModTime(),
+			Size:           fileInfo.Size(),
+			IsDir:          fileInfo.IsDir(),
+			Enabled:        enabledConfigMap[file.Name()],
+			SiteCategoryID: siteCategoryId,
+			SiteCategory:   siteCategory,
+		})
 	}
 
 	configs = config.Sort(orderBy, sort, configs)

+ 22 - 13
api/sites/router.go

@@ -3,23 +3,32 @@ package sites
 import "github.com/gin-gonic/gin"
 
 func InitRouter(r *gin.RouterGroup) {
-	r.GET("domains", GetSiteList)
-	r.GET("domain/:name", GetSite)
-	r.POST("domain/:name", SaveSite)
-	r.POST("domain/:name/enable", EnableSite)
-	r.POST("domain/:name/disable", DisableSite)
-	r.POST("domain/:name/advance", DomainEditByAdvancedMode)
-	r.DELETE("domain/:name", DeleteSite)
-	r.POST("domain/:name/duplicate", DuplicateSite)
+	r.GET("sites", GetSiteList)
+	r.GET("sites/:name", GetSite)
+	r.PUT("sites", BatchUpdateSites)
+	r.POST("sites/:name/advance", DomainEditByAdvancedMode)
 	r.POST("auto_cert/:name", AddDomainToAutoCert)
 	r.DELETE("auto_cert/:name", RemoveDomainFromAutoCert)
+
+	// rename site
+	r.POST("sites/:name/rename", RenameSite)
+	// enable site
+	r.POST("sites/:name/enable", EnableSite)
+	// disable site
+	r.POST("sites/:name/disable", DisableSite)
+	// save site
+	r.POST("sites/:name", SaveSite)
+	// delete site
+	r.DELETE("sites/:name", DeleteSite)
+	// duplicate site
+	r.POST("sites/:name/duplicate", DuplicateSite)
 }
 
 func InitCategoryRouter(r *gin.RouterGroup) {
 	r.GET("site_categories", GetCategoryList)
-	r.GET("site_category/:id", GetCategory)
-	r.POST("site_category", AddCategory)
-	r.PUT("site_category/:id", ModifyCategory)
-	r.DELETE("site_category/:id", DeleteCategory)
-	r.POST("site_category/:id/recover", RecoverCategory)
+	r.GET("site_categories/:id", GetCategory)
+	r.POST("site_categories", AddCategory)
+	r.POST("site_categories/:id", ModifyCategory)
+	r.DELETE("site_categories/:id", DeleteCategory)
+	r.POST("site_categories/:id/recover", RecoverCategory)
 }

+ 216 - 0
api/sites/site.go

@@ -0,0 +1,216 @@
+package sites
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/cert"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/site"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/gin-gonic/gin"
+	"github.com/sashabaranov/go-openai"
+	"github.com/uozi-tech/cosy"
+	"github.com/uozi-tech/cosy/logger"
+	"gorm.io/gorm/clause"
+	"net/http"
+	"os"
+)
+
+func GetSite(c *gin.Context) {
+	name := c.Param("name")
+
+	path := nginx.GetConfPath("sites-available", name)
+	file, err := os.Stat(path)
+	if os.IsNotExist(err) {
+		c.JSON(http.StatusNotFound, gin.H{
+			"message": "file not found",
+		})
+		return
+	}
+
+	enabled := true
+	if _, err := os.Stat(nginx.GetConfPath("sites-enabled", name)); os.IsNotExist(err) {
+		enabled = false
+	}
+
+	g := query.ChatGPTLog
+	chatgpt, err := g.Where(g.Name.Eq(path)).FirstOrCreate()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	if chatgpt.Content == nil {
+		chatgpt.Content = make([]openai.ChatCompletionMessage, 0)
+	}
+
+	s := query.Site
+	siteModel, err := s.Where(s.Path.Eq(path)).FirstOrCreate()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	certModel, err := model.FirstCert(name)
+	if err != nil {
+		logger.Warn(err)
+	}
+
+	if siteModel.Advanced {
+		origContent, err := os.ReadFile(path)
+		if err != nil {
+			api.ErrHandler(c, err)
+			return
+		}
+
+		c.JSON(http.StatusOK, Site{
+			ModifiedAt:      file.ModTime(),
+			Site:            siteModel,
+			Enabled:         enabled,
+			Name:            name,
+			Config:          string(origContent),
+			AutoCert:        certModel.AutoCert == model.AutoCertEnabled,
+			ChatGPTMessages: chatgpt.Content,
+			Filepath:        path,
+		})
+		return
+	}
+
+	nginxConfig, err := nginx.ParseNgxConfig(path)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	certInfoMap := make(map[int][]*cert.Info)
+	for serverIdx, server := range nginxConfig.Servers {
+		for _, directive := range server.Directives {
+			if directive.Directive == "ssl_certificate" {
+				pubKey, err := cert.GetCertInfo(directive.Params)
+				if err != nil {
+					logger.Error("Failed to get certificate information", err)
+					continue
+				}
+				certInfoMap[serverIdx] = append(certInfoMap[serverIdx], pubKey)
+			}
+		}
+	}
+
+	c.JSON(http.StatusOK, Site{
+		Site:            siteModel,
+		ModifiedAt:      file.ModTime(),
+		Enabled:         enabled,
+		Name:            name,
+		Config:          nginxConfig.FmtCode(),
+		Tokenized:       nginxConfig,
+		AutoCert:        certModel.AutoCert == model.AutoCertEnabled,
+		CertInfo:        certInfoMap,
+		ChatGPTMessages: chatgpt.Content,
+		Filepath:        path,
+	})
+}
+
+func SaveSite(c *gin.Context) {
+	name := c.Param("name")
+
+	var json struct {
+		Content        string   `json:"content" binding:"required"`
+		SiteCategoryID uint64   `json:"site_category_id"`
+		SyncNodeIDs    []uint64 `json:"sync_node_ids"`
+		Overwrite      bool     `json:"overwrite"`
+	}
+
+	if !api.BindAndValid(c, &json) {
+		return
+	}
+
+	err := site.Save(name, json.Content, json.Overwrite, json.SiteCategoryID, json.SyncNodeIDs)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	GetSite(c)
+}
+
+func RenameSite(c *gin.Context) {
+	oldName := c.Param("name")
+	var json struct {
+		NewName string `json:"new_name"`
+	}
+	if !cosy.BindAndValid(c, &json) {
+		return
+	}
+
+	err := site.Rename(oldName, json.NewName)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}
+
+func EnableSite(c *gin.Context) {
+	err := site.Enable(c.Param("name"))
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}
+
+func DisableSite(c *gin.Context) {
+	err := site.Disable(c.Param("name"))
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}
+
+func DeleteSite(c *gin.Context) {
+	err := site.Delete(c.Param("name"))
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}
+
+func BatchUpdateSites(c *gin.Context) {
+	cosy.Core[model.Site](c).SetValidRules(gin.H{
+		"site_category_id": "required",
+	}).SetItemKey("path").
+		BeforeExecuteHook(func(ctx *cosy.Ctx[model.Site]) {
+			effectedPath := make([]string, len(ctx.BatchEffectedIDs))
+			var sites []*model.Site
+			for i, name := range ctx.BatchEffectedIDs {
+				path := nginx.GetConfPath("sites-available", name)
+				effectedPath[i] = path
+				sites = append(sites, &model.Site{
+					Path: path,
+				})
+			}
+			s := query.Site
+			err := s.Clauses(clause.OnConflict{
+				DoNothing: true,
+			}).Create(sites...)
+			if err != nil {
+				ctx.AbortWithError(err)
+				return
+			}
+			ctx.BatchEffectedIDs = effectedPath
+		}).BatchModify()
+}

+ 3 - 2
api/sites/sites.go → api/sites/type.go

@@ -3,15 +3,16 @@ package sites
 import (
 	"github.com/0xJacky/Nginx-UI/internal/cert"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/sashabaranov/go-openai"
 	"time"
 )
 
 type Site struct {
+	*model.Site
+	Name            string                         `json:"name"`
 	ModifiedAt      time.Time                      `json:"modified_at"`
-	Advanced        bool                           `json:"advanced"`
 	Enabled         bool                           `json:"enabled"`
-	Name            string                         `json:"name"`
 	Config          string                         `json:"config"`
 	AutoCert        bool                           `json:"auto_cert"`
 	ChatGPTMessages []openai.ChatCompletionMessage `json:"chatgpt_messages,omitempty"`

+ 7 - 7
api/streams/router.go

@@ -4,11 +4,11 @@ import "github.com/gin-gonic/gin"
 
 func InitRouter(r *gin.RouterGroup) {
 	r.GET("streams", GetStreams)
-	r.GET("stream/:name", GetStream)
-	r.POST("stream/:name", SaveStream)
-	r.POST("stream/:name/enable", EnableStream)
-	r.POST("stream/:name/disable", DisableStream)
-	r.POST("stream/:name/advance", AdvancedEdit)
-	r.DELETE("stream/:name", DeleteStream)
-	r.POST("stream/:name/duplicate", Duplicate)
+	r.GET("streams/:name", GetStream)
+	r.POST("streams/:name", SaveStream)
+	r.POST("streams/:name/enable", EnableStream)
+	r.POST("streams/:name/disable", DisableStream)
+	r.POST("streams/:name/advance", AdvancedEdit)
+	r.DELETE("streams/:name", DeleteStream)
+	r.POST("streams/:name/duplicate", Duplicate)
 }

+ 5 - 5
api/template/router.go

@@ -3,9 +3,9 @@ package template
 import "github.com/gin-gonic/gin"
 
 func InitRouter(r *gin.RouterGroup) {
-	r.GET("template", GetTemplate)
-	r.GET("template/configs", GetTemplateConfList)
-	r.GET("template/blocks", GetTemplateBlockList)
-	r.GET("template/block/:name", GetTemplateBlock)
-	r.POST("template/block/:name", GetTemplateBlock)
+	r.GET("templates", GetTemplate)
+	r.GET("templates/configs", GetTemplateConfList)
+	r.GET("templates/blocks", GetTemplateBlockList)
+	r.GET("templates/block/:name", GetTemplateBlock)
+	r.POST("templates/block/:name", GetTemplateBlock)
 }

+ 2 - 2
api/user/passkey.go

@@ -23,7 +23,7 @@ import (
 
 const passkeyTimeout = 30 * time.Second
 
-func buildCachePasskeyRegKey(id int) string {
+func buildCachePasskeyRegKey(id uint64) string {
 	return fmt.Sprintf("passkey-reg-%d", id)
 }
 
@@ -130,7 +130,7 @@ func FinishPasskeyLogin(c *gin.Context) {
 				LastUsedAt: time.Now().Unix(),
 			})
 
-			outUser, err = u.FirstByID(cast.ToInt(string(userHandle)))
+			outUser, err = u.FirstByID(cast.ToUint64(string(userHandle)))
 			return outUser, err
 		}, *sessionData, c.Request)
 	if err != nil {

+ 5 - 5
api/user/router.go

@@ -19,11 +19,11 @@ func InitAuthRouter(r *gin.RouterGroup) {
 
 func InitManageUserRouter(r *gin.RouterGroup) {
 	r.GET("users", GetUsers)
-	r.GET("user/:id", GetUser)
-	r.POST("user", AddUser)
-	r.POST("user/:id", EditUser)
-	r.DELETE("user/:id", DeleteUser)
-	r.PATCH("user/:id", RecoverUser)
+	r.GET("users/:id", GetUser)
+	r.POST("users", AddUser)
+	r.POST("users/:id", EditUser)
+	r.DELETE("users/:id", DeleteUser)
+	r.PATCH("users/:id", RecoverUser)
 }
 
 func InitUserRouter(r *gin.RouterGroup) {

+ 2 - 2
api/user/user.go

@@ -17,7 +17,7 @@ func GetUsers(c *gin.Context) {
 }
 
 func GetUser(c *gin.Context) {
-	id := cast.ToInt(c.Param("id"))
+	id := cast.ToUint64(c.Param("id"))
 
 	u := query.User
 
@@ -69,7 +69,7 @@ func AddUser(c *gin.Context) {
 }
 
 func EditUser(c *gin.Context) {
-	userId := cast.ToInt(c.Param("id"))
+	userId := cast.ToUint64(c.Param("id"))
 
 	if settings.NodeSettings.Demo && userId == 1 {
 		c.JSON(http.StatusNotAcceptable, gin.H{

+ 95 - 0
app/.eslint-auto-import.mjs

@@ -0,0 +1,95 @@
+export default {
+  "globals": {
+    "$gettext": true,
+    "$ngettext": true,
+    "$npgettext": true,
+    "$pgettext": true,
+    "Component": true,
+    "ComponentPublicInstance": true,
+    "ComputedRef": true,
+    "DirectiveBinding": true,
+    "EffectScope": true,
+    "ExtractDefaultPropTypes": true,
+    "ExtractPropTypes": true,
+    "ExtractPublicPropTypes": true,
+    "InjectionKey": true,
+    "MaybeRef": true,
+    "MaybeRefOrGetter": true,
+    "PropType": true,
+    "Ref": true,
+    "VNode": true,
+    "WritableComputedRef": true,
+    "acceptHMRUpdate": true,
+    "computed": true,
+    "createApp": true,
+    "createPinia": true,
+    "customRef": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "defineStore": true,
+    "effectScope": true,
+    "getActivePinia": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "h": true,
+    "inject": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "mapActions": true,
+    "mapGetters": true,
+    "mapState": true,
+    "mapStores": true,
+    "mapWritableState": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeRouteLeave": true,
+    "onBeforeRouteUpdate": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "onWatcherCleanup": true,
+    "provide": true,
+    "reactive": true,
+    "readonly": true,
+    "ref": true,
+    "resolveComponent": true,
+    "setActivePinia": true,
+    "setMapStoreSuffix": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "storeToRefs": true,
+    "toRaw": true,
+    "toRef": true,
+    "toRefs": true,
+    "toValue": true,
+    "triggerRef": true,
+    "unref": true,
+    "useAttrs": true,
+    "useCssModule": true,
+    "useCssVars": true,
+    "useId": true,
+    "useLink": true,
+    "useModel": true,
+    "useRoute": true,
+    "useRouter": true,
+    "useSlots": true,
+    "useTemplateRef": true,
+    "watch": true,
+    "watchEffect": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true
+  }
+}

+ 2 - 0
app/components.d.ts

@@ -49,6 +49,8 @@ declare module 'vue' {
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
     APopover: typeof import('ant-design-vue/es')['Popover']
     AProgress: typeof import('ant-design-vue/es')['Progress']
+    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']
     ASelect: typeof import('ant-design-vue/es')['Select']

+ 5 - 1
app/eslint.config.mjs

@@ -1,10 +1,14 @@
 import createConfig from '@antfu/eslint-config'
 import sonarjs from 'eslint-plugin-sonarjs'
+import autoImport from './.eslint-auto-import.mjs'
 
 export default createConfig(
   {
     stylistic: true,
-    ignores: ['**/version.json', 'tsconfig.json', 'tsconfig.node.json'],
+    ignores: ['**/version.json', 'tsconfig.json', 'tsconfig.node.json', '.eslint-auto-import.mjs'],
+    languageOptions: {
+      globals: autoImport.globals,
+    },
   },
   sonarjs.configs.recommended,
   {

+ 7 - 0
app/package.json

@@ -39,6 +39,7 @@
     "reconnecting-websocket": "^4.4.0",
     "sortablejs": "^1.15.3",
     "universal-cookie": "^7.2.1",
+    "unocss": "^0.63.6",
     "vite-plugin-build-id": "0.4.2",
     "vue": "^3.5.12",
     "vue-dompurify-html": "^5.1.0",
@@ -51,6 +52,12 @@
   },
   "devDependencies": {
     "@antfu/eslint-config": "^3.8.0",
+    "@iconify-json/fa": "1.1.5",
+    "@iconify-json/tabler": "1.1.95",
+    "@iconify/tools": "3.0.5",
+    "@iconify/types": "^2.0.0",
+    "@iconify/utils": "^2.1.33",
+    "@iconify/vue": "4.1.1",
     "@simplewebauthn/types": "^11.0.0",
     "@types/lodash": "^4.17.12",
     "@types/nprogress": "^0.2.3",

文件差异内容过多而无法显示
+ 514 - 0
app/pnpm-lock.yaml


+ 0 - 6
app/postcss.config.cjs

@@ -1,6 +0,0 @@
-module.exports = {
-  plugins: {
-    tailwindcss: {},
-    autoprefixer: {},
-  },
-}

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

@@ -11,7 +11,7 @@ export interface AcmeUser extends ModelBase {
 
 class ACMEUserCurd extends Curd<AcmeUser> {
   constructor() {
-    super('acme_user', 'acme_users')
+    super('acme_users')
   }
 
   public async register(id: number) {

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

@@ -37,6 +37,6 @@ export interface CertificateResult {
   key_type: PrivateKeyType
 }
 
-const cert: Curd<Cert> = new Curd('/cert')
+const cert: Curd<Cert> = new Curd('/certs')
 
 export default cert

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

@@ -14,7 +14,7 @@ export interface Config {
 
 class ConfigCurd extends Curd<Config> {
   constructor() {
-    super('/config')
+    super('/configs')
   }
 
   get_base_path() {

+ 52 - 12
app/src/api/curd.ts

@@ -15,28 +15,31 @@ export interface Pagination {
 
 export interface GetListResponse<T> {
   data: T[]
-  pagination: Pagination
+  pagination?: Pagination
 }
 
 class Curd<T> {
   protected readonly baseUrl: string
-  protected readonly plural: 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, plural: string | null = null) {
+  constructor(baseUrl: string) {
     this.baseUrl = baseUrl
-    this.plural = plural ?? `${this.baseUrl}s`
   }
 
   // eslint-disable-next-line ts/no-explicit-any
   _get_list(params: any = null): Promise<GetListResponse<T>> {
-    return http.get(this.plural, { params })
+    return http.get(this.baseUrl, { params })
   }
 
   // eslint-disable-next-line ts/no-explicit-any
@@ -45,10 +48,25 @@ class Curd<T> {
   }
 
   // eslint-disable-next-line ts/no-explicit-any
-  _save(id: any = null, data: any = undefined, config: any = undefined): Promise<T> {
+  _save(id: any = null, data: any = {}, config: any = undefined): Promise<T> {
     return http.post(this.baseUrl + (id ? `/${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}/${id}`, { params })
@@ -59,12 +77,34 @@ class Curd<T> {
     return http.patch(`${this.baseUrl}/${id}`)
   }
 
-  _update_order(data: {
-    target_id: number
-    direction: number
-    affected_ids: number[]
-  }) {
-    return http.post(`${this.plural}/order`, data)
+  _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,
+      },
+    })
   }
 }
 

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

@@ -17,6 +17,6 @@ export interface DnsCredential extends ModelBase {
   }
 }
 
-const dns_credential: Curd<DnsCredential> = new Curd('/dns_credential')
+const dns_credential: Curd<DnsCredential> = new Curd('/dns_credentials')
 
 export default dns_credential

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

@@ -18,11 +18,11 @@ export interface Node {
 
 class EnvironmentCurd extends Curd<Environment> {
   constructor() {
-    super('/environment')
+    super('/environments')
   }
 
   load_from_settings() {
-    return http.post(`${this.plural}/load_from_settings`)
+    return http.post(`${this.baseUrl}/load_from_settings`)
   }
 }
 

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

@@ -10,10 +10,10 @@ export interface Notification extends ModelBase {
 
 class NotificationCurd extends Curd<Notification> {
   public clear() {
-    return http.delete(this.plural)
+    return http.delete(this.baseUrl)
   }
 }
 
-const notification = new NotificationCurd('/notification')
+const notification = new NotificationCurd('/notifications')
 
 export default notification

+ 11 - 3
app/src/api/domain.ts → app/src/api/site.ts

@@ -1,6 +1,7 @@
 import type { CertificateInfo } from '@/api/cert'
 import type { NgxConfig } from '@/api/ngx'
 import type { ChatComplicationMessage } from '@/api/openai'
+import type { SiteCategory } from '@/api/site_category'
 import type { PrivateKeyType } from '@/constants'
 import Curd from '@/api/curd'
 import http from '@/lib/http'
@@ -16,6 +17,9 @@ export interface Site {
   chatgpt_messages: ChatComplicationMessage[]
   tokenized?: NgxConfig
   cert_info?: Record<number, CertificateInfo[]>
+  site_category_id: number
+  site_category?: SiteCategory
+  sync_node_ids: number[]
 }
 
 export interface AutoCertRequest {
@@ -25,7 +29,7 @@ export interface AutoCertRequest {
   key_type: PrivateKeyType
 }
 
-class Domain extends Curd<Site> {
+class SiteCurd extends Curd<Site> {
   // eslint-disable-next-line ts/no-explicit-any
   enable(name: string, config?: any) {
     return http.post(`${this.baseUrl}/${name}/enable`, undefined, config)
@@ -35,6 +39,10 @@ class Domain extends Curd<Site> {
     return http.post(`${this.baseUrl}/${name}/disable`)
   }
 
+  rename(oldName: string, newName: string) {
+    return http.post(`${this.baseUrl}/${oldName}/rename`, { new_name: newName })
+  }
+
   get_template() {
     return http.get('template')
   }
@@ -56,6 +64,6 @@ class Domain extends Curd<Site> {
   }
 }
 
-const domain = new Domain('/domain')
+const site = new SiteCurd('/sites')
 
-export default domain
+export default site

+ 11 - 0
app/src/api/site_category.ts

@@ -0,0 +1,11 @@
+import type { ModelBase } from '@/api/curd'
+import Curd from '@/api/curd'
+
+export interface SiteCategory extends ModelBase {
+  name: string
+  sync_node_ids: number[]
+}
+
+const site_category = new Curd<SiteCategory>('site_categories')
+
+export default site_category

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

@@ -33,6 +33,6 @@ class StreamCurd extends Curd<Stream> {
   }
 }
 
-const stream = new StreamCurd('/stream')
+const stream = new StreamCurd('/streams')
 
 export default stream

+ 6 - 6
app/src/api/template.ts

@@ -23,26 +23,26 @@ export interface Template extends NgxServer {
 
 class TemplateApi extends Curd<Template> {
   get_config_list() {
-    return http.get('template/configs')
+    return http.get('templates/configs')
   }
 
   get_block_list() {
-    return http.get('template/blocks')
+    return http.get('templates/blocks')
   }
 
   get_config(name: string) {
-    return http.get(`template/config/${name}`)
+    return http.get(`templates/config/${name}`)
   }
 
   get_block(name: string) {
-    return http.get(`template/block/${name}`)
+    return http.get(`templates/block/${name}`)
   }
 
   build_block(name: string, data: Variable) {
-    return http.post(`template/block/${name}`, data)
+    return http.post(`templates/block/${name}`, data)
   }
 }
 
-const template = new TemplateApi('/template')
+const template = new TemplateApi('/templates')
 
 export default template

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

@@ -6,6 +6,6 @@ export interface User extends ModelBase {
   password: string
 }
 
-const user: Curd<User> = new Curd('user')
+const user: Curd<User> = new Curd('users')
 
 export default user

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

@@ -22,7 +22,7 @@ onMounted(async () => {
       r.data?.forEach(node => {
         data_map.value[node.id] = node
       })
-      hasMore = r.data.length === r.pagination.per_page
+      hasMore = r.data.length === r.pagination?.per_page
       page++
     }).catch(() => {
       hasMore = false
@@ -35,7 +35,6 @@ const value = computed({
     return target.value
   },
   set(v: number[]) {
-    console.log(v)
     if (typeof map.value === 'object') {
       const _map = {}
 
@@ -70,7 +69,7 @@ const noData = computed(() => {
         <ACheckbox :value="0">
           {{ $gettext('Local') }}
         </ACheckbox>
-        <ATag color="blue">
+        <ATag color="green">
           {{ $gettext('Online') }}
         </ATag>
       </ACol>
@@ -83,7 +82,7 @@ const noData = computed(() => {
         </ACheckbox>
         <ATag
           v-if="node.status"
-          color="blue"
+          color="green"
         >
           {{ $gettext('Online') }}
         </ATag>

+ 1 - 1
app/src/components/Notification/Notification.vue

@@ -18,7 +18,7 @@ function init() {
   loading.value = true
   notification.get_list().then(r => {
     data.value = r.data
-    unreadCount.value = r.pagination.total
+    unreadCount.value = r.pagination?.total || 0
   }).catch(e => {
     message.error($gettext(e?.message ?? 'Server error'))
   }).finally(() => {

+ 67 - 2
app/src/components/Notification/config.ts

@@ -8,7 +8,7 @@ export function syncConfigError(text: string) {
   const data = JSON.parse(text)
 
   if (data.status_code === 404) {
-    return $gettext('Sync config %{config_name} to %{env_name} failed, please upgrade the remote Nginx UI to the latest version', { config_name: data.config_name, env_name: data.env_name }, true)
+    return $gettext('Please upgrade the remote Nginx UI to the latest version')
   }
 
   return $gettext('Sync config %{config_name} to %{env_name} failed, response: %{resp}', { config_name: data.cert_name, env_name: data.env_name, resp: data.resp_body }, true)
@@ -24,8 +24,73 @@ export function syncRenameConfigError(text: string) {
   const data = JSON.parse(text)
 
   if (data.status_code === 404) {
-    return $gettext('Rename %{orig_path} to %{new_path} on %{env_name} failed, please upgrade the remote Nginx UI to the latest version', { orig_path: data.orig_path, new_path: data.orig_path, env_name: data.env_name }, true)
+    return $gettext('Please upgrade the remote Nginx UI to the latest version')
   }
 
   return $gettext('Rename %{orig_path} to %{new_path} on %{env_name} failed, response: %{resp}', { orig_path: data.orig_path, new_path: data.orig_path, resp: data.resp_body, env_name: data.env_name }, true)
 }
+
+export function saveSiteSuccess(text: string) {
+  const data = JSON.parse(text)
+  return $gettext('Save Site %{site} to %{node} successfully', { site: data.site, node: data.node })
+}
+
+export function saveSiteError(text: string) {
+  const data = JSON.parse(text)
+  if (data.status_code === 404) {
+    return $gettext('Please upgrade the remote Nginx UI to the latest version')
+  }
+  return $gettext('Save site %{site} to %{node} error, response: %{resp}', { site: data.name, node: data.node, resp: JSON.stringify(data.response) }, true)
+}
+
+export function deleteSiteSuccess(text: string) {
+  const data = JSON.parse(text)
+  return $gettext('Remove Site %{site} from %{node} successfully', { site: data.name, node: data.node })
+}
+
+export function deleteSiteError(text: string) {
+  const data = JSON.parse(text)
+  if (data.status_code === 404) {
+    return $gettext('Please upgrade the remote Nginx UI to the latest version')
+  }
+  return $gettext('Remove site %{site} from %{node} error, response: %{resp}', { site: data.name, node: data.node, resp: JSON.stringify(data.response) }, true)
+}
+
+export function enableSiteSuccess(text: string) {
+  const data = JSON.parse(text)
+  return $gettext('Enable Site %{site} on %{node} successfully', { site: data.name, node: data.node })
+}
+
+export function enableSiteError(text: string) {
+  const data = JSON.parse(text)
+  if (data.status_code === 404) {
+    return $gettext('Please upgrade the remote Nginx UI to the latest version')
+  }
+  return $gettext('Enable site %{site} on %{node} error, response: %{resp}', { site: data.name, node: data.node, resp: JSON.stringify(data.response) }, true)
+}
+
+export function disableSiteSuccess(text: string) {
+  const data = JSON.parse(text)
+  return $gettext('Disable Site %{site} on %{node} successfully', { site: data.name, node: data.node })
+}
+
+export function disableSiteError(text: string) {
+  const data = JSON.parse(text)
+  if (data.status_code === 404) {
+    return $gettext('Please upgrade the remote Nginx UI to the latest version')
+  }
+  return $gettext('Disable site %{site} on %{node} error, response: %{resp}', { site: data.name, node: data.node, resp: JSON.stringify(data.response) }, true)
+}
+
+export function renameSiteSuccess(text: string) {
+  const data = JSON.parse(text)
+  return $gettext('Rename Site %{site} to %{new_site} on %{node} successfully', { site: data.name, new_site: data.new_name, node: data.node })
+}
+
+export function renameSiteError(text: string) {
+  const data = JSON.parse(text)
+  if (data.status_code === 404) {
+    return $gettext('Please upgrade the remote Nginx UI to the latest version')
+  }
+  return $gettext('Rename Site %{site} to %{new_site} on %{node} error, response: %{resp}', { site: data.name, new_site: data.new_name, node: data.node, resp: JSON.stringify(data.response) }, true)
+}

+ 32 - 0
app/src/components/Notification/detailRender.ts

@@ -1,6 +1,16 @@
 import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { syncCertificateError, syncCertificateSuccess } from '@/components/Notification/cert'
 import {
+  deleteSiteError,
+  deleteSiteSuccess,
+  disableSiteError,
+  disableSiteSuccess,
+  enableSiteError,
+  enableSiteSuccess,
+  renameSiteError,
+  renameSiteSuccess,
+  saveSiteError,
+  saveSiteSuccess,
   syncConfigError,
   syncConfigSuccess,
   syncRenameConfigError,
@@ -17,6 +27,28 @@ export function detailRender(args: CustomRenderProps) {
       return syncRenameConfigSuccess(args.text)
     case 'Rename Remote Config Error':
       return syncRenameConfigError(args.text)
+
+    case 'Save Remote Site Success':
+      return saveSiteSuccess(args.text)
+    case 'Save Remote Site Error':
+      return saveSiteError(args.text)
+    case 'Delete Remote Site Success':
+      return deleteSiteSuccess(args.text)
+    case 'Delete Remote Site Error':
+      return deleteSiteError(args.text)
+    case 'Enable Remote Site Success':
+      return enableSiteSuccess(args.text)
+    case 'Enable Remote Site Error':
+      return enableSiteError(args.text)
+    case 'Disable Remote Site Success':
+      return disableSiteSuccess(args.text)
+    case 'Disable Remote Site Error':
+      return disableSiteError(args.text)
+    case 'Rename Remote Site Success':
+      return renameSiteSuccess(args.text)
+    case 'Rename Remote Site Error':
+      return renameSiteError(args.text)
+
     case 'Sync Config Success':
       return syncConfigSuccess(args.text)
     case 'Sync Config Error':

+ 43 - 23
app/src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue

@@ -1,48 +1,60 @@
 <script setup lang="ts">
+import type Curd from '@/api/curd'
+import type { Column } from '@/components/StdDesign/types'
+import { getPithyColumns } from '@/components/StdDesign/StdDataDisplay/methods/columns'
 import StdDataEntry from '@/components/StdDesign/StdDataEntry'
 import { message } from 'ant-design-vue'
 
 const props = defineProps<{
   // eslint-disable-next-line ts/no-explicit-any
-  api: (ids: number[], data: any) => Promise<void>
+  api: Curd<any>
   beforeSave?: () => Promise<void>
+  columns: Column[]
 }>()
 
-const emit = defineEmits(['onSave'])
+const emit = defineEmits(['save'])
 
-const batchColumns = ref([])
+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)
 
-const selectedRowKeys = ref([])
 // eslint-disable-next-line ts/no-explicit-any
-function showModal(c: any, rowKeys: 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,
 })
 
-const data = reactive({})
-const error = reactive({})
-const loading = ref(false)
-
 async function ok() {
   loading.value = true
 
   await props.beforeSave?.()
 
-  await props.api(selectedRowKeys.value, data).then(async () => {
-    message.success($gettext('Save successfully'))
-    emit('onSave')
-  }).catch(e => {
-    message.error($gettext(e?.message) ?? $gettext('Server error'))
-  }).finally(() => {
-    loading.value = false
-  })
+  await props.api.batch_save(selectedRowKeys.value, data.value)
+    .then(async () => {
+      message.success($gettext('Save successfully'))
+      emit('save')
+      visible.value = false
+    })
+    .catch(e => {
+      error.value = e.errors
+      message.error($gettext(e?.message) ?? $gettext('Server error'))
+    })
+    .finally(() => {
+      loading.value = false
+    })
 }
 </script>
 
@@ -52,23 +64,31 @@ async function ok() {
     class="std-curd-edit-modal"
     :mask="false"
     :title="$gettext('Batch Modify')"
-    :cancel-text="$gettext('Cancel')"
-    :ok-text="$gettext('OK')"
+    :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"
-      :error="error"
+      :errors="error"
     />
 
     <slot name="extra" />
   </AModal>
 </template>
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 43 - 28
app/src/components/StdDesign/StdDataDisplay/StdCurd.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts" generic="T=any">
-import type { StdTableSlots } from '@/components/StdDesign/StdDataDisplay/types'
 import type { Column } from '@/components/StdDesign/types'
-import type { ComputedRef } from 'vue'
+import type { ComputedRef, Ref } from 'vue'
 import type { StdTableProps } from './StdTable.vue'
+import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
 import StdCurdDetail from '@/components/StdDesign/StdDataDisplay/StdCurdDetail.vue'
 import StdDataEntry from '@/components/StdDesign/StdDataEntry'
 import { message } from 'ant-design-vue'
@@ -14,7 +14,7 @@ export interface StdCurdProps<T> extends StdTableProps<T> {
   modalMask?: boolean
   exportExcel?: boolean
   importExcel?: boolean
-  disableTrash?: boolean
+
   disableAdd?: boolean
   onClickAdd?: () => void
 
@@ -24,6 +24,10 @@ export interface StdCurdProps<T> extends StdTableProps<T> {
 }
 
 const props = defineProps<StdTableProps<T> & StdCurdProps<T>>()
+
+const selectedRowKeys = ref<(string | number)[]>([])
+const selectedRows: Ref<T[]> = ref([])
+
 const visible = ref(false)
 // eslint-disable-next-line ts/no-explicit-any
 const data: any = reactive({ id: null })
@@ -61,24 +65,13 @@ function add(preset: any = undefined) {
   if (preset)
     Object.assign(data, preset)
 
-  clear_error()
+  clearError()
   visible.value = true
   editMode.value = 'create'
   modifyMode.value = true
 }
 
-const table = ref()
-
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRowKeys = defineModel<any[]>('selectedRowKeys', {
-  default: () => [],
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRows = defineModel<any[]>('selectedRows', {
-  type: Array,
-  default: () => [],
-})
+const table = useTemplateRef('table')
 
 const getParams = reactive({
   trash: false,
@@ -101,20 +94,23 @@ defineExpose({
   setParams,
 })
 
-function clear_error() {
+function clearError() {
   Object.keys(error).forEach(v => {
     delete error[v]
   })
 }
 
-const stdEntryRef = ref()
+const stdEntryRef = useTemplateRef('stdEntryRef')
 
 async function ok() {
+  if (!stdEntryRef.value)
+    return
+
   const { formRef } = stdEntryRef.value
 
-  clear_error()
+  clearError()
   try {
-    await formRef.validateFields()
+    await formRef?.validateFields()
     props?.beforeSave?.(data)
     props
       .api!.save(data.id, { ...data, ...props.overwriteParams }, { params: { ...props.overwriteParams } }).then(r => {
@@ -135,7 +131,7 @@ async function ok() {
 function cancel() {
   visible.value = false
 
-  clear_error()
+  clearError()
 
   if (shouldRefetchList.value) {
     get_list()
@@ -159,7 +155,6 @@ function view(id: number | string) {
   get(id).then(() => {
     visible.value = true
     modifyMode.value = false
-    editMode.value = 'modify'
   }).catch(e => {
     message.error($gettext(e?.message ?? 'Server error'), 5)
   })
@@ -183,6 +178,17 @@ const modalTitle = computed(() => {
 })
 
 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>
@@ -202,7 +208,7 @@ const localOverwriteParams = reactive(props.overwriteParams ?? {})
             @click="add"
           >{{ $gettext('Add') }}</a>
           <slot name="extra" />
-          <template v-if="!disableDelete && !disableTrash">
+          <template v-if="!disableDelete">
             <a
               v-if="!getParams.trash"
               @click="getParams.trash = true"
@@ -219,21 +225,23 @@ const localOverwriteParams = reactive(props.overwriteParams ?? {})
         </ASpace>
       </template>
 
+      <slot name="beforeTable" />
       <StdTable
         ref="table"
-        v-model:selected-row-keys="selectedRowKeys"
-        v-model:selected-rows="selectedRows"
         v-bind="{
           ...props,
           getParams,
           overwriteParams: localOverwriteParams,
         }"
+        v-model:selected-row-keys="selectedRowKeys"
+        v-model:selected-rows="selectedRows"
         @click-edit="edit"
         @click-view="view"
         @selected="onSelect"
+        @click-batch-modify="handleClickBatchEdit"
       >
         <template
-          v-for="(_, key) in ($slots as unknown as StdTableSlots)"
+          v-for="(_, key) in $slots"
           :key="key"
           #[key]="slotProps"
         >
@@ -295,10 +303,17 @@ const localOverwriteParams = reactive(props.overwriteParams ?? {})
 
       <StdCurdDetail
         v-else
-        :columns="columns"
-        :data="data"
+        :columns
+        :data
       />
     </AModal>
+
+    <StdBatchEdit
+      ref="stdBatchEditRef"
+      :api
+      :columns
+      @save="handleBatchUpdated"
+    />
   </div>
 </template>
 

+ 70 - 70
app/src/components/StdDesign/StdDataDisplay/StdTable.vue

@@ -8,9 +8,11 @@ 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 { getPithyColumns } from '@/components/StdDesign/StdDataDisplay/methods/columns'
 import useSortable from '@/components/StdDesign/StdDataDisplay/methods/sortable'
 import StdDataEntry from '@/components/StdDesign/StdDataEntry'
 import { HolderOutlined } from '@ant-design/icons-vue'
+import { watchPausable } from '@vueuse/core'
 import { message } from 'ant-design-vue'
 import _ from 'lodash'
 import StdPagination from './StdPagination.vue'
@@ -20,6 +22,7 @@ export interface StdTableProps<T = any> {
   title?: string
   mode?: string
   rowKey?: string
+
   api: Curd<T>
   columns: Column[]
   // eslint-disable-next-line ts/no-explicit-any
@@ -48,7 +51,20 @@ const props = withDefaults(defineProps<StdTableProps<T>>(), {
   rowKey: 'id',
 })
 
-const emit = defineEmits(['clickEdit', 'clickView', 'clickBatchModify', 'update:selectedRowKeys'])
+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([])
@@ -93,17 +109,6 @@ const params = reactive({
   ...props.getParams,
 })
 
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRowKeys = defineModel<any[]>('selectedRowKeys', {
-  default: () => [],
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRows = defineModel<any[]>('selectedRows', {
-  type: Array,
-  default: () => [],
-})
-
 onMounted(() => {
   selectedRows.value.forEach(v => {
     selectedRecords.value[v[props.rowKey]] = v
@@ -122,7 +127,9 @@ const searchColumns = computed(() => {
         })
       }
 
-      else { _searchColumns.push({ ...column }) }
+      else {
+        _searchColumns.push({ ...column })
+      }
     }
   })
 
@@ -130,11 +137,8 @@ const searchColumns = computed(() => {
 })
 
 const pithyColumns = computed<Column[]>(() => {
-  if (props.pithy) {
-    return props.columns?.filter(c => {
-      return c.pithy === true && !c.hiddenInTable
-    })
-  }
+  if (props.pithy)
+    return getPithyColumns(props.columns)
 
   return props.columns?.filter(c => {
     return !c.hiddenInTable
@@ -142,19 +146,12 @@ const pithyColumns = computed<Column[]>(() => {
 })
 
 const batchColumns = computed(() => {
-  const batch: Column[] = []
-
-  props.columns?.forEach(column => {
-    if (column.batch)
-      batch.push(column)
-  })
-
-  return batch
+  return props.columns?.filter(column => column.batch) || []
 })
 
 const get_list = _.debounce(_get_list, 100, {
-  leading: false,
-  trailing: true,
+  leading: true,
+  trailing: false,
 })
 
 const filterParams = reactive({})
@@ -184,15 +181,13 @@ onMounted(() => {
   if (props.sortable)
     initSortable()
 
-  if (!selectedRowKeys.value?.length)
-    selectedRowKeys.value = []
-
   init.value = true
 })
 
 defineExpose({
   get_list,
   pagination,
+  resetSelection,
 })
 
 function destroy(id: number | string) {
@@ -229,13 +224,17 @@ function buildIndexMap(data: any, level: number = 0, index: number = 0, total: n
   }
 }
 
-async function _get_list(page_num = null, page_size = 20) {
+async function _get_list(page_num: number | null = null, page_size = 20) {
   dataSource.value = []
   loading.value = true
   if (page_num) {
     params.page = page_num
     params.page_size = page_size
   }
+  else {
+    params.page = 1
+    params.page_size = page_size
+  }
   props.api?.get_list({ ...params, ...props.overwriteParams }).then(async r => {
     dataSource.value = r.data
     rowsKeyIndexMap.value = {}
@@ -245,9 +244,7 @@ async function _get_list(page_num = null, page_size = 20) {
     if (r.pagination)
       Object.assign(pagination, r.pagination)
 
-    setTimeout(() => {
-      loading.value = false
-    }, 200)
+    loading.value = false
   }).catch(e => {
     message.error(e?.message ?? $gettext('Server error'))
   })
@@ -288,7 +285,8 @@ function expandedTable(keys: Key[]) {
 
 // eslint-disable-next-line ts/no-explicit-any
 async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
-  if (props.selectionType === 'checkbox' || props.exportExcel) {
+  // console.log('onSelect', record, selected, _selectedRows)
+  if (props.selectionType === 'checkbox' || batchColumns.value.length > 0 || props.exportExcel) {
     if (selected) {
       _selectedRows.forEach(v => {
         if (v) {
@@ -300,20 +298,11 @@ async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
       })
     }
     else {
-      // eslint-disable-next-line ts/no-explicit-any
-      selectedRowKeys.value = selectedRowKeys.value.filter((v: any) => v !== record[props.rowKey])
+      selectedRowKeys.value.splice(selectedRowKeys.value.indexOf(record[props.rowKey]), 1)
       delete selectedRecords.value[record[props.rowKey]]
     }
-
-    await nextTick(async () => {
-      // eslint-disable-next-line ts/no-explicit-any
-      const filteredRows: any[] = []
-
-      selectedRowKeys.value.forEach(v => {
-        filteredRows.push(selectedRecords.value[v])
-      })
-      selectedRows.value = filteredRows
-    })
+    await nextTick()
+    selectedRows.value = [...selectedRowKeys.value.map(v => selectedRecords.value[v])]
   }
   else if (selected) {
     selectedRowKeys.value = record[props.rowKey]
@@ -327,7 +316,7 @@ async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
 
 // eslint-disable-next-line ts/no-explicit-any
 async function onSelectAll(selected: boolean, _selectedRows: any[], changeRows: any[]) {
-  // console.log(selected, selectedRows, changeRows)
+  // console.log('onSelectAll', selected, selectedRows, changeRows)
   // eslint-disable-next-line ts/no-explicit-any
   changeRows.forEach((v: any) => {
     if (v) {
@@ -342,22 +331,19 @@ async function onSelectAll(selected: boolean, _selectedRows: any[], changeRows:
   })
 
   if (!selected) {
-    selectedRowKeys.value = selectedRowKeys.value.filter(v => {
-      return selectedRecords.value[v]
-    })
+    selectedRowKeys.value.splice(0, selectedRowKeys.value.length, ...selectedRowKeys.value.filter(v => selectedRecords.value[v]))
   }
 
   // console.log(selectedRowKeysBuffer.value, selectedRecords.value)
 
-  await nextTick(async () => {
-    // eslint-disable-next-line ts/no-explicit-any
-    const filteredRows: any[] = []
+  await nextTick()
+  selectedRows.value.splice(0, selectedRows.value.length, ...selectedRowKeys.value.map(v => selectedRecords.value[v]))
+}
 
-    selectedRowKeys.value.forEach(v => {
-      filteredRows.push(selectedRecords.value[v])
-    })
-    selectedRows.value = filteredRows
-  })
+function resetSelection() {
+  selectedRowKeys.value = reactive([])
+  selectedRows.value = reactive([])
+  selectedRecords.value = reactive({})
 }
 
 const router = useRouter()
@@ -381,7 +367,7 @@ async function resetSearch() {
   updateFilter.value++
 }
 
-watch(params, v => {
+const { stop: stopWatchParams, resume: resumeWatchParams } = watchPausable(params, v => {
   if (!init.value)
     return
 
@@ -393,7 +379,6 @@ watch(params, v => {
 
 watch(() => route.query, async () => {
   params.trash = route.query.trash === 'true'
-  params.team_id = route.query.team_id
 
   if (init.value)
     await get_list()
@@ -425,14 +410,16 @@ if (props.overwriteParams) {
 const rowSelection = computed(() => {
   if (batchColumns.value.length > 0 || props.selectionType || props.exportExcel) {
     return {
-      selectedRowKeys: selectedRowKeys.value,
+      selectedRowKeys: unref(selectedRowKeys),
       onSelect,
       onSelectAll,
       getCheckboxProps: props?.getCheckboxProps,
       type: (batchColumns.value.length > 0 || props.exportExcel) ? 'checkbox' : props.selectionType,
     }
   }
-  else { return null }
+  else {
+    return null
+  }
 }) as ComputedRef<TableProps['rowSelection']>
 
 const hasSelectedRow = computed(() => {
@@ -440,18 +427,21 @@ const hasSelectedRow = computed(() => {
 })
 
 function clickBatchEdit() {
-  emit('clickBatchModify', batchColumns.value, selectedRowKeys.value)
+  emit('clickBatchModify', batchColumns.value, selectedRowKeys.value, selectedRows.value)
 }
 
 function initSortable() {
   useSortable(props, randomId, dataSource, rowsKeyIndexMap, expandKeysList)
 }
 
-function changePage(page: number, page_size: number) {
+async function changePage(page: number, page_size: number) {
+  stopWatchParams()
   Object.assign(params, {
     page,
     page_size,
   })
+  resumeWatchParams()
+  await get_list(page, page_size)
 }
 
 const paginationSize = computed(() => {
@@ -529,10 +519,7 @@ const paginationSize = computed(() => {
             >
               {{ $gettext('Modify') }}
             </AButton>
-            <ADivider
-              v-if="!props.disableDelete"
-              type="vertical"
-            />
+            <ADivider type="vertical" />
           </template>
 
           <slot
@@ -569,6 +556,7 @@ const paginationSize = computed(() => {
                 {{ $gettext('Recover') }}
               </AButton>
             </APopconfirm>
+            <ADivider type="vertical" />
             <APopconfirm
               v-if="params.trash"
               :cancel-text="$gettext('No')"
@@ -601,8 +589,14 @@ const paginationSize = computed(() => {
 .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>
@@ -624,6 +618,12 @@ const paginationSize = computed(() => {
 :deep(.ant-form-inline .ant-form-item) {
   margin-bottom: 10px;
 }
+
+.ant-divider {
+  &:last-child {
+    display: none;
+  }
+}
 </style>
 
 <style lang="less">

+ 4 - 2
app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx

@@ -48,8 +48,10 @@ export function mask(maskObj: any): (args: CustomRenderProps) => JSX.Element {
 export function arrayToTextRender(args: CustomRenderProps) {
   return args.text?.join(', ')
 }
-export function actualValueRender(args: CustomRenderProps, actualDataIndex: string | string[]) {
-  return get(args.record, actualDataIndex)
+export function actualValueRender(actualDataIndex: string | string[]) {
+  return (args: CustomRenderProps) => {
+    return get(args.record, actualDataIndex) || '/'
+  }
 }
 
 export function longTextWithEllipsis(len: number): (args: CustomRenderProps) => JSX.Element {

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

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

+ 4 - 2
app/src/components/StdDesign/StdDataDisplay/methods/exportCsv.ts

@@ -1,5 +1,6 @@
+import type { GetListResponse } from '@/api/curd'
 import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
-import type { Column, StdTableResponse } from '@/components/StdDesign/types'
+import type { Column } from '@/components/StdDesign/types'
 import type { ComputedRef } from 'vue'
 import { downloadCsv } from '@/lib/helper'
 import { message } from 'ant-design-vue'
@@ -33,7 +34,8 @@ async function exportCsv(props: StdTableProps, pithyColumns: ComputedRef<Column[
   while (hasMore) {
     // 准备 DataSource
     await props
-      .api!.get_list({ page }).then((r: StdTableResponse) => {
+    // eslint-disable-next-line ts/no-explicit-any
+      .api!.get_list({ page }).then((r: GetListResponse<any>) => {
       if (r.data.length === 0) {
         hasMore = false
 

+ 17 - 19
app/src/components/StdDesign/StdDataEntry/StdDataEntry.vue

@@ -1,5 +1,6 @@
 <script setup lang="tsx">
 import type { Column, JSXElements, StdDesignEdit } from '@/components/StdDesign/types'
+import type { FormInstance } from 'ant-design-vue'
 import type { Ref } from 'vue'
 import { labelRender } from '@/components/StdDesign/StdDataEntry'
 import StdFormItem from '@/components/StdDesign/StdDataEntry/StdFormItem.vue'
@@ -7,26 +8,13 @@ import { Form } from 'ant-design-vue'
 
 const props = defineProps<{
   dataList: Column[]
-  // eslint-disable-next-line ts/no-explicit-any
-  dataSource: Record<string, any>
   errors?: Record<string, string>
   type?: 'search' | 'edit'
   layout?: 'horizontal' | 'vertical' | 'inline'
 }>()
 
-const emit = defineEmits<{
-  // eslint-disable-next-line ts/no-explicit-any
-  'update:dataSource': [data: Record<string, any>]
-}>()
-
-const dataSource = computed({
-  get() {
-    return props.dataSource
-  },
-  set(v) {
-    emit('update:dataSource', v)
-  },
-})
+// eslint-disable-next-line ts/no-explicit-any
+const dataSource = defineModel<Record<string, any>>('dataSource')
 
 const slots = useSlots()
 
@@ -37,7 +25,7 @@ function extraRender(extra?: string | (() => string)) {
   return extra
 }
 
-const formRef = ref<InstanceType<typeof Form>>()
+const formRef = ref<FormInstance>()
 
 defineExpose({
   formRef,
@@ -50,7 +38,7 @@ function Render() {
   props.dataList.forEach((v: Column) => {
     const dataIndex = (v.edit?.actualDataIndex ?? v.dataIndex) as string
 
-    dataSource.value[dataIndex] = props.dataSource[dataIndex]
+    dataSource.value![dataIndex] = dataSource.value![dataIndex]
     if (props.type === 'search') {
       if (v.search) {
         const type = (v.search as StdDesignEdit)?.type || v.edit?.type
@@ -75,7 +63,7 @@ function Render() {
 
     let show = true
     if (v.edit?.show && typeof v.edit.show === 'function')
-      show = v.edit.show(props.dataSource)
+      show = v.edit.show(dataSource.value)
 
     if (v.edit?.type && show) {
       template.push(
@@ -87,6 +75,7 @@ function Render() {
           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>,
@@ -97,7 +86,16 @@ function Render() {
   if (slots.action)
     template.push(<div class="std-data-entry-action">{slots.action()}</div>)
 
-  return <Form ref={formRef} model={dataSource.value} layout={props.layout || 'vertical'}>{template}</Form>
+  return (
+    <Form
+      class="my-10px!"
+      ref={formRef}
+      model={dataSource.value}
+      layout={props.layout || 'vertical'}
+    >
+      {template}
+    </Form>
+  )
 }
 </script>
 

+ 31 - 4
app/src/components/StdDesign/StdDataEntry/StdFormItem.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import type { Column } from '@/components/StdDesign/types'
-import { computed } from 'vue'
+import type { Rule } from 'ant-design-vue/es/form'
+import FormErrors from '@/constants/form_errors'
 
 const props = defineProps<Props>()
 
@@ -13,18 +14,42 @@ export interface Props {
     [key: string]: string
   }
   required?: boolean
+  noValidate?: boolean
 }
 
 const tag = computed(() => {
   return props.error?.[props.dataIndex!.toString()] ?? ''
 })
 
+// const valid_status = computed(() => {
+//   if (tag.value)
+//     return 'error'
+//   else return 'success'
+// })
+
 const help = computed(() => {
-  if (tag.value.includes('required'))
-    return $gettext('This field should not be empty')
+  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>
@@ -32,7 +57,9 @@ const help = computed(() => {
     :name="dataIndex as string"
     :label="label"
     :help="help"
-    :required="required"
+    :rules="{ required, validator }"
+    :validate-status="tag ? 'error' : undefined"
+    :auto-link="false"
   >
     <slot />
   </AFormItem>

+ 112 - 82
app/src/components/StdDesign/StdDataEntry/components/StdSelector.vue

@@ -1,13 +1,13 @@
 <script setup lang="ts">
 import type Curd from '@/api/curd'
 import type { Column } from '@/components/StdDesign/types'
-import type { Ref } from 'vue'
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
+import { watchOnce } from '@vueuse/core'
 import _ from 'lodash'
 
 const props = defineProps<{
+  placeholder?: string
   label?: string
-  selectedKey: number | number[] | undefined | null
   selectionType: 'radio' | 'checkbox'
   recordValueIndex: string // to index the value of the record
   // eslint-disable-next-line ts/no-explicit-any
@@ -24,9 +24,19 @@ const props = defineProps<{
   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
 }>()
 
-const emit = defineEmits(['update:selectedKey'])
+const selectedKey = defineModel<number | number[] | undefined | null | string | string[]>('selectedKey')
+
+onMounted(() => {
+  if (!selectedKey.value)
+    watchOnce(selectedKey, _init)
+  else
+    _init()
+})
 
 const getParams = computed(() => {
   return props.getParams
@@ -34,19 +44,23 @@ const getParams = computed(() => {
 
 const visible = ref(false)
 // eslint-disable-next-line ts/no-explicit-any
-const M_values = ref([]) as any
+const M_values = ref([]) as Ref<any[]>
 
-const init = _.debounce(_init, 500, {
-  leading: true,
-  trailing: false,
+const ComputedMValue = computed(() => {
+  return M_values.value.filter(v => v && Object.keys(v).length > 0)
 })
 
-onMounted(() => {
-  init()
+// eslint-disable-next-line ts/no-explicit-any
+const records = defineModel<any[]>('selectedRecords', {
+  default: () => [],
 })
 
-// eslint-disable-next-line ts/no-explicit-any
-const records = ref([]) as Ref<any[]>
+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
@@ -55,22 +69,22 @@ async function _init() {
   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 && props.selectedKey) {
-      api.get(props.selectedKey, props.getParams).then(r => {
+    // 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 props.selectedKey === 'object') {
-    M_values.value = props.value || []
-
+  else if (typeof selectedKey.value === 'object') {
+    // M_values.value = props.value || []
     // not init value, we need to fetch them from api
-    if (!props.value && (props.selectedKey?.length || 0) > 0) {
+    if (!props.value && (selectedKey.value?.length || 0) > 0) {
       api.get_list({
         ...props.getParams,
-        id: props.selectedKey,
+        id: selectedKey.value,
       }).then(r => {
         M_values.value = r.data
         records.value = r.data
@@ -85,11 +99,22 @@ function show() {
 }
 
 const selectedKeyBuffer = ref()
+// eslint-disable-next-line ts/no-explicit-any
+const selectedBuffer: Ref<any[]> = ref([])
 
-if (props.selectionType === 'radio')
-  selectedKeyBuffer.value = [props.selectedKey]
-else
-  selectedKeyBuffer.value = props.selectedKey
+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() {
@@ -103,76 +128,80 @@ const computedSelectedKeys = computed({
   },
 })
 
-onMounted(() => {
-  if (props.selectedKey === undefined || props.selectedKey === null) {
-    if (props.selectionType === 'radio')
-      emit('update:selectedKey', '')
-    else
-      emit('update:selectedKey', [])
-  }
-})
-
 async function ok() {
   visible.value = false
-  emit('update:selectedKey', selectedKeyBuffer.value)
-
+  selectedKey.value = selectedKeyBuffer.value
+  records.value = selectedBuffer.value
+  await nextTick()
   M_values.value = _.clone(records.value)
 }
 
-watchEffect(() => {
-  init()
-})
-
 // function clear() {
 //   M_values.value = []
 //   emit('update:selectedKey', '')
 // }
+
+defineExpose({ show })
 </script>
 
 <template>
-  <div class="std-selector-container">
+  <div>
     <div
-      class="std-selector"
-      @click="show"
+      v-if="!hideInputContainer"
+      class="std-selector-container"
     >
-      <div class="chips-container">
-        <ATag
-          v-for="(chipText, index) in M_values"
-          :key="index"
-          class="mr-1"
-          color="orange"
-          :bordered="false"
-          @click="show"
-        >
-          {{ chipText?.[recordValueIndex] }}
-        </ATag>
-      </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"
+      <div
+        class="std-selector"
+        @click="show"
       >
-        {{ description }}
-        <StdTable
-          v-model:selected-row-keys="computedSelectedKeys"
-          v-model:selected-rows="records"
-          :api="api"
-          :columns="columns"
-          :disable-search="disableSearch"
-          pithy
-          :row-key="itemKey"
-          :get-params="getParams"
-          :selection-type="selectionType"
-          disable-query-params
-        />
-      </AModal>
+        <div class="chips-container">
+          <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>
     </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"
+        :get-params
+        :selection-type
+        :get-checkbox-props
+        pithy
+        disable-query-params
+      />
+    </AModal>
   </div>
 </template>
 
@@ -180,7 +209,7 @@ watchEffect(() => {
 .std-selector-container {
   min-height: 39.9px;
   display: flex;
-  align-items: flex-start;
+  align-items: self-start;
 
   .std-selector {
     overflow-y: auto;
@@ -195,7 +224,7 @@ watchEffect(() => {
     line-height: 1.5;
     background-image: none;
     border: 1px solid #d9d9d9;
-    border-radius: 4px;
+    border-radius: 6px;
     transition: all 0.3s;
     //margin: 0 10px 0 0;
     cursor: pointer;
@@ -203,9 +232,10 @@ watchEffect(() => {
   }
 }
 
-.chips-container {
-  span {
-    margin: 2px;
+.dark {
+  .std-selector {
+    border: 1px solid #424242;
+    background-color: #141414;
   }
 }
 </style>

+ 15 - 11
app/src/components/StdDesign/types.d.ts

@@ -1,4 +1,3 @@
-import type { Pagination } from '@/api/curd'
 import type Curd from '@/api/curd'
 import type { TableColumnType } from 'ant-design-vue'
 import type { Ref } from 'vue'
@@ -46,19 +45,29 @@ export interface StdDesignEdit {
 
   config?: {
     label?: string | (() => string) // label for form item
-    size?: string // class size of Std image upload
+    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
-    error_messages?: Ref
-    required?: boolean
-    // eslint-disable-next-line ts/no-explicit-any
-    defaultValue?: any
     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
@@ -104,8 +113,3 @@ export interface Column extends TableColumnType {
     dataSourceValueIndex?: any // relative to dataSource
   }
 }
-
-export interface StdTableResponse {
-  data: any[]
-  pagination: Pagination
-}

+ 7 - 0
app/src/constants/form_errors.ts

@@ -0,0 +1,7 @@
+export default {
+  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 -_.'),
+}

+ 11 - 0
app/src/language/constants.ts

@@ -42,4 +42,15 @@ export const msg = [
 
   $gettext('Sync Config Success'),
   $gettext('Sync Config Error'),
+
+  $gettext('Save Remote Site Success'),
+  $gettext('Save Remote Site Error'),
+  $gettext('Delete Remote Site Success'),
+  $gettext('Delete Remote Site Error'),
+  $gettext('Disable Remote Site Success'),
+  $gettext('Disable Remote Site Error'),
+  $gettext('Enable Remote Site Success'),
+  $gettext('Enable Remote Site Error'),
+  $gettext('Rename Remote Site Success'),
+  $gettext('Rename Remote Site Error'),
 ]

文件差异内容过多而无法显示
+ 225 - 160
app/src/language/en/app.po


文件差异内容过多而无法显示
+ 228 - 163
app/src/language/es/app.po


文件差异内容过多而无法显示
+ 225 - 160
app/src/language/fr_FR/app.po


文件差异内容过多而无法显示
+ 228 - 163
app/src/language/ko_KR/app.po


文件差异内容过多而无法显示
+ 211 - 168
app/src/language/messages.pot


文件差异内容过多而无法显示
+ 228 - 163
app/src/language/ru_RU/app.po


文件差异内容过多而无法显示
+ 237 - 169
app/src/language/tr_TR/app.po


文件差异内容过多而无法显示
+ 225 - 160
app/src/language/vi_VN/app.po


二进制
app/src/language/zh_CN/app.mo


文件差异内容过多而无法显示
+ 222 - 168
app/src/language/zh_CN/app.po


文件差异内容过多而无法显示
+ 228 - 163
app/src/language/zh_TW/app.po


+ 6 - 4
app/src/lib/http/index.ts

@@ -1,9 +1,9 @@
 import type { AxiosRequestConfig } from 'axios'
 import use2FAModal from '@/components/TwoFA/use2FAModal'
+import { useNProgress } from '@/lib/nprogress/nprogress'
 import { useSettingsStore, useUserStore } from '@/pinia'
 import router from '@/routes'
 import axios from 'axios'
-import NProgress from 'nprogress'
 
 import { storeToRefs } from 'pinia'
 import 'nprogress/nprogress.css'
@@ -26,9 +26,11 @@ const instance = axios.create({
   }],
 })
 
+const nprogress = useNProgress()
+
 instance.interceptors.request.use(
   config => {
-    NProgress.start()
+    nprogress.start()
     if (token.value) {
       // eslint-disable-next-line ts/no-explicit-any
       (config.headers as any).Authorization = token.value
@@ -53,12 +55,12 @@ instance.interceptors.request.use(
 
 instance.interceptors.response.use(
   response => {
-    NProgress.done()
+    nprogress.done()
 
     return Promise.resolve(response.data)
   },
   async error => {
-    NProgress.done()
+    nprogress.done()
 
     const otpModal = use2FAModal()
     switch (error.response.status) {

+ 16 - 0
app/src/lib/nprogress/nprogress.ts

@@ -0,0 +1,16 @@
+import _ from 'lodash'
+import NProgress from 'nprogress'
+
+NProgress.configure({ showSpinner: false, trickleSpeed: 300 })
+
+const done = _.debounce(NProgress.done, 300, {
+  leading: false,
+  trailing: true,
+})
+
+export function useNProgress() {
+  return {
+    start: NProgress.start,
+    done,
+  }
+}

+ 1 - 1
app/src/main.ts

@@ -7,7 +7,7 @@ import VueDOMPurifyHTML from 'vue-dompurify-html'
 import App from './App.vue'
 import gettext from './gettext'
 import router from './routes'
-import './style.css'
+import 'virtual:uno.css'
 
 const pinia = createPinia()
 

+ 14 - 8
app/src/routes/index.ts

@@ -1,6 +1,7 @@
 import type { RouteRecordRaw } from 'vue-router'
-import { useSettingsStore, useUserStore } from '@/pinia'
+import { useNProgress } from '@/lib/nprogress/nprogress'
 
+import { useSettingsStore, useUserStore } from '@/pinia'
 import {
   BellOutlined,
   CloudOutlined,
@@ -15,10 +16,8 @@ import {
   ShareAltOutlined,
   UserOutlined,
 } from '@ant-design/icons-vue'
-import NProgress from 'nprogress'
 
 import { createRouter, createWebHashHistory } from 'vue-router'
-
 import 'nprogress/nprogress.css'
 
 export const routes: RouteRecordRaw[] = [
@@ -52,7 +51,7 @@ export const routes: RouteRecordRaw[] = [
         children: [{
           path: 'list',
           name: 'Sites List',
-          component: () => import('@/views/site/SiteList.vue'),
+          component: () => import('@/views/site/site_list/SiteList.vue'),
           meta: {
             name: () => $gettext('Sites List'),
           },
@@ -64,10 +63,17 @@ export const routes: RouteRecordRaw[] = [
             name: () => $gettext('Add Site'),
             lastRouteName: 'Sites List',
           },
+        }, {
+          path: 'categories',
+          name: 'Site Categories',
+          component: () => import('@/views/site/site_category/SiteCategory.vue'),
+          meta: {
+            name: () => $gettext('Site Categories'),
+          },
         }, {
           path: ':name',
           name: 'Edit Site',
-          component: () => import('@/views/site/SiteEdit.vue'),
+          component: () => import('@/views/site/site_edit/SiteEdit.vue'),
           meta: {
             name: () => $gettext('Edit Site'),
             hiddenInSidebar: true,
@@ -317,12 +323,12 @@ const router = createRouter({
   routes,
 })
 
-NProgress.configure({ showSpinner: false })
+const nprogress = useNProgress()
 
 router.beforeEach((to, _, next) => {
   document.title = `${to?.meta.name?.() ?? ''} | Nginx UI`
 
-  NProgress.start()
+  nprogress.start()
 
   const user = useUserStore()
 
@@ -333,7 +339,7 @@ router.beforeEach((to, _, next) => {
 })
 
 router.afterEach(() => {
-  NProgress.done()
+  nprogress.done()
 })
 
 export default router

+ 0 - 3
app/src/style.css

@@ -1,3 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;

+ 1 - 1
app/src/views/certificate/ACMEUserSelector.vue

@@ -47,7 +47,7 @@ onMounted(async () => {
       const r = await acme_user.get_list({ page })
 
       users.value.push(...r.data)
-      if (r?.data?.length < r?.pagination?.per_page)
+      if (r?.data?.length < (r?.pagination?.per_page ?? 0))
         break
       page++
     }

+ 1 - 1
app/src/views/dashboard/Environments.vue

@@ -32,7 +32,7 @@ onMounted(async () => {
   while (hasMore) {
     await environment.get_list({ page, enabled: true }).then(r => {
       data.value.push(...r.data)
-      hasMore = r.data.length === r.pagination.per_page
+      hasMore = r.data.length === r.pagination?.per_page
       page++
     }).catch(() => {
       hasMore = false

+ 3 - 3
app/src/views/notification/Notification.vue

@@ -7,11 +7,11 @@ import { message } from 'ant-design-vue'
 
 const { unreadCount } = storeToRefs(useUserStore())
 
-const curd = ref()
+const curd = useTemplateRef('curd')
 function clear() {
   notification.clear().then(() => {
     message.success($gettext('Cleared successfully'))
-    curd.value.get_list()
+    curd.value?.get_list()
     unreadCount.value = 0
   }).catch(e => {
     message.error($gettext(e?.message ?? 'Server error'))
@@ -19,7 +19,7 @@ function clear() {
 }
 
 watch(unreadCount, () => {
-  curd.value.get_list()
+  curd.value?.get_list()
 })
 </script>
 

+ 4 - 4
app/src/views/site/SiteAdd.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import type { NgxConfig } from '@/api/ngx'
-import domain from '@/api/domain'
 import ngx from '@/api/ngx'
+import site from '@/api/site'
 import DirectiveEditor from '@/views/site/ngx_conf/directive/DirectiveEditor.vue'
 import LocationEditor from '@/views/site/ngx_conf/LocationEditor.vue'
 import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
@@ -26,17 +26,17 @@ onMounted(() => {
 })
 
 function init() {
-  domain.get_template().then(r => {
+  site.get_template().then(r => {
     Object.assign(ngx_config, r.tokenized)
   })
 }
 
 async function save() {
   return ngx.build_config(ngx_config).then(r => {
-    domain.save(ngx_config.name, { name: ngx_config.name, content: r.content, overwrite: true }).then(() => {
+    site.save(ngx_config.name, { name: ngx_config.name, content: r.content, overwrite: true }).then(() => {
       message.success($gettext('Saved successfully'))
 
-      domain.enable(ngx_config.name).then(() => {
+      site.enable(ngx_config.name).then(() => {
         message.success($gettext('Enabled successfully'))
         window.scroll({ top: 0, left: 0, behavior: 'smooth' })
       }).catch(e => {

+ 3 - 3
app/src/views/site/cert/components/ObtainCert.vue

@@ -4,7 +4,7 @@ import type { CertificateResult } from '@/api/cert'
 import type { NgxConfig, NgxDirective } from '@/api/ngx'
 import type { PrivateKeyType } from '@/constants'
 import type { ComputedRef, Ref } from 'vue'
-import domain from '@/api/domain'
+import site from '@/api/site'
 import AutoCertStepOne from '@/views/site/cert/components/AutoCertStepOne.vue'
 import ObtainCertLive from '@/views/site/cert/components/ObtainCertLive.vue'
 import { message, Modal } from 'ant-design-vue'
@@ -59,7 +59,7 @@ async function resolveCert({ ssl_certificate, ssl_certificate_key, key_type }: C
 
 function change_auto_cert(status: boolean, key_type?: PrivateKeyType) {
   if (status) {
-    domain.add_auto_cert(props.configName, {
+    site.add_auto_cert(props.configName, {
       domains: name.value.trim().split(' '),
       challenge_method: data.value.challenge_method!,
       dns_credential_id: data.value.dns_credential_id!,
@@ -71,7 +71,7 @@ function change_auto_cert(status: boolean, key_type?: PrivateKeyType) {
     })
   }
   else {
-    domain.remove_auto_cert(props.configName).then(() => {
+    site.remove_auto_cert(props.configName).then(() => {
       message.success($gettext('Auto-renewal disabled for %{name}', { name: name.value }))
     }).catch(e => {
       message.error(e.message ?? $gettext('Disable auto-renewal failed for %{name}', { name: name.value }))

+ 0 - 122
app/src/views/site/components/Deploy.vue

@@ -1,122 +0,0 @@
-<script setup lang="ts">
-import type { Ref } from 'vue'
-import domain from '@/api/domain'
-import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
-import { InfoCircleOutlined } from '@ant-design/icons-vue'
-import { Modal, notification } from 'ant-design-vue'
-
-const node_map = ref({})
-const target = ref([])
-const overwrite = ref(false)
-const enabled = ref(false)
-const name = inject('name') as Ref<string>
-const [modal, ContextHolder] = Modal.useModal()
-function deploy() {
-  modal.confirm({
-    title: () => $ngettext('Do you want to deploy this file to remote server?', 'Do you want to deploy this file to remote servers?', target.value.length),
-    mask: false,
-    centered: true,
-    okText: $gettext('OK'),
-    cancelText: $gettext('Cancel'),
-    onOk() {
-      target.value.forEach(id => {
-        const node_name = node_map.value[id]
-
-        // get source content
-        domain.get(name.value).then(r => {
-          domain.save(name.value, {
-            name: name.value,
-            content: r.config,
-            overwrite: overwrite.value,
-
-          }, { headers: { 'X-Node-ID': id } }).then(async () => {
-            notification.success({
-              message: $gettext('Deploy successfully'),
-              description:
-                $gettext('Deploy %{conf_name} to %{node_name} successfully', { conf_name: name.value, node_name }),
-            })
-            if (enabled.value) {
-              domain.enable(name.value, { headers: { 'X-Node-ID': id } }).then(() => {
-                notification.success({
-                  message: $gettext('Enable successfully'),
-                  description:
-                    $gettext('Enable %{conf_name} in %{node_name} successfully', { conf_name: name.value, node_name }),
-                })
-              }).catch(e => {
-                notification.error({
-                  message: $gettext('Enable %{conf_name} in %{node_name} failed', {
-                    conf_name: name.value,
-                    node_name,
-                  }),
-                  description: $gettext(e?.message ?? 'Server error'),
-                })
-              })
-            }
-          }).catch(e => {
-            notification.error({
-              message: $gettext('Deploy %{conf_name} to %{node_name} failed', {
-                conf_name: name.value,
-                node_name,
-              }),
-              description: $gettext(e?.message ?? 'Server error'),
-            })
-          })
-        })
-      })
-    },
-  })
-}
-</script>
-
-<template>
-  <div>
-    <ContextHolder />
-    <NodeSelector
-      v-model:target="target"
-      v-model:map="node_map"
-      hidden-local
-    />
-    <div class="node-deploy-control">
-      <ACheckbox v-model:checked="enabled">
-        {{ $gettext('Enable') }}
-      </ACheckbox>
-      <div class="overwrite">
-        <ACheckbox v-model:checked="overwrite">
-          {{ $gettext('Overwrite') }}
-        </ACheckbox>
-        <ATooltip placement="bottom">
-          <template #title>
-            {{ $gettext('Overwrite exist file') }}
-          </template>
-          <InfoCircleOutlined />
-        </ATooltip>
-      </div>
-
-      <AButton
-        :disabled="target.length === 0"
-        type="primary"
-        ghost
-        @click="deploy"
-      >
-        {{ $gettext('Deploy') }}
-      </AButton>
-    </div>
-  </div>
-</template>
-
-<style scoped lang="less">
-.overwrite {
-  margin-right: 15px;
-
-  span {
-    color: #9b9b9b;
-  }
-}
-
-.node-deploy-control {
-  display: flex;
-  justify-content: flex-end;
-  margin-top: 10px;
-  align-items: center;
-}
-</style>

+ 0 - 128
app/src/views/site/components/RightSettings.vue

@@ -1,128 +0,0 @@
-<script setup lang="ts">
-import type { Site } from '@/api/domain'
-import type { ChatComplicationMessage } from '@/api/openai'
-import type { CheckedType } from '@/types'
-import type { Ref } from 'vue'
-import domain from '@/api/domain'
-import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
-import { formatDateTime } from '@/lib/helper'
-import { useSettingsStore } from '@/pinia'
-import Deploy from '@/views/site/components/Deploy.vue'
-import { message, Modal } from 'ant-design-vue'
-
-const settings = useSettingsStore()
-
-const configText = inject('configText') as Ref<string>
-const enabled = inject('enabled') as Ref<boolean>
-const name = inject('name') as Ref<string>
-const filepath = inject('filepath') as Ref<string>
-const history_chatgpt_record = inject('history_chatgpt_record') as Ref<ChatComplicationMessage[]>
-const filename = inject('filename') as Ref<string | number | undefined>
-const data = inject('data') as Ref<Site>
-
-const [modal, ContextHolder] = Modal.useModal()
-
-const active_key = ref(['1', '2', '3'])
-
-function enable() {
-  domain.enable(name.value).then(() => {
-    message.success($gettext('Enabled successfully'))
-    enabled.value = true
-  }).catch(r => {
-    message.error($gettext('Failed to enable %{msg}', { msg: r.message ?? '' }), 10)
-  })
-}
-
-function disable() {
-  domain.disable(name.value).then(() => {
-    message.success($gettext('Disabled successfully'))
-    enabled.value = false
-  }).catch(r => {
-    message.error($gettext('Failed to disable %{msg}', { msg: r.message ?? '' }))
-  })
-}
-
-function on_change_enabled(checked: CheckedType) {
-  modal.confirm({
-    title: checked ? $gettext('Do you want to enable this site?') : $gettext('Do you want to disable this site?'),
-    mask: false,
-    centered: true,
-    okText: $gettext('OK'),
-    cancelText: $gettext('Cancel'),
-    async onOk() {
-      if (checked)
-        enable()
-      else
-        disable()
-    },
-  })
-}
-</script>
-
-<template>
-  <ACard
-    class="right-settings"
-    :bordered="false"
-  >
-    <ContextHolder />
-    <ACollapse
-      v-model:active-key="active_key"
-      ghost
-    >
-      <ACollapsePanel
-        key="1"
-        :header="$gettext('Basic')"
-      >
-        <AFormItem :label="$gettext('Enabled')">
-          <ASwitch
-            :checked="enabled"
-            @change="on_change_enabled"
-          />
-        </AFormItem>
-        <AFormItem :label="$gettext('Name')">
-          <AInput v-model:value="filename" />
-        </AFormItem>
-        <AFormItem :label="$gettext('Updated at')">
-          {{ formatDateTime(data.modified_at) }}
-        </AFormItem>
-      </ACollapsePanel>
-      <ACollapsePanel
-        v-if="!settings.is_remote"
-        key="2"
-        :header="$gettext('Deploy')"
-      >
-        <Deploy />
-      </ACollapsePanel>
-      <ACollapsePanel
-        key="3"
-        header="ChatGPT"
-      >
-        <ChatGPT
-          v-model:history-messages="history_chatgpt_record"
-          :content="configText"
-          :path="filepath"
-        />
-      </ACollapsePanel>
-    </ACollapse>
-  </ACard>
-</template>
-
-<style scoped lang="less">
-.right-settings {
-  position: sticky;
-  top: 78px;
-
-  :deep(.ant-card-body) {
-    max-height: 100vh;
-    overflow-y: scroll;
-  }
-}
-
-:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
-  padding: 0;
-}
-
-:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
-  padding: 0 0 10px 0;
-}
-</style>

+ 0 - 149
app/src/views/site/components/SiteDuplicate.vue

@@ -1,149 +0,0 @@
-<script setup lang="ts">
-import domain from '@/api/domain'
-
-import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
-import gettext from '@/gettext'
-import { useSettingsStore } from '@/pinia'
-import { Form, message, notification } from 'ant-design-vue'
-
-const props = defineProps<{
-  visible: boolean
-  name: string
-}>()
-
-const emit = defineEmits(['update:visible', 'duplicated'])
-
-const settings = useSettingsStore()
-
-const show = computed({
-  get() {
-    return props.visible
-  },
-  set(v) {
-    emit('update:visible', v)
-  },
-})
-
-interface Model {
-  name: string // site name
-  target: number[] // ids of deploy targets
-}
-
-const modelRef: Model = reactive({ name: '', target: [] })
-
-const rulesRef = reactive({
-  name: [
-    {
-      required: true,
-      message: () => $gettext('Please input name, '
-        + 'this will be used as the filename of the new configuration!'),
-    },
-  ],
-  target: [
-    {
-      required: true,
-      message: () => $gettext('Please select at least one node!'),
-    },
-  ],
-})
-
-const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
-
-const loading = ref(false)
-
-const node_map: Record<number, string> = reactive({})
-
-function onSubmit() {
-  validate().then(async () => {
-    loading.value = true
-
-    modelRef.target.forEach(id => {
-      if (id === 0) {
-        domain.duplicate(props.name, { name: modelRef.name }).then(() => {
-          message.success($gettext('Duplicate to local successfully'))
-          show.value = false
-          emit('duplicated')
-        }).catch(e => {
-          message.error($gettext(e?.message ?? 'Server error'))
-        })
-      }
-      else {
-        // get source content
-
-        domain.get(props.name).then(r => {
-          domain.save(modelRef.name, {
-            name: modelRef.name,
-            content: r.config,
-
-          }, { headers: { 'X-Node-ID': id } }).then(() => {
-            notification.success({
-              message: $gettext('Duplicate successfully'),
-              description:
-                $gettext('Duplicate %{conf_name} to %{node_name} successfully', { conf_name: props.name, node_name: node_map[id] }),
-            })
-          }).catch(e => {
-            notification.error({
-              message: $gettext('Duplicate failed'),
-              description: $gettext(e?.message ?? 'Server error'),
-            })
-          })
-          if (r.enabled) {
-            domain.enable(modelRef.name, { headers: { 'X-Node-ID': id } }).then(() => {
-              notification.success({
-                message: $gettext('Enabled successfully'),
-              })
-            })
-          }
-        })
-      }
-    })
-
-    loading.value = false
-  })
-}
-
-watch(() => props.visible, v => {
-  if (v) {
-    modelRef.name = props.name // default with source name
-    modelRef.target = [0]
-    nextTick(() => clearValidate())
-  }
-})
-
-watch(() => gettext.current, () => {
-  clearValidate()
-})
-</script>
-
-<template>
-  <AModal
-    v-model:open="show"
-    :title="$gettext('Duplicate')"
-    :confirm-loading="loading"
-    :mask="false"
-    @ok="onSubmit"
-  >
-    <AForm layout="vertical">
-      <AFormItem
-        :label="$gettext('Name')"
-        v-bind="validateInfos.name"
-      >
-        <AInput v-model:value="modelRef.name" />
-      </AFormItem>
-      <AFormItem
-        v-if="!settings.is_remote"
-        :label="$gettext('Target')"
-        v-bind="validateInfos.target"
-      >
-        <NodeSelector
-          v-model:target="modelRef.target"
-          v-model:map="node_map"
-        />
-      </AFormItem>
-    </AForm>
-  </AModal>
-</template>
-
-<style lang="less" scoped>
-
-</style>

+ 28 - 0
app/src/views/site/site_category/SiteCategory.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import site_category from '@/api/site_category'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
+import { StdCurd } from '@/components/StdDesign/StdDataDisplay'
+import columns from '@/views/site/site_category/columns'
+</script>
+
+<template>
+  <StdCurd
+    :title="$gettext('Site Categories')"
+    :api="site_category"
+    :columns="columns"
+  >
+    <template #edit="{ data }">
+      <div class="mb-2">
+        {{ $gettext('Sync Nodes') }}
+      </div>
+      <NodeSelector
+        v-model:target="data.sync_node_ids"
+        hidden-local
+      />
+    </template>
+  </StdCurd>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 30 - 0
app/src/views/site/site_category/columns.ts

@@ -0,0 +1,30 @@
+import type { Column } from '@/components/StdDesign/types'
+import { datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import { input } from '@/components/StdDesign/StdDataEntry'
+
+const columns: Column[] = [{
+  dataIndex: 'name',
+  title: () => $gettext('Name'),
+  search: true,
+  edit: {
+    type: input,
+  },
+  pithy: true,
+}, {
+  title: () => $gettext('Created at'),
+  dataIndex: 'created_at',
+  customRender: datetime,
+  sorter: true,
+  pithy: true,
+}, {
+  title: () => $gettext('Updated at'),
+  dataIndex: 'updated_at',
+  customRender: datetime,
+  sorter: true,
+  pithy: true,
+}, {
+  title: () => $gettext('Action'),
+  dataIndex: 'action',
+}]
+
+export default columns

+ 168 - 0
app/src/views/site/site_edit/RightSettings.vue

@@ -0,0 +1,168 @@
+<script setup lang="ts">
+import type { ChatComplicationMessage } from '@/api/openai'
+import type { Site } from '@/api/site'
+import type { CheckedType } from '@/types'
+import type { Ref } from 'vue'
+import site from '@/api/site'
+import site_category from '@/api/site_category'
+import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
+import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelector.vue'
+import { formatDateTime } from '@/lib/helper'
+import { useSettingsStore } from '@/pinia'
+import siteCategoryColumns from '@/views/site/site_category/columns'
+import ConfigName from '@/views/site/site_edit/components/ConfigName.vue'
+import { InfoCircleOutlined } from '@ant-design/icons-vue'
+import { message, Modal } from 'ant-design-vue'
+
+const settings = useSettingsStore()
+
+const configText = inject('configText') as Ref<string>
+const enabled = inject('enabled') as Ref<boolean>
+const name = inject('name') as ComputedRef<string>
+const filepath = inject('filepath') as Ref<string>
+const historyChatgptRecord = inject('history_chatgpt_record') as Ref<ChatComplicationMessage[]>
+const data = inject('data') as Ref<Site>
+
+const [modal, ContextHolder] = Modal.useModal()
+
+const activeKey = ref(['1', '2', '3'])
+
+function enable() {
+  site.enable(name.value).then(() => {
+    message.success($gettext('Enabled successfully'))
+    enabled.value = true
+  }).catch(r => {
+    message.error($gettext('Failed to enable %{msg}', { msg: r.message ?? '' }), 10)
+  })
+}
+
+function disable() {
+  site.disable(name.value).then(() => {
+    message.success($gettext('Disabled successfully'))
+    enabled.value = false
+  }).catch(r => {
+    message.error($gettext('Failed to disable %{msg}', { msg: r.message ?? '' }))
+  })
+}
+
+function onChangeEnabled(checked: CheckedType) {
+  modal.confirm({
+    title: checked ? $gettext('Do you want to enable this site?') : $gettext('Do you want to disable this site?'),
+    mask: false,
+    centered: true,
+    okText: $gettext('OK'),
+    cancelText: $gettext('Cancel'),
+    async onOk() {
+      if (checked)
+        enable()
+      else
+        disable()
+    },
+  })
+}
+</script>
+
+<template>
+  <ACard
+    class="right-settings"
+    :bordered="false"
+  >
+    <ContextHolder />
+    <ACollapse
+      v-model:active-key="activeKey"
+      ghost
+      collapsible="header"
+    >
+      <ACollapsePanel
+        key="1"
+        :header="$gettext('Basic')"
+      >
+        <AForm layout="vertical">
+          <AFormItem :label="$gettext('Enabled')">
+            <ASwitch
+              :checked="enabled"
+              @change="onChangeEnabled"
+            />
+          </AFormItem>
+          <AFormItem :label="$gettext('Name')">
+            <ConfigName v-if="name" :name />
+          </AFormItem>
+          <AFormItem :label="$gettext('Category')">
+            <StdSelector
+              v-model:selected-key="data.site_category_id"
+              :api="site_category"
+              :columns="siteCategoryColumns"
+              record-value-index="name"
+              selection-type="radio"
+            />
+          </AFormItem>
+          <AFormItem :label="$gettext('Updated at')">
+            {{ formatDateTime(data.modified_at) }}
+          </AFormItem>
+        </AForm>
+      </ACollapsePanel>
+      <ACollapsePanel
+        v-if="!settings.is_remote"
+        key="2"
+      >
+        <template #header>
+          {{ $gettext('Synchronization') }}
+        </template>
+        <template #extra>
+          <APopover placement="bottomRight" :title="$gettext('Sync strategy')">
+            <template #content>
+              <div class="max-w-200px mb-2">
+                {{ $gettext('When you enable/disable, delete, or save this site, '
+                  + 'the nodes set in the site category and the nodes selected below will be synchronized.') }}
+              </div>
+              <div class="max-w-200px">
+                {{ $gettext('Note, if the configuration file include other configurations or certificates, '
+                  + 'please synchronize them to the remote nodes in advance.') }}
+              </div>
+            </template>
+            <div class="text-trueGray-600">
+              <InfoCircleOutlined class="mr-1" />
+              {{ $gettext('Sync strategy') }}
+            </div>
+          </APopover>
+        </template>
+        <NodeSelector
+          v-model:target="data.sync_node_ids"
+          class="mb-4"
+          hidden-local
+        />
+      </ACollapsePanel>
+      <ACollapsePanel
+        key="3"
+        header="ChatGPT"
+      >
+        <ChatGPT
+          v-model:history-messages="historyChatgptRecord"
+          :content="configText"
+          :path="filepath"
+        />
+      </ACollapsePanel>
+    </ACollapse>
+  </ACard>
+</template>
+
+<style scoped lang="less">
+.right-settings {
+  position: sticky;
+  top: 78px;
+
+  :deep(.ant-card-body) {
+    max-height: 100vh;
+    overflow-y: scroll;
+  }
+}
+
+:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
+  padding: 0;
+}
+
+:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
+  padding: 0 0 10px 0;
+}
+</style>

+ 11 - 15
app/src/views/site/SiteEdit.vue → app/src/views/site/site_edit/SiteEdit.vue

@@ -1,27 +1,23 @@
 <script setup lang="ts">
 import type { CertificateInfo } from '@/api/cert'
-import type { Site } from '@/api/domain'
 import type { NgxConfig } from '@/api/ngx'
-
 import type { ChatComplicationMessage } from '@/api/openai'
+
+import type { Site } from '@/api/site'
 import type { CheckedType } from '@/types'
 import config from '@/api/config'
-import domain from '@/api/domain'
 import ngx from '@/api/ngx'
+import site from '@/api/site'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
-import RightSettings from '@/views/site/components/RightSettings.vue'
 import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
+import RightSettings from '@/views/site/site_edit/RightSettings.vue'
 import { message } from 'ant-design-vue'
 
 const route = useRoute()
 const router = useRouter()
 
-const name = ref(route.params.name.toString())
-
-watch(route, () => {
-  name.value = route.params?.name?.toString() ?? ''
-})
+const name = computed(() => route.params?.name?.toString() ?? '')
 
 const ngx_config: NgxConfig = reactive({
   name: '',
@@ -40,7 +36,7 @@ const saving = ref(false)
 const filename = ref('')
 const parse_error_status = ref(false)
 const parse_error_message = ref('')
-const data = ref({})
+const data = ref({}) as Ref<Site>
 
 init()
 
@@ -77,7 +73,7 @@ function handle_response(r: Site) {
 
 function init() {
   if (name.value) {
-    domain.get(name.value).then(r => {
+    site.get(name.value).then(r => {
       handle_response(r)
     }).catch(handle_parse_error)
   }
@@ -96,7 +92,7 @@ function handle_parse_error(e: { error?: string, message: string }) {
 }
 
 function on_mode_change(advanced: CheckedType) {
-  domain.advance_mode(name.value, { advanced: advanced as boolean }).then(() => {
+  site.advance_mode(name.value, { advanced: advanced as boolean }).then(() => {
     advanceMode.value = advanced as boolean
     if (advanced) {
       build_config()
@@ -130,10 +126,11 @@ async function save() {
     }
   }
 
-  return domain.save(name.value, {
-    name: filename.value || name.value,
+  return site.save(name.value, {
     content: configText.value,
     overwrite: true,
+    site_category_id: data.value.site_category_id,
+    sync_node_ids: data.value.sync_node_ids,
   }).then(r => {
     handle_response(r)
     router.push({
@@ -152,7 +149,6 @@ provide('ngx_config', ngx_config)
 provide('history_chatgpt_record', history_chatgpt_record)
 provide('enabled', enabled)
 provide('name', name)
-provide('filename', filename)
 provide('filepath', filepath)
 provide('data', data)
 </script>

+ 63 - 0
app/src/views/site/site_edit/components/ConfigName.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import site from '@/api/site'
+import { message } from 'ant-design-vue'
+
+const props = defineProps<{
+  name: string
+}>()
+
+const router = useRouter()
+
+const modify = ref(false)
+const buffer = ref('')
+const loading = ref(false)
+
+onMounted(() => {
+  buffer.value = props.name
+})
+
+function clickModify() {
+  modify.value = true
+}
+
+function save() {
+  loading.value = true
+  site.rename(props.name, buffer.value).then(() => {
+    modify.value = false
+    message.success($gettext('Renamed successfully'))
+    router.push({
+      path: `/sites/${buffer.value}`,
+    })
+  }).catch(e => {
+    message.error($gettext(e?.message ?? 'Server error'))
+  }).finally(() => {
+    loading.value = false
+  })
+}
+</script>
+
+<template>
+  <div v-if="!modify" class="flex items-center">
+    <div class="mr-2">
+      {{ buffer }}
+    </div>
+    <div>
+      <AButton type="link" size="small" @click="clickModify">
+        {{ $gettext('Rename') }}
+      </AButton>
+    </div>
+  </div>
+  <div v-else>
+    <AInput v-model:value="buffer">
+      <template #suffix>
+        <AButton :disabled="buffer === name" type="link" size="small" :loading @click="save">
+          {{ $gettext('Save') }}
+        </AButton>
+      </template>
+    </AInput>
+  </div>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 92 - 0
app/src/views/site/site_list/SiteDuplicate.vue

@@ -0,0 +1,92 @@
+<script setup lang="ts">
+import site from '@/api/site'
+
+import gettext from '@/gettext'
+import { Form, message } from 'ant-design-vue'
+
+const props = defineProps<{
+  visible: boolean
+  name: string
+}>()
+
+const emit = defineEmits(['update:visible', 'duplicated'])
+
+const show = computed({
+  get() {
+    return props.visible
+  },
+  set(v) {
+    emit('update:visible', v)
+  },
+})
+
+interface Model {
+  name: string // site name
+}
+
+const modelRef: Model = reactive({ name: '' })
+
+const rulesRef = reactive({
+  name: [
+    {
+      required: true,
+      message: () => $gettext('Please input name, '
+        + 'this will be used as the filename of the new configuration.'),
+    },
+  ],
+})
+
+const { validate, validateInfos, clearValidate } = Form.useForm(modelRef, rulesRef)
+
+const loading = ref(false)
+
+function onSubmit() {
+  validate().then(async () => {
+    loading.value = true
+
+    site.duplicate(props.name, { name: modelRef.name }).then(() => {
+      message.success($gettext('Duplicate to local successfully'))
+      show.value = false
+      emit('duplicated')
+    }).catch(e => {
+      message.error($gettext(e?.message ?? 'Server error'))
+    })
+
+    loading.value = false
+  })
+}
+
+watch(() => props.visible, v => {
+  if (v) {
+    modelRef.name = props.name // default with source name
+    nextTick(() => clearValidate())
+  }
+})
+
+watch(() => gettext.current, () => {
+  clearValidate()
+})
+</script>
+
+<template>
+  <AModal
+    v-model:open="show"
+    :title="$gettext('Duplicate')"
+    :confirm-loading="loading"
+    :mask="false"
+    @ok="onSubmit"
+  >
+    <AForm layout="vertical">
+      <AFormItem
+        :label="$gettext('Name')"
+        v-bind="validateInfos.name"
+      >
+        <AInput v-model:value="modelRef.name" />
+      </AFormItem>
+    </AForm>
+  </AModal>
+</template>
+
+<style lang="less" scoped>
+
+</style>

+ 65 - 63
app/src/views/site/SiteList.vue → app/src/views/site/site_list/SiteList.vue

@@ -1,66 +1,49 @@
 <script setup lang="tsx">
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
-import type { Column, JSXElements } from '@/components/StdDesign/types'
-import domain from '@/api/domain'
+import type { Site } from '@/api/site'
+import type { SiteCategory } from '@/api/site_category'
+import type { Column } from '@/components/StdDesign/types'
+import site from '@/api/site'
+import site_category from '@/api/site_category'
+import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
-import { datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
-import { input, select } from '@/components/StdDesign/StdDataEntry'
 import InspectConfig from '@/views/config/InspectConfig.vue'
-import SiteDuplicate from '@/views/site/components/SiteDuplicate.vue'
-import { Badge, message } from 'ant-design-vue'
-
-const columns: Column[] = [{
-  title: () => $gettext('Name'),
-  dataIndex: 'name',
-  sorter: true,
-  pithy: true,
-  edit: {
-    type: input,
-  },
-  search: true,
-}, {
-  title: () => $gettext('Status'),
-  dataIndex: 'enabled',
-  customRender: (args: CustomRenderProps) => {
-    const template: JSXElements = []
-    const { text } = args
-    if (text === true || text > 0) {
-      template.push(<Badge status="success" />)
-      template.push($gettext('Enabled'))
-    }
-    else {
-      template.push(<Badge status="warning" />)
-      template.push($gettext('Disabled'))
-    }
+import columns from '@/views/site/site_list/columns'
+import SiteDuplicate from '@/views/site/site_list/SiteDuplicate.vue'
+import { message } from 'ant-design-vue'
 
-    return h('div', template)
-  },
-  search: {
-    type: select,
-    mask: {
-      true: $gettext('Enabled'),
-      false: $gettext('Disabled'),
-    },
-  },
-  sorter: true,
-  pithy: true,
-}, {
-  title: () => $gettext('Updated at'),
-  dataIndex: 'modified_at',
-  customRender: datetime,
-  sorter: true,
-  pithy: true,
-}, {
-  title: () => $gettext('Action'),
-  dataIndex: 'action',
-}]
+const route = useRoute()
+const router = useRouter()
 
 const table = ref()
-
 const inspect_config = ref()
 
+const siteCategoryId = ref(Number.parseInt(route.query.site_category_id as string) || 0)
+const siteCategories = ref([]) as Ref<SiteCategory[]>
+
+watch(route, () => {
+  inspect_config.value?.test()
+})
+
+onMounted(async () => {
+  while (true) {
+    try {
+      const { data, pagination } = await site_category.get_list()
+      if (!data || !pagination)
+        return
+      siteCategories.value.push(...data)
+      if (data.length < pagination?.per_page) {
+        return
+      }
+    }
+    catch (e: any) {
+      message.error(e?.message ?? $gettext('Server error'))
+      return
+    }
+  }
+})
+
 function enable(name: string) {
-  domain.enable(name).then(() => {
+  site.enable(name).then(() => {
     message.success($gettext('Enabled successfully'))
     table.value?.get_list()
     inspect_config.value?.test()
@@ -70,7 +53,7 @@ function enable(name: string) {
 }
 
 function disable(name: string) {
-  domain.disable(name).then(() => {
+  site.disable(name).then(() => {
     message.success($gettext('Disabled successfully'))
     table.value?.get_list()
     inspect_config.value?.test()
@@ -80,7 +63,7 @@ function disable(name: string) {
 }
 
 function destroy(site_name: string) {
-  domain.destroy(site_name).then(() => {
+  site.destroy(site_name).then(() => {
     table.value.get_list()
     message.success($gettext('Delete site: %{site_name}', { site_name }))
     inspect_config.value?.test()
@@ -98,30 +81,43 @@ function handle_click_duplicate(name: string) {
   target.value = name
 }
 
-const route = useRoute()
+const stdBatchEditRef = useTemplateRef('stdBatchEditRef')
 
-watch(route, () => {
-  inspect_config.value?.test()
-})
+async function handleClickBatchEdit(batchColumns: Column[], selectedRowKeys: string[], selectedRows: Site[]) {
+  stdBatchEditRef.value?.showModal(batchColumns, selectedRowKeys, selectedRows)
+}
+
+function handleBatchUpdated() {
+  table.value?.get_list()
+  table.value?.resetSelection()
+}
 </script>
 
 <template>
   <ACard :title="$gettext('Manage Sites')">
     <InspectConfig ref="inspect_config" />
 
+    <ATabs v-model:active-key="siteCategoryId">
+      <ATabPane :key="0" :tab="$gettext('All')" />
+      <ATabPane v-for="c in siteCategories" :key="c.id" :tab="c.name" />
+    </ATabs>
+
     <StdTable
       ref="table"
-      :api="domain"
+      :api="site"
       :columns="columns"
       row-key="name"
       disable-delete
       disable-view
-      @click-edit="r => $router.push({
+      :get-params="{
+        site_category_id: siteCategoryId,
+      }"
+      @click-edit="(r: string) => router.push({
         path: `/sites/${r}`,
       })"
+      @click-batch-modify="handleClickBatchEdit"
     >
       <template #actions="{ record }">
-        <ADivider type="vertical" />
         <AButton
           v-if="record.enabled"
           type="link"
@@ -164,6 +160,12 @@ watch(route, () => {
         </APopconfirm>
       </template>
     </StdTable>
+    <StdBatchEdit
+      ref="stdBatchEditRef"
+      :api="site"
+      :columns
+      @save="handleBatchUpdated"
+    />
     <SiteDuplicate
       v-model:visible="show_duplicator"
       :name="target"

+ 74 - 0
app/src/views/site/site_list/columns.tsx

@@ -0,0 +1,74 @@
+import type { Column, JSXElements } from '@/components/StdDesign/types'
+import site_category from '@/api/site_category'
+import {
+  actualValueRender,
+  type CustomRenderProps,
+  datetime,
+} from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import { input, select, selector } from '@/components/StdDesign/StdDataEntry'
+import siteCategoryColumns from '@/views/site/site_category/columns'
+import { Badge } from 'ant-design-vue'
+
+const columns: Column[] = [{
+  title: () => $gettext('Name'),
+  dataIndex: 'name',
+  sorter: true,
+  pithy: true,
+  edit: {
+    type: input,
+  },
+  search: true,
+}, {
+  title: () => $gettext('Category'),
+  dataIndex: 'site_category_id',
+  customRender: actualValueRender('site_category.name'),
+  edit: {
+    type: selector,
+    selector: {
+      api: site_category,
+      columns: siteCategoryColumns,
+      recordValueIndex: 'name',
+      selectionType: 'radio',
+    },
+  },
+  sorter: true,
+  pithy: true,
+  batch: true,
+}, {
+  title: () => $gettext('Status'),
+  dataIndex: 'enabled',
+  customRender: (args: CustomRenderProps) => {
+    const template: JSXElements = []
+    const { text } = args
+    if (text === true || text > 0) {
+      template.push(<Badge status="success" />)
+      template.push($gettext('Enabled'))
+    }
+    else {
+      template.push(<Badge status="warning" />)
+      template.push($gettext('Disabled'))
+    }
+
+    return h('div', template)
+  },
+  search: {
+    type: select,
+    mask: {
+      true: $gettext('Enabled'),
+      false: $gettext('Disabled'),
+    },
+  },
+  sorter: true,
+  pithy: true,
+}, {
+  title: () => $gettext('Updated at'),
+  dataIndex: 'modified_at',
+  customRender: datetime,
+  sorter: true,
+  pithy: true,
+}, {
+  title: () => $gettext('Action'),
+  dataIndex: 'action',
+}]
+
+export default columns

+ 0 - 1
app/src/views/stream/StreamList.vue

@@ -135,7 +135,6 @@ function handleAddStream() {
       })"
     >
       <template #actions="{ record }">
-        <ADivider type="vertical" />
         <AButton
           v-if="record.enabled"
           type="link"

+ 0 - 14
app/tailwind.config.js

@@ -1,14 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-export default {
-  content: [
-    './index.html',
-    './src/**/*.{vue,js,ts,jsx,tsx}',
-  ],
-  theme: {
-    extend: {},
-  },
-  plugins: [],
-  corePlugins: {
-    preflight: false,
-  },
-}

+ 66 - 0
app/uno.config.ts

@@ -0,0 +1,66 @@
+// uno.config.ts
+import {
+  defineConfig,
+  presetAttributify,
+  presetIcons,
+  presetTypography,
+  presetUno,
+  presetWebFonts,
+  transformerDirectives,
+  transformerVariantGroup,
+} from 'unocss'
+
+export default defineConfig({
+  shortcuts: [],
+  rules: [],
+  variants: [
+    // 使用工具函数
+    matcher => {
+      if (!matcher.endsWith('!'))
+        return matcher
+      return {
+        matcher: matcher.slice(0, -1),
+        selector: s => `${s}!important`,
+      }
+    },
+  ],
+  theme: {
+    colors: {
+      // ...
+    },
+  },
+  presets: [
+    presetUno(),
+    presetAttributify(),
+    presetIcons({
+      collections: {
+        tabler: () => import('@iconify-json/tabler/icons.json').then(i => i.default),
+      },
+      extraProperties: {
+        'display': 'inline-block',
+        'height': '1.2em',
+        'width': '1.2em',
+        'vertical-align': 'text-bottom',
+      },
+    }),
+    presetTypography(),
+    presetWebFonts(),
+  ],
+  transformers: [
+    transformerDirectives(),
+    transformerVariantGroup(),
+  ],
+  content: {
+    pipeline: {
+      include: [
+        // default
+        /\.(vue|[jt]sx|ts)($|\?)/,
+
+        // 参考:https://unocss.dev/guide/extracting#extracting-from-build-tools-pipeline
+      ],
+
+      // exclude files
+      // exclude: []
+    },
+  },
+})

+ 6 - 2
app/vite.config.ts

@@ -1,10 +1,10 @@
 import { fileURLToPath, URL } from 'node:url'
 import vue from '@vitejs/plugin-vue'
 import vueJsx from '@vitejs/plugin-vue-jsx'
+import UnoCSS from 'unocss/vite'
 import AutoImport from 'unplugin-auto-import/vite'
 import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
 import Components from 'unplugin-vue-components/vite'
-
 import DefineOptions from 'unplugin-vue-define-options/vite'
 import { defineConfig, loadEnv } from 'vite'
 import vitePluginBuildId from 'vite-plugin-build-id'
@@ -34,9 +34,9 @@ export default defineConfig(({ mode }) => {
     plugins: [
       vue(),
       vueJsx(),
-
       vitePluginBuildId(),
       svgLoader(),
+      UnoCSS(),
       Components({
         resolvers: [AntDesignVueResolver({ importStyle: false })],
         directoryAsNamespace: true,
@@ -56,6 +56,10 @@ export default defineConfig(({ mode }) => {
           },
         ],
         vueTemplate: true,
+        eslintrc: {
+          enabled: true,
+          filepath: '.eslint-auto-import.mjs',
+        },
       }),
       DefineOptions(),
     ],

+ 4 - 3
cmd/generate/generate.go

@@ -4,7 +4,8 @@ import (
 	"flag"
 	"fmt"
 	"github.com/0xJacky/Nginx-UI/model"
-	"github.com/uozi-tech/cosy/settings"
+	"github.com/0xJacky/Nginx-UI/settings"
+	cSettings "github.com/uozi-tech/cosy/settings"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gen"
 	"gorm.io/gorm"
@@ -39,8 +40,8 @@ func main() {
 	flag.StringVar(&confPath, "config", "app.ini", "Specify the configuration file")
 	flag.Parse()
 
-	settings.Init(confPath)
-	dbPath := path.Join(path.Dir(confPath), fmt.Sprintf("%s.db", settings.DataBaseSettings.Name))
+	cSettings.Init(confPath)
+	dbPath := path.Join(path.Dir(confPath), fmt.Sprintf("%s.db", settings.DatabaseSettings.Name))
 
 	var err error
 	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{

+ 5 - 5
go.mod

@@ -17,6 +17,7 @@ require (
 	github.com/go-acme/lego/v4 v4.19.2
 	github.com/go-co-op/gocron/v2 v2.12.1
 	github.com/go-playground/validator/v10 v10.22.1
+	github.com/go-resty/resty/v2 v2.15.3
 	github.com/go-webauthn/webauthn v0.11.2
 	github.com/golang-jwt/jwt/v5 v5.2.1
 	github.com/google/uuid v1.6.0
@@ -72,7 +73,7 @@ require (
 	github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
 	github.com/StackExchange/wmi v1.2.1 // indirect
 	github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
-	github.com/aliyun/alibaba-cloud-sdk-go v1.63.38 // indirect
+	github.com/aliyun/alibaba-cloud-sdk-go v1.63.39 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.32.2 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.28.0 // indirect
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect
@@ -120,7 +121,6 @@ require (
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
-	github.com/go-resty/resty/v2 v2.15.3 // indirect
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
 	github.com/go-webauthn/x v0.1.15 // indirect
@@ -143,7 +143,7 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
 	github.com/hashicorp/go-uuid v1.0.3 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
-	github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.118 // indirect
+	github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.119 // indirect
 	github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
 	github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
 	github.com/itchyny/timefmt-go v0.1.6 // indirect
@@ -215,7 +215,7 @@ require (
 	github.com/shopspring/decimal v1.4.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
-	github.com/softlayer/softlayer-go v1.1.6 // indirect
+	github.com/softlayer/softlayer-go v1.1.7 // indirect
 	github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
 	github.com/sony/gobreaker v1.0.0 // indirect
 	github.com/sony/sonyflake v1.2.0 // indirect
@@ -236,7 +236,7 @@ require (
 	github.com/uozi-tech/cosy-driver-mysql v0.2.2 // indirect
 	github.com/uozi-tech/cosy-driver-postgres v0.2.1 // indirect
 	github.com/vinyldns/go-vinyldns v0.9.16 // indirect
-	github.com/vultr/govultr/v3 v3.11.0 // indirect
+	github.com/vultr/govultr/v3 v3.11.1 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yandex-cloud/go-genproto v0.0.0-20241021132621-28bb61d00c2f // indirect
 	github.com/yandex-cloud/go-sdk v0.0.0-20241021153520-213d4c625eca // indirect

+ 8 - 10
go.sum

@@ -683,8 +683,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.38 h1:MVTkZJ63DE8XMVLQ5a0M1Elv+RHePK8UPrKjDdgbzDM=
-github.com/aliyun/alibaba-cloud-sdk-go v1.63.38/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.39 h1:zlenrBGDiSEu7YnpWiAPscKNolgIo9Z6jvM5pcWAEL4=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.39/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
@@ -812,8 +812,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
-github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
 github.com/dgraph-io/ristretto/v2 v2.0.0-alpha h1:JBy5Mm/z1HBj3hyDLFBS2uHalL971q3yjUaKcia0Sgo=
 github.com/dgraph-io/ristretto/v2 v2.0.0-alpha/go.mod h1:7bFGBdXzLfFFjKCN8YDQ7+98m/AEYcrdqH7s0En96Qg=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
@@ -1163,8 +1161,8 @@ github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/J
 github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.118 h1:YHcixaT7Le4PxuxN07KQ5j9nPeH4ZdyXtMTSgA+Whh8=
-github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.118/go.mod h1:JWz2ujO9X3oU5wb6kXp+DpR2UuDj2SldDbX8T0FSuhI=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.119 h1:2pi/hbcuv0CNVcsODkTYZY+X9j5uc1GTjSjX1cWMp/4=
+github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.119/go.mod h1:JWz2ujO9X3oU5wb6kXp+DpR2UuDj2SldDbX8T0FSuhI=
 github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -1582,8 +1580,8 @@ github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/gunit v1.1.3 h1:32x+htJCu3aMswhPw3teoJ+PnWPONqdNgaGs6Qt8ZaU=
 github.com/smartystreets/gunit v1.1.3/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ=
-github.com/softlayer/softlayer-go v1.1.6 h1:VRNXiXZTpb7cfKjimU5E7W9zzKYzWMr/xtqlJ0pHwkQ=
-github.com/softlayer/softlayer-go v1.1.6/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw=
+github.com/softlayer/softlayer-go v1.1.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE=
+github.com/softlayer/softlayer-go v1.1.7/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw=
 github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ=
 github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums=
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
@@ -1684,8 +1682,8 @@ github.com/uozi-tech/cosy-driver-sqlite v0.2.0 h1:eTpIMyGoFUK4JcaiKfJHD5AyiM6vtC
 github.com/uozi-tech/cosy-driver-sqlite v0.2.0/go.mod h1:87a6mzn5IuEtIR4z7U4Ey8eKLGfNEOSkv7kPQlbNQgM=
 github.com/vinyldns/go-vinyldns v0.9.16 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ=
 github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q=
-github.com/vultr/govultr/v3 v3.11.0 h1:YlAal70AaJ0k848RqcmjAzFcmLS9n8VtPgU68UxvVm8=
-github.com/vultr/govultr/v3 v3.11.0/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w=
+github.com/vultr/govultr/v3 v3.11.1 h1:Wc6wFTwh/gBZlOqSK1Hn3P9JWoFa7NCf52vGLwQcJOg=
+github.com/vultr/govultr/v3 v3.11.1/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=

+ 1 - 1
internal/analytic/node.go

@@ -42,7 +42,7 @@ type Node struct {
 
 var mutex sync.Mutex
 
-type TNodeMap map[int]*Node
+type TNodeMap map[uint64]*Node
 
 var NodeMap TNodeMap
 

+ 3 - 3
internal/cert/payload.go

@@ -16,11 +16,11 @@ import (
 )
 
 type ConfigPayload struct {
-	CertID                  int                        `json:"cert_id"`
+	CertID                  uint64                     `json:"cert_id"`
 	ServerName              []string                   `json:"server_name"`
 	ChallengeMethod         string                     `json:"challenge_method"`
-	DNSCredentialID         int                        `json:"dns_credential_id"`
-	ACMEUserID              int                        `json:"acme_user_id"`
+	DNSCredentialID         uint64                     `json:"dns_credential_id"`
+	ACMEUserID              uint64                     `json:"acme_user_id"`
 	KeyType                 certcrypto.KeyType         `json:"key_type"`
 	Resource                *model.CertificateResource `json:"resource,omitempty"`
 	MustStaple              bool                       `json:"must_staple"`

部分文件因为文件数量过多而无法显示