refactor: refactor app and api

This commit is contained in:
Jacky 2023-11-29 22:15:19 +08:00 committed by GitHub
commit 9524e89c17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
190 changed files with 9446 additions and 4526 deletions

View file

@ -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

View file

@ -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

1
.gitignore vendored
View file

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

8
.idea/.gitignore generated vendored Normal file
View file

@ -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

View file

@ -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
.idea/modules.xml generated Normal file
View file

@ -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
.idea/nginx-ui.iml generated Normal file
View file

@ -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
.idea/vcs.xml generated Normal file
View file

@ -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>

View file

@ -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)
return
}
diskStat, err := analytic2.GetDiskStat()
network, err := net.IOCounters(false)
if err != nil {
logger.Error(err)
}
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()
loadAvg, err := load.Avg()
c.JSON(http.StatusOK, gin.H{
"host": hostInfo,
"cpu": gin.H{
"info": cpuInfo,
"user": analytic2.CpuUserRecord,
"total": analytic2.CpuTotalRecord,
if err != nil {
logger.Error(err)
}
c.JSON(http.StatusOK, InitResp{
Host: hostInfo,
CPU: CPURecords{
Info: cpuInfo,
User: analytic.CpuUserRecord,
Total: analytic.CpuTotalRecord,
},
"network": gin.H{
"init": _net,
"bytesRecv": analytic2.NetRecvRecord,
"bytesSent": analytic2.NetSentRecord,
Network: NetworkRecords{
Init: _net,
BytesRecv: analytic.NetRecvRecord,
BytesSent: analytic.NetSentRecord,
},
"disk_io": gin.H{
"writes": analytic2.DiskWriteRecord,
"reads": analytic2.DiskReadRecord,
DiskIO: DiskIORecords{
Writes: analytic.DiskWriteRecord,
Reads: analytic.DiskReadRecord,
},
"memory": memory,
"disk": diskStat,
"loadavg": loadAvg,
Memory: memory,
Disk: diskStat,
LoadAvg: loadAvg,
})
}
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
},
}
// 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)
}
}

70
api/analytic/nodes.go Normal file
View file

@ -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
api/analytic/type.go Normal file
View file

@ -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"`
}

View file

@ -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,

View file

@ -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
api/config/add.go Normal file
View file

@ -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,
})
}

View file

@ -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
api/config/get.go Normal file
View file

@ -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
api/config/list.go Normal file
View file

@ -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
api/config/modify.go Normal file
View file

@ -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
api/cosy/cosy.go Normal file
View file

@ -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
api/cosy/create.go Normal file
View file

@ -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
api/cosy/custom.go Normal file
View file

@ -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
api/cosy/delete.go Normal file
View file

@ -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
api/cosy/error.go Normal file
View file

@ -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
api/cosy/list.go Normal file
View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
api/cosy/order.go Normal file
View file

@ -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
api/cosy/update.go Normal file
View file

@ -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)
}
}

View file

@ -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,
})
}

View file

@ -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
api/openai/store.go Normal file
View file

@ -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
api/sites/advance.go Normal file
View file

@ -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
api/sites/auto_cert.go Normal file
View file

@ -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)
}

View file

@ -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
api/sites/duplicate.go Normal file
View file

@ -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
api/sites/sites.go Normal file
View file

@ -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"`
}

View file

@ -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
app/.eslintrc.js Normal file
View file

@ -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
app/.idea/.gitignore generated vendored Normal file
View file

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

View file

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

13
app/.idea/frontend.iml generated Normal file
View file

@ -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>

View file

@ -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
app/.idea/jsLibraryMappings.xml generated Normal file
View file

@ -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
app/.idea/jsLinters/eslint.xml generated Normal file
View file

@ -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
app/.idea/modules.xml generated Normal file
View file

@ -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
app/.idea/vcs.xml generated Normal file
View file

@ -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
app/.idea/watcherTasks.xml generated Normal file
View file

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

17
app/components.d.ts vendored
View file

@ -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
app/env.d.ts vendored Normal file
View file

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

View file

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

View file

@ -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"

2240
app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -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">

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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'
class Domain extends Curd {
enable(name: string, config: AxiosRequestConfig) {
return http.post(this.baseUrl + '/' + name + '/enable', undefined, config)
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<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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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
app/src/components/Chart/types.d.ts vendored Normal file
View file

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

View file

@ -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;

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>
}
}
})

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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>
}

View file

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

View file

@ -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

View file

@ -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 have changed in this diff Show more