mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-12 02:45:49 +02:00
refactor: refactor app and api
This commit is contained in:
commit
9524e89c17
190 changed files with 9446 additions and 4526 deletions
|
@ -13,11 +13,11 @@ bin = "tmp/main"
|
|||
# Customize binary.
|
||||
full_bin = "APP_ENV=dev APP_USER=air ./tmp/main"
|
||||
# Watch these filename extensions.
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "toml"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "toml", "po"]
|
||||
# Ignore these filename extensions or directories.
|
||||
exclude_dir = ["assets", "tmp", "vendor", "app/node_modules", "upload", "docs", "resources", .ts", ".vue", ".tsx", ".idea"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "app/node_modules", "upload", "docs", "resources", ".idea"]
|
||||
# Watch these directories if you specified.
|
||||
include_dir = ["app/src/language"]
|
||||
include_dir = []
|
||||
# Exclude files.
|
||||
exclude_file = []
|
||||
# Exclude specific regular expressions.
|
||||
|
@ -51,3 +51,6 @@ runner = "green"
|
|||
[misc]
|
||||
# Delete tmp directory on exit
|
||||
clean_on_exit = true
|
||||
|
||||
[screen]
|
||||
keep_scroll = true
|
||||
|
|
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
|
@ -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
1
.gitignore
vendored
|
@ -1,5 +1,4 @@
|
|||
.DS_Store
|
||||
.idea
|
||||
database.db
|
||||
tmp
|
||||
node_modules
|
||||
|
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
|
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
9
.idea/nginx-ui.iml
generated
Normal 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
7
.idea/vcs.xml
generated
Normal 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>
|
|
@ -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
70
api/analytic/nodes.go
Normal 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
52
api/analytic/type.go
Normal 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"`
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
56
api/config/add.go
Normal 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,
|
||||
})
|
||||
}
|
|
@ -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
51
api/config/get.go
Normal 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
69
api/config/list.go
Normal 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
51
api/config/modify.go
Normal 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
134
api/cosy/cosy.go
Normal 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
72
api/cosy/create.go
Normal 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
39
api/cosy/custom.go
Normal 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
91
api/cosy/delete.go
Normal 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
23
api/cosy/error.go
Normal 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
158
api/cosy/list.go
Normal 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)
|
||||
}
|
||||
}
|
56
api/cosy/map2struct/hook.go
Normal file
56
api/cosy/map2struct/hook.go
Normal 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
|
||||
}
|
||||
}
|
24
api/cosy/map2struct/map2struct.go
Normal file
24
api/cosy/map2struct/map2struct.go
Normal 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
42
api/cosy/order.go
Normal 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
90
api/cosy/update.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
44
api/openai/store.go
Normal 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
42
api/sites/advance.go
Normal 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
64
api/sites/auto_cert.go
Normal 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)
|
||||
}
|
|
@ -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
44
api/sites/duplicate.go
Normal 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
26
api/sites/sites.go
Normal 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"`
|
||||
}
|
|
@ -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
258
app/.eslintrc.js
Normal 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
5
app/.idea/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
5
app/.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
app/.idea/codeStyles/codeStyleConfig.xml
generated
Normal 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
13
app/.idea/frontend.iml
generated
Normal 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>
|
7
app/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
7
app/.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
6
app/.idea/jsLibraryMappings.xml
generated
Normal 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
7
app/.idea/jsLinters/eslint.xml
generated
Normal 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
8
app/.idea/modules.xml
generated
Normal 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
6
app/.idea/vcs.xml
generated
Normal 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
4
app/.idea/watcherTasks.xml
generated
Normal 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
17
app/components.d.ts
vendored
|
@ -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
5
app/env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
declare module '*.svg' {
|
||||
import React from 'react'
|
||||
const content: React.FC<React.SVGProps<SVGElement>>
|
||||
export default content
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const i18n = require('./i18n.json')
|
||||
|
||||
module.exports = {
|
||||
|
|
|
@ -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
2240
app/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
6
app/src/components/Chart/types.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type {Usage} from '@/api/analytic'
|
||||
|
||||
export interface Series {
|
||||
name: string
|
||||
data: Usage[]
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import {VAceEditor} from 'vue3-ace-editor'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import 'ace-builds/src-noconflict/mode-nginx'
|
||||
import 'ace-builds/src-noconflict/theme-monokai'
|
||||
import {computed} from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(['content', 'defaultHeight'])
|
||||
const props = defineProps<{
|
||||
content?: string
|
||||
defaultHeight?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:content'])
|
||||
|
||||
|
@ -12,21 +15,22 @@ const value = computed({
|
|||
get() {
|
||||
return props.content ?? ''
|
||||
},
|
||||
set(value) {
|
||||
emit('update:content', value)
|
||||
}
|
||||
set(v) {
|
||||
emit('update:content', v)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-ace-editor
|
||||
<VAceEditor
|
||||
v-model:value="value"
|
||||
lang="nginx"
|
||||
theme="monokai"
|
||||
:style="{
|
||||
minHeight: defaultHeight || '100vh',
|
||||
borderRadius: '5px'
|
||||
}"/>
|
||||
minHeight: defaultHeight || '100vh',
|
||||
borderRadius: '5px',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
import CodeEditor from './CodeEditor'
|
||||
import CodeEditor from './CodeEditor.vue'
|
||||
|
||||
export default CodeEditor
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
|
@ -1,21 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import {reactive, ref} from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import gettext from '@/gettext'
|
||||
import StdDataEntry from '@/components/StdDataEntry'
|
||||
import {message} from 'ant-design-vue'
|
||||
import StdDataEntry from '@/components/StdDesign/StdDataEntry'
|
||||
|
||||
const {$gettext} = gettext
|
||||
const props = defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
api: (ids: number[], data: any) => Promise<void>
|
||||
beforeSave?: () => Promise<void>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['onSave'])
|
||||
|
||||
const props = defineProps(['api', 'beforeSave'])
|
||||
const { $gettext } = gettext
|
||||
|
||||
const batchColumns = ref([])
|
||||
|
||||
const visible = ref(false)
|
||||
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function showModal(c: any, rowKeys: any) {
|
||||
visible.value = true
|
||||
selectedRowKeys.value = rowKeys
|
||||
|
@ -23,7 +26,7 @@ function showModal(c: any, rowKeys: any) {
|
|||
}
|
||||
|
||||
defineExpose({
|
||||
showModal
|
||||
showModal,
|
||||
})
|
||||
|
||||
const data = reactive({})
|
||||
|
@ -38,7 +41,7 @@ async function ok() {
|
|||
await props.api(selectedRowKeys.value, data).then(async () => {
|
||||
message.success($gettext('Save successfully'))
|
||||
emit('onSave')
|
||||
}).catch((e: any) => {
|
||||
}).catch(e => {
|
||||
message.error($gettext(e?.message) ?? $gettext('Server error'))
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
|
@ -47,28 +50,26 @@ async function ok() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
<AModal
|
||||
v-model:open="visible"
|
||||
class="std-curd-edit-modal"
|
||||
:mask="false"
|
||||
:title="$gettext('Batch Modify')"
|
||||
v-model:open="visible"
|
||||
:cancel-text="$gettext('Cancel')"
|
||||
:ok-text="$gettext('OK')"
|
||||
@ok="ok"
|
||||
:confirm-loading="loading"
|
||||
:width="600"
|
||||
destroyOnClose
|
||||
destroy-on-close
|
||||
@ok="ok"
|
||||
>
|
||||
|
||||
<std-data-entry
|
||||
ref="std_data_entry"
|
||||
<StdDataEntry
|
||||
:data-list="batchColumns"
|
||||
:data-source="data"
|
||||
:error="error"
|
||||
/>
|
||||
|
||||
<slot name="extra"/>
|
||||
</a-modal>
|
||||
<slot name="extra" />
|
||||
</AModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
174
app/src/components/StdDesign/StdDataDisplay/StdCurd.vue
Normal file
174
app/src/components/StdDesign/StdDataDisplay/StdCurd.vue
Normal 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>
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import {useGettext} from 'vue3-gettext'
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = defineProps(['pagination', 'size'])
|
||||
const emit = defineEmits(['change', 'changePageSize'])
|
||||
const {$gettext} = useGettext()
|
||||
import type { Pagination } from '@/api/curd'
|
||||
|
||||
const props = defineProps<{
|
||||
pagination: Pagination
|
||||
size?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['change', 'changePageSize', 'update:pagination'])
|
||||
|
||||
function change(num: number, pageSize: number) {
|
||||
emit('change', num, pageSize)
|
||||
|
@ -16,16 +19,19 @@ const pageSize = computed({
|
|||
},
|
||||
set(v) {
|
||||
emit('changePageSize', v)
|
||||
props.pagination.per_page = v
|
||||
}
|
||||
emit('update:pagination', { ...props.pagination, per_page: v })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pagination-container" v-if="pagination.total>pagination.per_page">
|
||||
<a-pagination
|
||||
:current="pagination.current_page"
|
||||
<div
|
||||
v-if="pagination.total > pagination.per_page"
|
||||
class="pagination-container"
|
||||
>
|
||||
<APagination
|
||||
v-model:pageSize="pageSize"
|
||||
:current="pagination.current_page"
|
||||
:size="size"
|
||||
:total="pagination.total"
|
||||
@change="change"
|
415
app/src/components/StdDesign/StdDataDisplay/StdTable.vue
Normal file
415
app/src/components/StdDesign/StdDataDisplay/StdTable.vue
Normal 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>
|
|
@ -2,10 +2,13 @@
|
|||
import dayjs from 'dayjs'
|
||||
|
||||
export interface customRender {
|
||||
value: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
text: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
record: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
index: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
column: any
|
||||
}
|
||||
|
||||
|
@ -16,17 +19,16 @@ export const datetime = (args: customRender) => {
|
|||
export const date = (args: customRender) => {
|
||||
return dayjs(args.text).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const mask = (args: customRender, maskObj: any) => {
|
||||
let v
|
||||
|
||||
if (typeof maskObj?.[args.text] === 'function') {
|
||||
if (typeof maskObj?.[args.text] === 'function')
|
||||
v = maskObj[args.text]()
|
||||
} else if (typeof maskObj?.[args.text] === 'string') {
|
||||
else if (typeof maskObj?.[args.text] === 'string')
|
||||
v = maskObj[args.text]
|
||||
} else {
|
||||
else
|
||||
v = args.text
|
||||
}
|
||||
|
||||
return <div>{v}</div>
|
||||
}
|
9
app/src/components/StdDesign/StdDataDisplay/index.ts
Normal file
9
app/src/components/StdDesign/StdDataDisplay/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import StdTable from './StdTable.vue'
|
||||
import StdCurd from './StdCurd.vue'
|
||||
import StdBatchEdit from './StdBatchEdit.vue'
|
||||
|
||||
export {
|
||||
StdTable,
|
||||
StdCurd,
|
||||
StdBatchEdit,
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import _ from 'lodash'
|
||||
import { downloadCsv } from '@/lib/helper'
|
||||
import type { Column, StdTableResponse } from '@/components/StdDesign/types'
|
||||
import gettext from '@/gettext'
|
||||
import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
|
||||
|
||||
const { $gettext } = gettext
|
||||
async function exportCsv(props: StdTableProps, pithyColumns: ComputedRef<Column[]>) {
|
||||
const header: { title?: string; key: string | string[] }[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const headerKeys: any[] = []
|
||||
const showColumnsMap: Record<string, Column> = {}
|
||||
|
||||
pithyColumns.value.forEach((column: Column) => {
|
||||
if (column.dataIndex === 'action')
|
||||
return
|
||||
let t = column.title
|
||||
if (typeof t === 'function')
|
||||
t = t()
|
||||
header.push({
|
||||
title: t,
|
||||
key: column.dataIndex,
|
||||
})
|
||||
headerKeys.push(column.dataIndex.toString())
|
||||
showColumnsMap[column.dataIndex.toString()] = column
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dataSource: any[] = []
|
||||
let hasMore = true
|
||||
let page = 1
|
||||
while (hasMore) {
|
||||
// 准备 DataSource
|
||||
await props.api!.get_list({ page }).then((r: StdTableResponse) => {
|
||||
if (r.data.length === 0) {
|
||||
hasMore = false
|
||||
|
||||
return
|
||||
}
|
||||
dataSource.push(...r.data)
|
||||
}).catch((e: { message?: string }) => {
|
||||
message.error(e.message ?? $gettext('Server error'))
|
||||
hasMore = false
|
||||
})
|
||||
page += 1
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: any[] = []
|
||||
|
||||
dataSource.forEach(row => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj: Record<string, any> = {}
|
||||
|
||||
headerKeys.forEach(key => {
|
||||
let _data = _.get(row, key)
|
||||
const c = showColumnsMap[key]
|
||||
|
||||
_data = c?.customRender?.({ text: _data }) ?? _data
|
||||
_.set(obj, c.dataIndex, _data)
|
||||
})
|
||||
data.push(obj)
|
||||
})
|
||||
|
||||
downloadCsv(header, data,
|
||||
`${$gettext('Export')}-${props.title}-${dayjs().format('YYYYMMDDHHmmss')}.csv`)
|
||||
}
|
||||
|
||||
export default exportCsv
|
132
app/src/components/StdDesign/StdDataDisplay/methods/sortable.ts
Normal file
132
app/src/components/StdDesign/StdDataDisplay/methods/sortable.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue