Browse Source

refactor: refactor app and api

Jacky 1 year ago
parent
commit
9524e89c17
100 changed files with 4323 additions and 2083 deletions
  1. 6 3
      .air.toml
  2. 10 0
      .github/workflows/build.yml
  3. 0 1
      .gitignore
  4. 8 0
      .idea/.gitignore
  5. 6 0
      .idea/inspectionProfiles/Project_Default.xml
  6. 8 0
      .idea/modules.xml
  7. 9 0
      .idea/nginx-ui.iml
  8. 7 0
      .idea/vcs.xml
  9. 33 101
      api/analytic/analytic.go
  10. 70 0
      api/analytic/nodes.go
  11. 52 0
      api/analytic/type.go
  12. 5 5
      api/certificate/dns_credential.go
  13. 2 2
      api/certificate/router.go
  14. 56 0
      api/config/add.go
  15. 0 206
      api/config/config.go
  16. 51 0
      api/config/get.go
  17. 69 0
      api/config/list.go
  18. 51 0
      api/config/modify.go
  19. 134 0
      api/cosy/cosy.go
  20. 72 0
      api/cosy/create.go
  21. 39 0
      api/cosy/custom.go
  22. 91 0
      api/cosy/delete.go
  23. 23 0
      api/cosy/error.go
  24. 158 0
      api/cosy/list.go
  25. 56 0
      api/cosy/map2struct/hook.go
  26. 24 0
      api/cosy/map2struct/map2struct.go
  27. 42 0
      api/cosy/order.go
  28. 90 0
      api/cosy/update.go
  29. 5 6
      api/nginx/nginx.go
  30. 5 40
      api/openai/openai.go
  31. 44 0
      api/openai/store.go
  32. 42 0
      api/sites/advance.go
  33. 64 0
      api/sites/auto_cert.go
  34. 70 206
      api/sites/domain.go
  35. 44 0
      api/sites/duplicate.go
  36. 26 0
      api/sites/sites.go
  37. 1 1
      api/template/template.go
  38. 258 0
      app/.eslintrc.js
  39. 5 0
      app/.idea/.gitignore
  40. 5 0
      app/.idea/codeStyles/codeStyleConfig.xml
  41. 13 0
      app/.idea/frontend.iml
  42. 7 0
      app/.idea/inspectionProfiles/Project_Default.xml
  43. 6 0
      app/.idea/jsLibraryMappings.xml
  44. 7 0
      app/.idea/jsLinters/eslint.xml
  45. 8 0
      app/.idea/modules.xml
  46. 6 0
      app/.idea/vcs.xml
  47. 4 0
      app/.idea/watcherTasks.xml
  48. 9 8
      app/components.d.ts
  49. 5 0
      app/env.d.ts
  50. 1 0
      app/gettext.config.js
  51. 18 6
      app/package.json
  52. 761 26
      app/pnpm-lock.yaml
  53. 10 9
      app/src/App.vue
  54. 113 2
      app/src/api/analytic.ts
  55. 14 11
      app/src/api/auth.ts
  56. 27 5
      app/src/api/auto_cert.ts
  57. 23 1
      app/src/api/cert.ts
  58. 10 1
      app/src/api/config.ts
  59. 40 9
      app/src/api/curd.ts
  60. 9 1
      app/src/api/dns_credential.ts
  61. 35 12
      app/src/api/domain.ts
  62. 15 1
      app/src/api/environment.ts
  63. 9 2
      app/src/api/install.ts
  64. 3 2
      app/src/api/nginx_log.ts
  65. 37 4
      app/src/api/ngx.ts
  66. 8 2
      app/src/api/openai.ts
  67. 2 1
      app/src/api/settings.ts
  68. 25 7
      app/src/api/template.ts
  69. 12 3
      app/src/api/upgrade.ts
  70. 7 1
      app/src/api/user.ts
  71. 17 15
      app/src/components/Breadcrumb/Breadcrumb.vue
  72. 57 46
      app/src/components/Chart/AreaChart.vue
  73. 45 27
      app/src/components/Chart/RadialBarChart.vue
  74. 15 12
      app/src/components/Chart/UsageProgressLine.vue
  75. 6 0
      app/src/components/Chart/types.d.ts
  76. 156 99
      app/src/components/ChatGPT/ChatGPT.vue
  77. 14 10
      app/src/components/CodeEditor/CodeEditor.vue
  78. 1 1
      app/src/components/CodeEditor/index.ts
  79. 25 19
      app/src/components/EnvIndicator/EnvIndicator.vue
  80. 7 5
      app/src/components/FooterToolbar/FooterToolBar.vue
  81. 7 2
      app/src/components/Logo/Logo.vue
  82. 58 36
      app/src/components/NginxControl/NginxControl.vue
  83. 57 24
      app/src/components/NodeSelector/NodeSelector.vue
  84. 10 13
      app/src/components/PageHeader/PageHeader.vue
  85. 20 12
      app/src/components/SetLanguage/SetLanguage.vue
  86. 0 201
      app/src/components/StdDataDisplay/StdCurd.vue
  87. 0 583
      app/src/components/StdDataDisplay/StdTable.vue
  88. 0 0
      app/src/components/StdDataDisplay/index.ts
  89. 0 37
      app/src/components/StdDataEntry/StdDataEntry.tsx
  90. 0 45
      app/src/components/StdDataEntry/StdFormItem.vue
  91. 0 45
      app/src/components/StdDataEntry/components/StdSelect.vue
  92. 0 133
      app/src/components/StdDataEntry/index.tsx
  93. 18 17
      app/src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue
  94. 174 0
      app/src/components/StdDesign/StdDataDisplay/StdCurd.vue
  95. 16 10
      app/src/components/StdDesign/StdDataDisplay/StdPagination.vue
  96. 415 0
      app/src/components/StdDesign/StdDataDisplay/StdTable.vue
  97. 8 6
      app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx
  98. 9 0
      app/src/components/StdDesign/StdDataDisplay/index.ts
  99. 71 0
      app/src/components/StdDesign/StdDataDisplay/methods/exportCsv.ts
  100. 132 0
      app/src/components/StdDesign/StdDataDisplay/methods/sortable.ts

+ 6 - 3
.air.toml

@@ -13,11 +13,11 @@ bin = "tmp/main"
 # Customize binary.
 full_bin = "APP_ENV=dev APP_USER=air ./tmp/main"
 # Watch these filename extensions.
-include_ext = ["go", "tpl", "tmpl", "html", "toml"]
+include_ext = ["go", "tpl", "tmpl", "html", "toml", "po"]
 # Ignore these filename extensions or directories.
-exclude_dir = ["assets", "tmp", "vendor", "app/node_modules", "upload", "docs", "resources", .ts", ".vue", ".tsx", ".idea"]
+exclude_dir = ["assets", "tmp", "vendor", "app/node_modules", "upload", "docs", "resources", ".idea"]
 # Watch these directories if you specified.
-include_dir = ["app/src/language"]
+include_dir = []
 # Exclude files.
 exclude_file = []
 # Exclude specific regular expressions.
@@ -51,3 +51,6 @@ runner = "green"
 [misc]
 # Delete tmp directory on exit
 clean_on_exit = true
+
+[screen]
+keep_scroll = true

+ 10 - 0
.github/workflows/build.yml

@@ -53,6 +53,16 @@ jobs:
                     pnpm install
                 working-directory: app
 
+            - name: Check frontend code style
+              run: |
+                pnpm run lint
+              working-directory: app
+
+            -   name: Check frontend types
+                run: |
+                    pnpm run typecheck
+                working-directory: app
+
             -   name: Build
                 run: |
                     npx browserslist@latest --update-db

+ 0 - 1
.gitignore

@@ -1,5 +1,4 @@
 .DS_Store
-.idea
 database.db
 tmp
 node_modules

+ 8 - 0
.idea/.gitignore

@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

+ 6 - 0
.idea/inspectionProfiles/Project_Default.xml

@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
+  </profile>
+</component>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/nginx-ui.iml" filepath="$PROJECT_DIR$/.idea/nginx-ui.iml" />
+    </modules>
+  </component>
+</project>

+ 9 - 0
.idea/nginx-ui.iml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+  <component name="Go" enabled="true" />
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 7 - 0
.idea/vcs.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="Git" />
+    <mapping directory="$PROJECT_DIR$/docs" vcs="Git" />
+  </component>
+</project>

+ 33 - 101
api/analytic/analytic.go

@@ -2,7 +2,7 @@ package analytic
 
 import (
 	"fmt"
-	analytic2 "github.com/0xJacky/Nginx-UI/internal/analytic"
+	"github.com/0xJacky/Nginx-UI/internal/analytic"
 	"github.com/0xJacky/Nginx-UI/internal/logger"
 	"github.com/shirou/gopsutil/v3/cpu"
 	"github.com/shirou/gopsutil/v3/host"
@@ -17,22 +17,6 @@ import (
 	"github.com/gorilla/websocket"
 )
 
-type CPUStat struct {
-	User   float64 `json:"user"`
-	System float64 `json:"system"`
-	Idle   float64 `json:"idle"`
-	Total  float64 `json:"total"`
-}
-
-type Stat struct {
-	Uptime  uint64             `json:"uptime"`
-	LoadAvg *load.AvgStat     `json:"loadavg"`
-	CPU     CPUStat           `json:"cpu"`
-	Memory  analytic2.MemStat `json:"memory"`
-	Disk    analytic2.DiskStat `json:"disk"`
-	Network net.IOCountersStat `json:"network"`
-}
-
 func Analytic(c *gin.Context) {
 	var upGrader = websocket.Upgrader{
 		CheckOrigin: func(r *http.Request) bool {
@@ -51,7 +35,7 @@ func Analytic(c *gin.Context) {
 	var stat Stat
 
 	for {
-		stat.Memory, err = analytic2.GetMemoryStat()
+		stat.Memory, err = analytic.GetMemoryStat()
 
 		if err != nil {
 			logger.Error(err)
@@ -76,7 +60,7 @@ func Analytic(c *gin.Context) {
 
 		stat.LoadAvg, _ = load.Avg()
 
-		stat.Disk, err = analytic2.GetDiskStat()
+		stat.Disk, err = analytic.GetDiskStat()
 
 		if err != nil {
 			logger.Error(err)
@@ -103,20 +87,24 @@ func Analytic(c *gin.Context) {
 }
 
 func GetAnalyticInit(c *gin.Context) {
-	cpuInfo, _ := cpu.Info()
-	network, _ := net.IOCounters(false)
-	memory, err := analytic2.GetMemoryStat()
+	cpuInfo, err := cpu.Info()
+	if err != nil {
+		logger.Error(err)
+	}
 
+	network, err := net.IOCounters(false)
 	if err != nil {
 		logger.Error(err)
-		return
 	}
 
-	diskStat, err := analytic2.GetDiskStat()
+	memory, err := analytic.GetMemoryStat()
+	if err != nil {
+		logger.Error(err)
+	}
 
+	diskStat, err := analytic.GetDiskStat()
 	if err != nil {
 		logger.Error(err)
-		return
 	}
 
 	var _net net.IOCountersStat
@@ -132,86 +120,30 @@ func GetAnalyticInit(c *gin.Context) {
 		hostInfo.Platform = "CentOS"
 	}
 
-	loadAvg, _ := load.Avg()
-
-	c.JSON(http.StatusOK, gin.H{
-		"host": hostInfo,
-		"cpu": gin.H{
-			"info":  cpuInfo,
-			"user":  analytic2.CpuUserRecord,
-			"total": analytic2.CpuTotalRecord,
-		},
-		"network": gin.H{
-			"init":      _net,
-			"bytesRecv": analytic2.NetRecvRecord,
-			"bytesSent": analytic2.NetSentRecord,
-		},
-		"disk_io": gin.H{
-			"writes": analytic2.DiskWriteRecord,
-			"reads":  analytic2.DiskReadRecord,
-		},
-		"memory":  memory,
-		"disk":    diskStat,
-		"loadavg": loadAvg,
-	})
-}
+	loadAvg, err := load.Avg()
 
-func GetNodeStat(c *gin.Context) {
-	var upGrader = websocket.Upgrader{
-		CheckOrigin: func(r *http.Request) bool {
-			return true
-		},
-	}
-	// upgrade http to websocket
-	ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
 	if err != nil {
 		logger.Error(err)
-		return
-	}
-
-	defer ws.Close()
-
-	for {
-		// write
-		err = ws.WriteJSON(analytic2.GetNodeStat())
-		if err != nil || websocket.IsUnexpectedCloseError(err,
-			websocket.CloseGoingAway,
-			websocket.CloseNoStatusReceived,
-			websocket.CloseNormalClosure) {
-			logger.Error(err)
-			break
-		}
-
-		time.Sleep(10 * time.Second)
 	}
-}
 
-func GetNodesAnalytic(c *gin.Context) {
-	var upGrader = websocket.Upgrader{
-		CheckOrigin: func(r *http.Request) bool {
-			return true
+	c.JSON(http.StatusOK, InitResp{
+		Host: hostInfo,
+		CPU: CPURecords{
+			Info:  cpuInfo,
+			User:  analytic.CpuUserRecord,
+			Total: analytic.CpuTotalRecord,
 		},
-	}
-	// upgrade http to websocket
-	ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
-	if err != nil {
-		logger.Error(err)
-		return
-	}
-
-	defer ws.Close()
-
-	for {
-		// write
-		err = ws.WriteJSON(analytic2.NodeMap)
-		if err != nil || websocket.IsUnexpectedCloseError(err,
-			websocket.CloseGoingAway,
-			websocket.CloseNoStatusReceived,
-			websocket.CloseNormalClosure) {
-			logger.Error(err)
-			break
-		}
-
-		time.Sleep(10 * time.Second)
-	}
+		Network: NetworkRecords{
+			Init:      _net,
+			BytesRecv: analytic.NetRecvRecord,
+			BytesSent: analytic.NetSentRecord,
+		},
+		DiskIO: DiskIORecords{
+			Writes: analytic.DiskWriteRecord,
+			Reads:  analytic.DiskReadRecord,
+		},
+		Memory:  memory,
+		Disk:    diskStat,
+		LoadAvg: loadAvg,
+	})
 }

+ 70 - 0
api/analytic/nodes.go

@@ -0,0 +1,70 @@
+package analytic
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/analytic"
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/gin-gonic/gin"
+	"github.com/gorilla/websocket"
+	"net/http"
+	"time"
+)
+
+func GetNodeStat(c *gin.Context) {
+	var upGrader = websocket.Upgrader{
+		CheckOrigin: func(r *http.Request) bool {
+			return true
+		},
+	}
+	// upgrade http to websocket
+	ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
+	if err != nil {
+		logger.Error(err)
+		return
+	}
+
+	defer ws.Close()
+
+	for {
+		// write
+		err = ws.WriteJSON(analytic.GetNodeStat())
+		if err != nil || websocket.IsUnexpectedCloseError(err,
+			websocket.CloseGoingAway,
+			websocket.CloseNoStatusReceived,
+			websocket.CloseNormalClosure) {
+			logger.Error(err)
+			break
+		}
+
+		time.Sleep(10 * time.Second)
+	}
+}
+
+func GetNodesAnalytic(c *gin.Context) {
+	var upGrader = websocket.Upgrader{
+		CheckOrigin: func(r *http.Request) bool {
+			return true
+		},
+	}
+	// upgrade http to websocket
+	ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
+	if err != nil {
+		logger.Error(err)
+		return
+	}
+
+	defer ws.Close()
+
+	for {
+		// write
+		err = ws.WriteJSON(analytic.NodeMap)
+		if err != nil || websocket.IsUnexpectedCloseError(err,
+			websocket.CloseGoingAway,
+			websocket.CloseNoStatusReceived,
+			websocket.CloseNormalClosure) {
+			logger.Error(err)
+			break
+		}
+
+		time.Sleep(10 * time.Second)
+	}
+}

+ 52 - 0
api/analytic/type.go

@@ -0,0 +1,52 @@
+package analytic
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/analytic"
+	"github.com/shirou/gopsutil/v3/cpu"
+	"github.com/shirou/gopsutil/v3/host"
+	"github.com/shirou/gopsutil/v3/load"
+	"github.com/shirou/gopsutil/v3/net"
+)
+
+type CPUStat struct {
+	User   float64 `json:"user"`
+	System float64 `json:"system"`
+	Idle   float64 `json:"idle"`
+	Total  float64 `json:"total"`
+}
+
+type Stat struct {
+	Uptime  uint64             `json:"uptime"`
+	LoadAvg *load.AvgStat      `json:"loadavg"`
+	CPU     CPUStat            `json:"cpu"`
+	Memory  analytic.MemStat   `json:"memory"`
+	Disk    analytic.DiskStat  `json:"disk"`
+	Network net.IOCountersStat `json:"network"`
+}
+
+type CPURecords struct {
+	Info  []cpu.InfoStat            `json:"info"`
+	User  []analytic.Usage[float64] `json:"user"`
+	Total []analytic.Usage[float64] `json:"total"`
+}
+
+type NetworkRecords struct {
+	Init      net.IOCountersStat       `json:"init"`
+	BytesRecv []analytic.Usage[uint64] `json:"bytesRecv"`
+	BytesSent []analytic.Usage[uint64] `json:"bytesSent"`
+}
+
+type DiskIORecords struct {
+	Writes []analytic.Usage[uint64] `json:"writes"`
+	Reads  []analytic.Usage[uint64] `json:"reads"`
+}
+
+type InitResp struct {
+	Host    *host.InfoStat    `json:"host"`
+	CPU     CPURecords        `json:"cpu"`
+	Network NetworkRecords    `json:"network"`
+	DiskIO  DiskIORecords     `json:"disk_io"`
+	Memory  analytic.MemStat  `json:"memory"`
+	Disk    analytic.DiskStat `json:"disk"`
+	LoadAvg *load.AvgStat     `json:"loadavg"`
+}

+ 5 - 5
api/certificate/dns_credential.go

@@ -3,7 +3,7 @@ package certificate
 import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/cert/dns"
-	model2 "github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/spf13/cast"
@@ -21,7 +21,7 @@ func GetDnsCredential(c *gin.Context) {
 		return
 	}
 	type apiDnsCredential struct {
-		model2.Model
+		model.Model
 		Name string `json:"name"`
 		dns.Config
 	}
@@ -35,7 +35,7 @@ func GetDnsCredential(c *gin.Context) {
 func GetDnsCredentialList(c *gin.Context) {
 	d := query.DnsCredential
 	provider := c.Query("provider")
-	var data []*model2.DnsCredential
+	var data []*model.DnsCredential
 	var err error
 	if provider != "" {
 		data, err = d.Where(d.Provider.Eq(provider)).Find()
@@ -65,7 +65,7 @@ func AddDnsCredential(c *gin.Context) {
 	}
 
 	json.Config.Name = json.Provider
-	dnsCredential := model2.DnsCredential{
+	dnsCredential := model.DnsCredential{
 		Name:     json.Name,
 		Config:   &json.Config,
 		Provider: json.Provider,
@@ -99,7 +99,7 @@ func EditDnsCredential(c *gin.Context) {
 	}
 
 	json.Config.Name = json.Provider
-	_, err = d.Where(d.ID.Eq(dnsCredential.ID)).Updates(&model2.DnsCredential{
+	_, err = d.Where(d.ID.Eq(dnsCredential.ID)).Updates(&model.DnsCredential{
 		Name:     json.Name,
 		Config:   &json.Config,
 		Provider: json.Provider,

+ 2 - 2
api/certificate/router.go

@@ -17,6 +17,6 @@ func InitCertificateRouter(r *gin.RouterGroup) {
 	r.POST("cert", AddCert)
 	r.POST("cert/:id", ModifyCert)
 	r.DELETE("cert/:id", RemoveCert)
-	r.GET("auto_cert/dns/providers", GetDNSProvidersList)
-	r.GET("auto_cert/dns/provider/:code", GetDNSProvider)
+	r.GET("certificate/dns_providers", GetDNSProvidersList)
+	r.GET("certificate/dns_provider/:code", GetDNSProvider)
 }

+ 56 - 0
api/config/add.go

@@ -0,0 +1,56 @@
+package config
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/gin-gonic/gin"
+	"net/http"
+	"os"
+)
+
+func AddConfig(c *gin.Context) {
+	var request struct {
+		Name    string `json:"name" binding:"required"`
+		Content string `json:"content" binding:"required"`
+	}
+
+	err := c.BindJSON(&request)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	name := request.Name
+	content := request.Content
+
+	path := nginx.GetConfPath("/", name)
+
+	if _, err = os.Stat(path); err == nil {
+		c.JSON(http.StatusNotAcceptable, gin.H{
+			"message": "config exist",
+		})
+		return
+	}
+
+	if content != "" {
+		err = os.WriteFile(path, []byte(content), 0644)
+		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, config.Config{
+		Name:    name,
+		Content: content,
+	})
+}

+ 0 - 206
api/config/config.go

@@ -1,206 +0,0 @@
-package config
-
-import (
-	"github.com/0xJacky/Nginx-UI/api"
-	"github.com/0xJacky/Nginx-UI/internal/config_list"
-	"github.com/0xJacky/Nginx-UI/internal/logger"
-	nginx2 "github.com/0xJacky/Nginx-UI/internal/nginx"
-	"github.com/0xJacky/Nginx-UI/query"
-	"github.com/gin-gonic/gin"
-	"github.com/sashabaranov/go-openai"
-	"net/http"
-	"os"
-)
-
-func GetConfigs(c *gin.Context) {
-	orderBy := c.Query("order_by")
-	sort := c.DefaultQuery("sort", "desc")
-	dir := c.DefaultQuery("dir", "/")
-
-	mySort := map[string]string{
-		"name":   "string",
-		"modify": "time",
-		"is_dir": "bool",
-	}
-
-	configFiles, err := os.ReadDir(nginx2.GetConfPath(dir))
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	var configs []gin.H
-
-	for i := range configFiles {
-		file := configFiles[i]
-		fileInfo, _ := file.Info()
-
-		switch mode := fileInfo.Mode(); {
-		case mode.IsRegular(): // regular file, not a hidden file
-			if "." == file.Name()[0:1] {
-				continue
-			}
-		case mode&os.ModeSymlink != 0: // is a symbol
-			var targetPath string
-			targetPath, err = os.Readlink(nginx2.GetConfPath(file.Name()))
-			if err != nil {
-				logger.Error("Read Symlink Error", targetPath, err)
-				continue
-			}
-
-			var targetInfo os.FileInfo
-			targetInfo, err = os.Stat(targetPath)
-			if err != nil {
-				logger.Error("Stat Error", targetPath, err)
-				continue
-			}
-			// but target file is not a dir
-			if targetInfo.IsDir() {
-				continue
-			}
-		}
-
-		configs = append(configs, gin.H{
-			"name":   file.Name(),
-			"size":   fileInfo.Size(),
-			"modify": fileInfo.ModTime(),
-			"is_dir": file.IsDir(),
-		})
-	}
-
-	configs = config_list.Sort(orderBy, sort, mySort[orderBy], configs)
-
-	c.JSON(http.StatusOK, gin.H{
-		"data": configs,
-	})
-}
-
-func GetConfig(c *gin.Context) {
-	name := c.Param("name")
-	path := nginx2.GetConfPath("/", name)
-
-	stat, err := os.Stat(path)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	content, err := os.ReadFile(path)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	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)
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"config":           string(content),
-		"chatgpt_messages": chatgpt.Content,
-		"file_path":        path,
-		"modified_at":      stat.ModTime(),
-	})
-
-}
-
-type AddConfigJson struct {
-	Name    string `json:"name" binding:"required"`
-	Content string `json:"content" binding:"required"`
-}
-
-func AddConfig(c *gin.Context) {
-	var request AddConfigJson
-	err := c.BindJSON(&request)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	name := request.Name
-	content := request.Content
-
-	path := nginx2.GetConfPath("/", name)
-
-	if _, err = os.Stat(path); err == nil {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "config exist",
-		})
-		return
-	}
-
-	if content != "" {
-		err = os.WriteFile(path, []byte(content), 0644)
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
-	}
-
-	output := nginx2.Reload()
-	if nginx2.GetLogLevel(output) >= nginx2.Warn {
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"name":    name,
-		"content": content,
-	})
-
-}
-
-type EditConfigJson struct {
-	Content string `json:"content" binding:"required"`
-}
-
-func EditConfig(c *gin.Context) {
-	name := c.Param("name")
-	var request EditConfigJson
-	err := c.BindJSON(&request)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-	path := nginx2.GetConfPath("/", name)
-	content := request.Content
-
-	origContent, err := os.ReadFile(path)
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	if content != "" && content != string(origContent) {
-		// model.CreateBackup(path)
-		err = os.WriteFile(path, []byte(content), 0644)
-		if err != nil {
-			api.ErrHandler(c, err)
-			return
-		}
-	}
-
-	output := nginx2.Reload()
-
-	if nginx2.GetLogLevel(output) >= nginx2.Warn {
-		c.JSON(http.StatusInternalServerError, gin.H{
-			"message": output,
-		})
-		return
-	}
-
-	GetConfig(c)
-}

+ 51 - 0
api/config/get.go

@@ -0,0 +1,51 @@
+package config
+
+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/query"
+	"github.com/gin-gonic/gin"
+	"github.com/sashabaranov/go-openai"
+	"net/http"
+	"os"
+)
+
+func GetConfig(c *gin.Context) {
+	name := c.Param("name")
+	path := nginx.GetConfPath("/", name)
+
+	stat, err := os.Stat(path)
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	content, err := os.ReadFile(path)
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	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)
+	}
+
+	c.JSON(http.StatusOK, config.Config{
+		Name:            name,
+		Content:         string(content),
+		ChatGPTMessages: chatgpt.Content,
+		FilePath:        path,
+		ModifiedAt:      stat.ModTime(),
+	})
+}

+ 69 - 0
api/config/list.go

@@ -0,0 +1,69 @@
+package config
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/config"
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/gin-gonic/gin"
+	"net/http"
+	"os"
+)
+
+func GetConfigs(c *gin.Context) {
+	orderBy := c.Query("order_by")
+	sort := c.DefaultQuery("sort", "desc")
+	dir := c.DefaultQuery("dir", "/")
+
+	configFiles, err := os.ReadDir(nginx.GetConfPath(dir))
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	var configs []config.Config
+
+	for i := range configFiles {
+		file := configFiles[i]
+		fileInfo, _ := file.Info()
+
+		switch mode := fileInfo.Mode(); {
+		case mode.IsRegular(): // regular file, not a hidden file
+			if "." == file.Name()[0:1] {
+				continue
+			}
+		case mode&os.ModeSymlink != 0: // is a symbol
+			var targetPath string
+			targetPath, err = os.Readlink(nginx.GetConfPath(file.Name()))
+			if err != nil {
+				logger.Error("Read Symlink Error", targetPath, err)
+				continue
+			}
+
+			var targetInfo os.FileInfo
+			targetInfo, err = os.Stat(targetPath)
+			if err != nil {
+				logger.Error("Stat Error", targetPath, err)
+				continue
+			}
+			// but target file is not a dir
+			if targetInfo.IsDir() {
+				continue
+			}
+		}
+
+		configs = append(configs, config.Config{
+			Name:       file.Name(),
+			ModifiedAt: fileInfo.ModTime(),
+			Size:       fileInfo.Size(),
+			IsDir:      fileInfo.IsDir(),
+		})
+	}
+
+	configs = config.Sort(orderBy, sort, configs)
+
+	c.JSON(http.StatusOK, gin.H{
+		"data": configs,
+	})
+}

+ 51 - 0
api/config/modify.go

@@ -0,0 +1,51 @@
+package config
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/gin-gonic/gin"
+	"net/http"
+	"os"
+)
+
+type EditConfigJson struct {
+	Content string `json:"content" binding:"required"`
+}
+
+func EditConfig(c *gin.Context) {
+	name := c.Param("name")
+	var request EditConfigJson
+	err := c.BindJSON(&request)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	path := nginx.GetConfPath("/", name)
+	content := request.Content
+
+	origContent, err := os.ReadFile(path)
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	if content != "" && content != string(origContent) {
+		// model.CreateBackup(path)
+		err = os.WriteFile(path, []byte(content), 0644)
+		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
+	}
+
+	GetConfig(c)
+}

+ 134 - 0
api/cosy/cosy.go

@@ -0,0 +1,134 @@
+package cosy
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/gin-gonic/gin"
+	"github.com/go-playground/validator/v10"
+	"gorm.io/gorm"
+)
+
+var validate *validator.Validate
+
+func init() {
+	validate = validator.New()
+}
+
+type Ctx[T any] struct {
+	ctx                   *gin.Context
+	rules                 gin.H
+	Payload               map[string]interface{}
+	Model                 T
+	abort                 bool
+	nextHandler           *gin.HandlerFunc
+	beforeDecodeHookFunc  []func(ctx *Ctx[T])
+	beforeExecuteHookFunc []func(ctx *Ctx[T])
+	executedHookFunc      []func(ctx *Ctx[T])
+	gormScopes            []func(tx *gorm.DB) *gorm.DB
+	preloads              []string
+	scan                  func(tx *gorm.DB) any
+	transformer           func(*T) any
+	SelectedFields        []string
+}
+
+func Core[T any](c *gin.Context) *Ctx[T] {
+	return &Ctx[T]{
+		ctx:                   c,
+		gormScopes:            make([]func(tx *gorm.DB) *gorm.DB, 0),
+		beforeExecuteHookFunc: make([]func(ctx *Ctx[T]), 0),
+		beforeDecodeHookFunc:  make([]func(ctx *Ctx[T]), 0),
+	}
+}
+
+func (c *Ctx[T]) SetValidRules(rules gin.H) *Ctx[T] {
+	c.rules = rules
+
+	return c
+}
+
+func (c *Ctx[T]) BeforeDecodeHook(hook ...func(ctx *Ctx[T])) *Ctx[T] {
+	c.beforeDecodeHookFunc = append(c.beforeDecodeHookFunc, hook...)
+	return c
+}
+
+func (c *Ctx[T]) BeforeExecuteHook(hook ...func(ctx *Ctx[T])) *Ctx[T] {
+	c.beforeExecuteHookFunc = append(c.beforeExecuteHookFunc, hook...)
+	return c
+}
+
+func (c *Ctx[T]) ExecutedHook(hook ...func(ctx *Ctx[T])) *Ctx[T] {
+	c.executedHookFunc = append(c.executedHookFunc, hook...)
+	return c
+}
+
+func (c *Ctx[T]) SetPreloads(args ...string) *Ctx[T] {
+	c.preloads = append(c.preloads, args...)
+	return c
+}
+
+func (c *Ctx[T]) beforeExecuteHook() {
+	if len(c.beforeExecuteHookFunc) > 0 {
+		for _, v := range c.beforeExecuteHookFunc {
+			v(c)
+		}
+	}
+}
+
+func (c *Ctx[T]) beforeDecodeHook() {
+	if len(c.beforeDecodeHookFunc) > 0 {
+		for _, v := range c.beforeDecodeHookFunc {
+			v(c)
+		}
+	}
+}
+
+func (c *Ctx[T]) validate() (errs gin.H) {
+	c.Payload = make(gin.H)
+
+	_ = c.ctx.ShouldBindJSON(&c.Payload)
+
+	errs = validate.ValidateMap(c.Payload, c.rules)
+
+	if len(errs) > 0 {
+		logger.Debug(errs)
+		for k := range errs {
+			errs[k] = c.rules[k]
+		}
+		return
+	}
+	// Make sure that the key in c.Payload is also the key of rules
+	validated := make(map[string]interface{})
+
+	for k, v := range c.Payload {
+		if _, ok := c.rules[k]; ok {
+			validated[k] = v
+		}
+	}
+
+	c.Payload = validated
+
+	return
+}
+
+func (c *Ctx[T]) SetScan(scan func(tx *gorm.DB) any) *Ctx[T] {
+	c.scan = scan
+	return c
+}
+
+func (c *Ctx[T]) SetTransformer(t func(m *T) any) *Ctx[T] {
+	c.transformer = t
+	return c
+}
+
+func (c *Ctx[T]) AbortWithError(err error) {
+	c.abort = true
+	errHandler(c.ctx, err)
+}
+
+func (c *Ctx[T]) Abort() {
+	c.abort = true
+}
+
+func (c *Ctx[T]) GormScope(hook func(tx *gorm.DB) *gorm.DB) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, hook)
+	return c
+}

+ 72 - 0
api/cosy/create.go

@@ -0,0 +1,72 @@
+package cosy
+
+import (
+	"github.com/0xJacky/Nginx-UI/api/cosy/map2struct"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm/clause"
+	"net/http"
+)
+
+func (c *Ctx[T]) Create() {
+
+	errs := c.validate()
+
+	if len(errs) > 0 {
+		c.ctx.JSON(http.StatusNotAcceptable, gin.H{
+			"message": "Requested with wrong parameters",
+			"errors":  errs,
+		})
+		return
+	}
+
+	db := model.UseDB()
+
+	c.beforeDecodeHook()
+
+	if c.abort {
+		return
+	}
+
+	err := map2struct.WeakDecode(c.Payload, &c.Model)
+
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	c.beforeExecuteHook()
+
+	if c.abort {
+		return
+	}
+
+	// skip all associations
+	err = db.Omit(clause.Associations).Create(&c.Model).Error
+
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	tx := db.Preload(clause.Associations)
+	for _, v := range c.preloads {
+		tx = tx.Preload(v)
+	}
+	tx.First(&c.Model)
+
+	if len(c.executedHookFunc) > 0 {
+		for _, v := range c.executedHookFunc {
+			v(c)
+
+			if c.abort {
+				return
+			}
+		}
+	}
+	if c.nextHandler != nil {
+		(*c.nextHandler)(c.ctx)
+	} else {
+		c.ctx.JSON(http.StatusOK, c.Model)
+	}
+}

+ 39 - 0
api/cosy/custom.go

@@ -0,0 +1,39 @@
+package cosy
+
+import (
+	"github.com/0xJacky/Nginx-UI/api/cosy/map2struct"
+	"github.com/gin-gonic/gin"
+	"net/http"
+)
+
+func (c *Ctx[T]) Custom(fx func(ctx *Ctx[T])) {
+	if c.abort {
+		return
+	}
+	errs := c.validate()
+
+	if len(errs) > 0 {
+		c.ctx.JSON(http.StatusNotAcceptable, gin.H{
+			"message": "Requested with wrong parameters",
+			"errors":  errs,
+		})
+		return
+	}
+
+	c.beforeDecodeHook()
+
+	for k := range c.Payload {
+		c.SelectedFields = append(c.SelectedFields, k)
+	}
+
+	err := map2struct.WeakDecode(c.Payload, &c.Model)
+
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	c.beforeExecuteHook()
+
+	fx(c)
+}

+ 91 - 0
api/cosy/delete.go

@@ -0,0 +1,91 @@
+package cosy
+
+import (
+	"github.com/0xJacky/Nginx-UI/model"
+	"gorm.io/gorm"
+	"net/http"
+)
+
+func (c *Ctx[T]) Destroy() {
+	if c.abort {
+		return
+	}
+	id := c.ctx.Param("id")
+
+	c.beforeExecuteHook()
+
+	db := model.UseDB()
+	var dbModel T
+
+	result := db
+	if len(c.gormScopes) > 0 {
+		result = result.Scopes(c.gormScopes...)
+	}
+
+	err := result.Session(&gorm.Session{}).First(&dbModel, id).Error
+
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	err = result.Delete(&dbModel).Error
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	if len(c.executedHookFunc) > 0 {
+		for _, v := range c.executedHookFunc {
+			v(c)
+
+			if c.abort {
+				return
+			}
+		}
+	}
+
+	c.ctx.JSON(http.StatusNoContent, nil)
+}
+
+func (c *Ctx[T]) Recover() {
+	if c.abort {
+		return
+	}
+	id := c.ctx.Param("id")
+
+	c.beforeExecuteHook()
+
+	db := model.UseDB()
+	var dbModel T
+
+	result := db.Unscoped()
+	if len(c.gormScopes) > 0 {
+		result = result.Scopes(c.gormScopes...)
+	}
+
+	err := result.Session(&gorm.Session{}).First(&dbModel, id).Error
+
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	err = result.Model(&dbModel).Update("deleted_at", nil).Error
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	if len(c.executedHookFunc) > 0 {
+		for _, v := range c.executedHookFunc {
+			v(c)
+
+			if c.abort {
+				return
+			}
+		}
+	}
+
+	c.ctx.JSON(http.StatusNoContent, nil)
+}

+ 23 - 0
api/cosy/error.go

@@ -0,0 +1,23 @@
+package cosy
+
+import (
+	"errors"
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/gin-gonic/gin"
+	"go.uber.org/zap"
+	"gorm.io/gorm"
+	"net/http"
+)
+
+func errHandler(c *gin.Context, err error) {
+	logger.GetLogger().WithOptions(zap.AddCallerSkip(1)).Errorln(err)
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		c.JSON(http.StatusNotFound, gin.H{
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusInternalServerError, gin.H{
+		"message": err.Error(),
+	})
+}

+ 158 - 0
api/cosy/list.go

@@ -0,0 +1,158 @@
+package cosy
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/logger"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/settings"
+	"github.com/spf13/cast"
+	"gorm.io/gorm"
+	"net/http"
+)
+
+func (c *Ctx[T]) SetFussy(keys ...string) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, func(tx *gorm.DB) *gorm.DB {
+		return model.QueryToFussySearch(c.ctx, tx, keys...)
+	})
+	return c
+}
+
+func (c *Ctx[T]) SetFussyKeys(value string, keys ...string) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, func(tx *gorm.DB) *gorm.DB {
+		return model.QueryToFussyKeysSearch(c.ctx, tx, value, keys...)
+	})
+	return c
+}
+
+func (c *Ctx[T]) SetEqual(keys ...string) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, func(tx *gorm.DB) *gorm.DB {
+		return model.QueryToEqualSearch(c.ctx, tx, keys...)
+	})
+	return c
+}
+
+func (c *Ctx[T]) SetIn(keys ...string) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, func(tx *gorm.DB) *gorm.DB {
+		return model.QueryToInSearch(c.ctx, tx, keys...)
+	})
+	return c
+}
+
+func (c *Ctx[T]) SetOrFussy(keys ...string) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, func(tx *gorm.DB) *gorm.DB {
+		return model.QueryToOrFussySearch(c.ctx, tx, keys...)
+	})
+	return c
+}
+
+func (c *Ctx[T]) SetOrEqual(keys ...string) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, func(tx *gorm.DB) *gorm.DB {
+		return model.QueryToOrEqualSearch(c.ctx, tx, keys...)
+	})
+	return c
+}
+
+func (c *Ctx[T]) SetOrIn(keys ...string) *Ctx[T] {
+	c.gormScopes = append(c.gormScopes, func(tx *gorm.DB) *gorm.DB {
+		return model.QueryToOrInSearch(c.ctx, tx, keys...)
+	})
+	return c
+}
+
+func (c *Ctx[T]) result() (*gorm.DB, bool) {
+	for _, v := range c.preloads {
+		t := v
+		c.GormScope(func(tx *gorm.DB) *gorm.DB {
+			tx = tx.Preload(t)
+			return tx
+		})
+	}
+
+	c.beforeExecuteHook()
+
+	var dbModel T
+	result := model.UseDB()
+
+	if c.ctx.Query("trash") == "true" {
+		stmt := &gorm.Statement{DB: model.UseDB()}
+		err := stmt.Parse(&dbModel)
+		if err != nil {
+			logger.Error(err)
+			return nil, false
+		}
+		result = result.Unscoped().Where(stmt.Schema.Table + ".deleted_at IS NOT NULL")
+	}
+
+	result = result.Model(&dbModel)
+
+	if len(c.gormScopes) > 0 {
+		result = result.Scopes(c.gormScopes...)
+	}
+
+	return result, true
+}
+
+func (c *Ctx[T]) ListAllData() ([]*T, bool) {
+	result, ok := c.result()
+	if !ok {
+		return nil, false
+	}
+
+	result = result.Scopes(model.SortOrder(c.ctx))
+	models := make([]*T, 0)
+	result.Find(&models)
+	return models, true
+}
+
+func (c *Ctx[T]) PagingListData() (*model.DataList, bool) {
+	result, ok := c.result()
+	if !ok {
+		return nil, false
+	}
+
+	result = result.Scopes(model.OrderAndPaginate(c.ctx))
+	data := &model.DataList{}
+	if c.scan == nil {
+		models := make([]*T, 0)
+		result.Find(&models)
+
+		if c.transformer != nil {
+			transformed := make([]any, 0)
+			for k := range models {
+				transformed = append(transformed, c.transformer(models[k]))
+			}
+			data.Data = transformed
+		} else {
+			data.Data = models
+		}
+	} else {
+		data.Data = c.scan(result)
+	}
+
+	page := cast.ToInt(c.ctx.Query("page"))
+	if page == 0 {
+		page = 1
+	}
+
+	pageSize := settings.AppSettings.PageSize
+	if reqPageSize := c.ctx.Query("page_size"); reqPageSize != "" {
+		pageSize = cast.ToInt(reqPageSize)
+	}
+
+	var totalRecords int64
+	result.Session(&gorm.Session{}).Count(&totalRecords)
+
+	data.Pagination = model.Pagination{
+		Total:       totalRecords,
+		PerPage:     pageSize,
+		CurrentPage: page,
+		TotalPages:  model.TotalPage(totalRecords, pageSize),
+	}
+	return data, true
+}
+
+func (c *Ctx[T]) PagingList() {
+	data, ok := c.PagingListData()
+	if ok {
+		c.ctx.JSON(http.StatusOK, data)
+	}
+}

+ 56 - 0
api/cosy/map2struct/hook.go

@@ -0,0 +1,56 @@
+package map2struct
+
+import (
+	"github.com/mitchellh/mapstructure"
+	"github.com/shopspring/decimal"
+	"github.com/spf13/cast"
+	"reflect"
+	"time"
+)
+
+var timeLocation *time.Location
+
+func init() {
+	timeLocation = time.Local
+}
+
+func ToTimeHookFunc() mapstructure.DecodeHookFunc {
+	return func(
+		f reflect.Type,
+		t reflect.Type,
+		data interface{}) (interface{}, error) {
+		if t != reflect.TypeOf(time.Time{}) {
+			return data, nil
+		}
+
+		switch f.Kind() {
+		case reflect.String:
+			return cast.ToTimeInDefaultLocationE(data, timeLocation)
+		case reflect.Float64:
+			return time.Unix(0, int64(data.(float64))*int64(time.Millisecond)), nil
+		case reflect.Int64:
+			return time.Unix(0, data.(int64)*int64(time.Millisecond)), nil
+		default:
+			return data, nil
+		}
+		// Convert it by parsing
+	}
+}
+
+func ToDecimalHookFunc() mapstructure.DecodeHookFunc {
+	return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) {
+
+		if t == reflect.TypeOf(decimal.Decimal{}) {
+			if f.Kind() == reflect.Float64 {
+				return decimal.NewFromFloat(data.(float64)), nil
+			}
+
+			if input := data.(string); input != "" {
+				return decimal.NewFromString(data.(string))
+			}
+			return decimal.Decimal{}, nil
+		}
+
+		return data, nil
+	}
+}

+ 24 - 0
api/cosy/map2struct/map2struct.go

@@ -0,0 +1,24 @@
+package map2struct
+
+import (
+	"github.com/mitchellh/mapstructure"
+)
+
+func WeakDecode(input, output interface{}) error {
+	config := &mapstructure.DecoderConfig{
+		Metadata:         nil,
+		Result:           output,
+		WeaklyTypedInput: true,
+		DecodeHook: mapstructure.ComposeDecodeHookFunc(
+			ToDecimalHookFunc(), ToTimeHookFunc(),
+		),
+		TagName: "json",
+	}
+
+	decoder, err := mapstructure.NewDecoder(config)
+	if err != nil {
+		return err
+	}
+
+	return decoder.Decode(input)
+}

+ 42 - 0
api/cosy/order.go

@@ -0,0 +1,42 @@
+package cosy
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/model"
+	"gorm.io/gorm"
+	"net/http"
+)
+
+func (c *Ctx[T]) UpdateOrder() {
+	var json struct {
+		TargetID    int   `json:"target_id"`
+		Direction   int   `json:"direction" binding:"oneof=-1 1"`
+		AffectedIDs []int `json:"affected_ids"`
+	}
+
+	if !api.BindAndValid(c.ctx, &json) {
+		return
+	}
+
+	affectedLen := len(json.AffectedIDs)
+
+	db := model.UseDB()
+
+	// update target
+	err := db.Model(&c.Model).Where("id = ?", json.TargetID).Update("order_id", gorm.Expr("order_id + ?", affectedLen*(-json.Direction))).Error
+
+	if err != nil {
+		api.ErrHandler(c.ctx, err)
+		return
+	}
+
+	// update affected
+	err = db.Model(&c.Model).Where("id in ?", json.AffectedIDs).Update("order_id", gorm.Expr("order_id + ?", json.Direction)).Error
+
+	if err != nil {
+		api.ErrHandler(c.ctx, err)
+		return
+	}
+
+	c.ctx.JSON(http.StatusOK, json)
+}

+ 90 - 0
api/cosy/update.go

@@ -0,0 +1,90 @@
+package cosy
+
+import (
+	"github.com/0xJacky/Nginx-UI/api/cosy/map2struct"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+	"net/http"
+)
+
+func (c *Ctx[T]) SetNextHandler(handler gin.HandlerFunc) *Ctx[T] {
+	c.nextHandler = &handler
+	return c
+}
+
+func (c *Ctx[T]) Modify() {
+	if c.abort {
+		return
+	}
+	id := c.ctx.Param("id")
+	errs := c.validate()
+
+	if len(errs) > 0 {
+		c.ctx.JSON(http.StatusNotAcceptable, gin.H{
+			"message": "Requested with wrong parameters",
+			"errors":  errs,
+		})
+		return
+	}
+
+	db := model.UseDB()
+
+	result := db
+	if len(c.gormScopes) > 0 {
+		result = result.Scopes(c.gormScopes...)
+	}
+
+	err := result.Session(&gorm.Session{}).First(&c.Model, id).Error
+
+	if err != nil {
+		c.AbortWithError(err)
+		return
+	}
+
+	c.beforeDecodeHook()
+	if c.abort {
+		return
+	}
+
+	var selectedFields []string
+
+	for k := range c.Payload {
+		selectedFields = append(selectedFields, k)
+	}
+
+	err = map2struct.WeakDecode(c.Payload, &c.Model)
+
+	if err != nil {
+		errHandler(c.ctx, err)
+		return
+	}
+
+	c.beforeExecuteHook()
+	if c.abort {
+		return
+	}
+
+	err = db.Model(&c.Model).Select(selectedFields).Updates(&c.Model).Error
+
+	if err != nil {
+		c.AbortWithError(err)
+		return
+	}
+
+	if len(c.executedHookFunc) > 0 {
+		for _, v := range c.executedHookFunc {
+			v(c)
+
+			if c.abort {
+				return
+			}
+		}
+	}
+
+	if c.nextHandler != nil {
+		(*c.nextHandler)(c.ctx)
+	} else {
+		c.ctx.JSON(http.StatusOK, c.Model)
+	}
+}

+ 5 - 6
api/nginx/nginx.go

@@ -2,14 +2,14 @@ package nginx
 
 import (
 	"github.com/0xJacky/Nginx-UI/api"
-	nginx2 "github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/gin-gonic/gin"
 	"net/http"
 	"os"
 )
 
 func BuildNginxConfig(c *gin.Context) {
-	var ngxConf nginx2.NgxConfig
+	var ngxConf nginx.NgxConfig
 	if !api.BindAndValid(c, &ngxConf) {
 		return
 	}
@@ -29,7 +29,7 @@ func TokenizeNginxConfig(c *gin.Context) {
 	}
 
 	c.Set("maybe_error", "nginx_config_syntax_error")
-	ngxConfig := nginx2.ParseNgxConfigByContent(json.Content)
+	ngxConfig := nginx.ParseNgxConfigByContent(json.Content)
 
 	c.JSON(http.StatusOK, ngxConfig)
 
@@ -46,12 +46,12 @@ func FormatNginxConfig(c *gin.Context) {
 
 	c.Set("maybe_error", "nginx_config_syntax_error")
 	c.JSON(http.StatusOK, gin.H{
-		"content": nginx2.FmtCode(json.Content),
+		"content": nginx.FmtCode(json.Content),
 	})
 }
 
 func Status(c *gin.Context) {
-	pidPath := nginx2.GetNginxPIDPath()
+	pidPath := nginx.GetNginxPIDPath()
 
 	running := true
 	if fileInfo, err := os.Stat(pidPath); err != nil || fileInfo.Size() == 0 { // fileInfo.Size() == 0 no process id
@@ -62,4 +62,3 @@ func Status(c *gin.Context) {
 		"running": running,
 	})
 }
-

+ 5 - 40
api/openai/openai.go

@@ -2,10 +2,9 @@ package openai
 
 import (
 	"context"
+	"crypto/tls"
 	"fmt"
 	"github.com/0xJacky/Nginx-UI/api"
-	"github.com/0xJacky/Nginx-UI/model"
-	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
@@ -35,7 +34,7 @@ func MakeChatCompletionRequest(c *gin.Context) {
 	}
 	messages = append(messages, json.Messages...)
 	// sse server
-	c.Writer.Header().Set("Content-Type", "text/event-stream")
+	c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
 	c.Writer.Header().Set("Cache-Control", "no-cache")
 	c.Writer.Header().Set("Connection", "keep-alive")
 	c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
@@ -66,7 +65,8 @@ func MakeChatCompletionRequest(c *gin.Context) {
 			return
 		}
 		transport := &http.Transport{
-			Proxy: http.ProxyURL(proxyUrl),
+			Proxy:           http.ProxyURL(proxyUrl),
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
 		}
 		config.HTTPClient = &http.Client{
 			Transport: transport,
@@ -100,17 +100,16 @@ func MakeChatCompletionRequest(c *gin.Context) {
 	defer stream.Close()
 	msgChan := make(chan string)
 	go func() {
+		defer close(msgChan)
 		for {
 			response, err := stream.Recv()
 			if errors.Is(err, io.EOF) {
-				close(msgChan)
 				fmt.Println()
 				return
 			}
 
 			if err != nil {
 				fmt.Printf("Stream error: %v\n", err)
-				close(msgChan)
 				return
 			}
 
@@ -133,37 +132,3 @@ func MakeChatCompletionRequest(c *gin.Context) {
 		return false
 	})
 }
-
-func StoreChatGPTRecord(c *gin.Context) {
-	var json struct {
-		FileName string                         `json:"file_name"`
-		Messages []openai.ChatCompletionMessage `json:"messages"`
-	}
-
-	if !api.BindAndValid(c, &json) {
-		return
-	}
-
-	name := json.FileName
-	g := query.ChatGPTLog
-	_, err := g.Where(g.Name.Eq(name)).FirstOrCreate()
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	_, err = g.Where(g.Name.Eq(name)).Updates(&model.ChatGPTLog{
-		Name:    name,
-		Content: json.Messages,
-	})
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"message": "ok",
-	})
-}

+ 44 - 0
api/openai/store.go

@@ -0,0 +1,44 @@
+package openai
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/gin-gonic/gin"
+	"github.com/sashabaranov/go-openai"
+	"net/http"
+)
+
+func StoreChatGPTRecord(c *gin.Context) {
+	var json struct {
+		FileName string                         `json:"file_name"`
+		Messages []openai.ChatCompletionMessage `json:"messages"`
+	}
+
+	if !api.BindAndValid(c, &json) {
+		return
+	}
+
+	name := json.FileName
+	g := query.ChatGPTLog
+	_, err := g.Where(g.Name.Eq(name)).FirstOrCreate()
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	_, err = g.Where(g.Name.Eq(name)).Updates(&model.ChatGPTLog{
+		Name:    name,
+		Content: json.Messages,
+	})
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}

+ 42 - 0
api/sites/advance.go

@@ -0,0 +1,42 @@
+package sites
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/gin-gonic/gin"
+	"net/http"
+)
+
+func DomainEditByAdvancedMode(c *gin.Context) {
+	var json struct {
+		Advanced bool `json:"advanced"`
+	}
+
+	if !api.BindAndValid(c, &json) {
+		return
+	}
+
+	name := c.Param("name")
+	path := nginx.GetConfPath("sites-available", name)
+
+	s := query.Site
+
+	_, err := s.Where(s.Path.Eq(path)).FirstOrCreate()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+
+}

+ 64 - 0
api/sites/auto_cert.go

@@ -0,0 +1,64 @@
+package sites
+
+import (
+	"github.com/0xJacky/Nginx-UI/api"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/gin-gonic/gin"
+	"net/http"
+)
+
+func AddDomainToAutoCert(c *gin.Context) {
+	name := c.Param("name")
+
+	var json struct {
+		DnsCredentialID int      `json:"dns_credential_id"`
+		ChallengeMethod string   `json:"challenge_method"`
+		Domains         []string `json:"domains"`
+	}
+
+	if !api.BindAndValid(c, &json) {
+		return
+	}
+
+	certModel, err := model.FirstOrCreateCert(name)
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	err = certModel.Updates(&model.Cert{
+		Name:            name,
+		Domains:         json.Domains,
+		AutoCert:        model.AutoCertEnabled,
+		DnsCredentialID: json.DnsCredentialID,
+		ChallengeMethod: json.ChallengeMethod,
+	})
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, certModel)
+}
+
+func RemoveDomainFromAutoCert(c *gin.Context) {
+	name := c.Param("name")
+	certModel, err := model.FirstCert(name)
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	err = certModel.Updates(&model.Cert{
+		AutoCert: model.AutoCertDisabled,
+	})
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, nil)
+}

+ 70 - 206
api/sites/domain.go

@@ -1,20 +1,19 @@
 package sites
 
 import (
-	"github.com/0xJacky/Nginx-UI/api"
-	"github.com/0xJacky/Nginx-UI/internal/cert"
-	"github.com/0xJacky/Nginx-UI/internal/config_list"
-	helper2 "github.com/0xJacky/Nginx-UI/internal/helper"
-	"github.com/0xJacky/Nginx-UI/internal/logger"
-	nginx2 "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"
-	"net/http"
-	"os"
-	"strings"
-	"time"
+    "github.com/0xJacky/Nginx-UI/api"
+    "github.com/0xJacky/Nginx-UI/internal/cert"
+    "github.com/0xJacky/Nginx-UI/internal/config"
+    "github.com/0xJacky/Nginx-UI/internal/helper"
+    "github.com/0xJacky/Nginx-UI/internal/logger"
+    "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"
+    "net/http"
+    "os"
+    "strings"
 )
 
 func GetDomains(c *gin.Context) {
@@ -22,20 +21,14 @@ func GetDomains(c *gin.Context) {
 	orderBy := c.Query("order_by")
 	sort := c.DefaultQuery("sort", "desc")
 
-	mySort := map[string]string{
-		"enabled": "bool",
-		"name":    "string",
-		"modify":  "time",
-	}
-
-	configFiles, err := os.ReadDir(nginx2.GetConfPath("sites-available"))
+	configFiles, err := os.ReadDir(nginx.GetConfPath("sites-available"))
 
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
 	}
 
-	enabledConfig, err := os.ReadDir(nginx2.GetConfPath("sites-enabled"))
+	enabledConfig, err := os.ReadDir(nginx.GetConfPath("sites-enabled"))
 
 	if err != nil {
 		api.ErrHandler(c, err)
@@ -47,7 +40,7 @@ func GetDomains(c *gin.Context) {
 		enabledConfigMap[enabledConfig[i].Name()] = true
 	}
 
-	var configs []gin.H
+	var configs []config.Config
 
 	for i := range configFiles {
 		file := configFiles[i]
@@ -56,29 +49,23 @@ func GetDomains(c *gin.Context) {
 			if name != "" && !strings.Contains(file.Name(), name) {
 				continue
 			}
-			configs = append(configs, gin.H{
-				"name":    file.Name(),
-				"size":    fileInfo.Size(),
-				"modify":  fileInfo.ModTime(),
-				"enabled": enabledConfigMap[file.Name()],
+			configs = append(configs, config.Config{
+				Name:       file.Name(),
+				ModifiedAt: fileInfo.ModTime(),
+				Size:       fileInfo.Size(),
+				IsDir:      fileInfo.IsDir(),
+				Enabled:    enabledConfigMap[file.Name()],
 			})
 		}
 	}
 
-	configs = config_list.Sort(orderBy, sort, mySort[orderBy], configs)
+	configs = config.Sort(orderBy, sort, configs)
 
 	c.JSON(http.StatusOK, gin.H{
 		"data": configs,
 	})
 }
 
-type CertificateInfo struct {
-	SubjectName string    `json:"subject_name"`
-	IssuerName  string    `json:"issuer_name"`
-	NotAfter    time.Time `json:"not_after"`
-	NotBefore   time.Time `json:"not_before"`
-}
-
 func GetDomain(c *gin.Context) {
 	rewriteName, ok := c.Get("rewriteConfigFileName")
 
@@ -89,7 +76,7 @@ func GetDomain(c *gin.Context) {
 		name = rewriteName.(string)
 	}
 
-	path := nginx2.GetConfPath("sites-available", name)
+	path := nginx.GetConfPath("sites-available", name)
 	file, err := os.Stat(path)
 	if os.IsNotExist(err) {
 		c.JSON(http.StatusNotFound, gin.H{
@@ -100,7 +87,7 @@ func GetDomain(c *gin.Context) {
 
 	enabled := true
 
-	if _, err := os.Stat(nginx2.GetConfPath("sites-enabled", name)); os.IsNotExist(err) {
+	if _, err := os.Stat(nginx.GetConfPath("sites-enabled", name)); os.IsNotExist(err) {
 		enabled = false
 	}
 
@@ -127,7 +114,7 @@ func GetDomain(c *gin.Context) {
 	certModel, err := model.FirstCert(name)
 
 	if err != nil {
-		logger.Warn("cert", err)
+		logger.Warn(err)
 	}
 
 	if site.Advanced {
@@ -137,20 +124,20 @@ func GetDomain(c *gin.Context) {
 			return
 		}
 
-		c.JSON(http.StatusOK, gin.H{
-			"modified_at":      file.ModTime(),
-			"advanced":         site.Advanced,
-			"enabled":          enabled,
-			"name":             name,
-			"config":           string(origContent),
-			"auto_cert":        certModel.AutoCert == model.AutoCertEnabled,
-			"chatgpt_messages": chatgpt.Content,
+		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,
 		})
 		return
 	}
 
 	c.Set("maybe_error", "nginx_config_syntax_error")
-	config, err := nginx2.ParseNgxConfig(path)
+	nginxConfig, err := nginx.ParseNgxConfig(path)
 
 	if err != nil {
 		api.ErrHandler(c, err)
@@ -160,7 +147,7 @@ func GetDomain(c *gin.Context) {
 	c.Set("maybe_error", "")
 
 	certInfoMap := make(map[int]CertificateInfo)
-	for serverIdx, server := range config.Servers {
+	for serverIdx, server := range nginxConfig.Servers {
 		for _, directive := range server.Directives {
 			if directive.Directive == "ssl_certificate" {
 
@@ -185,18 +172,17 @@ func GetDomain(c *gin.Context) {
 
 	c.Set("maybe_error", "nginx_config_syntax_error")
 
-	c.JSON(http.StatusOK, gin.H{
-		"modified_at":      file.ModTime(),
-		"advanced":         site.Advanced,
-		"enabled":          enabled,
-		"name":             name,
-		"config":           config.FmtCode(),
-		"tokenized":        config,
-		"auto_cert":        certModel.AutoCert == model.AutoCertEnabled,
-		"cert_info":        certInfoMap,
-		"chatgpt_messages": chatgpt.Content,
+	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,
 	})
-
 }
 
 func SaveDomain(c *gin.Context) {
@@ -219,9 +205,9 @@ func SaveDomain(c *gin.Context) {
 		return
 	}
 
-	path := nginx2.GetConfPath("sites-available", name)
+	path := nginx.GetConfPath("sites-available", name)
 
-	if !json.Overwrite && helper2.FileExists(path) {
+	if !json.Overwrite && helper.FileExists(path) {
 		c.JSON(http.StatusNotAcceptable, gin.H{
 			"message": "File exists",
 		})
@@ -233,24 +219,24 @@ func SaveDomain(c *gin.Context) {
 		api.ErrHandler(c, err)
 		return
 	}
-	enabledConfigFilePath := nginx2.GetConfPath("sites-enabled", name)
+	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
 	// rename the config file if needed
 	if name != json.Name {
-		newPath := nginx2.GetConfPath("sites-available", 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 helper2.FileExists(newPath) {
+		if helper.FileExists(newPath) {
 			c.JSON(http.StatusNotAcceptable, gin.H{
 				"message": "File exists",
 			})
 			return
 		}
 		// recreate soft link
-		if helper2.FileExists(enabledConfigFilePath) {
+		if helper.FileExists(enabledConfigFilePath) {
 			_ = os.Remove(enabledConfigFilePath)
-			enabledConfigFilePath = nginx2.GetConfPath("sites-enabled", json.Name)
+			enabledConfigFilePath = nginx.GetConfPath("sites-enabled", json.Name)
 			err = os.Symlink(newPath, enabledConfigFilePath)
 
 			if err != nil {
@@ -269,12 +255,12 @@ func SaveDomain(c *gin.Context) {
 		c.Set("rewriteConfigFileName", name)
 	}
 
-	enabledConfigFilePath = nginx2.GetConfPath("sites-enabled", name)
-	if helper2.FileExists(enabledConfigFilePath) {
+	enabledConfigFilePath = nginx.GetConfPath("sites-enabled", name)
+	if helper.FileExists(enabledConfigFilePath) {
 		// Test nginx configuration
-		output := nginx2.TestConf()
+		output := nginx.TestConf()
 
-		if nginx2.GetLogLevel(output) > nginx2.Warn {
+		if nginx.GetLogLevel(output) > nginx.Warn {
 			c.JSON(http.StatusInternalServerError, gin.H{
 				"message": output,
 				"error":   "nginx_config_syntax_error",
@@ -282,9 +268,9 @@ func SaveDomain(c *gin.Context) {
 			return
 		}
 
-		output = nginx2.Reload()
+		output = nginx.Reload()
 
-		if nginx2.GetLogLevel(output) > nginx2.Warn {
+		if nginx.GetLogLevel(output) > nginx.Warn {
 			c.JSON(http.StatusInternalServerError, gin.H{
 				"message": output,
 			})
@@ -296,8 +282,8 @@ func SaveDomain(c *gin.Context) {
 }
 
 func EnableDomain(c *gin.Context) {
-	configFilePath := nginx2.GetConfPath("sites-available", c.Param("name"))
-	enabledConfigFilePath := nginx2.GetConfPath("sites-enabled", c.Param("name"))
+	configFilePath := nginx.GetConfPath("sites-available", c.Param("name"))
+	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", c.Param("name"))
 
 	_, err := os.Stat(configFilePath)
 
@@ -316,9 +302,9 @@ func EnableDomain(c *gin.Context) {
 	}
 
 	// Test nginx config, if not pass then disable the site.
-	output := nginx2.TestConf()
+	output := nginx.TestConf()
 
-	if nginx2.GetLogLevel(output) > nginx2.Warn {
+	if nginx.GetLogLevel(output) > nginx.Warn {
 		_ = os.Remove(enabledConfigFilePath)
 		c.JSON(http.StatusInternalServerError, gin.H{
 			"message": output,
@@ -326,9 +312,9 @@ func EnableDomain(c *gin.Context) {
 		return
 	}
 
-	output = nginx2.Reload()
+	output = nginx.Reload()
 
-	if nginx2.GetLogLevel(output) > nginx2.Warn {
+	if nginx.GetLogLevel(output) > nginx.Warn {
 		c.JSON(http.StatusInternalServerError, gin.H{
 			"message": output,
 		})
@@ -341,7 +327,7 @@ func EnableDomain(c *gin.Context) {
 }
 
 func DisableDomain(c *gin.Context) {
-	enabledConfigFilePath := nginx2.GetConfPath("sites-enabled", c.Param("name"))
+	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", c.Param("name"))
 
 	_, err := os.Stat(enabledConfigFilePath)
 
@@ -365,9 +351,9 @@ func DisableDomain(c *gin.Context) {
 		return
 	}
 
-	output := nginx2.Reload()
+	output := nginx.Reload()
 
-	if nginx2.GetLogLevel(output) > nginx2.Warn {
+	if nginx.GetLogLevel(output) > nginx.Warn {
 		c.JSON(http.StatusInternalServerError, gin.H{
 			"message": output,
 		})
@@ -382,8 +368,8 @@ func DisableDomain(c *gin.Context) {
 func DeleteDomain(c *gin.Context) {
 	var err error
 	name := c.Param("name")
-	availablePath := nginx2.GetConfPath("sites-available", name)
-	enabledPath := nginx2.GetConfPath("sites-enabled", 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{
@@ -412,126 +398,4 @@ func DeleteDomain(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{
 		"message": "ok",
 	})
-
-}
-
-func AddDomainToAutoCert(c *gin.Context) {
-	name := c.Param("name")
-
-	var json struct {
-		model.Cert
-		Domains []string `json:"domains"`
-	}
-
-	if !api.BindAndValid(c, &json) {
-		return
-	}
-
-	certModel, err := model.FirstOrCreateCert(name)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	err = certModel.Updates(&model.Cert{
-		Name:            name,
-		Domains:         json.Domains,
-		AutoCert:        model.AutoCertEnabled,
-		DnsCredentialID: json.DnsCredentialID,
-		ChallengeMethod: json.ChallengeMethod,
-	})
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	c.JSON(http.StatusOK, certModel)
-}
-
-func RemoveDomainFromAutoCert(c *gin.Context) {
-	name := c.Param("name")
-	certModel, err := model.FirstCert(name)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	err = certModel.Updates(&model.Cert{
-		AutoCert: model.AutoCertDisabled,
-	})
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-	c.JSON(http.StatusOK, nil)
-}
-
-func DuplicateSite(c *gin.Context) {
-	name := c.Param("name")
-
-	var json struct {
-		Name string `json:"name" binding:"required"`
-	}
-
-	if !api.BindAndValid(c, &json) {
-		return
-	}
-
-	src := nginx2.GetConfPath("sites-available", name)
-	dst := nginx2.GetConfPath("sites-available", json.Name)
-
-	if helper2.FileExists(dst) {
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"message": "File exists",
-		})
-		return
-	}
-
-	_, err := helper2.CopyFile(src, dst)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"dst": dst,
-	})
-}
-
-func DomainEditByAdvancedMode(c *gin.Context) {
-	var json struct {
-		Advanced bool `json:"advanced"`
-	}
-
-	if !api.BindAndValid(c, &json) {
-		return
-	}
-
-	name := c.Param("name")
-	path := nginx2.GetConfPath("sites-available", name)
-
-	s := query.Site
-
-	_, err := s.Where(s.Path.Eq(path)).FirstOrCreate()
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	_, err = s.Where(s.Path.Eq(path)).Update(s.Advanced, json.Advanced)
-
-	if err != nil {
-		api.ErrHandler(c, err)
-		return
-	}
-
-	c.JSON(http.StatusOK, gin.H{
-		"message": "ok",
-	})
-
 }

+ 44 - 0
api/sites/duplicate.go

@@ -0,0 +1,44 @@
+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/gin-gonic/gin"
+	"net/http"
+)
+
+func DuplicateSite(c *gin.Context) {
+	// Source name
+	name := c.Param("name")
+
+	// Destination name
+	var json struct {
+		Name string `json:"name" binding:"required"`
+	}
+
+	if !api.BindAndValid(c, &json) {
+		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)
+
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"dst": dst,
+	})
+}

+ 26 - 0
api/sites/sites.go

@@ -0,0 +1,26 @@
+package sites
+
+import (
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/sashabaranov/go-openai"
+	"time"
+)
+
+type CertificateInfo struct {
+	SubjectName string    `json:"subject_name"`
+	IssuerName  string    `json:"issuer_name"`
+	NotAfter    time.Time `json:"not_after"`
+	NotBefore   time.Time `json:"not_before"`
+}
+
+type Site struct {
+	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"`
+	Tokenized       *nginx.NgxConfig               `json:"tokenized,omitempty"`
+	CertInfo        map[int]CertificateInfo        `json:"cert_info,omitempty"`
+}

+ 1 - 1
api/template/template.go

@@ -76,7 +76,7 @@ func GetTemplateBlock(c *gin.Context) {
 		template.ConfigInfoItem
 		template.ConfigDetail
 	}
-	var bindData map[string]template.TVariable
+	var bindData map[string]template.Variable
 	_ = c.ShouldBindJSON(&bindData)
 	info := template.GetTemplateInfo("block", c.Param("name"))
 

+ 258 - 0
app/.eslintrc.js

@@ -0,0 +1,258 @@
+module.exports = {
+  env: {
+    browser: true,
+    es2021: true,
+  },
+  extends: [
+    '@antfu/eslint-config-vue',
+    'plugin:vue/vue3-recommended',
+    'plugin:import/recommended',
+    'plugin:import/typescript',
+    'plugin:promise/recommended',
+    'plugin:sonarjs/recommended',
+    'plugin:@typescript-eslint/recommended',
+
+    // 'plugin:unicorn/recommended',
+  ],
+  parser: 'vue-eslint-parser',
+  parserOptions: {
+    ecmaVersion: 13,
+    parser: '@typescript-eslint/parser',
+    sourceType: 'module',
+  },
+  plugins: [
+    'vue',
+    '@typescript-eslint',
+    'regex',
+  ],
+  ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
+  rules: {
+    'vue/no-v-html': 'off',
+
+    'vue/block-tag-newline': 'off',
+    // eslint-disable-next-line n/prefer-global/process
+    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+    // eslint-disable-next-line n/prefer-global/process
+    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+
+    // indentation (Already present in TypeScript)
+    'comma-spacing': ['error', {
+      before: false,
+      after: true,
+    }],
+    'key-spacing': ['error', { afterColon: true }],
+
+    'vue/first-attribute-linebreak': ['error', {
+      singleline: 'beside',
+      multiline: 'below',
+    }],
+
+    'antfu/top-level-function': 'off',
+
+    // Enforce trailing comma (Already present in TypeScript)
+    'comma-dangle': ['error', 'always-multiline'],
+
+    // Disable max-len
+    'max-len': 'off',
+
+    // we don't want it
+    'semi': ['error', 'never'],
+
+    // add parens ony when required in arrow function
+    'arrow-parens': ['error', 'as-needed'],
+
+    // add new line above comment
+    'newline-before-return': 'error',
+
+    // add new line above comment
+    'lines-around-comment': [
+      'error',
+      {
+        beforeBlockComment: true,
+        beforeLineComment: true,
+        allowBlockStart: true,
+        allowClassStart: true,
+        allowObjectStart: true,
+        allowArrayStart: true,
+      },
+    ],
+
+    // Ignore _ as unused variable
+    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_+$' }],
+
+    'array-element-newline': ['error', 'consistent'],
+    'array-bracket-newline': ['error', 'consistent'],
+
+    'vue/multi-word-component-names': 'off',
+
+    'padding-line-between-statements': [
+      'error',
+      {
+        blankLine: 'always',
+        prev: 'expression',
+        next: 'const',
+      },
+      {
+        blankLine: 'always',
+        prev: 'const',
+        next: 'expression',
+      },
+      {
+        blankLine: 'always',
+        prev: 'multiline-const',
+        next: '*',
+      },
+      {
+        blankLine: 'always',
+        prev: '*',
+        next: 'multiline-const',
+      },
+    ],
+
+    // Plugin: eslint-plugin-import
+    'import/prefer-default-export': 'off',
+    'import/newline-after-import': ['error', { count: 1 }],
+    'no-restricted-imports': ['error', 'vuetify/components'],
+
+    // For omitting extension for ts files
+    'import/extensions': [
+      'error',
+      'ignorePackages',
+      {
+        mjs: 'never',
+        js: 'never',
+        jsx: 'never',
+        ts: 'never',
+        tsx: 'never',
+      },
+    ],
+
+    // ignore virtual files
+    'import/no-unresolved': [2, {
+      ignore: [
+        '~pages$',
+        'virtual:generated-layouts',
+
+        // Ignore vite's ?raw imports
+        '.*\?raw',
+      ],
+    }],
+
+    // Thanks: https://stackoverflow.com/a/63961972/10796681
+    'no-shadow': 'off',
+    '@typescript-eslint/no-shadow': ['error'],
+
+    '@typescript-eslint/consistent-type-imports': 'error',
+
+    // Plugin: eslint-plugin-promise
+    'promise/always-return': 'off',
+    'promise/catch-or-return': 'off',
+
+    // ESLint plugin vue
+    'vue/component-api-style': 'error',
+    'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false }],
+    'vue/custom-event-name-casing': ['error', 'camelCase', {
+      ignores: [
+        '/^(click):[a-z]+((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?/',
+      ],
+    }],
+    'vue/define-macros-order': 'error',
+    'vue/html-comment-content-newline': 'error',
+    'vue/html-comment-content-spacing': 'error',
+    'vue/html-comment-indent': 'error',
+    'vue/match-component-file-name': 'error',
+    'vue/no-child-content': 'error',
+    'vue/require-default-prop': 'off',
+
+    // NOTE this rule only supported in SFC,  Users of the unplugin-vue-define-options should disable that rule: https://github.com/vuejs/eslint-plugin-vue/issues/1886
+    // 'vue/no-duplicate-attr-inheritance': 'error',
+    'vue/no-multiple-objects-in-class': 'error',
+    'vue/no-reserved-component-names': 'error',
+    'vue/no-template-target-blank': 'error',
+    'vue/no-useless-mustaches': 'error',
+    'vue/no-useless-v-bind': 'error',
+    'vue/padding-line-between-blocks': 'error',
+    'vue/prefer-separate-static-class': 'error',
+    'vue/prefer-true-attribute-shorthand': 'error',
+    'vue/v-on-function-call': 'error',
+    'vue/valid-v-slot': ['error', {
+      allowModifiers: true,
+    }],
+
+    // -- Extension Rules
+    'vue/no-irregular-whitespace': 'error',
+
+    // -- Sonarlint
+    'sonarjs/no-duplicate-string': 'off',
+    'sonarjs/no-nested-template-literals': 'off',
+
+    // -- Unicorn
+    // 'unicorn/filename-case': 'off',
+    // 'unicorn/prevent-abbreviations': ['error', {
+    //   replacements: {
+    //     props: false,
+    //   },
+    // }],
+    // https://github.com/gmullerb/eslint-plugin-regex
+    'regex/invalid': [
+      'error',
+      [
+        {
+          regex: '@/assets/images',
+          replacement: '@images',
+          message: 'Use \'@images\' path alias for image imports',
+        },
+        {
+          regex: '@/styles',
+          replacement: '@styles',
+          message: 'Use \'@styles\' path alias for importing styles from \'src/styles\'',
+        },
+
+        // {
+        //   id: 'Disallow icon of icon library',
+        //   regex: 'tabler-\\w',
+        //   message: 'Only \'mdi\' icons are allowed',
+        // },
+
+        {
+          regex: '@core/\\w',
+          message: 'You can\'t use @core when you are in @layouts module',
+          files: {
+            inspect: '@layouts/.*',
+          },
+        },
+        {
+          regex: 'useLayouts\\(',
+          message: '`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
+          files: {
+            inspect: '^(?!.*(@core|@layouts)).*',
+          },
+        },
+      ],
+
+      // Ignore files
+      '\.eslintrc\.js',
+    ],
+  },
+  settings: {
+    'import/resolver': {
+      node: {
+        extensions: ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.png', '.jpg'],
+      },
+      typescript: {},
+      alias: {
+        map: [
+          ['@', './src'],
+        ],
+      },
+    },
+  },
+  overrides: [
+    {
+      files: ['*.json'],
+      rules: {
+        'no-invalid-meta': 'off',
+      },
+    },
+  ],
+}

+ 5 - 0
app/.idea/.gitignore

@@ -0,0 +1,5 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/

+ 5 - 0
app/.idea/codeStyles/codeStyleConfig.xml

@@ -0,0 +1,5 @@
+<component name="ProjectCodeStyleConfiguration">
+  <state>
+    <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
+  </state>
+</component>

+ 13 - 0
app/.idea/frontend.iml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$">
+      <excludeFolder url="file://$MODULE_DIR$/.tmp" />
+      <excludeFolder url="file://$MODULE_DIR$/temp" />
+      <excludeFolder url="file://$MODULE_DIR$/tmp" />
+      <excludeFolder url="file://$MODULE_DIR$/dist" />
+    </content>
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 7 - 0
app/.idea/inspectionProfiles/Project_Default.xml

@@ -0,0 +1,7 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="Eslint" enabled="true" level="ERROR" enabled_by_default="true" editorAttributes="ERRORS_ATTRIBUTES" />
+    <inspection_tool class="StandardJS" enabled="true" level="ERROR" enabled_by_default="true" />
+  </profile>
+</component>

+ 6 - 0
app/.idea/jsLibraryMappings.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="JavaScriptLibraryMappings">
+    <includedPredefinedLibrary name="Node.js Core" />
+  </component>
+</project>

+ 7 - 0
app/.idea/jsLinters/eslint.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="EslintConfiguration">
+    <custom-configuration-file used="true" path="$PROJECT_DIR$/.eslintrc.js" />
+    <option name="fix-on-save" value="true" />
+  </component>
+</project>

+ 8 - 0
app/.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
+    </modules>
+  </component>
+</project>

+ 6 - 0
app/.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$/.." vcs="Git" />
+  </component>
+</project>

+ 4 - 0
app/.idea/watcherTasks.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectTasksOptions" suppressed-tasks="Less" />
+</project>

+ 9 - 8
app/components.d.ts

@@ -77,14 +77,15 @@ declare module 'vue' {
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     SetLanguageSetLanguage: typeof import('./src/components/SetLanguage/SetLanguage.vue')['default']
-    StdDataDisplayStdBatchEdit: typeof import('./src/components/StdDataDisplay/StdBatchEdit.vue')['default']
-    StdDataDisplayStdCurd: typeof import('./src/components/StdDataDisplay/StdCurd.vue')['default']
-    StdDataDisplayStdPagination: typeof import('./src/components/StdDataDisplay/StdPagination.vue')['default']
-    StdDataDisplayStdTable: typeof import('./src/components/StdDataDisplay/StdTable.vue')['default']
-    StdDataEntryComponentsStdPassword: typeof import('./src/components/StdDataEntry/components/StdPassword.vue')['default']
-    StdDataEntryComponentsStdSelect: typeof import('./src/components/StdDataEntry/components/StdSelect.vue')['default']
-    StdDataEntryComponentsStdSelector: typeof import('./src/components/StdDataEntry/components/StdSelector.vue')['default']
-    StdDataEntryStdFormItem: typeof import('./src/components/StdDataEntry/StdFormItem.vue')['default']
+    StdDesignStdDataDisplayStdBatchEdit: typeof import('./src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue')['default']
+    StdDesignStdDataDisplayStdCurd: typeof import('./src/components/StdDesign/StdDataDisplay/StdCurd.vue')['default']
+    StdDesignStdDataDisplayStdPagination: typeof import('./src/components/StdDesign/StdDataDisplay/StdPagination.vue')['default']
+    StdDesignStdDataDisplayStdTable: typeof import('./src/components/StdDesign/StdDataDisplay/StdTable.vue')['default']
+    StdDesignStdDataEntryComponentsStdPassword: typeof import('./src/components/StdDesign/StdDataEntry/components/StdPassword.vue')['default']
+    StdDesignStdDataEntryComponentsStdSelect: typeof import('./src/components/StdDesign/StdDataEntry/components/StdSelect.vue')['default']
+    StdDesignStdDataEntryComponentsStdSelector: typeof import('./src/components/StdDesign/StdDataEntry/components/StdSelector.vue')['default']
+    StdDesignStdDataEntryStdDataEntry: typeof import('./src/components/StdDesign/StdDataEntry/StdDataEntry.vue')['default']
+    StdDesignStdDataEntryStdFormItem: typeof import('./src/components/StdDesign/StdDataEntry/StdFormItem.vue')['default']
     SwitchAppearanceIconsVPIconMoon: typeof import('./src/components/SwitchAppearance/icons/VPIconMoon.vue')['default']
     SwitchAppearanceIconsVPIconSun: typeof import('./src/components/SwitchAppearance/icons/VPIconSun.vue')['default']
     SwitchAppearanceSwitchAppearance: typeof import('./src/components/SwitchAppearance/SwitchAppearance.vue')['default']

+ 5 - 0
app/env.d.ts

@@ -0,0 +1,5 @@
+declare module '*.svg' {
+  import React from 'react'
+  const content: React.FC<React.SVGProps<SVGElement>>
+  export default content
+}

+ 1 - 0
app/gettext.config.js

@@ -1,3 +1,4 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
 const i18n = require('./i18n.json')
 
 module.exports = {

+ 18 - 6
app/package.json

@@ -1,10 +1,10 @@
 {
   "name": "nginx-ui-app-next",
-  "private": true,
   "version": "2.0.0-beta.4",
-  "type": "commonjs",
   "scripts": {
     "dev": "vite",
+    "typecheck": "vue-tsc --noEmit",
+    "lint": "eslint . -c .eslintrc.js --fix --ext .ts,.vue,.tsx,.d.ts",
     "build": "vite build",
     "preview": "vite preview",
     "gettext:extract": "vue-gettext-extract",
@@ -13,11 +13,9 @@
   "dependencies": {
     "@ant-design/icons-vue": "^7.0.1",
     "@formkit/auto-animate": "^0.8.0",
-    "@types/lodash": "^4.14.202",
-    "@types/nprogress": "^0.2.0",
-    "@types/sortablejs": "^1.15.0",
     "@vue/reactivity": "^3.3.9",
     "@vue/shared": "^3.3.9",
+    "@vueuse/core": "^10.6.1",
     "ant-design-vue": "4.0.7",
     "apexcharts": "^3.36.3",
     "axios": "^1.6.2",
@@ -43,11 +41,25 @@
     "xterm-addon-fit": "^0.8.0"
   },
   "devDependencies": {
+    "@types/lodash": "^4.14.202",
+    "@types/nprogress": "^0.2.0",
+    "@types/sortablejs": "^1.15.0",
+    "@vue/tsconfig": "^0.4.0",
+    "@antfu/eslint-config-vue": "^0.43.1",
+    "@typescript-eslint/eslint-plugin": "^6.13.0",
+    "@typescript-eslint/parser": "^6.13.0",
     "@vitejs/plugin-vue": "^4.5.0",
     "@vitejs/plugin-vue-jsx": "^3.1.0",
     "@vue/compiler-sfc": "^3.3.9",
     "ace-builds": "^1.31.2",
     "autoprefixer": "^10.4.16",
+    "eslint": "^8.54.0",
+    "eslint-import-resolver-alias": "^1.1.2",
+    "eslint-import-resolver-typescript": "^3.6.1",
+    "eslint-plugin-import": "^2.29.0",
+    "eslint-plugin-regex": "^1.10.0",
+    "eslint-plugin-sonarjs": "^0.23.0",
+    "eslint-plugin-vue": "^9.18.1",
     "less": "^4.2.0",
     "postcss": "^8.4.31",
     "tailwindcss": "^3.3.5",
@@ -55,7 +67,7 @@
     "unplugin-auto-import": "^0.17.1",
     "unplugin-vue-components": "^0.25.2",
     "unplugin-vue-define-options": "^1.4.0",
-    "vite": "^5.0.2",
+    "vite": "^5.0.3",
     "vite-plugin-html": "^3.2.0",
     "vite-svg-loader": "^5.1.0",
     "vue-tsc": "^1.8.22"

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


+ 10 - 9
app/src/App.vue

@@ -1,25 +1,26 @@
 <script setup lang="ts">
+
 // This starter template is using Vue 3 <script setup> SFCs
 // Check out https://vuejs.org/api/sfc-script-setup.html#script-setup
-import {useSettingsStore} from '@/pinia'
-import {computed, provide} from 'vue'
+import { computed, provide } from 'vue'
+import { useSettingsStore } from '@/pinia'
 
 const media = window.matchMedia('(prefers-color-scheme: dark)')
 
-const callback = (media: { matches: any; }) => {
+const callback = () => {
   const settings = useSettingsStore()
   if (settings.preference_theme === 'auto') {
-    if (media.matches) {
+    if (media.matches)
       settings.set_theme('dark')
-    } else {
+    else
       settings.set_theme('light')
-    }
-  } else {
+  }
+  else {
     settings.set_theme(settings.preference_theme)
   }
 }
 
-callback(media)
+callback()
 
 const devicePrefersTheme = computed(() => {
   return media.matches ? 'dark' : 'light'
@@ -31,7 +32,7 @@ media.addEventListener('change', callback)
 </script>
 
 <template>
-  <router-view/>
+  <RouterView />
 </template>
 
 <style lang="less">

+ 113 - 2
app/src/api/analytic.ts

@@ -1,9 +1,120 @@
 import http from '@/lib/http'
+import ws from '@/lib/websocket'
+
+export interface CPUInfoStat {
+  cpu: number
+  vendorId: string
+  family: string
+  model: string
+  stepping: number
+  physicalId: string
+  coreId: string
+  cores: number
+  modelName: string
+  mhz: number
+  cacheSize: number
+  flags: string[]
+  microcode: string
+}
+
+export interface IOCountersStat {
+  name: string
+  bytesSent: number
+  bytesRecv: number
+  packetsSent: number
+  packetsRecv: number
+  errin: number
+  errout: number
+  dropin: number
+  dropout: number
+  fifoin: number
+  fifoout: number
+}
+
+export interface HostInfoStat {
+  hostname: string
+  uptime: number
+  bootTime: number
+  procs: number
+  os: string
+  platform: string
+  platformFamily: string
+  platformVersion: string
+  kernelVersion: string
+  kernelArch: string
+  virtualizationSystem: string
+  virtualizationRole: string
+  hostId: string
+}
+
+export interface MemStat {
+  total: string
+  used: string
+  cached: string
+  free: string
+  swap_used: string
+  swap_total: string
+  swap_cached: string
+  swap_percent: number
+  pressure: number
+}
+
+export interface DiskStat {
+  total: string
+  used: string
+  percentage: number
+  writes: Usage
+  reads: Usage
+}
+
+export interface LoadStat {
+  load1: number
+  load5: number
+  load15: number
+}
+
+export interface Usage {
+  x: string
+  y: number
+}
+
+export interface CPURecords {
+  info: CPUInfoStat[]
+  user: Usage[]
+  total: Usage[]
+}
+
+export interface NetworkRecords {
+  init: IOCountersStat
+  bytesRecv: Usage[]
+  bytesSent: Usage[]
+}
+
+export interface DiskIORecords {
+  writes: Usage[]
+  reads: Usage[]
+}
+
+export interface AnalyticInit {
+  host: HostInfoStat
+  cpu: CPURecords
+  network: NetworkRecords
+  disk_io: DiskIORecords
+  disk: DiskStat
+  memory: MemStat
+  loadavg: LoadStat
+}
 
 const analytic = {
-  init() {
+  init(): Promise<AnalyticInit> {
     return http.get('/analytic/init')
-  }
+  },
+  server() {
+    return ws('/api/analytic')
+  },
+  nodes() {
+    return ws('/api/analytic/nodes')
+  },
 }
 
 export default analytic

+ 14 - 11
app/src/api/auth.ts

@@ -1,24 +1,27 @@
 import http from '@/lib/http'
-import {useUserStore} from '@/pinia'
+import { useUserStore } from '@/pinia'
 
-const user = useUserStore()
-const {login, logout} = user
+const { login, logout } = useUserStore()
+
+export interface AuthResponse {
+  token: string
+}
 
 const auth = {
   async login(name: string, password: string) {
     return http.post('/login', {
-      name: name,
-      password: password
-    }).then(r => {
+      name,
+      password,
+    }).then((r: AuthResponse) => {
       login(r.token)
     })
   },
-  async casdoorLogin(code: string, state: string) {
+  async casdoor_login(code?: string, state?: string) {
     await http.post('/casdoor_callback', {
-      code: code,
-      state: state
+      code,
+      state,
     })
-      .then((r) => {
+      .then((r: AuthResponse) => {
         login(r.token)
       })
   },
@@ -26,7 +29,7 @@ const auth = {
     return http.delete('/logout').then(async () => {
       logout()
     })
-  }
+  },
 }
 
 export default auth

+ 27 - 5
app/src/api/auto_cert.ts

@@ -1,13 +1,35 @@
 import http from '@/lib/http'
 
+export interface DNSProvider {
+  name?: string
+  code: string
+  provider?: string
+  configuration: {
+    credentials: {
+      [key: string]: string
+    }
+    additional: {
+      [key: string]: string
+    }
+  }
+  links?: {
+    api: string
+    go_client: string
+  }
+}
+export interface DnsChallenge extends DNSProvider {
+  dns_credential_id: number
+  challenge_method: string
+}
+
 const auto_cert = {
-  get_dns_providers() {
-    return http.get('/auto_cert/dns/providers')
+  get_dns_providers(): Promise<DNSProvider[]> {
+    return http.get('/certificate/dns_providers')
   },
 
-  get_dns_provider(code: string) {
-    return http.get('/auto_cert/dns/provider/' + code)
-  }
+  get_dns_provider(code: string): Promise<DNSProvider> {
+    return http.get(`/certificate/dns_provider/${code}`)
+  },
 }
 
 export default auto_cert

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

@@ -1,5 +1,27 @@
+import type { ModelBase } from '@/api/curd'
 import Curd from '@/api/curd'
+import type { DnsCredential } from '@/api/dns_credential'
 
-const cert = new Curd('/cert')
+export interface Cert extends ModelBase {
+  name: string
+  domains: string[]
+  filename: string
+  ssl_certificate_path: string
+  ssl_certificate_key_path: string
+  auto_cert: number
+  challenge_method: string
+  dns_credential_id: number
+  dns_credential?: DnsCredential
+  log: string
+}
+
+export interface CertificateInfo {
+  subject_name: string
+  issuer_name: string
+  not_after: string
+  not_before: string
+}
+
+const cert: Curd<Cert> = new Curd('/cert')
 
 export default cert

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

@@ -1,5 +1,14 @@
 import Curd from '@/api/curd'
+import type { ChatComplicationMessage } from '@/api/openai'
 
-const config = new Curd('/config')
+export interface Config {
+  name: string
+  content: string
+  chatgpt_messages: ChatComplicationMessage[]
+  file_path: string
+  modified_at: string
+}
+
+const config: Curd<Config> = new Curd('/config')
 
 export default config

+ 40 - 9
app/src/api/curd.ts

@@ -1,6 +1,24 @@
 import http from '@/lib/http'
 
-class Curd {
+export interface ModelBase {
+  id: number
+  created_at: string
+  updated_at: string
+}
+
+export interface Pagination {
+  total: number
+  per_page: number
+  current_page: number
+  total_pages: number
+}
+
+export interface IGetListResponse<T> {
+  data: T[]
+  pagination: Pagination
+}
+
+class Curd<T> {
   protected readonly baseUrl: string
   protected readonly plural: string
 
@@ -8,26 +26,39 @@ class Curd {
   get = this._get.bind(this)
   save = this._save.bind(this)
   destroy = this._destroy.bind(this)
+  update_order = this._update_order.bind(this)
 
   constructor(baseUrl: string, plural: string | null = null) {
     this.baseUrl = baseUrl
-    this.plural = plural ?? this.baseUrl + 's'
+    this.plural = plural ?? `${this.baseUrl}s`
   }
 
-  _get_list(params: any = null) {
-    return http.get(this.plural, {params: params})
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  _get_list(params: any = null): Promise<IGetListResponse<T>> {
+    return http.get(this.plural, { params })
   }
 
-  _get(id: any = null) {
-    return http.get(this.baseUrl + (id ? '/' + id : ''))
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  _get(id: any = null): Promise<T> {
+    return http.get(this.baseUrl + (id ? `/${id}` : ''))
   }
 
-  _save(id: any = null, data: any, config: any = undefined) {
-    return http.post(this.baseUrl + (id ? '/' + id : ''), data, config)
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  _save(id: any = null, data: any, config: any = undefined): Promise<T> {
+    return http.post(this.baseUrl + (id ? `/${id}` : ''), data, config)
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   _destroy(id: any = null) {
-    return http.delete(this.baseUrl + '/' + id)
+    return http.delete(`${this.baseUrl}/${id}`)
+  }
+
+  _update_order(data: {
+    target_id: number
+    direction: number
+    affected_ids: number[]
+  }) {
+    return http.post(`${this.plural}/order`, data)
   }
 }
 

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

@@ -1,5 +1,13 @@
+import type { ModelBase } from '@/api/curd'
 import Curd from '@/api/curd'
+import type { DNSProvider } from '@/api/auto_cert'
 
-const dns_credential = new Curd('/dns_credential')
+export interface DnsCredential extends ModelBase {
+  name: string
+  config?: DNSProvider
+  provider: string
+}
+
+const dns_credential: Curd<DnsCredential> = new Curd('/dns_credential')
 
 export default dns_credential

+ 35 - 12
app/src/api/domain.ts

@@ -1,34 +1,57 @@
 import Curd from '@/api/curd'
 import http from '@/lib/http'
-import {AxiosRequestConfig} from 'axios/index'
+import type { ChatComplicationMessage } from '@/api/openai'
+import type { CertificateInfo } from '@/api/cert'
+import type { NgxConfig } from '@/api/ngx'
+
+export interface Site {
+  modified_at: string
+  advanced: boolean
+  enabled: boolean
+  name: string
+  config: string
+  auto_cert: boolean
+  chatgpt_messages: ChatComplicationMessage[]
+  tokenized?: NgxConfig
+  cert_info?: {
+    [key: number]: CertificateInfo
+  }
+}
+
+export interface AutoCertRequest {
+  dns_credential_id: number
+  challenge_method: string
+  domains: string[]
+}
 
-class Domain extends Curd {
-  enable(name: string, config: AxiosRequestConfig) {
-    return http.post(this.baseUrl + '/' + name + '/enable', undefined, config)
+class Domain extends Curd<Site> {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  enable(name: string, config?: any) {
+    return http.post(`${this.baseUrl}/${name}/enable`, undefined, config)
   }
 
   disable(name: string) {
-    return http.post(this.baseUrl + '/' + name + '/disable')
+    return http.post(`${this.baseUrl}/${name}/disable`)
   }
 
   get_template() {
     return http.get('template')
   }
 
-  add_auto_cert(domain: string, data: any) {
-    return http.post('auto_cert/' + domain, data)
+  add_auto_cert(domain: string, data: AutoCertRequest) {
+    return http.post(`auto_cert/${domain}`, data)
   }
 
   remove_auto_cert(domain: string) {
-    return http.delete('auto_cert/' + domain)
+    return http.delete(`auto_cert/${domain}`)
   }
 
-  duplicate(name: string, data: any) {
-    return http.post(this.baseUrl + '/' + name + '/duplicate', data)
+  duplicate(name: string, data: { name: string }): Promise<{ dst: string }> {
+    return http.post(`${this.baseUrl}/${name}/duplicate`, data)
   }
 
-  advance_mode(name: string, data: any) {
-    return http.post(this.baseUrl + '/' + name + '/advance', data)
+  advance_mode(name: string, data: { advanced: boolean }) {
+    return http.post(`${this.baseUrl}/${name}/advance`, data)
   }
 }
 

+ 15 - 1
app/src/api/environment.ts

@@ -1,5 +1,19 @@
+import type { ModelBase } from '@/api/curd'
 import Curd from '@/api/curd'
 
-const environment = new Curd('/environment')
+export interface Environment extends ModelBase {
+  name: string
+  url: string
+  token: string
+  status?: boolean
+}
+
+export interface Node {
+  id: number
+  name: string
+  token: string
+  response_at?: Date
+}
+const environment: Curd<Environment> = new Curd('/environment')
 
 export default environment

+ 9 - 2
app/src/api/install.ts

@@ -1,12 +1,19 @@
 import http from '@/lib/http'
 
+export interface InstallRequest {
+  email: string
+  username: string
+  password: string
+  database: string
+}
+
 const install = {
   get_lock() {
     return http.get('/install')
   },
-  install_nginx_ui(data: any) {
+  install_nginx_ui(data: InstallRequest) {
     return http.post('/install', data)
-  }
+  },
 }
 
 export default install

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

@@ -9,7 +9,8 @@ export interface INginxLogData {
 
 const nginx_log = {
   page(page = 0, data: INginxLogData) {
-    return http.post('/nginx_log?page=' + page, data)
-  }
+    return http.post(`/nginx_log?page=${page}`, data)
+  },
 }
+
 export default nginx_log

+ 37 - 4
app/src/api/ngx.ts

@@ -1,16 +1,49 @@
 import http from '@/lib/http'
 
+export interface NgxConfig {
+  file_name?: string
+  name: string
+  upstreams?: NgxUpstream[]
+  servers: NgxServer[]
+  custom?: string
+}
+
+export interface NgxServer {
+  directives?: NgxDirective[]
+  locations?: NgxLocation[]
+  comments?: string
+}
+
+export interface NgxUpstream {
+  name: string
+  directives: NgxDirective[]
+  comments?: string
+}
+
+export interface NgxDirective {
+  idx?: number
+  directive: string
+  params: string
+  comments?: string
+}
+
+export interface NgxLocation {
+  path: string
+  content: string
+  comments: string
+}
+
 const ngx = {
-  build_config(ngxConfig: any) {
+  build_config(ngxConfig: NgxConfig) {
     return http.post('/ngx/build_config', ngxConfig)
   },
 
   tokenize_config(content: string) {
-    return http.post('/ngx/tokenize_config', {content})
+    return http.post('/ngx/tokenize_config', { content })
   },
 
   format_code(content: string) {
-    return http.post('/ngx/format_code', {content})
+    return http.post('/ngx/format_code', { content })
   },
 
   status() {
@@ -27,7 +60,7 @@ const ngx = {
 
   test() {
     return http.post('/nginx/test')
-  }
+  },
 }
 
 export default ngx

+ 8 - 2
app/src/api/openai.ts

@@ -1,9 +1,15 @@
 import http from '@/lib/http'
 
+export interface ChatComplicationMessage {
+  role: string
+  content: string
+  name?: string
+}
+
 const openai = {
-  store_record(data: any) {
+  store_record(data: { file_name?: string; messages: ChatComplicationMessage[] }) {
     return http.post('/chat_gpt_record', data)
-  }
+  },
 }
 
 export default openai

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

@@ -4,9 +4,10 @@ const settings = {
   get() {
     return http.get('/settings')
   },
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   save(data: any) {
     return http.post('/settings', data)
-  }
+  },
 }
 
 export default settings

+ 25 - 7
app/src/api/template.ts

@@ -1,7 +1,26 @@
 import Curd from '@/api/curd'
 import http from '@/lib/http'
+import type { NgxServer } from '@/api/ngx'
 
-class Template extends Curd {
+export interface Variable {
+  type?: string
+  name?: { [key: string]: string }
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  value?: any
+}
+
+export interface Template extends NgxServer {
+  name: string
+  description: { [key: string]: string }
+  author: string
+  filename: string
+  variables: {
+    [key: string]: Variable
+  }
+  custom: string
+}
+
+class TemplateApi extends Curd<Template> {
   get_config_list() {
     return http.get('template/configs')
   }
@@ -11,19 +30,18 @@ class Template extends Curd {
   }
 
   get_config(name: string) {
-    return http.get('template/config/' + name)
+    return http.get(`template/config/${name}`)
   }
 
   get_block(name: string) {
-    return http.get('template/block/' + name)
+    return http.get(`template/block/${name}`)
   }
 
-  build_block(name: string, data: any) {
-    return http.post('template/block/' + name, data)
+  build_block(name: string, data: Variable) {
+    return http.post(`template/block/${name}`, data)
   }
-
 }
 
-const template = new Template('/template')
+const template = new TemplateApi('/template')
 
 export default template

+ 12 - 3
app/src/api/upgrade.ts

@@ -1,16 +1,25 @@
 import http from '@/lib/http'
 
+export interface RuntimeInfo {
+  name: string
+  os: string
+  arch: string
+  ex_path: string
+  body: string
+  published_at: string
+}
+
 const upgrade = {
   get_latest_release(channel: string) {
     return http.get('/upgrade/release', {
       params: {
-        channel
-      }
+        channel,
+      },
     })
   },
   current_version() {
     return http.get('/upgrade/current')
-  }
+  },
 }
 
 export default upgrade

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

@@ -1,5 +1,11 @@
+import type { ModelBase } from '@/api/curd'
 import Curd from '@/api/curd'
 
-const user: Curd = new Curd('user')
+export interface User extends ModelBase {
+  name: string
+  password: string
+}
+
+const user: Curd<User> = new Curd('user')
 
 export default user

+ 17 - 15
app/src/components/Breadcrumb/Breadcrumb.vue

@@ -1,9 +1,8 @@
 <script setup lang="ts">
-import {computed, ref} from 'vue'
-import {useRoute} from 'vue-router'
+import { useRoute } from 'vue-router'
 
 interface bread {
-  name: any
+  name: () => string
   path: string
 }
 
@@ -11,35 +10,38 @@ const name = ref()
 const route = useRoute()
 
 const breadList = computed(() => {
-  let _breadList: bread[] = []
+  const _breadList: bread[] = []
 
   name.value = route.name
 
   route.matched.forEach(item => {
-    //item.name !== 'index' && this.breadList.push(item)
+    // item.name !== 'index' && this.breadList.push(item)
     _breadList.push({
-      name: item.name,
-      path: item.path
+      name: item.name as never as () => string,
+      path: item.path,
     })
   })
 
   return _breadList
 })
 
-
 </script>
 
 <template>
-  <a-breadcrumb class="breadcrumb">
-    <a-breadcrumb-item v-for="(item, index) in breadList" :key="item.name">
-      <router-link
+  <ABreadcrumb class="breadcrumb">
+    <ABreadcrumbItem
+      v-for="(item, index) in breadList"
+      :key="item.name"
+    >
+      <RouterLink
         v-if="item.name !== name && index !== 1"
         :to="{ path: item.path === '' ? '/' : item.path }"
-      >{{ item.name() }}
-      </router-link>
+      >
+        {{ item.name() }}
+      </RouterLink>
       <span v-else>{{ item.name() }}</span>
-    </a-breadcrumb-item>
-  </a-breadcrumb>
+    </ABreadcrumbItem>
+  </ABreadcrumb>
 </template>
 
 <style scoped>

+ 57 - 46
app/src/components/Chart/AreaChart.vue

@@ -1,86 +1,90 @@
 <script setup lang="ts">
 import VueApexCharts from 'vue3-apexcharts'
-import {ref, watch} from 'vue'
-import {useSettingsStore} from '@/pinia'
-import {storeToRefs} from 'pinia'
+import { storeToRefs } from 'pinia'
+import type { Ref } from 'vue'
+import { useSettingsStore } from '@/pinia'
+import type { Series } from '@/components/Chart/types'
 
-const {series, max, y_formatter} = defineProps(['series', 'max', 'y_formatter'])
+const { series, max, yFormatter } = defineProps<{
+  series: Series[]
+  max?: number
+  yFormatter?: (value: number) => string
+}>()
 
 const settings = useSettingsStore()
-const {theme} = storeToRefs(settings)
+const { theme } = storeToRefs(settings)
 
 const fontColor = () => {
   return theme.value === 'dark' ? '#b4b4b4' : undefined
 }
 
-const chart = ref(null)
+const chart: Ref<ApexCharts | undefined> = ref()
 
 let chartOptions = {
   chart: {
     type: 'area',
     zoom: {
-      enabled: false
+      enabled: false,
     },
     animations: {
-      enabled: false
+      enabled: false,
     },
     toolbar: {
-      show: false
-    }
+      show: false,
+    },
   },
   colors: ['#ff6385', '#36a3eb'],
   fill: {
     // type: ['solid', 'gradient'],
     gradient: {
-      shade: 'light'
-    }
-    //colors:  ['#ff6385', '#36a3eb'],
+      shade: 'light',
+    },
+
+    // colors:  ['#ff6385', '#36a3eb'],
   },
   dataLabels: {
-    enabled: false
+    enabled: false,
   },
   stroke: {
     curve: 'smooth',
-    width: 0
+    width: 0,
   },
   xaxis: {
     type: 'datetime',
     labels: {
       datetimeUTC: false,
       style: {
-        colors: fontColor()
-      }
-    }
+        colors: fontColor(),
+      },
+    },
   },
   tooltip: {
-    enabled: false
+    enabled: false,
   },
   yaxis: {
-    max: max,
+    max,
     tickAmount: 4,
     min: 0,
     labels: {
       style: {
-        colors: fontColor()
+        colors: fontColor(),
       },
-      formatter: y_formatter
-    }
+      formatter: yFormatter,
+    },
   },
   legend: {
     labels: {
-      colors: fontColor()
+      colors: fontColor(),
     },
     onItemClick: {
-      toggleDataSeries: false
+      toggleDataSeries: false,
     },
     onItemHover: {
-      highlightDataSeries: false
-    }
-  }
+      highlightDataSeries: false,
+    },
+  },
 }
 
-let instance: ApexCharts | null = chart.value
-
 const callback = () => {
   chartOptions = {
     ...chartOptions,
@@ -90,45 +94,52 @@ const callback = () => {
         labels: {
           datetimeUTC: false,
           style: {
-            colors: fontColor()
-          }
-        }
+            colors: fontColor(),
+          },
+        },
       },
       yaxis: {
-        max: max,
+        max,
         tickAmount: 4,
         min: 0,
         labels: {
           style: {
-            colors: fontColor()
+            colors: fontColor(),
           },
-          formatter: y_formatter
-        }
+          formatter: yFormatter,
+        },
       },
       legend: {
         labels: {
-          colors: fontColor()
+          colors: fontColor(),
         },
         onItemClick: {
-          toggleDataSeries: false
+          toggleDataSeries: false,
         },
         onItemHover: {
-          highlightDataSeries: false
-        }
-      }
-    }
+          highlightDataSeries: false,
+        },
+      },
+    },
   }
-  instance?.updateOptions?.(chartOptions)
+  chart.value?.updateOptions?.(chartOptions)
 }
+
 watch(theme, callback)
 </script>
 
 <template>
   <!-- Use theme as key to rerender the chart when theme changes to prevent style issues -->
-  <VueApexCharts :key="theme" type="area" height="200" :options="chartOptions" :series="series" ref="chart"/>
+  <VueApexCharts
+    :key="theme"
+    ref="chart"
+    type="area"
+    height="200"
+    :options="chartOptions"
+    :series="series"
+  />
 </template>
 
-
 <style scoped>
 
 </style>

+ 45 - 27
app/src/components/Chart/RadialBarChart.vue

@@ -1,21 +1,28 @@
 <script setup lang="ts">
 import VueApexCharts from 'vue3-apexcharts'
-import {reactive} from 'vue'
-import {useSettingsStore} from '@/pinia'
-import {storeToRefs} from 'pinia'
+import { reactive } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useSettingsStore } from '@/pinia'
+import type { Series } from '@/components/Chart/types'
 
-const {series, centerText, colors, name, bottomText}
-  = defineProps(['series', 'centerText', 'colors', 'name', 'bottomText'])
+const { series, centerText, colors, name, bottomText }
+  = defineProps<{
+    series: Series[] | number[]
+    centerText?: string
+    colors?: string
+    name?: string
+    bottomText?: string
+  }>()
 
 const settings = useSettingsStore()
 
-const {theme} = storeToRefs(settings)
+const { theme } = storeToRefs(settings)
 
 const chartOptions = reactive({
-  series: series,
+  series,
   chart: {
     type: 'radialBar',
-    offsetY: 0
+    offsetY: 0,
   },
   plotOptions: {
     radialBar: {
@@ -25,7 +32,7 @@ const chartOptions = reactive({
         name: {
           fontSize: '14px',
           color: colors,
-          offsetY: 36
+          offsetY: 36,
         },
         value: {
           offsetY: 50,
@@ -33,42 +40,53 @@ const chartOptions = reactive({
           color: undefined,
           formatter: () => {
             return ''
-          }
-        }
-      }
-    }
+          },
+        },
+      },
+    },
   },
   fill: {
-    colors: colors
+    colors,
   },
   labels: [name],
   states: {
     hover: {
       filter: {
-        type: 'none'
-      }
+        type: 'none',
+      },
     },
     active: {
       filter: {
-        type: 'none'
-      }
-    }
-  }
+        type: 'none',
+      },
+    },
+  },
 })
 </script>
 
 <template>
   <!-- Use theme as key to rerender the chart when theme changes to prevent style issues -->
-  <div class="radial-bar-container" :key="theme">
-    <p class="text">{{ centerText }}</p>
-    <p class="bottom_text">{{ bottomText }}</p>
-    <VueApexCharts v-if="centerText" class="radialBar" type="radialBar" height="205" :options="chartOptions"
-                   :series="series"
-                   ref="chart"/>
+  <div
+    :key="theme"
+    class="radial-bar-container"
+  >
+    <p class="text">
+      {{ centerText }}
+    </p>
+    <p class="bottom_text">
+      {{ bottomText }}
+    </p>
+    <VueApexCharts
+      v-if="centerText"
+      class="radialBar"
+      type="radialBar"
+      height="205"
+      :options="chartOptions"
+      :series="series"
+    />
   </div>
 </template>
 
-
 <style lang="less" scoped>
 .radial-bar-container {
   position: relative;

+ 15 - 12
app/src/components/Chart/UsageProgressLine.vue

@@ -1,37 +1,40 @@
 <script setup lang="ts">
-import {computed} from 'vue'
+import { computed } from 'vue'
 
 const props = withDefaults(defineProps<{
   percent: number
 }>(), {
-  percent: 0
+  percent: 0,
 })
 
 const color = computed(() => {
-  if (props.percent < 80) {
+  if (props.percent < 80)
     return '#1890ff'
-  } else if (props.percent >= 80 && props.percent < 90) {
+  else if (props.percent >= 80 && props.percent < 90)
     return '#faad14'
-  } else {
+  else
     return '#ff6385'
-  }
 })
 
 const fixed_percent = computed(() => {
-  return parseFloat(props.percent.toFixed(2))
+  return Number.parseFloat(props.percent.toFixed(2))
 })
 </script>
 
 <template>
   <div>
     <div>
-      <span class="slot-icon"><slot name="icon"></slot></span>
+      <span class="slot-icon"><slot name="icon" /></span>
       <span class="slot">
-                <slot></slot>
-            </span>
-      <span class="dot"> ·</span> {{ fixed_percent + '%' }}
+        <slot />
+      </span>
+      <span class="dot"> ·</span> {{ `${fixed_percent}%` }}
     </div>
-    <a-progress :percent="fixed_percent" :stroke-color="color" :show-info="false"/>
+    <AProgress
+      :percent="fixed_percent"
+      :stroke-color="color"
+      :show-info="false"
+    />
   </div>
 </template>
 

+ 6 - 0
app/src/components/Chart/types.d.ts

@@ -0,0 +1,6 @@
+import type {Usage} from '@/api/analytic'
+
+export interface Series {
+  name: string
+  data: Usage[]
+}

+ 156 - 99
app/src/components/ChatGPT/ChatGPT.vue

@@ -1,47 +1,56 @@
 <script setup lang="ts">
-import {computed, onMounted, ref, watch} from 'vue'
-import {useGettext} from 'vue3-gettext'
-import {useUserStore} from '@/pinia'
-import {storeToRefs} from 'pinia'
-import {urlJoin} from '@/lib/helper'
-import {marked} from 'marked'
+import Icon, { SendOutlined } from '@ant-design/icons-vue'
+import { useGettext } from 'vue3-gettext'
+import { storeToRefs } from 'pinia'
+import { marked } from 'marked'
 import hljs from 'highlight.js'
+import type { Ref } from 'vue'
+import { urlJoin } from '@/lib/helper'
+import { useSettingsStore, useUserStore } from '@/pinia'
 import 'highlight.js/styles/vs2015.css'
-import Icon, {SendOutlined} from '@ant-design/icons-vue'
 
+import type { ChatComplicationMessage } from '@/api/openai'
 import openai from '@/api/openai'
 import ChatGPT_logo from '@/assets/svg/ChatGPT_logo.svg'
 
-const {$gettext} = useGettext()
+const props = defineProps<{
+  content: string
+  path?: string
+  historyMessages: ChatComplicationMessage[]
+}>()
 
-const props = defineProps(['content', 'path', 'history_messages'])
 const emit = defineEmits(['update:history_messages'])
-const history_messages = computed(() => props.history_messages)
+
+const { $gettext } = useGettext()
+
+const { language: current } = storeToRefs(useSettingsStore())
+
+const history_messages = computed(() => props.historyMessages)
+const messages = ref([]) as Ref<ChatComplicationMessage[]>
 
 onMounted(() => {
-  messages.value = props.history_messages
+  messages.value = props.historyMessages
 })
 
 watch(history_messages, () => {
-  messages.value = props.history_messages
+  messages.value = props.historyMessages
 })
 
-const {current} = useGettext()
-
-const messages: any = ref([])
-
 const loading = ref(false)
 const ask_buffer = ref('')
 
+// eslint-disable-next-line sonarjs/cognitive-complexity
 async function request() {
   loading.value = true
+
   const t = ref({
     role: 'assistant',
-    content: ''
+    content: '',
   })
+
   const user = useUserStore()
 
-  const {token} = storeToRefs(user)
+  const { token } = storeToRefs(user)
 
   console.log('fetching...')
 
@@ -49,35 +58,39 @@ async function request() {
 
   emit('update:history_messages', messages.value)
 
-  let res = await fetch(urlJoin(window.location.pathname, '/api/chat_gpt'), {
+  const res = await fetch(urlJoin(window.location.pathname, '/api/chat_gpt'), {
     method: 'POST',
-    headers: {'Accept': 'text/event-stream', Authorization: token.value},
-    body: JSON.stringify({messages: messages.value.slice(0, messages.value?.length - 1)})
+    headers: { Accept: 'text/event-stream', Authorization: token.value },
+    body: JSON.stringify({ messages: messages.value.slice(0, messages.value?.length - 1) }),
   })
+
   // read body as stream
-  console.log('reading...')
-  let reader = res.body!.getReader()
+  console.info('reading...')
+
+  const reader = res.body!.getReader()
 
   // read stream
-  console.log('reading stream...')
+  console.info('reading stream...')
 
   let buffer = ''
 
   let hasCodeBlockIndicator = false
 
   while (true) {
-    let {done, value} = await reader.read()
+    const { done, value } = await reader.read()
     if (done) {
-      console.log('done')
+      console.info('done')
+      setTimeout(() => {
+        scrollToBottom()
+      }, 500)
       loading.value = false
       store_record()
       break
     }
-
-    apply(value)
+    apply(value!)
   }
 
-  function apply(input: any) {
+  function apply(input: Uint8Array) {
     const decoder = new TextDecoder('utf-8')
     const raw = decoder.decode(input)
 
@@ -87,56 +100,66 @@ async function request() {
 
     line?.forEach(v => {
       const data = v.slice('event:message\ndata:'.length)
-      if (!data) {
+      if (!data)
         return
-      }
+
       const content = JSON.parse(data).content
 
-      if (!hasCodeBlockIndicator) {
-        hasCodeBlockIndicator = content.indexOf('`') > -1
-      }
+      if (!hasCodeBlockIndicator)
+        hasCodeBlockIndicator = content.includes('`')
 
-      for (let c of content) {
+      for (const c of content) {
         buffer += c
         if (hasCodeBlockIndicator) {
           if (isCodeBlockComplete(buffer)) {
             t.value.content = buffer
             hasCodeBlockIndicator = false
-          } else {
-            t.value.content = buffer + '\n```'
           }
-        } else {
+          else {
+            t.value.content = `${buffer}\n\`\`\``
+          }
+        }
+        else {
           t.value.content = buffer
         }
       }
+
+      // keep container scroll to bottom
+      scrollToBottom()
     })
   }
 
   function isCodeBlockComplete(text: string) {
     const codeBlockRegex = /```/g
     const matches = text.match(codeBlockRegex)
-    if (matches) {
+    if (matches)
       return matches.length % 2 === 0
-    } else {
+    else
       return true
-    }
   }
 
+  function scrollToBottom() {
+    const container = document.querySelector('.right-settings .ant-card-body')
+    if (container)
+      container.scrollTop = container.scrollHeight
+  }
 }
 
 async function send() {
-  if (!messages.value) {
+  if (!messages.value)
     messages.value = []
-  }
+
   if (messages.value.length === 0) {
+    console.log(current.value)
     messages.value.push({
       role: 'user',
-      content: props.content + '\n\nCurrent Language Code: ' + current
+      content: `${props.content}\n\nCurrent Language Code: ${current.value}`,
     })
-  } else {
+  }
+  else {
     messages.value.push({
       role: 'user',
-      content: ask_buffer.value
+      content: ask_buffer.value,
     })
     ask_buffer.value = ''
   }
@@ -144,109 +167,143 @@ async function send() {
 }
 
 const renderer = new marked.Renderer()
+
 renderer.code = (code, lang: string) => {
   const language = hljs.getLanguage(lang) ? lang : 'nginx'
-  const highlightedCode = hljs.highlight(code, {language}).value
+  const highlightedCode = hljs.highlight(code, { language }).value
+
   return `<pre><code class="hljs ${language}">${highlightedCode}</code></pre>`
 }
 
 marked.setOptions({
-  renderer: renderer,
-  langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class.
+  renderer,
   pedantic: false,
   gfm: true,
   breaks: false,
-  sanitize: false,
-  smartypants: true,
-  xhtml: false
 })
 
 function store_record() {
   openai.store_record({
     file_name: props.path,
-    messages: messages.value
+    messages: messages.value,
   })
 }
 
 function clear_record() {
   openai.store_record({
     file_name: props.path,
-    messages: []
+    messages: [],
   })
   messages.value = []
   emit('update:history_messages', [])
 }
-
+const editing_idx = ref(-1)
 async function regenerate(index: number) {
   editing_idx.value = -1
   messages.value = messages.value.slice(0, index)
   await request()
 }
 
-const editing_idx = ref(-1)
-
-const show = computed(() => messages?.value?.length === 0)
-
+const show = computed(() => !messages.value || messages.value?.length === 0)
 </script>
 
 <template>
-  <div class="chat-start" v-if="show">
-    <a-button @click="send" :loading="loading">
-      <Icon v-if="!loading" :component="ChatGPT_logo"/>
+  <div
+    v-if="show"
+    class="chat-start"
+  >
+    <AButton
+      :loading="loading"
+      @click="send"
+    >
+      <Icon
+        v-if="!loading"
+        :component="ChatGPT_logo"
+      />
       {{ $gettext('Ask ChatGPT for Help') }}
-    </a-button>
+    </AButton>
   </div>
-  <div class="chatgpt-container" v-else>
-    <a-list
+  <div
+    v-else
+    class="chatgpt-container"
+  >
+    <AList
       class="chatgpt-log"
       item-layout="horizontal"
       :data-source="messages"
     >
       <template #renderItem="{ item, index }">
-        <a-list-item>
-          <a-comment :author="item.role==='assistant'?$gettext('Assistant'):$gettext('User')">
+        <AListItem>
+          <AComment :author="item.role === 'assistant' ? $gettext('Assistant') : $gettext('User')">
             <template #content>
-              <div class="content" v-if="item.role==='assistant'||editing_idx!=index"
-                   v-html="marked.parse(item.content)"></div>
-              <a-input style="padding: 0" v-else v-model:value="item.content"
-                       :bordered="false"/>
+              <div
+                v-if="item.role === 'assistant' || editing_idx !== index"
+                class="content"
+                v-html="marked.parse(item.content)"
+              />
+              <AInput
+                v-else
+                v-model:value="item.content"
+                style="padding: 0"
+                :bordered="false"
+              />
             </template>
             <template #actions>
-                                    <span v-if="item.role==='user'&&editing_idx!==index" @click="editing_idx=index">
-                                        {{ $gettext('Modify') }}
-                                    </span>
-              <template v-else-if="editing_idx==index">
-                <span @click="regenerate(index+1)">{{ $gettext('Save') }}</span>
-                <span @click="editing_idx=-1">{{ $gettext('Cancel') }}</span>
+              <span
+                v-if="item.role === 'user' && editing_idx !== index"
+                @click="editing_idx = index"
+              >
+                {{ $gettext('Modify') }}
+              </span>
+              <template v-else-if="editing_idx === index">
+                <span @click="regenerate(index + 1)">{{ $gettext('Save') }}</span>
+                <span @click="editing_idx = -1">{{ $gettext('Cancel') }}</span>
               </template>
-              <span v-else-if="!loading" @click="regenerate(index)" :disabled="loading">
-                                        {{ $gettext('Reload') }}
-                                    </span>
+              <span
+                v-else-if="!loading"
+                @click="regenerate(index)"
+              >
+                {{ $gettext('Reload') }}
+              </span>
             </template>
-          </a-comment>
-        </a-list-item>
+          </AComment>
+        </AListItem>
       </template>
-    </a-list>
+    </AList>
     <div class="input-msg">
       <div class="control-btn">
-        <a-space v-show="!loading">
-          <a-popconfirm
-            :cancelText="$gettext('No')"
-            :okText="$gettext('OK')"
+        <ASpace v-show="!loading">
+          <APopconfirm
+            :cancel-text="$gettext('No')"
+            :ok-text="$gettext('OK')"
             :title="$gettext('Are you sure you want to clear the record of chat?')"
-            @confirm="clear_record">
-            <a-button type="text">{{ $gettext('Clear') }}</a-button>
-          </a-popconfirm>
-          <a-button type="text" @click="regenerate(messages?.length-1)">
+            @confirm="clear_record"
+          >
+            <AButton type="text">
+              {{ $gettext('Clear') }}
+            </AButton>
+          </APopconfirm>
+          <AButton
+            type="text"
+            @click="regenerate(messages?.length - 1)"
+          >
             {{ $gettext('Regenerate response') }}
-          </a-button>
-        </a-space>
+          </AButton>
+        </ASpace>
       </div>
-      <a-textarea auto-size v-model:value="ask_buffer"/>
-      <div class="sned-btn">
-        <a-button size="small" type="text" :loading="loading" @click="send">
-          <send-outlined/>
-        </a-button>
+      <ATextarea
+        v-model:value="ask_buffer"
+        auto-size
+      />
+      <div class="send-btn">
+        <AButton
+          size="small"
+          type="text"
+          :loading="loading"
+          @click="send"
+        >
+          <SendOutlined />
+        </AButton>
       </div>
     </div>
   </div>
@@ -299,7 +356,7 @@ const show = computed(() => messages?.value?.length === 0)
       justify-content: center;
     }
 
-    .sned-btn {
+    .send-btn {
       position: absolute;
       right: 0;
       bottom: 3px;

+ 14 - 10
app/src/components/CodeEditor/CodeEditor.vue

@@ -1,10 +1,13 @@
 <script setup lang="ts">
-import {VAceEditor} from 'vue3-ace-editor'
+import { VAceEditor } from 'vue3-ace-editor'
 import 'ace-builds/src-noconflict/mode-nginx'
 import 'ace-builds/src-noconflict/theme-monokai'
-import {computed} from 'vue'
+import { computed } from 'vue'
 
-const props = defineProps(['content', 'defaultHeight'])
+const props = defineProps<{
+  content?: string
+  defaultHeight?: string
+}>()
 
 const emit = defineEmits(['update:content'])
 
@@ -12,21 +15,22 @@ const value = computed({
   get() {
     return props.content ?? ''
   },
-  set(value) {
-    emit('update:content', value)
-  }
+  set(v) {
+    emit('update:content', v)
+  },
 })
 </script>
 
 <template>
-  <v-ace-editor
+  <VAceEditor
     v-model:value="value"
     lang="nginx"
     theme="monokai"
     :style="{
-            minHeight: defaultHeight || '100vh',
-            borderRadius: '5px'
-        }"/>
+      minHeight: defaultHeight || '100vh',
+      borderRadius: '5px',
+    }"
+  />
 </template>
 
 <style scoped>

+ 1 - 1
app/src/components/CodeEditor/index.ts

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

+ 25 - 19
app/src/components/EnvIndicator/EnvIndicator.vue

@@ -1,15 +1,15 @@
 <script setup lang="ts">
-import {useGettext} from 'vue3-gettext'
-import {CloseOutlined, DashboardOutlined, DatabaseOutlined} from '@ant-design/icons-vue'
-import {useSettingsStore} from '@/pinia'
-import {storeToRefs} from 'pinia'
-import {useRouter} from 'vue-router'
-import {computed, watch} from 'vue'
+import { useGettext } from 'vue3-gettext'
+import { CloseOutlined, DashboardOutlined, DatabaseOutlined } from '@ant-design/icons-vue'
+import { storeToRefs } from 'pinia'
+import { useRouter } from 'vue-router'
+import { computed, watch } from 'vue'
+import { useSettingsStore } from '@/pinia'
 
-const {$gettext} = useGettext()
+const { $gettext } = useGettext()
 const settingsStore = useSettingsStore()
 
-const {environment} = storeToRefs(settingsStore)
+const { environment } = storeToRefs(settingsStore)
 const router = useRouter()
 
 async function clear_env() {
@@ -32,17 +32,23 @@ watch(node_id, async () => {
 <template>
   <div class="indicator">
     <div class="container">
-      <database-outlined/>
-      <span class="env-name" v-if="is_local">
-                 {{ $gettext('Local') }}
-            </span>
-      <span class="env-name" v-else>
-                 {{ environment.name }}
-            </span>
-      <a-tag @click="clear_env">
-        <dashboard-outlined v-if="is_local"/>
-        <close-outlined v-else/>
-      </a-tag>
+      <DatabaseOutlined />
+      <span
+        v-if="is_local"
+        class="env-name"
+      >
+        {{ $gettext('Local') }}
+      </span>
+      <span
+        v-else
+        class="env-name"
+      >
+        {{ environment.name }}
+      </span>
+      <ATag @click="clear_env">
+        <DashboardOutlined v-if="is_local" />
+        <CloseOutlined v-else />
+      </ATag>
     </div>
   </div>
 </template>

+ 7 - 5
app/src/components/FooterToolbar/FooterToolBar.vue

@@ -6,16 +6,18 @@ defineProps<{
 </script>
 
 <template>
-  <teleport to="body">
-    <div class="ant-pro-footer-toolbar" ref="refToolBar">
+  <Teleport to="body">
+    <div class="ant-pro-footer-toolbar">
       <div style="float: left">
-        <slot name="extra">{{ extra }}</slot>
+        <slot name="extra">
+          {{ extra }}
+        </slot>
       </div>
       <div style="float: right">
-        <slot></slot>
+        <slot />
       </div>
     </div>
-  </teleport>
+  </Teleport>
 </template>
 
 <style lang="less" scoped>

+ 7 - 2
app/src/components/Logo/Logo.vue

@@ -3,8 +3,13 @@ import logo from '@/assets/img/logo.png'</script>
 
 <template>
   <div class="logo">
-    <img :src="logo" alt="logo"/>
-    <p class="text">Nginx UI</p>
+    <img
+      :src="logo"
+      alt="logo"
+    >
+    <p class="text">
+      Nginx UI
+    </p>
   </div>
 </template>
 

+ 58 - 36
app/src/components/NginxControl/NginxControl.vue

@@ -1,36 +1,32 @@
 <script setup lang="ts">
+import { message } from 'ant-design-vue'
+import { ReloadOutlined } from '@ant-design/icons-vue'
 import gettext from '@/gettext'
 import ngx from '@/api/ngx'
-import logLevel from '@/views/config/constants'
-import {message} from 'ant-design-vue'
-import {ReloadOutlined} from '@ant-design/icons-vue'
-
-import {ref, watch} from 'vue'
-
-const {$gettext} = gettext
+import { logLevel } from '@/views/config/constants'
 
+const { $gettext } = gettext
+const status = ref(0)
 function get_status() {
   ngx.status().then(r => {
-    if (r?.running === true) {
+    if (r?.running === true)
       status.value = 0
-    } else {
+    else
       status.value = -1
-    }
   })
 }
 
 function reload_nginx() {
   status.value = 1
   ngx.reload().then(r => {
-    if (r.level < logLevel.Warn) {
+    if (r.level < logLevel.Warn)
       message.success($gettext('Nginx reloaded successfully'))
-    } else if (r.level === logLevel.Warn) {
+    else if (r.level === logLevel.Warn)
       message.warn(r.message)
-    } else {
+    else
       message.error(r.message)
-    }
   }).catch(e => {
-    message.error($gettext('Server error') + ' ' + e?.message)
+    message.error(`${$gettext('Server error')} ${e?.message}`)
   }).finally(() => {
     status.value = 0
   })
@@ -39,52 +35,78 @@ function reload_nginx() {
 function restart_nginx() {
   status.value = 2
   ngx.restart().then(r => {
-    if (r.level < logLevel.Warn) {
+    if (r.level < logLevel.Warn)
       message.success($gettext('Nginx restarted successfully'))
-    } else if (r.level === logLevel.Warn) {
+    else if (r.level === logLevel.Warn)
       message.warn(r.message)
-    } else {
+    else
       message.error(r.message)
-    }
   }).catch(e => {
-    message.error($gettext('Server error') + ' ' + e?.message)
+    message.error(`${$gettext('Server error')} ${e?.message}`)
   }).finally(() => {
     status.value = 0
   })
 }
 
-const status = ref(0)
-
 const visible = ref(false)
 
-watch(visible, (v) => {
-  if (v) get_status()
+watch(visible, v => {
+  if (v)
+    get_status()
 })
 </script>
 
 <template>
-  <a-popover
+  <APopover
     v-model:open="visible"
-    @confirm="reload_nginx"
     placement="bottomRight"
+    @confirm="reload_nginx"
   >
     <template #content>
       <div class="content-wrapper">
         <h4>{{ $gettext('Nginx Control') }}</h4>
-        <a-badge v-if="status===0" color="green" :text="$gettext('Running')"/>
-        <a-badge v-else-if="status===1" color="blue" :text="$gettext('Reloading')"/>
-        <a-badge v-else-if="status===2" color="orange" :text="$gettext('Restarting')"/>
-        <a-badge v-else color="red" :text="$gettext('Stopped')"/>
+        <ABadge
+          v-if="status === 0"
+          color="green"
+          :text="$gettext('Running')"
+        />
+        <ABadge
+          v-else-if="status === 1"
+          color="blue"
+          :text="$gettext('Reloading')"
+        />
+        <ABadge
+          v-else-if="status === 2"
+          color="orange"
+          :text="$gettext('Restarting')"
+        />
+        <ABadge
+          v-else
+          color="red"
+          :text="$gettext('Stopped')"
+        />
       </div>
-      <a-space>
-        <a-button size="small" @click="restart_nginx" type="link">{{ $gettext('Restart') }}</a-button>
-        <a-button size="small" @click="reload_nginx" type="link">{{ $gettext('Reload') }}</a-button>
-      </a-space>
+      <ASpace>
+        <AButton
+          size="small"
+          type="link"
+          @click="restart_nginx"
+        >
+          {{ $gettext('Restart') }}
+        </AButton>
+        <AButton
+          size="small"
+          type="link"
+          @click="reload_nginx"
+        >
+          {{ $gettext('Reload') }}
+        </AButton>
+      </ASpace>
     </template>
     <a>
-      <ReloadOutlined/>
+      <ReloadOutlined />
     </a>
-  </a-popover>
+  </APopover>
 </template>
 
 <style lang="less" scoped>

+ 57 - 24
app/src/components/NodeSelector/NodeSelector.vue

@@ -1,20 +1,26 @@
 <script setup lang="ts">
-import {computed, ref} from 'vue'
+import { useGettext } from 'vue3-gettext'
+import type { Ref } from 'vue'
+import type { Environment } from '@/api/environment'
 import environment from '@/api/environment'
-import {useGettext} from 'vue3-gettext'
 
-const {$gettext} = useGettext()
+const props = defineProps<{
+  target: number[]
+  map?: Record<number, string>
+  hiddenLocal?: boolean
+}>()
 
-const props = defineProps(['target', 'map', 'hidden_local'])
-const emit = defineEmits(['update:target'])
+const emit = defineEmits(['update:target', 'update:map'])
 
-const data = ref([])
-const data_map = ref({})
+const { $gettext } = useGettext()
+
+const data = ref([]) as Ref<Environment[]>
+const data_map = ref({}) as Ref<Record<number, Environment>>
 
 environment.get_list().then(r => {
   data.value = r.data
   r.data.forEach(node => {
-    data_map[node.id] = node
+    data_map.value[node.id] = node
   })
 })
 
@@ -25,29 +31,56 @@ const value = computed({
   set(v) {
     if (typeof props.map === 'object') {
       v.forEach(id => {
-        if (id !== 0) props.map[id] = data_map[id].name
+        if (id !== 0)
+          emit('update:map', { ...props.map, [id]: data_map.value[id].name })
       })
     }
     emit('update:target', v)
-  }
+  },
 })
 </script>
 
 <template>
-  <a-checkbox-group v-model:value="value" style="width: 100%">
-    <a-row :gutter="[16,16]">
-      <a-col :span="8" v-if="!hidden_local">
-        <a-checkbox :value="0">{{ $gettext('Local') }}</a-checkbox>
-        <a-tag color="blue">{{ $gettext('Online') }}</a-tag>
-      </a-col>
-      <a-col :span="8" v-for="node in data">
-        <a-checkbox :value="node.id">{{ node.name }}</a-checkbox>
-        <a-tag color="blue" v-if="node.status">{{ $gettext('Online') }}</a-tag>
-        <a-tag color="error" v-else>{{ $gettext('Offline') }}</a-tag>
-      </a-col>
-    </a-row>
-    <a-empty v-if="hidden_local&&data.length===0"/>
-  </a-checkbox-group>
+  <ACheckboxGroup
+    v-model:value="value"
+    style="width: 100%"
+  >
+    <ARow :gutter="[16, 16]">
+      <ACol
+        v-if="!hiddenLocal"
+        :span="8"
+      >
+        <ACheckbox :value="0">
+          {{ $gettext('Local') }}
+        </ACheckbox>
+        <ATag color="blue">
+          {{ $gettext('Online') }}
+        </ATag>
+      </ACol>
+      <ACol
+        v-for="(node, index) in data"
+        :key="index"
+        :span="8"
+      >
+        <ACheckbox :value="node.id">
+          {{ node.name }}
+        </ACheckbox>
+        <ATag
+          v-if="node.status"
+          color="blue"
+        >
+          {{ $gettext('Online') }}
+        </ATag>
+        <ATag
+          v-else
+          color="error"
+        >
+          {{ $gettext('Offline') }}
+        </ATag>
+      </ACol>
+    </ARow>
+    <AEmpty v-if="hiddenLocal && data.length === 0" />
+  </ACheckboxGroup>
 </template>
 
 <style scoped lang="less">

+ 10 - 13
app/src/components/PageHeader/PageHeader.vue

@@ -1,9 +1,6 @@
 <script setup lang="ts">
+import { useRoute } from 'vue-router'
 import Breadcrumb from '@/components/Breadcrumb/Breadcrumb.vue'
-import {useRoute} from 'vue-router'
-import {computed, ref, watch} from 'vue'
-
-const {title, logo, avatar} = defineProps(['title', 'logo', 'avatar'])
 
 const route = useRoute()
 
@@ -11,26 +8,26 @@ const display = computed(() => {
   return !route.meta.hiddenHeaderContent
 })
 
-const name = ref(route.name)
-watch(() => route.name, () => {
-  name.value = route.name
+const name = computed(() => {
+  return (route.name as never as () => string)()
 })
-
 </script>
 
 <template>
-  <div v-if="display" class="page-header">
+  <div
+    v-if="display"
+    class="page-header"
+  >
     <div class="page-header-index-wide">
-      <Breadcrumb/>
+      <Breadcrumb />
       <div class="detail">
         <div class="main">
           <div class="row">
-            <img v-if="logo" :src="logo" class="logo"/>
             <h1 class="title">
-              {{ name() }}
+              {{ name }}
             </h1>
             <div class="action">
-              <slot name="action"></slot>
+              <slot name="action" />
             </div>
           </div>
         </div>

+ 20 - 12
app/src/components/SetLanguage/SetLanguage.vue

@@ -1,10 +1,8 @@
 <script setup lang="ts">
+import { ref, watch } from 'vue'
 import gettext from '@/gettext'
 
-import {ref, watch} from 'vue'
-
-import {useSettingsStore} from '@/pinia'
-import {useRoute} from 'vue-router'
+import { useSettingsStore } from '@/pinia'
 import http from '@/lib/http'
 
 const settings = useSettingsStore()
@@ -17,7 +15,7 @@ const languageAvailable = gettext.available
 
 function init() {
   if (current.value !== 'en') {
-    http.get('/translation/' + current.value).then(r => {
+    http.get(`/translation/${current.value}`).then(r => {
       gettext.translations[current.value] = r
     })
   }
@@ -25,23 +23,33 @@ function init() {
 
 init()
 
-watch(current, (v) => {
+watch(current, v => {
   init()
   settings.set_language(v)
   gettext.current = v
-  // @ts-ignored
-  document.title = route.name() + ' | Nginx UI'
+
+  const name = route.name as never as () => string
+
+  document.title = `${name()} | Nginx UI`
 })
 
 </script>
 
 <template>
   <div>
-    <a-select v-model:value="current" size="small" style="width: 60px">
-      <a-select-option v-for="(language, key) in languageAvailable" :value="key" :key="key">
+    <ASelect
+      v-model:value="current"
+      size="small"
+      style="width: 60px"
+    >
+      <ASelectOption
+        v-for="(language, key) in languageAvailable"
+        :key="key"
+        :value="key"
+      >
         {{ language }}
-      </a-select-option>
-    </a-select>
+      </ASelectOption>
+    </ASelect>
   </div>
 </template>
 

+ 0 - 201
app/src/components/StdDataDisplay/StdCurd.vue

@@ -1,201 +0,0 @@
-<script setup lang="ts">
-import gettext from '@/gettext'
-import StdTable from './StdTable.vue'
-
-import StdDataEntry from '@/components/StdDataEntry'
-
-import {provide, reactive, ref} from 'vue'
-import {message} from 'ant-design-vue'
-
-const {$gettext} = gettext
-
-const props = defineProps({
-  api: Object,
-  columns: Array,
-  title: String,
-  data_key: {
-    type: String,
-    default: 'data'
-  },
-  disable_search: {
-    type: Boolean,
-    default: false
-  },
-  disable_add: {
-    type: Boolean,
-    default: false
-  },
-  soft_delete: {
-    type: Boolean,
-    default: false
-  },
-  edit_text: String,
-  deletable: {
-    type: Boolean,
-    default: true
-  },
-  get_params: {
-    type: Object,
-    default() {
-      return {}
-    }
-  },
-  editable: {
-    type: Boolean,
-    default: true
-  },
-  beforeSave: {
-    type: Function,
-    default: () => {
-    }
-  },
-  exportCsv: {
-    type: Boolean,
-    default: false
-  },
-  modalWidth: {
-    type: Number,
-    default: 600
-  },
-  useSortable: Boolean
-})
-
-const visible = ref(false)
-const update = ref(0)
-const data: any = reactive({id: null})
-provide('data', data)
-const error: any = reactive({})
-const selected = ref([])
-
-function onSelect(keys: any) {
-  selected.value = keys
-}
-
-function editableColumns() {
-  return props.columns!.filter((c: any) => {
-    return c.edit
-  })
-}
-
-function add() {
-  Object.keys(data).forEach(v => {
-    delete data[v]
-  })
-
-  clear_error()
-  visible.value = true
-}
-
-function get_list() {
-  const t: Table = table.value!
-  t!.get_list()
-}
-
-defineExpose({
-  add,
-  get_list,
-  data
-})
-
-const table = ref(null)
-
-interface Table {
-  get_list(): void
-}
-
-function clear_error() {
-  Object.keys(error).forEach(v => {
-    delete error[v]
-  })
-}
-
-const ok = async () => {
-  clear_error()
-  await props?.beforeSave!?.(data)
-  props.api!.save(data.id, data).then((r: any) => {
-    message.success($gettext('Save Successfully'))
-    Object.assign(data, r)
-    get_list()
-    visible.value = false
-  }).catch((e: any) => {
-    message.error($gettext(e?.message ?? 'Server error'), 5)
-    Object.assign(error, e.errors)
-  })
-}
-
-function cancel() {
-  visible.value = false
-
-  clear_error()
-}
-
-function edit(id: any) {
-  props.api!.get(id).then(async (r: any) => {
-    Object.keys(data).forEach(k => {
-      delete data[k]
-    })
-    data.id = null
-    Object.assign(data, r)
-    visible.value = true
-  }).catch((e: any) => {
-    message.error($gettext(e?.message ?? 'Server error'), 5)
-  })
-}
-
-const selectedRowKeys = ref([])
-</script>
-
-<template>
-  <div class="std-curd">
-    <a-card :title="title||$gettext('Table')">
-      <template v-if="!disable_add" #extra>
-        <a @click="add">{{ $gettext('Add') }}</a>
-      </template>
-
-      <std-table
-        ref="table"
-        v-model:selected-row-keys="selectedRowKeys"
-        v-bind="props"
-        @clickEdit="edit"
-        @selected="onSelect"
-        :key="update"
-      >
-        <template v-slot:actions="slotProps">
-          <slot name="actions" :actions="slotProps.record"/>
-        </template>
-      </std-table>
-    </a-card>
-
-    <a-modal
-      class="std-curd-edit-modal"
-      :mask="false"
-      :title="edit_text?edit_text:(data.id ? $gettext('Modify') : $gettext('Add'))"
-      :open="visible"
-      :cancel-text="$gettext('Cancel')"
-      :ok-text="$gettext('OK')"
-      @cancel="cancel"
-      @ok="ok"
-      :width="modalWidth"
-      destroyOnClose
-    >
-      <div class="before-edit" v-if="$slots.beforeEdit">
-        <slot name="beforeEdit" :data="data"/>
-      </div>
-
-      <std-data-entry
-        ref="std_data_entry"
-        :data-list="editableColumns()"
-        :data-source="data"
-        :error="error"
-      />
-
-      <slot name="edit" :data="data"/>
-    </a-modal>
-  </div>
-</template>
-
-<style lang="less" scoped>
-:deep(.before-edit:last-child) {
-  margin-bottom: 20px;
-}
-</style>

+ 0 - 583
app/src/components/StdDataDisplay/StdTable.vue

@@ -1,583 +0,0 @@
-<script setup lang="ts">
-import gettext from '@/gettext'
-import StdDataEntry from '@/components/StdDataEntry'
-import StdPagination from './StdPagination.vue'
-import {computed, onMounted, reactive, ref, watch} from 'vue'
-import {useRoute, useRouter} from 'vue-router'
-import {message} from 'ant-design-vue'
-import {downloadCsv} from '@/lib/helper'
-import dayjs from 'dayjs'
-import Sortable from 'sortablejs'
-import {HolderOutlined} from '@ant-design/icons-vue'
-import {toRaw} from '@vue/reactivity'
-
-const {$gettext, interpolate} = gettext
-
-const emit = defineEmits(['onSelected', 'onSelectedRecord', 'clickEdit', 'update:selectedRowKeys', 'clickBatchModify'])
-
-const props = defineProps({
-  api: Object,
-  columns: Array,
-  data_key: {
-    type: String,
-    default: 'data'
-  },
-  disable_search: {
-    type: Boolean,
-    default: false
-  },
-  disable_query_params: {
-    type: Boolean,
-    default: false
-  },
-  disable_add: {
-    type: Boolean,
-    default: false
-  },
-  edit_text: String,
-  deletable: {
-    type: Boolean,
-    default: true
-  },
-  get_params: {
-    type: Object,
-    default() {
-      return {}
-    }
-  },
-  editable: {
-    type: Boolean,
-    default: true
-  },
-  selectionType: {
-    type: String,
-    validator: function (value: string) {
-      return ['checkbox', 'radio'].indexOf(value) !== -1
-    }
-  },
-  pithy: {
-    type: Boolean,
-    default: false
-  },
-  scrollX: {
-    type: [Number, Boolean],
-    default: true
-  },
-  rowKey: {
-    type: String,
-    default: 'id'
-  },
-  exportCsv: {
-    type: Boolean,
-    default: false
-  },
-  size: String,
-  selectedRowKeys: {
-    type: Array
-  },
-  useSortable: Boolean
-})
-
-const data_source: any = ref([])
-const expand_keys_list: any = ref([])
-const rows_key_index_map: any = ref({})
-
-const loading = ref(true)
-const pagination = reactive({
-  total: 1,
-  per_page: 10,
-  current_page: 1,
-  total_pages: 1
-})
-
-const route = useRoute()
-const params = reactive({
-  ...props.get_params
-})
-
-const selectedKeysLocalBuffer: any = ref([])
-
-const selectedRowKeysBuffer = computed({
-  get() {
-    return props.selectedRowKeys || selectedKeysLocalBuffer.value
-  },
-  set(v) {
-    selectedKeysLocalBuffer.value = v
-    emit('update:selectedRowKeys', v)
-  }
-})
-
-const searchColumns = getSearchColumns()
-const pithyColumns = getPithyColumns()
-const batchColumns = getBatchEditColumns()
-
-onMounted(() => {
-  if (!props.disable_query_params) {
-    Object.assign(params, route.query)
-  }
-  get_list()
-
-  if (props.useSortable) {
-    initSortable()
-  }
-})
-
-defineExpose({
-  get_list
-})
-
-function destroy(id: any) {
-  props.api!.destroy(id).then(() => {
-    get_list()
-    message.success(interpolate($gettext('Delete ID: %{id}'), {id: id}))
-  }).catch((e: any) => {
-    message.error($gettext(e?.message ?? 'Server error'))
-  })
-}
-
-function get_list(page_num = null, page_size = 20) {
-  loading.value = true
-  if (page_num) {
-    params['page'] = page_num
-    params['page_size'] = page_size
-  }
-  props.api!.get_list(params).then(async (r: any) => {
-    data_source.value = r.data
-    rows_key_index_map.value = {}
-    if (props.useSortable) {
-      function buildIndexMap(data: any, level: number = 0, index: number = 0, total: number[] = []) {
-        if (data && data.length > 0) {
-          data.forEach((v: any) => {
-            v.level = level
-            let current_index = [...total, index++]
-            rows_key_index_map.value[v.id] = current_index
-            if (v.children) buildIndexMap(v.children, level + 1, 0, current_index)
-          })
-        }
-      }
-
-      buildIndexMap(r.data)
-    }
-
-    if (r.pagination !== undefined) {
-      Object.assign(pagination, r.pagination)
-    }
-
-    loading.value = false
-  }).catch((e: any) => {
-    message.error(e?.message ?? $gettext('Server error'))
-  })
-}
-
-function stdChange(pagination: any, filters: any, sorter: any) {
-  if (sorter) {
-    selectedRowKeysBuffer.value = []
-    params['order_by'] = sorter.field
-    params['sort'] = sorter.order === 'ascend' ? 'asc' : 'desc'
-    switch (sorter.order) {
-      case 'ascend':
-        params['sort'] = 'asc'
-        break
-      case 'descend':
-        params['sort'] = 'desc'
-        break
-      default:
-        params['sort'] = null
-        break
-    }
-  }
-  if (pagination) {
-    selectedRowKeysBuffer.value = []
-  }
-}
-
-function expandedTable(keys: any) {
-  expand_keys_list.value = keys
-}
-
-function getSearchColumns() {
-  let searchColumns: any = []
-  props.columns!.forEach((column: any) => {
-    if (column.search) {
-      searchColumns.push(column)
-    }
-  })
-  return searchColumns
-}
-
-function getBatchEditColumns() {
-  let batch: any = []
-  props.columns!.forEach((column: any) => {
-    if (column.batch) {
-      batch.push(column)
-    }
-  })
-  return batch
-}
-
-function getPithyColumns() {
-  if (props.pithy) {
-    return props.columns!.filter((c: any, index: any, columns: any) => {
-      return c.pithy === true && c.display !== false
-    })
-  }
-  return props.columns!.filter((c: any, index: any, columns: any) => {
-    return c.display !== false
-  })
-}
-
-function checked(c: any) {
-  params[c.target.value] = c.target.checked
-}
-
-const crossPageSelect: any = {}
-
-async function onSelectChange(_selectedRowKeys: any) {
-  const page = params.page || 1
-
-  crossPageSelect[page] = await _selectedRowKeys
-
-  let t: any = []
-  Object.keys(crossPageSelect).forEach(v => {
-    t.push(...crossPageSelect[v])
-  })
-  const n: any = [..._selectedRowKeys]
-  t = await t.concat(n)
-  // console.log(crossPageSelect)
-  const set = new Set(t)
-  selectedRowKeysBuffer.value = Array.from(set)
-  emit('onSelected', selectedRowKeysBuffer.value)
-}
-
-function onSelect(record: any) {
-  emit('onSelectedRecord', record)
-}
-
-const router = useRouter()
-
-const reset_search = async () => {
-  Object.keys(params).forEach(v => {
-    delete params[v]
-  })
-
-  Object.assign(params, {
-    ...props.get_params
-  })
-
-  router.push({query: {}}).catch(() => {
-  })
-}
-
-watch(params, () => {
-  if (!props.disable_query_params) {
-    router.push({query: params})
-  }
-  get_list()
-})
-
-const rowSelection = computed(() => {
-  if (batchColumns.length > 0 || props.selectionType) {
-    return {
-      selectedRowKeys: selectedRowKeysBuffer.value, onChange: onSelectChange,
-      onSelect: onSelect, type: batchColumns.length > 0 ? 'checkbox' : props.selectionType
-    }
-  } else {
-    return null
-  }
-})
-
-function fn(obj: Object, desc: string) {
-  const arr: string[] = desc.split('.')
-  while (arr.length) {
-    // @ts-ignore
-    const top = obj[arr.shift()]
-    if (top === undefined) {
-      return null
-    }
-    obj = top
-  }
-  return obj
-}
-
-async function export_csv() {
-  let header = []
-  let headerKeys: any[] = []
-  const showColumnsMap: any = {}
-  // @ts-ignore
-  for (let showColumnsKey in pithyColumns) {
-    // @ts-ignore
-    if (pithyColumns[showColumnsKey].dataIndex === 'action') continue
-    // @ts-ignore
-    let t = pithyColumns[showColumnsKey].title
-
-    if (typeof t === 'function') {
-      t = t()
-    }
-    header.push({
-      title: t,
-      // @ts-ignore
-      key: pithyColumns[showColumnsKey].dataIndex
-    })
-    // @ts-ignore
-    headerKeys.push(pithyColumns[showColumnsKey].dataIndex)
-    // @ts-ignore
-    showColumnsMap[pithyColumns[showColumnsKey].dataIndex] = pithyColumns[showColumnsKey]
-  }
-
-  let dataSource: any = []
-  let hasMore = true
-  let page = 1
-  while (hasMore) {
-    // 准备 DataSource
-    await props.api!.get_list({page}).then((response: any) => {
-      if (response.data.length === 0) {
-        hasMore = false
-        return
-      }
-      if (response[props.data_key] === undefined) {
-        dataSource = dataSource.concat(...response.data)
-      } else {
-        dataSource = dataSource.concat(...response[props.data_key])
-      }
-    }).catch((e: any) => {
-      message.error(e.message ?? $gettext('Server error'))
-      hasMore = false
-      return
-    })
-    page += 1
-  }
-  const data: any[] = []
-  dataSource.forEach((row: Object) => {
-    let obj: any = {}
-    headerKeys.forEach(key => {
-      let data = fn(row, key)
-      const c = showColumnsMap[key]
-      data = c?.customRender?.({text: data}) ?? data
-      obj[c.dataIndex] = data
-    })
-    data.push(obj)
-  })
-
-  downloadCsv(header, data,
-    `${$gettext('Export')}-${dayjs().format('YYYYMMDDHHmmss')}.csv`)
-}
-
-const hasSelectedRow = computed(() => {
-  return batchColumns.length > 0 && selectedRowKeysBuffer.value.length > 0
-})
-
-function click_batch_edit() {
-  emit('clickBatchModify', batchColumns, selectedRowKeysBuffer.value)
-}
-
-function getLeastIndex(index: number) {
-  return index >= 1 ? index : 1
-}
-
-function getTargetData(data: any, indexList: number[]): any {
-  let target: any = {children: data}
-  indexList.forEach((index: number) => {
-    target.children[index].parent = target
-    target = target.children[index]
-  })
-  return target
-}
-
-function initSortable() {
-  const table: any = document.querySelector('#std-table tbody')
-  new Sortable(table, {
-    handle: '.ant-table-drag-icon',
-    animation: 150,
-    sort: true,
-    forceFallback: true,
-    setData: function (dataTransfer) {
-      dataTransfer.setData('Text', '')
-    },
-    onStart({item}) {
-      let targetRowKey = Number(item.dataset.rowKey)
-      if (targetRowKey) {
-        expand_keys_list.value = expand_keys_list.value.filter((item: number) => item !== targetRowKey)
-      }
-    },
-    onMove({dragged, related}) {
-      const oldRow: number[] = rows_key_index_map.value?.[Number(dragged.dataset.rowKey)]
-      const newRow: number[] = rows_key_index_map.value?.[Number(related.dataset.rowKey)]
-      if (oldRow.length !== newRow.length || oldRow[oldRow.length - 2] != newRow[newRow.length - 2]) {
-        return false
-      }
-    },
-    async onEnd({item, newIndex, oldIndex}) {
-      if (newIndex === oldIndex) return
-
-      const indexDelta: number = Number(oldIndex) - Number(newIndex)
-      const direction: number = indexDelta > 0 ? +1 : -1
-
-      let rowIndex: number[] = rows_key_index_map.value?.[Number(item.dataset.rowKey)]
-      const newRow = getTargetData(data_source.value, rowIndex)
-      const newRowParent = newRow.parent
-      const level: number = newRow.level
-
-      let currentRowIndex: number[] = [...rows_key_index_map.value?.
-        [Number(table.children[Number(newIndex) + direction].dataset.rowKey)]]
-      let currentRow: any = getTargetData(data_source.value, currentRowIndex)
-      // Reset parent
-      currentRow.parent = newRow.parent = null
-      newRowParent.children.splice(rowIndex[level], 1)
-      newRowParent.children.splice(currentRowIndex[level], 0, toRaw(newRow))
-
-      let changeIds: number[] = []
-
-      function processChanges(row: any, children: boolean = false, newIndex: number | undefined = undefined) {
-        // Build changes ID list expect new row
-        if (children || newIndex === undefined) changeIds.push(row.id)
-
-        if (newIndex !== undefined)
-          rows_key_index_map.value[row.id][level] = newIndex
-        else if (children)
-          rows_key_index_map.value[row.id][level] += direction
-
-        row.parent = null
-        if (row.children) {
-          row.children.forEach((v: any) => processChanges(v, true, newIndex))
-        }
-      }
-
-      // Replace row index for new row
-      processChanges(newRow, false, currentRowIndex[level])
-      // Rebuild row index maps for changes row
-      for (let i = Number(oldIndex); i != newIndex; i -= direction) {
-        let rowIndex: number[] = rows_key_index_map.value?.[table.children[i].dataset.rowKey]
-        rowIndex[level] += direction
-        processChanges(getTargetData(data_source.value, rowIndex))
-      }
-      console.log('Change row id', newRow.id, 'order', newRow.id, '=>', currentRow.id, ', direction: ', direction,
-        ', changes IDs:', changeIds)
-
-      props.api!.update_order({
-        target_id: newRow.id,
-        direction: direction,
-        affected_ids: changeIds
-      }).then(() => {
-        message.success($gettext('Updated successfully'))
-      }).catch((e: any) => {
-        message.error(e?.message ?? $gettext('Server error'))
-      })
-    }
-  })
-}
-
-
-</script>
-
-<template>
-  <div class="std-table">
-    <std-data-entry
-      v-if="!disable_search && searchColumns.length"
-      :data-list="searchColumns"
-      :data-source="params"
-      layout="inline"
-    >
-      <template #action>
-        <a-space class="action-btn">
-          <a-button v-if="exportCsv" @click="export_csv" type="primary" ghost>
-            {{ $gettext('Export') }}
-          </a-button>
-          <a-button @click="reset_search">
-            {{ $gettext('Reset') }}
-          </a-button>
-          <a-button v-if="hasSelectedRow" @click="click_batch_edit">
-            {{ $gettext('Batch Modify') }}
-          </a-button>
-        </a-space>
-      </template>
-    </std-data-entry>
-    <a-table
-      :columns="pithyColumns"
-      :data-source="data_source"
-      :loading="loading"
-      :pagination="false"
-      :row-key="rowKey"
-      :rowSelection="rowSelection"
-      @change="stdChange"
-      :scroll="{ x: scrollX }"
-      :size="size"
-      id="std-table"
-      @expandedRowsChange="expandedTable"
-      :expandedRowKeys="expand_keys_list"
-    >
-      <template
-        v-slot:bodyCell="{text, record, index, column}"
-      >
-        <template v-if="column.handle === true">
-          <span class="ant-table-drag-icon"><HolderOutlined/></span>
-          {{ text }}
-        </template>
-        <template v-if="column.dataIndex === 'action'">
-          <a-button type="link" size="small" v-if="props.editable"
-                    @click="$emit('clickEdit', record[props.rowKey], record)">
-            {{ props.edit_text || $gettext('Modify') }}
-          </a-button>
-          <slot name="actions" :record="record"/>
-          <template v-if="props.deletable">
-            <a-divider type="vertical"/>
-            <a-popconfirm
-              :cancelText="$gettext('No')"
-              :okText="$gettext('OK')"
-              :title="$gettext('Are you sure you want to delete?')"
-              @confirm="destroy(record[rowKey])">
-              <a-button type="link" size="small">{{ $gettext('Delete') }}</a-button>
-            </a-popconfirm>
-          </template>
-        </template>
-      </template>
-    </a-table>
-    <std-pagination :size="size" :pagination="pagination" @change="get_list" @changePageSize="stdChange"/>
-  </div>
-</template>
-
-<style lang="less">
-.ant-table-scroll {
-  .ant-table-body {
-    overflow-x: auto !important;
-  }
-}
-</style>
-
-<style lang="less" scoped>
-.ant-form {
-  margin: 10px 0 20px 0;
-}
-
-.ant-slider {
-  min-width: 90px;
-}
-
-.std-table {
-  .ant-table-wrapper {
-    // overflow-x: scroll;
-  }
-}
-
-.action-btn {
-  // min-height: 50px;
-  height: 100%;
-  display: flex;
-  align-items: flex-start;
-}
-
-:deep(.ant-form-inline .ant-form-item) {
-  margin-bottom: 10px;
-}
-</style>
-
-<style lang="less">
-.ant-table-drag-icon {
-  float: left;
-  margin-right: 16px;
-  cursor: grab;
-}
-
-.sortable-ghost *, .sortable-chosen * {
-  cursor: grabbing !important;
-}
-</style>

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


+ 0 - 37
app/src/components/StdDataEntry/StdDataEntry.tsx

@@ -1,37 +0,0 @@
-import {defineComponent} from 'vue'
-import {Form} from 'ant-design-vue'
-import StdFormItem from '@/components/StdDataEntry/StdFormItem.vue'
-import './style.less'
-
-export default defineComponent({
-  props: ['dataList', 'dataSource', 'error', 'layout'],
-  emits: ['update:dataSource'],
-  setup(props, {slots}) {
-    return () => {
-      const template: any = []
-      props.dataList.forEach((v: any) => {
-        let show = true
-        if (v.edit.show) {
-          if (typeof v.edit.show === 'boolean') {
-            show = v.edit.show
-          } else if (typeof v.edit.show === 'function') {
-            show = v.edit.show(props.dataSource)
-          }
-        }
-        if (v.edit.type && show) {
-          template.push(
-            <StdFormItem dataIndex={v.dataIndex} label={v.title()} extra={v.extra} error={props.error}>
-              {v.edit.type(v.edit, props.dataSource, v.dataIndex)}
-            </StdFormItem>
-          )
-        }
-      })
-
-      if (slots.action) {
-        template.push(<div class={'std-data-entry-action'}>{slots.action()}</div>)
-      }
-
-      return <Form layout={props.layout || 'vertical'}>{template}</Form>
-    }
-  }
-})

+ 0 - 45
app/src/components/StdDataEntry/StdFormItem.vue

@@ -1,45 +0,0 @@
-<script setup lang="ts">
-import {computed} from 'vue'
-import {useGettext} from 'vue3-gettext'
-
-const {$gettext} = useGettext()
-
-export interface Props {
-  dataIndex?: string
-  label?: string
-  extra?: string
-  error?: any
-}
-
-const props = defineProps<Props>()
-
-const tag = computed(() => {
-  return props.error?.[props.dataIndex] ?? ''
-})
-
-const valid_status = computed(() => {
-  if (!!tag.value) {
-    return 'error'
-  } else {
-    return 'success'
-  }
-})
-
-const help = computed(() => {
-  if (tag.value.indexOf('required') > -1) {
-    return () => $gettext('This field should not be empty')
-  }
-  return () => {
-  }
-})
-</script>
-
-<template>
-  <a-form-item :label="label" :extra="extra" :validate-status="valid_status" :help="help?.()">
-    <slot/>
-  </a-form-item>
-</template>
-
-<style scoped lang="less">
-
-</style>

+ 0 - 45
app/src/components/StdDataEntry/components/StdSelect.vue

@@ -1,45 +0,0 @@
-<script setup lang="ts">
-import {computed, ref} from 'vue'
-import {SelectProps} from 'ant-design-vue'
-
-const props = defineProps(['value', 'mask'])
-const emit = defineEmits(['update:value'])
-
-const options = computed(() => {
-  const _options = ref<SelectProps['options']>([])
-
-  for (const [key, value] of Object.entries(props.mask)) {
-    const v = value as any
-    _options.value!.push({label: v?.(), value: key})
-  }
-
-  return _options
-})
-
-const _value = computed({
-  get() {
-    let v
-
-    if (typeof props.mask?.[props.value] === 'function') {
-      v = props.mask[props.value]()
-    } else if (typeof props.mask?.[props.value] === 'string') {
-      v = props.mask[props.value]
-    } else {
-      v = props.value
-    }
-    return v
-  },
-  set(v) {
-    emit('update:value', v)
-  }
-})
-</script>
-
-<template>
-  <a-select v-model:value="_value"
-            :options="options.value" style="min-width: 180px"/>
-</template>
-
-<style lang="less" scoped>
-
-</style>

+ 0 - 133
app/src/components/StdDataEntry/index.tsx

@@ -1,133 +0,0 @@
-import StdDataEntry from './StdDataEntry.js'
-import {h} from 'vue'
-import {Input, InputNumber, Switch, Textarea} from 'ant-design-vue'
-import StdSelector from './components/StdSelector.vue'
-import StdSelect from './components/StdSelect.vue'
-import StdPassword from './components/StdPassword.vue'
-
-interface IEdit {
-  type: Function
-  placeholder: any
-  mask: any
-  key: any
-  value: any
-  recordValueIndex: any
-  selectionType: any
-  api: Object,
-  columns: any,
-  data_key: any,
-  disable_search: boolean,
-  get_params: Object,
-  description: string
-  generate: boolean
-  min: number
-  max: number,
-  extra: string
-}
-
-function fn(obj: Object, desc: any) {
-  let arr: string[]
-  if (typeof desc === 'string') {
-    arr = desc.split('.')
-  } else {
-    arr = [...desc]
-  }
-
-  while (arr.length) {
-    // @ts-ignore
-    const top = obj[arr.shift()]
-    if (top === undefined) {
-      return null
-    }
-    obj = top
-  }
-  return obj
-}
-
-function readonly(edit: IEdit, dataSource: any, dataIndex: any) {
-  return h('p', fn(dataSource, dataIndex))
-}
-
-function input(edit: IEdit, dataSource: any, dataIndex: any) {
-  return h(Input, {
-    placeholder: edit.placeholder?.() ?? '',
-    value: dataSource?.[dataIndex],
-    'onUpdate:value': value => {
-      dataSource[dataIndex] = value
-    }
-  })
-}
-
-function inputNumber(edit: IEdit, dataSource: any, dataIndex: any) {
-  return h(InputNumber, {
-    placeholder: edit.placeholder?.() ?? '',
-    min: edit.min,
-    max: edit.max,
-    value: dataSource?.[dataIndex],
-    'onUpdate:value': value => {
-      dataSource[dataIndex] = value
-    }
-  })
-}
-
-function textarea(edit: IEdit, dataSource: any, dataIndex: any) {
-  return h(Textarea, {
-    placeholder: edit.placeholder?.() ?? '',
-    value: dataSource?.[dataIndex],
-    'onUpdate:value': value => {
-      dataSource[dataIndex] = value
-    }
-  })
-}
-
-function password(edit: IEdit, dataSource: any, dataIndex: any) {
-  return <StdPassword
-    v-model:value={dataSource[dataIndex]}
-    generate={edit.generate}
-    placeholder={edit.placeholder}
-  />
-}
-
-function select(edit: IEdit, dataSource: any, dataIndex: any) {
-  return <StdSelect
-    v-model:value={dataSource[dataIndex]}
-    mask={edit.mask}
-  />
-}
-
-function selector(edit: IEdit, dataSource: any, dataIndex: any) {
-  return <StdSelector
-    v-model:selectedKey={dataSource[dataIndex]}
-    value={edit.value}
-    recordValueIndex={edit.recordValueIndex}
-    selectionType={edit.selectionType}
-    api={edit.api}
-    columns={edit.columns}
-    data_key={edit.data_key}
-    disable_search={edit.disable_search}
-    get_params={edit.get_params}
-    description={edit.description}
-  />
-}
-
-function antSwitch(edit: IEdit, dataSource: any, dataIndex: any) {
-  return h(Switch, {
-    checked: dataSource?.[dataIndex],
-    'onUpdate:checked': (value: any) => {
-      dataSource[dataIndex] = value
-    }
-  })
-}
-
-export {
-  readonly,
-  input,
-  textarea,
-  select,
-  selector,
-  password,
-  inputNumber,
-  antSwitch
-}
-
-export default StdDataEntry

+ 18 - 17
app/src/components/StdDataDisplay/StdBatchEdit.vue → app/src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue

@@ -1,21 +1,24 @@
 <script setup lang="ts">
-import {reactive, ref} from 'vue'
+import { message } from 'ant-design-vue'
 import gettext from '@/gettext'
-import StdDataEntry from '@/components/StdDataEntry'
-import {message} from 'ant-design-vue'
+import StdDataEntry from '@/components/StdDesign/StdDataEntry'
 
-const {$gettext} = gettext
+const props = defineProps<{
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  api: (ids: number[], data: any) => Promise<void>
+  beforeSave?: () => Promise<void>
+}>()
 
 const emit = defineEmits(['onSave'])
 
-const props = defineProps(['api', 'beforeSave'])
+const { $gettext } = gettext
 
 const batchColumns = ref([])
 
 const visible = ref(false)
 
 const selectedRowKeys = ref([])
-
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 function showModal(c: any, rowKeys: any) {
   visible.value = true
   selectedRowKeys.value = rowKeys
@@ -23,7 +26,7 @@ function showModal(c: any, rowKeys: any) {
 }
 
 defineExpose({
-  showModal
+  showModal,
 })
 
 const data = reactive({})
@@ -38,7 +41,7 @@ async function ok() {
   await props.api(selectedRowKeys.value, data).then(async () => {
     message.success($gettext('Save successfully'))
     emit('onSave')
-  }).catch((e: any) => {
+  }).catch(e => {
     message.error($gettext(e?.message) ?? $gettext('Server error'))
   }).finally(() => {
     loading.value = false
@@ -47,28 +50,26 @@ async function ok() {
 </script>
 
 <template>
-  <a-modal
+  <AModal
+    v-model:open="visible"
     class="std-curd-edit-modal"
     :mask="false"
     :title="$gettext('Batch Modify')"
-    v-model:open="visible"
     :cancel-text="$gettext('Cancel')"
     :ok-text="$gettext('OK')"
-    @ok="ok"
     :confirm-loading="loading"
     :width="600"
-    destroyOnClose
+    destroy-on-close
+    @ok="ok"
   >
-
-    <std-data-entry
-      ref="std_data_entry"
+    <StdDataEntry
       :data-list="batchColumns"
       :data-source="data"
       :error="error"
     />
 
-    <slot name="extra"/>
-  </a-modal>
+    <slot name="extra" />
+  </AModal>
 </template>
 
 <style scoped>

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

@@ -0,0 +1,174 @@
+<script setup lang="ts">
+import { message } from 'ant-design-vue'
+import type { ComputedRef } from 'vue'
+import type { StdTableProps } from './StdTable.vue'
+import StdTable from './StdTable.vue'
+import gettext from '@/gettext'
+import StdDataEntry from '@/components/StdDesign/StdDataEntry'
+import type { Column } from '@/components/StdDesign/types'
+
+export interface StdCurdProps {
+  cardTitleKey?: string
+  modalMaxWidth?: string | number
+  disableAdd?: boolean
+  onClickAdd?: () => void
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  onClickEdit?: (id: number | string, record: any, index: number) => void
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  beforeSave?: (data: any) => Promise<void>
+}
+
+const props = defineProps<StdTableProps & StdCurdProps>()
+
+const { $gettext } = gettext
+
+const visible = ref(false)
+const update = ref(0)
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const data: any = reactive({ id: null })
+
+provide('data', data)
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const error: any = reactive({})
+const selected = ref([])
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function onSelect(keys: any) {
+  selected.value = keys
+}
+
+const editableColumns = computed(() => {
+  return props.columns!.filter(c => {
+    return c.edit
+  })
+}) as ComputedRef<Column[]>
+
+function add() {
+  Object.keys(data).forEach(v => {
+    delete data[v]
+  })
+
+  clear_error()
+  visible.value = true
+}
+const table = ref()
+function get_list() {
+  table.value?.get_list()
+}
+
+defineExpose({
+  add,
+  get_list,
+  data,
+})
+
+function clear_error() {
+  Object.keys(error).forEach(v => {
+    delete error[v]
+  })
+}
+
+const ok = async () => {
+  clear_error()
+  await props?.beforeSave?.(data)
+  props.api!.save(data.id, data).then(r => {
+    message.success($gettext('Save Successfully'))
+    Object.assign(data, r)
+    get_list()
+    visible.value = false
+  }).catch(e => {
+    message.error($gettext(e?.message ?? 'Server error'), 5)
+    Object.assign(error, e.errors)
+  })
+}
+
+function cancel() {
+  visible.value = false
+
+  clear_error()
+}
+
+function edit(id: number | string) {
+  props.api!.get(id).then(async r => {
+    Object.keys(data).forEach(k => {
+      delete data[k]
+    })
+    data.id = null
+    Object.assign(data, r)
+    visible.value = true
+  }).catch(e => {
+    message.error($gettext(e?.message ?? 'Server error'), 5)
+  })
+}
+
+const selectedRowKeys = ref([])
+</script>
+
+<template>
+  <div class="std-curd">
+    <ACard :title="title || $gettext('Table')">
+      <template
+        v-if="!disableAdd"
+        #extra
+      >
+        <a @click="add">{{ $gettext('Add') }}</a>
+      </template>
+
+      <StdTable
+        ref="table"
+        v-bind="props"
+        :key="update"
+        v-model:selected-row-keys="selectedRowKeys"
+        @click-edit="edit"
+        @selected="onSelect"
+      >
+        <template #actions="slotProps">
+          <slot
+            name="actions"
+            :actions="slotProps.record"
+          />
+        </template>
+      </StdTable>
+    </ACard>
+
+    <AModal
+      class="std-curd-edit-modal"
+      :mask="false"
+      :title="data.id ? $gettext('Modify') : $gettext('Add')"
+      :open="visible"
+      :cancel-text="$gettext('Cancel')"
+      :ok-text="$gettext('OK')"
+      :width="modalMaxWidth"
+      destroy-on-close
+      @cancel="cancel"
+      @ok="ok"
+    >
+      <div
+        v-if="$slots.beforeEdit"
+        class="before-edit"
+      >
+        <slot
+          name="beforeEdit"
+          :data="data"
+        />
+      </div>
+
+      <StdDataEntry
+        :data-list="editableColumns"
+        :data-source="data"
+        :error="error"
+      />
+
+      <slot
+        name="edit"
+        :data="data"
+      />
+    </AModal>
+  </div>
+</template>
+
+<style lang="less" scoped>
+:deep(.before-edit:last-child) {
+  margin-bottom: 20px;
+}
+</style>

+ 16 - 10
app/src/components/StdDataDisplay/StdPagination.vue → app/src/components/StdDesign/StdDataDisplay/StdPagination.vue

@@ -1,10 +1,13 @@
 <script setup lang="ts">
-import {useGettext} from 'vue3-gettext'
-import {computed} from 'vue'
 
-const props = defineProps(['pagination', 'size'])
-const emit = defineEmits(['change', 'changePageSize'])
-const {$gettext} = useGettext()
+import type { Pagination } from '@/api/curd'
+
+const props = defineProps<{
+  pagination: Pagination
+  size?: string
+}>()
+
+const emit = defineEmits(['change', 'changePageSize', 'update:pagination'])
 
 function change(num: number, pageSize: number) {
   emit('change', num, pageSize)
@@ -16,16 +19,19 @@ const pageSize = computed({
   },
   set(v) {
     emit('changePageSize', v)
-    props.pagination.per_page = v
-  }
+    emit('update:pagination', { ...props.pagination, per_page: v })
+  },
 })
 </script>
 
 <template>
-  <div class="pagination-container" v-if="pagination.total>pagination.per_page">
-    <a-pagination
-      :current="pagination.current_page"
+  <div
+    v-if="pagination.total > pagination.per_page"
+    class="pagination-container"
+  >
+    <APagination
       v-model:pageSize="pageSize"
+      :current="pagination.current_page"
       :size="size"
       :total="pagination.total"
       @change="change"

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

@@ -0,0 +1,415 @@
+<script setup lang="ts">
+import { message } from 'ant-design-vue'
+import { HolderOutlined } from '@ant-design/icons-vue'
+import { useGettext } from 'vue3-gettext'
+import type { ComputedRef, Ref } from 'vue'
+import type { SorterResult } from 'ant-design-vue/lib/table/interface'
+import StdPagination from './StdPagination.vue'
+import StdDataEntry from '@/components/StdDesign/StdDataEntry'
+import type { Pagination } from '@/api/curd'
+import type { Column } from '@/components/StdDesign/types'
+import exportCsvHandler from '@/components/StdDesign/StdDataDisplay/methods/exportCsv'
+import useSortable from '@/components/StdDesign/StdDataDisplay/methods/sortable'
+import type Curd from '@/api/curd'
+
+export interface StdTableProps {
+  title?: string
+  mode?: string
+  rowKey?: string
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  api: Curd<any>
+  columns: Column[]
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  getParams?: Record<string, any>
+  size?: string
+  disableQueryParams?: boolean
+  disableSearch?: boolean
+  pithy?: boolean
+  exportCsv?: boolean
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  overwriteParams?: Record<string, any>
+  disabledModify?: boolean
+  selectionType?: string
+  sortable?: boolean
+  disableDelete?: boolean
+  disablePagination?: boolean
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  selectedRowKeys?: any | any[]
+  sortableMoveHook?: (oldRow: number[], newRow: number[]) => boolean
+  scrollX?: string | number
+}
+
+const props = withDefaults(defineProps<StdTableProps>(), {
+  rowKey: 'id',
+})
+
+const emit = defineEmits(['onSelected', 'onSelectedRecord', 'clickEdit', 'update:selectedRowKeys', 'clickBatchModify'])
+const { $gettext } = useGettext()
+const route = useRoute()
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const dataSource: Ref<any[]> = ref([])
+const expandKeysList: Ref<number[]> = ref([])
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const rowsKeyIndexMap: Ref<Record<number, any>> = ref({})
+const loading = ref(true)
+
+// This can be useful if there are more than one StdTable in the same page.
+const randomId = ref(Math.random().toString(36).substring(2, 8))
+
+const pagination: Pagination = reactive({
+  total: 1,
+  per_page: 10,
+  current_page: 1,
+  total_pages: 1,
+})
+
+const params = reactive({
+  ...props.getParams,
+})
+
+const selectedKeysLocalBuffer = ref([])
+
+const selectedRowKeysBuffer = computed({
+  get() {
+    return props.selectedRowKeys || selectedKeysLocalBuffer.value
+  },
+  set(v) {
+    selectedKeysLocalBuffer.value = v
+    emit('update:selectedRowKeys', v)
+  },
+})
+
+const searchColumns = computed(() => {
+  const _searchColumns: Column[] = []
+
+  props.columns?.forEach(column => {
+    if (column.search)
+      _searchColumns.push(column)
+  })
+
+  return _searchColumns
+})
+
+const pithyColumns = computed(() => {
+  if (props.pithy) {
+    return props.columns?.filter(c => {
+      return c.pithy === true && !c.hidden
+    })
+  }
+
+  return props.columns?.filter(c => {
+    return !c.hidden
+  })
+}) as ComputedRef<Column[]>
+
+const batchColumns = computed(() => {
+  const batch: Column[] = []
+
+  props.columns?.forEach(column => {
+    if (column.batch)
+      batch.push(column)
+  })
+
+  return batch
+})
+
+onMounted(() => {
+  if (!props.disableQueryParams)
+    Object.assign(params, route.query)
+
+  get_list()
+
+  if (props.sortable)
+    initSortable()
+})
+
+defineExpose({
+  get_list,
+})
+
+function destroy(id: number | string) {
+  props.api!.destroy(id).then(() => {
+    get_list()
+    message.success($gettext('Deleted successfully'))
+  }).catch(e => {
+    message.error($gettext(e?.message ?? 'Server error'))
+  })
+}
+
+function get_list(page_num = null, page_size = 20) {
+  loading.value = true
+  if (page_num) {
+    params.page = page_num
+    params.page_size = page_size
+  }
+  props.api?.get_list(params).then(async r => {
+    dataSource.value = r.data
+    rowsKeyIndexMap.value = {}
+    if (props.sortable)
+
+      buildIndexMap(r.data)
+
+    if (r.pagination)
+      Object.assign(pagination, r.pagination)
+
+    loading.value = false
+  }).catch(e => {
+    message.error(e?.message ?? $gettext('Server error'))
+  })
+}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function buildIndexMap(data: any, level: number = 0, index: number = 0, total: number[] = []) {
+  if (data && data.length > 0) {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    data.forEach((v: any) => {
+      v.level = level
+
+      const current_indexes = [...total, index++]
+
+      rowsKeyIndexMap.value[v.id] = current_indexes
+      if (v.children)
+        buildIndexMap(v.children, level + 1, 0, current_indexes)
+    })
+  }
+}
+function orderPaginationChange(_pagination: Pagination, filters: never, sorter: SorterResult) {
+  if (sorter) {
+    selectedRowKeysBuffer.value = []
+    params.order_by = sorter.field
+    params.sort = sorter.order === 'ascend' ? 'asc' : 'desc'
+    switch (sorter.order) {
+      case 'ascend':
+        params.sort = 'asc'
+        break
+      case 'descend':
+        params.sort = 'desc'
+        break
+      default:
+        params.sort = null
+        break
+    }
+  }
+  if (_pagination)
+    selectedRowKeysBuffer.value = []
+}
+
+function expandedTable(keys: number[]) {
+  expandKeysList.value = keys
+}
+
+const crossPageSelect: Record<string, number[]> = {}
+
+async function onSelectChange(_selectedRowKeys: number[]) {
+  const page = params.page || 1
+
+  crossPageSelect[page] = _selectedRowKeys
+
+  let t: number[] = []
+  Object.keys(crossPageSelect).forEach((v: string) => {
+    t.push(...crossPageSelect[v])
+  })
+
+  const n = [..._selectedRowKeys]
+
+  t = t.concat(n)
+
+  // console.log(crossPageSelect)
+  const set = new Set(t)
+
+  selectedRowKeysBuffer.value = Array.from(set)
+  emit('onSelected', selectedRowKeysBuffer.value)
+}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function onSelect(record: any) {
+  emit('onSelectedRecord', record)
+}
+
+const router = useRouter()
+
+const reset_search = async () => {
+  Object.keys(params).forEach(v => {
+    delete params[v]
+  })
+
+  Object.assign(params, {
+    ...props.getParams,
+  })
+
+  router.push({ query: {} }).catch(() => {
+  })
+}
+
+watch(params, () => {
+  if (!props.disableQueryParams)
+    router.push({ query: params })
+
+  get_list()
+})
+
+const rowSelection = computed(() => {
+  if (batchColumns.value.length > 0 || props.selectionType) {
+    return {
+      selectedRowKeys: selectedRowKeysBuffer.value,
+      onChange: onSelectChange,
+      onSelect,
+      type: batchColumns.value.length > 0 ? 'checkbox' : props.selectionType,
+    }
+  }
+  else {
+    return null
+  }
+})
+
+const hasSelectedRow = computed(() => {
+  return batchColumns.value.length > 0 && selectedRowKeysBuffer.value.length > 0
+})
+
+function clickBatchEdit() {
+  emit('clickBatchModify', batchColumns.value, selectedRowKeysBuffer.value)
+}
+
+function initSortable() {
+  useSortable(props, randomId, dataSource, rowsKeyIndexMap, expandKeysList)
+}
+
+function export_csv() {
+  exportCsvHandler(props, pithyColumns)
+}
+</script>
+
+<template>
+  <div class="std-table">
+    <StdDataEntry
+      v-if="!disableSearch && searchColumns.length"
+      :data-list="searchColumns"
+      :data-source="params"
+      layout="inline"
+    >
+      <template #action>
+        <ASpace class="action-btn">
+          <AButton
+            v-if="props.exportCsv"
+            type="primary"
+            ghost
+            @click="export_csv"
+          >
+            {{ $gettext('Export') }}
+          </AButton>
+          <AButton @click="reset_search">
+            {{ $gettext('Reset') }}
+          </AButton>
+          <AButton
+            v-if="hasSelectedRow"
+            @click="clickBatchEdit"
+          >
+            {{ $gettext('Batch Modify') }}
+          </AButton>
+        </ASpace>
+      </template>
+    </StdDataEntry>
+    <ATable
+      id="std-table"
+      :columns="pithyColumns"
+      :data-source="dataSource"
+      :loading="loading"
+      :pagination="false"
+      :row-key="rowKey"
+      :row-selection="rowSelection"
+      :scroll="{ x: scrollX }"
+      :size="size"
+      :expanded-row-keys="expandKeysList"
+      @change="orderPaginationChange"
+      @expanded-rows-change="expandedTable"
+    >
+      <template #bodyCell="{ text, record, column }">
+        <template v-if="column.handle === true">
+          <span class="ant-table-drag-icon"><HolderOutlined /></span>
+          {{ text }}
+        </template>
+        <template v-if="column.dataIndex === 'action'">
+          <AButton
+            v-if="!props.disabledModify"
+            type="link"
+            size="small"
+            @click="$emit('clickEdit', record[props.rowKey], record)"
+          >
+            {{ $gettext('Modify') }}
+          </AButton>
+          <slot
+            name="actions"
+            :record="record"
+          />
+          <template v-if="!props.disableDelete">
+            <ADivider type="vertical" />
+            <APopconfirm
+              :cancel-text="$gettext('No')"
+              :ok-text="$gettext('OK')"
+              :title="$gettext('Are you sure you want to delete?')"
+              @confirm="destroy(record[rowKey])"
+            >
+              <AButton
+                type="link"
+                size="small"
+              >
+                {{ $gettext('Delete') }}
+              </AButton>
+            </APopconfirm>
+          </template>
+        </template>
+      </template>
+    </ATable>
+    <StdPagination
+      :size="size"
+      :pagination="pagination"
+      @change="get_list"
+      @change-page-size="orderPaginationChange"
+    />
+  </div>
+</template>
+
+<style lang="less">
+.ant-table-scroll {
+  .ant-table-body {
+    overflow-x: auto !important;
+  }
+}
+</style>
+
+<style lang="less" scoped>
+.ant-form {
+  margin: 10px 0 20px 0;
+}
+
+.ant-slider {
+  min-width: 90px;
+}
+
+.std-table {
+  .ant-table-wrapper {
+    // overflow-x: scroll;
+  }
+}
+
+.action-btn {
+  // min-height: 50px;
+  height: 100%;
+  display: flex;
+  align-items: flex-start;
+}
+
+:deep(.ant-form-inline .ant-form-item) {
+  margin-bottom: 10px;
+}
+</style>
+
+<style lang="less">
+.ant-table-drag-icon {
+  float: left;
+  margin-right: 16px;
+  cursor: grab;
+}
+
+.sortable-ghost *, .sortable-chosen * {
+  cursor: grabbing !important;
+}
+</style>

+ 8 - 6
app/src/components/StdDataDisplay/StdTableTransformer.tsx → app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx

@@ -2,10 +2,13 @@
 import dayjs from 'dayjs'
 
 export interface customRender {
-  value: any
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   text: any
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   record: any
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   index: any
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   column: any
 }
 
@@ -16,17 +19,16 @@ export const datetime = (args: customRender) => {
 export const date = (args: customRender) => {
   return dayjs(args.text).format('YYYY-MM-DD')
 }
-
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const mask = (args: customRender, maskObj: any) => {
   let v
 
-  if (typeof maskObj?.[args.text] === 'function') {
+  if (typeof maskObj?.[args.text] === 'function')
     v = maskObj[args.text]()
-  } else if (typeof maskObj?.[args.text] === 'string') {
+  else if (typeof maskObj?.[args.text] === 'string')
     v = maskObj[args.text]
-  } else {
+  else
     v = args.text
-  }
 
   return <div>{v}</div>
 }

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

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

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

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

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

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

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