Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67c41cde5c | ||
|
|
4500f48d4c | ||
|
|
381c6911af | ||
|
|
42445d18eb | ||
|
|
d3969079a5 | ||
|
|
23fa089608 |
@@ -180,19 +180,18 @@ jobs:
|
|||||||
cat release_notes.md
|
cat release_notes.md
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ inputs.version }}
|
tag_name: ${{ inputs.version }}
|
||||||
name: Release ${{ inputs.version }}
|
name: Release ${{ inputs.version }}
|
||||||
body_path: release_notes.md
|
body_path: release_notes.md
|
||||||
files: |
|
files: |-
|
||||||
dist/*.tar.gz
|
dist/*.tar.gz
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/SHA256SUMS
|
dist/SHA256SUMS
|
||||||
draft: ${{ inputs.draft }}
|
draft: ${{ inputs.draft }}
|
||||||
prerelease: ${{ inputs.prerelease }}
|
prerelease: ${{ inputs.prerelease }}
|
||||||
env:
|
token: ${{ secrets.ACCOUNT_TOKEN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.ACCOUNT_TOKEN }}
|
|
||||||
|
|
||||||
- name: Release created successfully
|
- name: Release created successfully
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -8,8 +8,16 @@ import (
|
|||||||
"github.com/gotunnel/internal/client/tunnel"
|
"github.com/gotunnel/internal/client/tunnel"
|
||||||
"github.com/gotunnel/pkg/crypto"
|
"github.com/gotunnel/pkg/crypto"
|
||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
|
"github.com/gotunnel/pkg/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 版本信息(通过 ldflags 注入)
|
||||||
|
var Version string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
version.SetVersion(Version)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
server := flag.String("s", "", "server address (ip:port)")
|
server := flag.String("s", "", "server address (ip:port)")
|
||||||
token := flag.String("t", "", "auth token")
|
token := flag.String("t", "", "auth token")
|
||||||
|
|||||||
@@ -28,8 +28,16 @@ import (
|
|||||||
"github.com/gotunnel/pkg/crypto"
|
"github.com/gotunnel/pkg/crypto"
|
||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
"github.com/gotunnel/pkg/plugin/sign"
|
"github.com/gotunnel/pkg/plugin/sign"
|
||||||
|
"github.com/gotunnel/pkg/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 版本信息(通过 ldflags 注入)
|
||||||
|
var Version string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
version.SetVersion(Version)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("c", "server.yaml", "config file path")
|
configPath := flag.String("c", "server.yaml", "config file path")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
@@ -176,7 +176,12 @@ func (c *Client) connect() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
authReq := protocol.AuthRequest{ClientID: c.ID, Token: c.Token}
|
authReq := protocol.AuthRequest{
|
||||||
|
ClientID: c.ID,
|
||||||
|
Token: c.Token,
|
||||||
|
OS: runtime.GOOS,
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
}
|
||||||
msg, _ := protocol.NewMessage(protocol.MsgTypeAuth, authReq)
|
msg, _ := protocol.NewMessage(protocol.MsgTypeAuth, authReq)
|
||||||
if err := protocol.WriteMessage(conn, msg); err != nil {
|
if err := protocol.WriteMessage(conn, msg); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type ClientResponse struct {
|
|||||||
Online bool `json:"online" example:"true"`
|
Online bool `json:"online" example:"true"`
|
||||||
LastPing string `json:"last_ping,omitempty" example:"2025-01-02T10:30:00Z"`
|
LastPing string `json:"last_ping,omitempty" example:"2025-01-02T10:30:00Z"`
|
||||||
RemoteAddr string `json:"remote_addr,omitempty" example:"192.168.1.100:54321"`
|
RemoteAddr string `json:"remote_addr,omitempty" example:"192.168.1.100:54321"`
|
||||||
|
OS string `json:"os,omitempty" example:"linux"`
|
||||||
|
Arch string `json:"arch,omitempty" example:"amd64"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientListItem 客户端列表项
|
// ClientListItem 客户端列表项
|
||||||
@@ -41,6 +43,8 @@ type ClientListItem struct {
|
|||||||
LastPing string `json:"last_ping,omitempty"`
|
LastPing string `json:"last_ping,omitempty"`
|
||||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||||
RuleCount int `json:"rule_count" example:"3"`
|
RuleCount int `json:"rule_count" example:"3"`
|
||||||
|
OS string `json:"os,omitempty" example:"linux"`
|
||||||
|
Arch string `json:"arch,omitempty" example:"amd64"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallPluginsRequest 安装插件到客户端请求
|
// InstallPluginsRequest 安装插件到客户端请求
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ func (h *ClientHandler) List(c *gin.Context) {
|
|||||||
item.Online = status.Online
|
item.Online = status.Online
|
||||||
item.LastPing = status.LastPing
|
item.LastPing = status.LastPing
|
||||||
item.RemoteAddr = status.RemoteAddr
|
item.RemoteAddr = status.RemoteAddr
|
||||||
|
item.OS = status.OS
|
||||||
|
item.Arch = status.Arch
|
||||||
}
|
}
|
||||||
result = append(result, item)
|
result = append(result, item)
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ func (h *ClientHandler) Get(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
online, lastPing, remoteAddr := h.app.GetServer().GetClientStatus(clientID)
|
online, lastPing, remoteAddr, clientOS, clientArch := h.app.GetServer().GetClientStatus(clientID)
|
||||||
|
|
||||||
// 复制插件列表
|
// 复制插件列表
|
||||||
plugins := make([]db.ClientPlugin, len(client.Plugins))
|
plugins := make([]db.ClientPlugin, len(client.Plugins))
|
||||||
@@ -151,6 +153,8 @@ func (h *ClientHandler) Get(c *gin.Context) {
|
|||||||
Online: online,
|
Online: online,
|
||||||
LastPing: lastPing,
|
LastPing: lastPing,
|
||||||
RemoteAddr: remoteAddr,
|
RemoteAddr: remoteAddr,
|
||||||
|
OS: clientOS,
|
||||||
|
Arch: clientArch,
|
||||||
}
|
}
|
||||||
|
|
||||||
Success(c, resp)
|
Success(c, resp)
|
||||||
@@ -237,7 +241,7 @@ func (h *ClientHandler) Delete(c *gin.Context) {
|
|||||||
func (h *ClientHandler) PushConfig(c *gin.Context) {
|
func (h *ClientHandler) PushConfig(c *gin.Context) {
|
||||||
clientID := c.Param("id")
|
clientID := c.Param("id")
|
||||||
|
|
||||||
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
online, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||||
if !online {
|
if !online {
|
||||||
ClientNotOnline(c)
|
ClientNotOnline(c)
|
||||||
return
|
return
|
||||||
@@ -306,7 +310,7 @@ func (h *ClientHandler) Restart(c *gin.Context) {
|
|||||||
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
|
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
|
||||||
clientID := c.Param("id")
|
clientID := c.Param("id")
|
||||||
|
|
||||||
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
online, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||||
if !online {
|
if !online {
|
||||||
ClientNotOnline(c)
|
ClientNotOnline(c)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ type AppInterface interface {
|
|||||||
|
|
||||||
// ServerInterface 服务端接口
|
// ServerInterface 服务端接口
|
||||||
type ServerInterface interface {
|
type ServerInterface interface {
|
||||||
GetClientStatus(clientID string) (online bool, lastPing string, remoteAddr string)
|
GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientOS, clientArch string)
|
||||||
GetAllClientStatus() map[string]struct {
|
GetAllClientStatus() map[string]struct {
|
||||||
Online bool
|
Online bool
|
||||||
LastPing string
|
LastPing string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
}
|
}
|
||||||
ReloadConfig() error
|
ReloadConfig() error
|
||||||
GetBindAddr() string
|
GetBindAddr() string
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func (h *JSPluginHandler) PushToClient(c *gin.Context) {
|
|||||||
c.ShouldBindJSON(&pushReq) // 忽略错误,允许空请求体
|
c.ShouldBindJSON(&pushReq) // 忽略错误,允许空请求体
|
||||||
|
|
||||||
// 检查客户端是否在线
|
// 检查客户端是否在线
|
||||||
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
online, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||||
if !online {
|
if !online {
|
||||||
ClientNotOnline(c)
|
ClientNotOnline(c)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func (h *LogHandler) StreamLogs(c *gin.Context) {
|
|||||||
clientID := c.Param("id")
|
clientID := c.Param("id")
|
||||||
|
|
||||||
// 检查客户端是否在线
|
// 检查客户端是否在线
|
||||||
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
online, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||||
if !online {
|
if !online {
|
||||||
c.JSON(400, gin.H{"code": 400, "message": "client not online"})
|
c.JSON(400, gin.H{"code": 400, "message": "client not online"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果客户端在线,同步配置
|
// 如果客户端在线,同步配置
|
||||||
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
online, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||||
if online {
|
if online {
|
||||||
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
|
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
|
||||||
PartialSuccess(c, gin.H{"status": "partial", "port_changed": portChanged}, "config saved but sync failed: "+err.Error())
|
PartialSuccess(c, gin.H{"status": "partial", "port_changed": portChanged}, "config saved but sync failed: "+err.Error())
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (h *PluginAPIHandler) ProxyRequest(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查客户端是否在线
|
// 检查客户端是否在线
|
||||||
online, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
online, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||||
if !online {
|
if !online {
|
||||||
ClientNotOnline(c)
|
ClientNotOnline(c)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (h *StoreHandler) Install(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查客户端是否在线
|
// 检查客户端是否在线
|
||||||
online, _, _ := h.app.GetServer().GetClientStatus(req.ClientID)
|
online, _, _, _, _ := h.app.GetServer().GetClientStatus(req.ClientID)
|
||||||
if !online {
|
if !online {
|
||||||
ClientNotOnline(c)
|
ClientNotOnline(c)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ type JSPluginEntry struct {
|
|||||||
type ClientSession struct {
|
type ClientSession struct {
|
||||||
ID string
|
ID string
|
||||||
RemoteAddr string // 客户端 IP 地址
|
RemoteAddr string // 客户端 IP 地址
|
||||||
|
OS string // 客户端操作系统
|
||||||
|
Arch string // 客户端架构
|
||||||
Session *yamux.Session
|
Session *yamux.Session
|
||||||
Rules []protocol.ProxyRule
|
Rules []protocol.ProxyRule
|
||||||
Listeners map[int]net.Listener
|
Listeners map[int]net.Listener
|
||||||
@@ -287,11 +289,11 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
security.LogAuthSuccess(clientIP, clientID)
|
security.LogAuthSuccess(clientIP, clientID)
|
||||||
s.setupClientSession(conn, clientID, rules)
|
s.setupClientSession(conn, clientID, authReq.OS, authReq.Arch, rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupClientSession 建立客户端会话
|
// setupClientSession 建立客户端会话
|
||||||
func (s *Server) setupClientSession(conn net.Conn, clientID string, rules []protocol.ProxyRule) {
|
func (s *Server) setupClientSession(conn net.Conn, clientID, clientOS, clientArch string, rules []protocol.ProxyRule) {
|
||||||
session, err := yamux.Server(conn, nil)
|
session, err := yamux.Server(conn, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Server] Yamux error: %v", err)
|
log.Printf("[Server] Yamux error: %v", err)
|
||||||
@@ -307,6 +309,8 @@ func (s *Server) setupClientSession(conn net.Conn, clientID string, rules []prot
|
|||||||
cs := &ClientSession{
|
cs := &ClientSession{
|
||||||
ID: clientID,
|
ID: clientID,
|
||||||
RemoteAddr: remoteAddr,
|
RemoteAddr: remoteAddr,
|
||||||
|
OS: clientOS,
|
||||||
|
Arch: clientArch,
|
||||||
Session: session,
|
Session: session,
|
||||||
Rules: rules,
|
Rules: rules,
|
||||||
Listeners: make(map[int]net.Listener),
|
Listeners: make(map[int]net.Listener),
|
||||||
@@ -567,16 +571,16 @@ func (s *Server) sendHeartbeat(cs *ClientSession) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetClientStatus 获取客户端状态
|
// GetClientStatus 获取客户端状态
|
||||||
func (s *Server) GetClientStatus(clientID string) (online bool, lastPing string, remoteAddr string) {
|
func (s *Server) GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientOS, clientArch string) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
if cs, ok := s.clients[clientID]; ok {
|
if cs, ok := s.clients[clientID]; ok {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
return true, cs.LastPing.Format(time.RFC3339), cs.RemoteAddr
|
return true, cs.LastPing.Format(time.RFC3339), cs.RemoteAddr, cs.OS, cs.Arch
|
||||||
}
|
}
|
||||||
return false, "", ""
|
return false, "", "", "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientPluginStatus 获取客户端插件运行状态
|
// GetClientPluginStatus 获取客户端插件运行状态
|
||||||
@@ -627,6 +631,8 @@ func (s *Server) GetAllClientStatus() map[string]struct {
|
|||||||
Online bool
|
Online bool
|
||||||
LastPing string
|
LastPing string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
} {
|
} {
|
||||||
// 先复制客户端引用,避免嵌套锁
|
// 先复制客户端引用,避免嵌套锁
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
@@ -640,6 +646,8 @@ func (s *Server) GetAllClientStatus() map[string]struct {
|
|||||||
Online bool
|
Online bool
|
||||||
LastPing string
|
LastPing string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, cs := range clients {
|
for _, cs := range clients {
|
||||||
@@ -648,10 +656,14 @@ func (s *Server) GetAllClientStatus() map[string]struct {
|
|||||||
Online bool
|
Online bool
|
||||||
LastPing string
|
LastPing string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
}{
|
}{
|
||||||
Online: true,
|
Online: true,
|
||||||
LastPing: cs.LastPing.Format(time.RFC3339),
|
LastPing: cs.LastPing.Format(time.RFC3339),
|
||||||
RemoteAddr: cs.RemoteAddr,
|
RemoteAddr: cs.RemoteAddr,
|
||||||
|
OS: cs.OS,
|
||||||
|
Arch: cs.Arch,
|
||||||
}
|
}
|
||||||
cs.mu.Unlock()
|
cs.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ type Message struct {
|
|||||||
type AuthRequest struct {
|
type AuthRequest struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
OS string `json:"os,omitempty"` // 客户端操作系统
|
||||||
|
Arch string `json:"arch,omitempty"` // 客户端架构
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthResponse 认证响应
|
// AuthResponse 认证响应
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 版本信息
|
// 版本信息
|
||||||
const Version = "1.0.0"
|
var Version = "1.0.0"
|
||||||
|
|
||||||
|
// SetVersion 设置版本号(由 main 包在初始化时调用)
|
||||||
|
func SetVersion(v string) {
|
||||||
|
if v != "" {
|
||||||
|
Version = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 仓库信息
|
// 仓库信息
|
||||||
const (
|
const (
|
||||||
|
|||||||
6
web/components.d.ts
vendored
6
web/components.d.ts
vendored
@@ -11,8 +11,14 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
GlassModal: typeof import('./src/components/GlassModal.vue')['default']
|
||||||
|
GlassSwitch: typeof import('./src/components/GlassSwitch.vue')['default']
|
||||||
|
GlassTag: typeof import('./src/components/GlassTag.vue')['default']
|
||||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||||
|
InlineLogPanel: typeof import('./src/components/InlineLogPanel.vue')['default']
|
||||||
LogViewer: typeof import('./src/components/LogViewer.vue')['default']
|
LogViewer: typeof import('./src/components/LogViewer.vue')['default']
|
||||||
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
}
|
}
|
||||||
|
|||||||
807
web/package-lock.json
generated
807
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "webui",
|
"name": "GoTunnel",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "webui",
|
"name": "GoTunnel",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
@@ -15,9 +15,13 @@
|
|||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"unplugin-auto-import": "^20.3.0",
|
"unplugin-auto-import": "^20.3.0",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
@@ -25,6 +29,19 @@
|
|||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
@@ -907,6 +924,277 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
|
"enhanced-resolve": "^5.18.3",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.30.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/postcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
|
"@tailwindcss/node": "4.1.18",
|
||||||
|
"@tailwindcss/oxide": "4.1.18",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -941,6 +1229,7 @@
|
|||||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1459,43 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/autoprefixer": {
|
||||||
|
"version": "10.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
|
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browserslist": "^4.28.1",
|
||||||
|
"caniuse-lite": "^1.0.30001760",
|
||||||
|
"fraction.js": "^5.3.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"autoprefixer": "bin/autoprefixer"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"postcss": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
@@ -1181,6 +1507,51 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/baseline-browser-mapping": {
|
||||||
|
"version": "2.9.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
|
||||||
|
"integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserslist": {
|
||||||
|
"version": "4.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
|
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
"electron-to-chromium": "^1.5.263",
|
||||||
|
"node-releases": "^2.0.27",
|
||||||
|
"update-browserslist-db": "^1.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"browserslist": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@@ -1194,6 +1565,27 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/caniuse-lite": {
|
||||||
|
"version": "1.0.30001765",
|
||||||
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
|
||||||
|
"integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -1234,6 +1626,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
|
"resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
|
||||||
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
|
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/hash": "~0.8.0",
|
"@emotion/hash": "~0.8.0",
|
||||||
"csstype": "~3.0.5"
|
"csstype": "~3.0.5"
|
||||||
@@ -1256,6 +1649,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@@ -1297,6 +1691,16 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1311,6 +1715,27 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-to-chromium": {
|
||||||
|
"version": "1.5.267",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||||
|
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.18.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||||
|
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
|
||||||
@@ -1410,6 +1835,16 @@
|
|||||||
"@esbuild/win32-x64": "0.27.2"
|
"@esbuild/win32-x64": "0.27.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||||
@@ -1496,6 +1931,20 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fraction.js": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/rawify"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1569,6 +2018,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -1617,6 +2073,17 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
@@ -1624,6 +2091,268 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-x64": "1.30.2",
|
||||||
|
"lightningcss-freebsd-x64": "1.30.2",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-musl": "1.30.2",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/local-pkg": {
|
"node_modules/local-pkg": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||||
@@ -1787,6 +2516,13 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-releases": {
|
||||||
|
"version": "2.0.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -1851,6 +2587,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -1860,6 +2597,13 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -1974,6 +2718,27 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2003,6 +2768,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2163,6 +2929,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/update-browserslist-db": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"escalade": "^3.2.0",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"update-browserslist-db": "cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"browserslist": ">= 4.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vdirs": {
|
"node_modules/vdirs": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
|
||||||
@@ -2181,6 +2978,7 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -2274,6 +3072,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.26",
|
"@vue/compiler-dom": "3.5.26",
|
||||||
"@vue/compiler-sfc": "3.5.26",
|
"@vue/compiler-sfc": "3.5.26",
|
||||||
|
|||||||
@@ -16,9 +16,13 @@
|
|||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"unplugin-auto-import": "^20.3.0",
|
"unplugin-auto-import": "^20.3.0",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
|
|||||||
5
web/postcss.config.js
Normal file
5
web/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
383
web/src/App.vue
383
web/src/App.vue
@@ -1,48 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, h, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
NLayout, NLayoutHeader, NLayoutContent, NLayoutSider, NMenu,
|
NConfigProvider, NMessageProvider, NDialogProvider, NGlobalStyle,
|
||||||
NButton, NIcon, NConfigProvider, NMessageProvider,
|
type GlobalThemeOverrides
|
||||||
NDialogProvider, NGlobalStyle, NDropdown, type GlobalThemeOverrides
|
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
HomeOutline, ExtensionPuzzleOutline, LogOutOutline,
|
HomeOutline, ExtensionPuzzleOutline, SettingsOutline,
|
||||||
ServerOutline, MenuOutline, PersonCircleOutline
|
PersonCircleOutline, LogOutOutline, LogoGithub
|
||||||
} from '@vicons/ionicons5'
|
} from '@vicons/ionicons5'
|
||||||
import type { MenuOption } from 'naive-ui'
|
import { getServerStatus, getVersionInfo, removeToken, getToken } from './api'
|
||||||
import { getServerStatus, removeToken, getToken } from './api'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const serverInfo = ref({ bind_addr: '', bind_port: 0 })
|
const serverInfo = ref({ bind_addr: '', bind_port: 0 })
|
||||||
const clientCount = ref(0)
|
const clientCount = ref(0)
|
||||||
const collapsed = ref(false)
|
const version = ref('')
|
||||||
|
const showUserMenu = ref(false)
|
||||||
|
|
||||||
const isLoginPage = computed(() => route.path === '/login')
|
const isLoginPage = computed(() => route.path === '/login')
|
||||||
|
|
||||||
const menuOptions: MenuOption[] = [
|
const navItems = [
|
||||||
{
|
{ key: 'home', label: '首页', icon: HomeOutline, path: '/' },
|
||||||
label: 'Dashboard',
|
{ key: 'plugins', label: '插件', icon: ExtensionPuzzleOutline, path: '/plugins' },
|
||||||
key: '/',
|
{ key: 'settings', label: '设置', icon: SettingsOutline, path: '/settings' }
|
||||||
icon: () => h(NIcon, null, { default: () => h(HomeOutline) })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Plugins Store',
|
|
||||||
key: '/plugins',
|
|
||||||
icon: () => h(NIcon, null, { default: () => h(ExtensionPuzzleOutline) })
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeKey = computed(() => {
|
const activeNav = computed(() => {
|
||||||
if (route.path.startsWith('/client/')) return '/'
|
const path = route.path
|
||||||
return route.path
|
if (path === '/' || path === '/home') return 'home'
|
||||||
|
if (path.startsWith('/client')) return 'home'
|
||||||
|
if (path === '/plugins') return 'plugins'
|
||||||
|
if (path === '/settings') return 'settings'
|
||||||
|
return 'home'
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleMenuUpdate = (key: string) => {
|
|
||||||
router.push(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchServerStatus = async () => {
|
const fetchServerStatus = async () => {
|
||||||
if (isLoginPage.value || !getToken()) return
|
if (isLoginPage.value || !getToken()) return
|
||||||
try {
|
try {
|
||||||
@@ -54,14 +46,26 @@ const fetchServerStatus = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
if (isLoginPage.value || !getToken()) return
|
||||||
|
try {
|
||||||
|
const { data } = await getVersionInfo()
|
||||||
|
version.value = data.version || ''
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get version', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => route.path, (newPath, oldPath) => {
|
watch(() => route.path, (newPath, oldPath) => {
|
||||||
if (oldPath === '/login' && newPath !== '/login') {
|
if (oldPath === '/login' && newPath !== '/login') {
|
||||||
fetchServerStatus()
|
fetchServerStatus()
|
||||||
|
fetchVersion()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchServerStatus()
|
fetchServerStatus()
|
||||||
|
fetchVersion()
|
||||||
})
|
})
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
@@ -69,31 +73,23 @@ const logout = () => {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
// User dropdown menu options
|
const toggleUserMenu = () => {
|
||||||
const userDropdownOptions = [
|
showUserMenu.value = !showUserMenu.value
|
||||||
{
|
|
||||||
label: '退出登录',
|
|
||||||
key: 'logout',
|
|
||||||
icon: () => h(NIcon, null, { default: () => h(LogOutOutline) })
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleUserDropdown = (key: string) => {
|
|
||||||
if (key === 'logout') {
|
|
||||||
logout()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme Overrides
|
// 紫色渐变主题
|
||||||
const themeOverrides: GlobalThemeOverrides = {
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
common: {
|
common: {
|
||||||
primaryColor: '#18a058',
|
primaryColor: '#6366f1',
|
||||||
primaryColorHover: '#36ad6a',
|
primaryColorHover: '#818cf8',
|
||||||
primaryColorPressed: '#0c7a43',
|
primaryColorPressed: '#4f46e5',
|
||||||
},
|
},
|
||||||
Layout: {
|
Layout: {
|
||||||
siderColor: '#f7fcf9',
|
|
||||||
headerColor: '#ffffff'
|
headerColor: '#ffffff'
|
||||||
|
},
|
||||||
|
Tabs: {
|
||||||
|
tabTextColorActiveLine: '#6366f1',
|
||||||
|
barColor: '#6366f1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -103,64 +99,55 @@ const themeOverrides: GlobalThemeOverrides = {
|
|||||||
<n-global-style />
|
<n-global-style />
|
||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<n-layout v-if="!isLoginPage" class="main-layout" has-sider position="absolute">
|
<div v-if="!isLoginPage" class="app-layout">
|
||||||
<n-layout-sider
|
<!-- Header -->
|
||||||
bordered
|
<header class="app-header">
|
||||||
collapse-mode="width"
|
<div class="header-left">
|
||||||
:collapsed-width="64"
|
<span class="logo">GoTunnel</span>
|
||||||
:width="240"
|
|
||||||
:collapsed="collapsed"
|
|
||||||
show-trigger
|
|
||||||
@collapse="collapsed = true"
|
|
||||||
@expand="collapsed = false"
|
|
||||||
style="background: #f9fafb;"
|
|
||||||
>
|
|
||||||
<div class="logo-container">
|
|
||||||
<n-icon size="32" color="#18a058"><ServerOutline /></n-icon>
|
|
||||||
<span v-if="!collapsed" class="logo-text">GoTunnel</span>
|
|
||||||
</div>
|
</div>
|
||||||
<n-menu
|
<nav class="header-nav">
|
||||||
:collapsed="collapsed"
|
<router-link
|
||||||
:collapsed-width="64"
|
v-for="item in navItems"
|
||||||
:collapsed-icon-size="22"
|
:key="item.key"
|
||||||
:options="menuOptions"
|
:to="item.path"
|
||||||
:value="activeKey"
|
class="nav-item"
|
||||||
@update:value="handleMenuUpdate"
|
:class="{ active: activeNav === item.key }"
|
||||||
/>
|
>
|
||||||
<div v-if="!collapsed" class="server-status-card">
|
<component :is="item.icon" class="nav-icon" />
|
||||||
<div class="status-item">
|
<span>{{ item.label }}</span>
|
||||||
<span class="label">Server:</span>
|
</router-link>
|
||||||
<span class="value">{{ serverInfo.bind_addr }}:{{ serverInfo.bind_port }}</span>
|
</nav>
|
||||||
</div>
|
<div class="header-right">
|
||||||
<div class="status-item">
|
<div class="user-menu" @click="toggleUserMenu">
|
||||||
<span class="label">Clients:</span>
|
<PersonCircleOutline class="user-icon" />
|
||||||
<span class="value">{{ clientCount }}</span>
|
<div v-if="showUserMenu" class="user-dropdown" @click.stop>
|
||||||
</div>
|
<button class="dropdown-item" @click="logout">
|
||||||
</div>
|
<LogOutOutline class="dropdown-icon" />
|
||||||
</n-layout-sider>
|
<span>退出登录</span>
|
||||||
|
</button>
|
||||||
<n-layout>
|
|
||||||
<n-layout-header bordered class="header">
|
|
||||||
<div class="header-content">
|
|
||||||
<n-button quaternary circle size="large" @click="collapsed = !collapsed" class="mobile-toggle">
|
|
||||||
<template #icon><n-icon><MenuOutline /></n-icon></template>
|
|
||||||
</n-button>
|
|
||||||
<div class="header-right">
|
|
||||||
<n-dropdown :options="userDropdownOptions" @select="handleUserDropdown">
|
|
||||||
<n-button quaternary circle size="large">
|
|
||||||
<template #icon>
|
|
||||||
<n-icon size="24"><PersonCircleOutline /></n-icon>
|
|
||||||
</template>
|
|
||||||
</n-button>
|
|
||||||
</n-dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-layout-header>
|
</div>
|
||||||
<n-layout-content content-style="padding: 24px; background-color: #f0f2f5; min-height: calc(100vh - 64px);">
|
</header>
|
||||||
<RouterView />
|
|
||||||
</n-layout-content>
|
<!-- Main Content -->
|
||||||
</n-layout>
|
<main class="main-content">
|
||||||
</n-layout>
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="footer-left">
|
||||||
|
<span class="brand">GoTunnel</span>
|
||||||
|
<span v-if="version" class="version">v{{ version }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="https://github.com/user/gotunnel" target="_blank" class="footer-link">
|
||||||
|
<LogoGithub class="footer-icon" />
|
||||||
|
<span>GitHub</span>
|
||||||
|
</a>
|
||||||
|
<span class="copyright">© 2024 Flik. MIT License</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
<RouterView v-else />
|
<RouterView v-else />
|
||||||
</n-message-provider>
|
</n-message-provider>
|
||||||
</n-dialog-provider>
|
</n-dialog-provider>
|
||||||
@@ -168,74 +155,196 @@ const themeOverrides: GlobalThemeOverrides = {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.main-layout {
|
.app-layout {
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-container {
|
/* Header */
|
||||||
height: 64px;
|
.app-header {
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(15, 12, 41, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
padding: 0 24px;
|
||||||
border-bottom: 1px solid #efeff5;
|
position: sticky;
|
||||||
overflow: hidden;
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #18a058;
|
color: white;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
/* Navigation */
|
||||||
height: 64px;
|
.header-nav {
|
||||||
background: white;
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 24px;
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.nav-item:hover {
|
||||||
width: 100%;
|
color: white;
|
||||||
display: flex;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-status-card {
|
.nav-item.active {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Menu */
|
||||||
|
.user-menu {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-icon:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
top: 100%;
|
||||||
width: 100%;
|
right: 0;
|
||||||
padding: 20px;
|
margin-top: 8px;
|
||||||
background: #f0fdf4;
|
background: rgba(30, 27, 75, 0.95);
|
||||||
border-top: 1px solid #d1fae5;
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item {
|
.dropdown-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.app-footer {
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(15, 12, 41, 0.9);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
padding: 0 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item .label {
|
.footer-link {
|
||||||
color: #64748b;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item .value {
|
.footer-link:hover {
|
||||||
font-weight: 600;
|
color: white;
|
||||||
color: #0f172a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-toggle {
|
.footer-icon {
|
||||||
display: none;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mobile-toggle {
|
.app-header {
|
||||||
display: inline-flex;
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.header-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.app-footer {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.copyright {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
112
web/src/components/GlassModal.vue
Normal file
112
web/src/components/GlassModal.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CloseOutline } from '@vicons/ionicons5'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
title: string
|
||||||
|
width?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="show" class="modal-overlay" @click.self="emit('close')">
|
||||||
|
<div class="modal-container" :style="{ maxWidth: width || '500px' }">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<button class="close-btn" @click="emit('close')">
|
||||||
|
<CloseOutline />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(30, 27, 75, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
85
web/src/components/GlassSwitch.vue
Normal file
85
web/src/components/GlassSwitch.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!props.disabled) {
|
||||||
|
emit('update:modelValue', !props.modelValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="glass-switch"
|
||||||
|
:class="[size || 'small', { active: modelValue, disabled }]"
|
||||||
|
@click="toggle"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<span class="switch-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.glass-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-switch.small {
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-switch:hover:not(.disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-switch.active {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-switch.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-switch.small .switch-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-switch.active .switch-thumb {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-switch.small.active .switch-thumb {
|
||||||
|
transform: translateX(14px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
web/src/components/GlassTag.vue
Normal file
60
web/src/components/GlassTag.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
type?: 'default' | 'success' | 'warning' | 'error' | 'info'
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
round?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="glass-tag" :class="[type || 'default', size || 'small', { round }]">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.glass-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tag.round {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tag.medium {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tag.success {
|
||||||
|
background: rgba(52, 211, 153, 0.15);
|
||||||
|
border-color: rgba(52, 211, 153, 0.3);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tag.warning {
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
border-color: rgba(251, 191, 36, 0.3);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tag.error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tag.info {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border-color: rgba(96, 165, 250, 0.3);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
299
web/src/components/InlineLogPanel.vue
Normal file
299
web/src/components/InlineLogPanel.vue
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { PlayOutline, StopOutline, TrashOutline } from '@vicons/ionicons5'
|
||||||
|
import { createLogStream } from '../api'
|
||||||
|
import type { LogEntry } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
clientId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const logs = ref<LogEntry[]>([])
|
||||||
|
const isStreaming = ref(false)
|
||||||
|
const autoScroll = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null
|
||||||
|
const logContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const startStream = () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
isStreaming.value = true
|
||||||
|
|
||||||
|
eventSource = createLogStream(
|
||||||
|
props.clientId,
|
||||||
|
{ lines: 100, follow: true, level: '' },
|
||||||
|
(entry) => {
|
||||||
|
logs.value.push(entry)
|
||||||
|
if (logs.value.length > 500) {
|
||||||
|
logs.value = logs.value.slice(-300)
|
||||||
|
}
|
||||||
|
if (autoScroll.value) {
|
||||||
|
nextTick(() => scrollToBottom())
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
isStreaming.value = false
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopStream = () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
eventSource = null
|
||||||
|
}
|
||||||
|
isStreaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearLogs = () => {
|
||||||
|
logs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (logContainer.value) {
|
||||||
|
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLevelColor = (level: string): string => {
|
||||||
|
switch (level) {
|
||||||
|
case 'error': return '#fca5a5'
|
||||||
|
case 'warn': return '#fcd34d'
|
||||||
|
case 'info': return '#60a5fa'
|
||||||
|
case 'debug': return '#9ca3af'
|
||||||
|
default: return 'rgba(255,255,255,0.7)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (ts: number): string => {
|
||||||
|
return new Date(ts).toLocaleTimeString('en-US', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startStream()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopStream()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="inline-log-panel">
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="log-title">实时日志</span>
|
||||||
|
<span v-if="isStreaming" class="streaming-badge">
|
||||||
|
<span class="streaming-dot"></span>
|
||||||
|
实时
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button v-if="!isStreaming" class="tool-btn" @click="startStream" title="开始">
|
||||||
|
<PlayOutline class="tool-icon" />
|
||||||
|
</button>
|
||||||
|
<button v-else class="tool-btn" @click="stopStream" title="停止">
|
||||||
|
<StopOutline class="tool-icon" />
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" @click="clearLogs" title="清空">
|
||||||
|
<TrashOutline class="tool-icon" />
|
||||||
|
</button>
|
||||||
|
<label class="auto-scroll-toggle">
|
||||||
|
<input type="checkbox" v-model="autoScroll" />
|
||||||
|
<span>自动滚动</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="logContainer" class="log-content">
|
||||||
|
<div v-if="loading && logs.length === 0" class="log-loading">
|
||||||
|
连接中...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="logs.length === 0" class="log-empty">
|
||||||
|
暂无日志
|
||||||
|
</div>
|
||||||
|
<div v-else class="log-lines">
|
||||||
|
<div v-for="(log, index) in logs" :key="index" class="log-line">
|
||||||
|
<span class="log-time">{{ formatTime(log.ts) }}</span>
|
||||||
|
<span class="log-level" :style="{ color: getLevelColor(log.level) }">
|
||||||
|
[{{ log.level.toUpperCase() }}]
|
||||||
|
</span>
|
||||||
|
<span class="log-src">[{{ log.src }}]</span>
|
||||||
|
<span class="log-msg">{{ log.msg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inline-log-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #34d399;
|
||||||
|
background: rgba(52, 211, 153, 0.15);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #34d399;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scroll-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scroll-toggle input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-loading,
|
||||||
|
.log-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-lines {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-src {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-msg {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
.log-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { NCard, NSpace, NButton, NSelect, NSwitch, NInput, NIcon, NEmpty, NSpin } from 'naive-ui'
|
import {
|
||||||
import { PlayOutline, StopOutline, TrashOutline, DownloadOutline } from '@vicons/ionicons5'
|
PlayOutline, StopOutline, TrashOutline, DownloadOutline, CloseOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
import { createLogStream } from '../api'
|
import { createLogStream } from '../api'
|
||||||
import type { LogEntry } from '../types'
|
import type { LogEntry } from '../types'
|
||||||
|
|
||||||
@@ -24,14 +25,6 @@ const loading = ref(false)
|
|||||||
let eventSource: EventSource | null = null
|
let eventSource: EventSource | null = null
|
||||||
const logContainer = ref<HTMLElement | null>(null)
|
const logContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const levelOptions = [
|
|
||||||
{ label: '所有级别', value: '' },
|
|
||||||
{ label: 'Info', value: 'info' },
|
|
||||||
{ label: 'Warning', value: 'warn' },
|
|
||||||
{ label: 'Error', value: 'error' },
|
|
||||||
{ label: 'Debug', value: 'debug' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const startStream = () => {
|
const startStream = () => {
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close()
|
eventSource.close()
|
||||||
@@ -133,100 +126,354 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card title="客户端日志" :closable="true" @close="emit('close')">
|
<div v-if="visible" class="log-overlay" @click.self="emit('close')">
|
||||||
<template #header-extra>
|
<div class="log-modal">
|
||||||
<n-space :size="8">
|
<!-- Header -->
|
||||||
<n-select
|
<div class="log-header">
|
||||||
v-model:value="levelFilter"
|
<h3>客户端日志</h3>
|
||||||
:options="levelOptions"
|
<div class="log-controls">
|
||||||
size="small"
|
<select v-model="levelFilter" class="log-select" @change="() => { stopStream(); logs = []; startStream(); }">
|
||||||
style="width: 110px;"
|
<option value="">所有级别</option>
|
||||||
@update:value="() => { stopStream(); logs = []; startStream(); }"
|
<option value="info">Info</option>
|
||||||
/>
|
<option value="warn">Warning</option>
|
||||||
<n-input
|
<option value="error">Error</option>
|
||||||
v-model:value="searchText"
|
<option value="debug">Debug</option>
|
||||||
placeholder="搜索..."
|
</select>
|
||||||
size="small"
|
<input v-model="searchText" type="text" class="log-input" placeholder="搜索..." />
|
||||||
style="width: 120px;"
|
<label class="log-toggle">
|
||||||
clearable
|
<input type="checkbox" v-model="autoScroll" />
|
||||||
/>
|
<span>自动滚动</span>
|
||||||
<n-switch v-model:value="autoScroll" size="small">
|
</label>
|
||||||
<template #checked>自动滚动</template>
|
<button class="icon-btn" @click="clearLogs" title="清空">
|
||||||
<template #unchecked>手动</template>
|
<TrashOutline />
|
||||||
</n-switch>
|
</button>
|
||||||
<n-button size="small" quaternary @click="clearLogs">
|
<button class="icon-btn" @click="downloadLogs" title="下载">
|
||||||
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
<DownloadOutline />
|
||||||
</n-button>
|
</button>
|
||||||
<n-button size="small" quaternary @click="downloadLogs">
|
<button class="action-btn" :class="isStreaming ? 'danger' : 'success'" @click="isStreaming ? stopStream() : startStream()">
|
||||||
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
<StopOutline v-if="isStreaming" />
|
||||||
</n-button>
|
<PlayOutline v-else />
|
||||||
<n-button
|
<span>{{ isStreaming ? '停止' : '开始' }}</span>
|
||||||
size="small"
|
</button>
|
||||||
:type="isStreaming ? 'error' : 'success'"
|
</div>
|
||||||
@click="isStreaming ? stopStream() : startStream()"
|
<button class="close-btn" @click="emit('close')">
|
||||||
>
|
<CloseOutline />
|
||||||
<template #icon>
|
</button>
|
||||||
<n-icon><StopOutline v-if="isStreaming" /><PlayOutline v-else /></n-icon>
|
</div>
|
||||||
</template>
|
|
||||||
{{ isStreaming ? '停止' : '开始' }}
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-spin :show="loading && logs.length === 0">
|
<!-- Content -->
|
||||||
<div
|
<div class="log-body">
|
||||||
ref="logContainer"
|
<div v-if="loading && logs.length === 0" class="log-loading">加载中...</div>
|
||||||
class="log-container"
|
<div ref="logContainer" class="log-container">
|
||||||
>
|
<div v-if="filteredLogs.length === 0" class="log-empty">暂无日志</div>
|
||||||
<n-empty v-if="filteredLogs.length === 0" description="暂无日志" />
|
<div v-for="(log, i) in filteredLogs" :key="i" class="log-line">
|
||||||
<div
|
<span class="log-time">{{ formatTime(log.ts) }}</span>
|
||||||
v-for="(log, i) in filteredLogs"
|
<span class="log-level" :style="{ color: getLevelColor(log.level) }">[{{ log.level.toUpperCase() }}]</span>
|
||||||
:key="i"
|
<span class="log-src">[{{ log.src }}]</span>
|
||||||
class="log-line"
|
<span class="log-msg">{{ log.msg }}</span>
|
||||||
>
|
</div>
|
||||||
<span class="log-time">{{ formatTime(log.ts) }}</span>
|
|
||||||
<span class="log-level" :style="{ color: getLevelColor(log.level) }">[{{ log.level.toUpperCase() }}]</span>
|
|
||||||
<span class="log-src">[{{ log.src }}]</span>
|
|
||||||
<span class="log-msg">{{ log.msg }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</div>
|
||||||
</n-card>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.log-container {
|
/* Overlay */
|
||||||
height: 400px;
|
.log-overlay {
|
||||||
overflow-y: auto;
|
position: fixed;
|
||||||
background: #1e1e1e;
|
inset: 0;
|
||||||
padding: 8px;
|
background: rgba(0, 0, 0, 0.6);
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
backdrop-filter: blur(4px);
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
border-radius: 4px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.log-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: rgba(30, 27, 75, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-controls {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select */
|
||||||
|
.log-select {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-select option {
|
||||||
|
background: #1e1b4b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.log-input {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 150px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-input:focus {
|
||||||
|
border-color: rgba(167, 139, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
.log-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon Button */
|
||||||
|
.icon-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Button */
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.success {
|
||||||
|
background: rgba(52, 211, 153, 0.2);
|
||||||
|
border-color: rgba(52, 211, 153, 0.3);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close Button */
|
||||||
|
.close-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.log-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.log-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log Line */
|
||||||
.log-line {
|
.log-line {
|
||||||
line-height: 1.6;
|
line-height: 1.8;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: #d4d4d4;
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-time {
|
.log-time {
|
||||||
color: #808080;
|
color: rgba(255, 255, 255, 0.4);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-level {
|
.log-level {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-src {
|
.log-src {
|
||||||
color: #a0a0a0;
|
color: rgba(167, 139, 250, 0.8);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-msg {
|
.log-msg {
|
||||||
color: #d4d4d4;
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
.log-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.log-overlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-modal {
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-controls {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
129
web/src/composables/useConfirm.ts
Normal file
129
web/src/composables/useConfirm.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { ref, createApp, h } from 'vue'
|
||||||
|
|
||||||
|
interface DialogOptions {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
positiveText?: string
|
||||||
|
negativeText?: string
|
||||||
|
onPositiveClick?: () => void | Promise<void>
|
||||||
|
onNegativeClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogOptions = ref<DialogOptions | null>(null)
|
||||||
|
|
||||||
|
const DialogComponent = {
|
||||||
|
setup() {
|
||||||
|
const handlePositive = async () => {
|
||||||
|
if (dialogOptions.value?.onPositiveClick) {
|
||||||
|
await dialogOptions.value.onPositiveClick()
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNegative = () => {
|
||||||
|
dialogOptions.value?.onNegativeClick?.()
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!dialogVisible.value || !dialogOptions.value) return null
|
||||||
|
|
||||||
|
return h('div', { class: 'dialog-overlay', onClick: handleNegative },
|
||||||
|
h('div', { class: 'dialog-container', onClick: (e: Event) => e.stopPropagation() }, [
|
||||||
|
h('h3', { class: 'dialog-title' }, dialogOptions.value.title),
|
||||||
|
h('p', { class: 'dialog-content' }, dialogOptions.value.content),
|
||||||
|
h('div', { class: 'dialog-footer' }, [
|
||||||
|
h('button', { class: 'dialog-btn', onClick: handleNegative },
|
||||||
|
dialogOptions.value.negativeText || '取消'),
|
||||||
|
h('button', { class: 'dialog-btn primary', onClick: handlePositive },
|
||||||
|
dialogOptions.value.positiveText || '确定')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let containerMounted = false
|
||||||
|
|
||||||
|
function ensureContainer() {
|
||||||
|
if (containerMounted) return
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.id = 'dialog-root'
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const app = createApp(DialogComponent)
|
||||||
|
app.mount(container)
|
||||||
|
containerMounted = true
|
||||||
|
|
||||||
|
// Add styles
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = `
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
.dialog-container {
|
||||||
|
background: rgba(30, 27, 75, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.dialog-title {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.dialog-content {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.dialog-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.dialog-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.dialog-btn.primary {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfirm() {
|
||||||
|
return {
|
||||||
|
warning: (options: DialogOptions) => {
|
||||||
|
ensureContainer()
|
||||||
|
dialogOptions.value = options
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
web/src/composables/useToast.ts
Normal file
101
web/src/composables/useToast.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { ref, createApp, h } from 'vue'
|
||||||
|
|
||||||
|
interface ToastOptions {
|
||||||
|
message: string
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info'
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = ref<Array<ToastOptions & { id: number }>>([])
|
||||||
|
let toastId = 0
|
||||||
|
|
||||||
|
const ToastContainer = {
|
||||||
|
setup() {
|
||||||
|
return () => h('div', { class: 'toast-container' },
|
||||||
|
toasts.value.map(toast =>
|
||||||
|
h('div', {
|
||||||
|
key: toast.id,
|
||||||
|
class: ['toast-item', toast.type]
|
||||||
|
}, toast.message)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let containerMounted = false
|
||||||
|
|
||||||
|
function ensureContainer() {
|
||||||
|
if (containerMounted) return
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.id = 'toast-root'
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const app = createApp(ToastContainer)
|
||||||
|
app.mount(container)
|
||||||
|
containerMounted = true
|
||||||
|
|
||||||
|
// Add styles
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = `
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.toast-item {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
animation: toast-in 0.3s ease;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
.toast-item.success {
|
||||||
|
background: rgba(52, 211, 153, 0.9);
|
||||||
|
}
|
||||||
|
.toast-item.error {
|
||||||
|
background: rgba(239, 68, 68, 0.9);
|
||||||
|
}
|
||||||
|
.toast-item.warning {
|
||||||
|
background: rgba(251, 191, 36, 0.9);
|
||||||
|
color: #1e1b4b;
|
||||||
|
}
|
||||||
|
.toast-item.info {
|
||||||
|
background: rgba(96, 165, 250, 0.9);
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateX(20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(options: ToastOptions) {
|
||||||
|
ensureContainer()
|
||||||
|
|
||||||
|
const id = ++toastId
|
||||||
|
toasts.value.push({ ...options, id })
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = toasts.value.findIndex(t => t.id === id)
|
||||||
|
if (index > -1) {
|
||||||
|
toasts.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}, options.duration || 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return {
|
||||||
|
success: (message: string) => showToast({ message, type: 'success' }),
|
||||||
|
error: (message: string) => showToast({ message, type: 'error' }),
|
||||||
|
warning: (message: string) => showToast({ message, type: 'warning' }),
|
||||||
|
info: (message: string) => showToast({ message, type: 'info' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,9 +26,9 @@ const router = createRouter({
|
|||||||
component: () => import('../views/PluginsView.vue'),
|
component: () => import('../views/PluginsView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/update',
|
path: '/settings',
|
||||||
name: 'update',
|
name: 'settings',
|
||||||
component: () => import('../views/UpdateView.vue'),
|
component: () => import('../views/SettingsView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -13,3 +15,19 @@ body {
|
|||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glass morphism utilities */
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card-hover:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow: 0 12px 48px 0 rgba(31, 38, 135, 0.45);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface ClientStatus {
|
|||||||
last_ping?: string
|
last_ping?: string
|
||||||
remote_addr?: string
|
remote_addr?: string
|
||||||
rule_count: number
|
rule_count: number
|
||||||
|
os?: string
|
||||||
|
arch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端详情
|
// 客户端详情
|
||||||
@@ -72,6 +74,8 @@ export interface ClientDetail {
|
|||||||
online: boolean
|
online: boolean
|
||||||
last_ping?: string
|
last_ping?: string
|
||||||
remote_addr?: string
|
remote_addr?: string
|
||||||
|
os?: string
|
||||||
|
arch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 服务器状态
|
// 服务器状态
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty, NIcon } from 'naive-ui'
|
|
||||||
import { ExtensionPuzzleOutline, CloudDownloadOutline } from '@vicons/ionicons5'
|
|
||||||
import { getClients } from '../api'
|
import { getClients } from '../api'
|
||||||
import type { ClientStatus } from '../types'
|
import type { ClientStatus } from '../types'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const clients = ref<ClientStatus[]>([])
|
const clients = ref<ClientStatus[]>([])
|
||||||
|
|
||||||
|
// Mock data for traffic (API not implemented yet)
|
||||||
|
const trafficStats = ref({
|
||||||
|
inbound: 0,
|
||||||
|
outbound: 0,
|
||||||
|
inboundUnit: 'GB',
|
||||||
|
outboundUnit: 'GB'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock 24h traffic data for chart
|
||||||
|
const trafficHistory = ref<Array<{ hour: string; inbound: number; outbound: number }>>([])
|
||||||
|
|
||||||
|
const generateMockTrafficData = () => {
|
||||||
|
const data = []
|
||||||
|
const now = new Date()
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const hour = new Date(now.getTime() - i * 60 * 60 * 1000)
|
||||||
|
data.push({
|
||||||
|
hour: hour.getHours().toString().padStart(2, '0') + ':00',
|
||||||
|
inbound: Math.random() * 100,
|
||||||
|
outbound: Math.random() * 80
|
||||||
|
})
|
||||||
|
}
|
||||||
|
trafficHistory.value = data
|
||||||
|
}
|
||||||
|
|
||||||
const loadClients = async () => {
|
const loadClients = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await getClients()
|
const { data } = await getClients()
|
||||||
@@ -18,80 +39,512 @@ const loadClients = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const onlineClients = computed(() => {
|
const onlineClients = computed(() => {
|
||||||
return clients.value.filter(client => client.online).length
|
return clients.value.filter(client => client.online).length
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalRules = computed(() => {
|
const totalRules = computed(() => {
|
||||||
return clients.value.reduce((sum, client) => sum + client.rule_count, 0)
|
return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadClients)
|
// Chart helpers
|
||||||
|
const maxTraffic = computed(() => {
|
||||||
|
const max = Math.max(
|
||||||
|
...trafficHistory.value.map(d => Math.max(d.inbound, d.outbound))
|
||||||
|
)
|
||||||
|
return max || 100
|
||||||
|
})
|
||||||
|
|
||||||
const viewClient = (id: string) => {
|
const getBarHeight = (value: number) => {
|
||||||
router.push(`/client/${id}`)
|
return (value / maxTraffic.value) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadClients()
|
||||||
|
generateMockTrafficData()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="dashboard-container">
|
||||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
<!-- Animated background particles -->
|
||||||
<div>
|
<div class="particles">
|
||||||
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
|
<div class="particle particle-1"></div>
|
||||||
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</p>
|
<div class="particle particle-2"></div>
|
||||||
|
<div class="particle particle-3"></div>
|
||||||
|
<div class="particle particle-4"></div>
|
||||||
|
<div class="particle particle-5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||||
|
<p class="text-white/70">Monitor your tunnel connections and traffic</p>
|
||||||
</div>
|
</div>
|
||||||
<n-space>
|
|
||||||
<n-button @click="router.push('/plugins')">
|
|
||||||
<template #icon><n-icon><ExtensionPuzzleOutline /></n-icon></template>
|
|
||||||
扩展商店
|
|
||||||
</n-button>
|
|
||||||
<n-button @click="router.push('/update')">
|
|
||||||
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
|
|
||||||
系统更新
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
|
|
||||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
<!-- Stats Grid -->
|
||||||
<n-gi>
|
<div class="stats-grid">
|
||||||
<n-card>
|
<!-- Outbound Traffic -->
|
||||||
<n-statistic label="总客户端" :value="clients.length" />
|
<div class="stat-card glass-stat">
|
||||||
</n-card>
|
<div class="stat-icon outbound">
|
||||||
</n-gi>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<n-gi>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12" />
|
||||||
<n-card>
|
</svg>
|
||||||
<n-statistic label="在线客户端" :value="onlineClients" />
|
</div>
|
||||||
</n-card>
|
<div class="stat-content">
|
||||||
</n-gi>
|
<span class="stat-label">出站流量</span>
|
||||||
<n-gi>
|
<span class="stat-value">{{ trafficStats.outbound.toFixed(2) }}</span>
|
||||||
<n-card>
|
<span class="stat-unit">{{ trafficStats.outboundUnit }}</span>
|
||||||
<n-statistic label="总规则数" :value="totalRules" />
|
</div>
|
||||||
</n-card>
|
</div>
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-empty v-if="clients.length === 0" description="暂无客户端连接" />
|
<!-- Inbound Traffic -->
|
||||||
|
<div class="stat-card glass-stat">
|
||||||
|
<div class="stat-icon inbound">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 13l-5 5m0 0l-5-5m5 5V6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">入站流量</span>
|
||||||
|
<span class="stat-value">{{ trafficStats.inbound.toFixed(2) }}</span>
|
||||||
|
<span class="stat-unit">{{ trafficStats.inboundUnit }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
<!-- Client Count -->
|
||||||
<n-gi v-for="client in clients" :key="client.id">
|
<div class="stat-card glass-stat">
|
||||||
<n-card hoverable style="cursor: pointer;" @click="viewClient(client.id)">
|
<div class="stat-icon clients">
|
||||||
<n-space justify="space-between" align="center">
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<div>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
<h3 style="margin: 0 0 4px 0;">{{ client.nickname || client.id }}</h3>
|
</svg>
|
||||||
<p v-if="client.nickname" style="margin: 0 0 4px 0; color: #999; font-size: 12px;">{{ client.id }}</p>
|
</div>
|
||||||
<p v-if="client.remote_addr && client.online" style="margin: 0 0 8px 0; color: #666; font-size: 12px;">IP: {{ client.remote_addr }}</p>
|
<div class="stat-content">
|
||||||
<n-space>
|
<span class="stat-label">客户端</span>
|
||||||
<n-tag :type="client.online ? 'success' : 'default'" size="small">
|
<div class="client-count">
|
||||||
{{ client.online ? '在线' : '离线' }}
|
<span class="stat-value online">{{ onlineClients }}</span>
|
||||||
</n-tag>
|
<span class="stat-separator">/</span>
|
||||||
<n-tag type="info" size="small">{{ client.rule_count }} 条规则</n-tag>
|
<span class="stat-value total">{{ clients.length }}</span>
|
||||||
</n-space>
|
|
||||||
</div>
|
</div>
|
||||||
<n-button size="small" @click.stop="viewClient(client.id)">查看详情</n-button>
|
<span class="stat-unit">在线 / 总数</span>
|
||||||
</n-space>
|
</div>
|
||||||
</n-card>
|
<div class="online-indicator" :class="{ active: onlineClients > 0 }">
|
||||||
</n-gi>
|
<span class="pulse"></span>
|
||||||
</n-grid>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules Count -->
|
||||||
|
<div class="stat-card glass-stat">
|
||||||
|
<div class="stat-icon rules">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">代理规则</span>
|
||||||
|
<span class="stat-value">{{ totalRules }}</span>
|
||||||
|
<span class="stat-unit">条规则</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Traffic Chart Section -->
|
||||||
|
<div class="chart-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">24小时流量趋势</h2>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<span class="legend-item inbound"><span class="legend-dot"></span>入站</span>
|
||||||
|
<span class="legend-item outbound"><span class="legend-dot"></span>出站</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card glass-card">
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-bars">
|
||||||
|
<div v-for="(data, index) in trafficHistory" :key="index" class="bar-group">
|
||||||
|
<div class="bar-wrapper">
|
||||||
|
<div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div>
|
||||||
|
<div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="bar-label">{{ data.hour }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-hint">
|
||||||
|
<span>流量统计功能开发中,当前显示模拟数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Container with gradient background */
|
||||||
|
.dashboard-container {
|
||||||
|
min-height: calc(100vh - 108px);
|
||||||
|
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating particles */
|
||||||
|
.particles {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
|
||||||
|
animation: float-particle 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-1 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
top: -100px;
|
||||||
|
right: -50px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-2 {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
bottom: 10%;
|
||||||
|
left: 5%;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-3 {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
top: 40%;
|
||||||
|
right: 20%;
|
||||||
|
animation-delay: -10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-4 {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
top: 20%;
|
||||||
|
left: 30%;
|
||||||
|
animation-delay: -15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-5 {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
bottom: 30%;
|
||||||
|
right: 10%;
|
||||||
|
animation-delay: -8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-particle {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
|
||||||
|
25% { transform: translate(30px, -40px) scale(1.1); opacity: 0.5; }
|
||||||
|
50% { transform: translate(-20px, -80px) scale(0.9); opacity: 0.4; }
|
||||||
|
75% { transform: translate(-40px, -40px) scale(1.05); opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.dashboard-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass stat card */
|
||||||
|
.glass-stat {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-stat:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat icon */
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.outbound {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.inbound {
|
||||||
|
background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.clients {
|
||||||
|
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.rules {
|
||||||
|
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon svg {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat content */
|
||||||
|
.stat-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client count special styling */
|
||||||
|
.client-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-count .stat-value.online {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-count .stat-value.total {
|
||||||
|
font-size: 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-separator {
|
||||||
|
font-size: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat trend indicator */
|
||||||
|
.stat-trend {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-trend.up {
|
||||||
|
background: rgba(52, 211, 153, 0.2);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Online indicator with pulse */
|
||||||
|
.online-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-indicator .pulse {
|
||||||
|
display: block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-indicator.active .pulse {
|
||||||
|
background: #34d399;
|
||||||
|
animation: pulse-animation 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-animation {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 8px rgba(52, 211, 153, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Section */
|
||||||
|
.chart-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item.inbound .legend-dot {
|
||||||
|
background: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item.outbound .legend-dot {
|
||||||
|
background: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Card */
|
||||||
|
.chart-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 200px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bars {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 600px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-group {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.inbound {
|
||||||
|
background: linear-gradient(180deg, #a78bfa 0%, #8b5cf6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.outbound {
|
||||||
|
background: linear-gradient(180deg, #60a5fa 0%, #3b82f6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass card base */
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui'
|
|
||||||
import { login, setToken } from '../api'
|
import { login, setToken } from '../api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -33,51 +32,66 @@ const handleLogin = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<n-card class="login-card" :bordered="false">
|
<!-- Animated particles -->
|
||||||
<template #header>
|
<div class="particles">
|
||||||
<div class="login-header">
|
<div class="particle particle-1"></div>
|
||||||
<h1 class="logo">GoTunnel</h1>
|
<div class="particle particle-2"></div>
|
||||||
<p class="subtitle">安全的内网穿透工具</p>
|
<div class="particle particle-3"></div>
|
||||||
</div>
|
<div class="particle particle-4"></div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<n-form @submit.prevent="handleLogin">
|
<!-- Login card -->
|
||||||
<n-form-item label="用户名">
|
<div class="login-card">
|
||||||
<n-input
|
<div class="login-header">
|
||||||
v-model:value="username"
|
<div class="logo-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="logo-text">GoTunnel</h1>
|
||||||
|
<p class="subtitle">安全的内网穿透工具</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">用户名</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="glass-input"
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</div>
|
||||||
|
|
||||||
<n-form-item label="密码">
|
<div class="form-group">
|
||||||
<n-input
|
<label class="form-label">密码</label>
|
||||||
v-model:value="password"
|
<input
|
||||||
|
v-model="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
class="glass-input"
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
show-password-on="click"
|
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</div>
|
||||||
|
|
||||||
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 16px;">
|
<div v-if="error" class="error-alert">
|
||||||
{{ error }}
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</n-alert>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-button
|
<button type="submit" class="glass-button" :disabled="loading">
|
||||||
type="primary"
|
<span v-if="loading" class="loading-spinner"></span>
|
||||||
block
|
|
||||||
:loading="loading"
|
|
||||||
attr-type="submit"
|
|
||||||
>
|
|
||||||
{{ loading ? '登录中...' : '登录' }}
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
</n-button>
|
</button>
|
||||||
</n-form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<div class="login-footer">
|
||||||
<div class="login-footer">欢迎使用 GoTunnel</div>
|
<span>欢迎使用 GoTunnel</span>
|
||||||
</template>
|
</div>
|
||||||
</n-card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -87,36 +101,236 @@ const handleLogin = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Particles */
|
||||||
|
.particles {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
|
||||||
|
animation: float-particle 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-1 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
top: -100px;
|
||||||
|
left: -100px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-2 {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
bottom: -50px;
|
||||||
|
right: -50px;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-3 {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
top: 50%;
|
||||||
|
right: 10%;
|
||||||
|
animation-delay: -10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-4 {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
bottom: 20%;
|
||||||
|
left: 10%;
|
||||||
|
animation-delay: -15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-particle {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
|
||||||
|
25% { transform: translate(30px, -40px) scale(1.1); opacity: 0.5; }
|
||||||
|
50% { transform: translate(-20px, -80px) scale(0.9); opacity: 0.4; }
|
||||||
|
75% { transform: translate(-40px, -40px) scale(1.05); opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login card */
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 40px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
.login-header {
|
.login-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 16px rgba(96, 165, 250, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon svg {
|
||||||
|
color: white;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #18a058;
|
color: white;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #666;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-footer {
|
/* Form */
|
||||||
text-align: center;
|
.login-form {
|
||||||
color: #999;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
color: white;
|
||||||
|
font-size: 15px;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error alert */
|
||||||
|
.error-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fca5a5;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-alert svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
.glass-button {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(96, 165, 250, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 24px rgba(96, 165, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
409
web/src/views/SettingsView.vue
Normal file
409
web/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { CloudDownloadOutline, RefreshOutline, ServerOutline } from '@vicons/ionicons5'
|
||||||
|
import GlassTag from '../components/GlassTag.vue'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
|
import {
|
||||||
|
getVersionInfo, checkServerUpdate, applyServerUpdate,
|
||||||
|
type UpdateInfo, type VersionInfo
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
const message = useToast()
|
||||||
|
const dialog = useConfirm()
|
||||||
|
|
||||||
|
const versionInfo = ref<VersionInfo | null>(null)
|
||||||
|
const serverUpdate = ref<UpdateInfo | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const checkingServer = ref(false)
|
||||||
|
const updatingServer = ref(false)
|
||||||
|
|
||||||
|
const loadVersionInfo = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getVersionInfo()
|
||||||
|
versionInfo.value = data
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load version info', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckServerUpdate = async () => {
|
||||||
|
checkingServer.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await checkServerUpdate()
|
||||||
|
serverUpdate.value = data
|
||||||
|
if (data.available) {
|
||||||
|
message.success('发现新版本: ' + data.latest)
|
||||||
|
} else {
|
||||||
|
message.info('已是最新版本')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data || '检查更新失败')
|
||||||
|
} finally {
|
||||||
|
checkingServer.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyServerUpdate = () => {
|
||||||
|
if (!serverUpdate.value?.download_url) {
|
||||||
|
message.error('没有可用的下载链接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认更新服务端',
|
||||||
|
content: `即将更新服务端到 ${serverUpdate.value.latest},更新后服务器将自动重启。确定要继续吗?`,
|
||||||
|
positiveText: '更新并重启',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
updatingServer.value = true
|
||||||
|
try {
|
||||||
|
await applyServerUpdate(serverUpdate.value!.download_url)
|
||||||
|
message.success('更新已开始,服务器将在几秒后重启')
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, 5000)
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data || '更新失败')
|
||||||
|
updatingServer.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadVersionInfo()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<!-- Particles -->
|
||||||
|
<div class="particles">
|
||||||
|
<div class="particle particle-1"></div>
|
||||||
|
<div class="particle particle-2"></div>
|
||||||
|
<div class="particle particle-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">系统设置</h1>
|
||||||
|
<p class="page-subtitle">管理服务端配置和系统更新</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version Info Card -->
|
||||||
|
<div class="glass-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>版本信息</h3>
|
||||||
|
<ServerOutline class="header-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="loading" class="loading-state">加载中...</div>
|
||||||
|
<div v-else-if="versionInfo" class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">版本号</span>
|
||||||
|
<span class="info-value">{{ versionInfo.version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Git 提交</span>
|
||||||
|
<span class="info-value mono">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">构建时间</span>
|
||||||
|
<span class="info-value">{{ versionInfo.build_time || 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Go 版本</span>
|
||||||
|
<span class="info-value">{{ versionInfo.go_version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">操作系统</span>
|
||||||
|
<span class="info-value">{{ versionInfo.os }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">架构</span>
|
||||||
|
<span class="info-value">{{ versionInfo.arch }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">无法加载版本信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Update Card -->
|
||||||
|
<div class="glass-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>服务端更新</h3>
|
||||||
|
<button class="glass-btn small" :disabled="checkingServer" @click="handleCheckServerUpdate">
|
||||||
|
<RefreshOutline class="btn-icon" />
|
||||||
|
检查更新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="!serverUpdate" class="empty-state">
|
||||||
|
点击检查更新按钮查看是否有新版本
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="serverUpdate.available" class="update-alert success">
|
||||||
|
发现新版本 {{ serverUpdate.latest }},当前版本 {{ serverUpdate.current }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="update-alert info">
|
||||||
|
当前已是最新版本 {{ serverUpdate.current }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverUpdate.download_url" class="download-info">
|
||||||
|
下载文件: {{ serverUpdate.asset_name }}
|
||||||
|
<GlassTag style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</GlassTag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverUpdate.release_note" class="release-note">
|
||||||
|
<span class="note-label">更新日志:</span>
|
||||||
|
<pre>{{ serverUpdate.release_note }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="serverUpdate.available && serverUpdate.download_url"
|
||||||
|
class="glass-btn primary"
|
||||||
|
:disabled="updatingServer"
|
||||||
|
@click="handleApplyServerUpdate"
|
||||||
|
>
|
||||||
|
<CloudDownloadOutline class="btn-icon" />
|
||||||
|
下载并更新服务端
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-page {
|
||||||
|
min-height: calc(100vh - 108px);
|
||||||
|
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particles {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
|
||||||
|
animation: float-particle 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-1 { width: 250px; height: 250px; top: -80px; right: -50px; }
|
||||||
|
.particle-2 { width: 180px; height: 180px; bottom: 10%; left: 5%; animation-delay: -7s; }
|
||||||
|
.particle-3 { width: 120px; height: 120px; top: 50%; right: 15%; animation-delay: -12s; }
|
||||||
|
|
||||||
|
@keyframes float-particle {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
|
||||||
|
50% { transform: translate(-20px, -60px) scale(0.95); opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass Card */
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Grid */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.info-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update Alert */
|
||||||
|
.update-alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-alert.success {
|
||||||
|
background: rgba(52, 211, 153, 0.15);
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.3);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-alert.info {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download Info */
|
||||||
|
.download-info {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Release Note */
|
||||||
|
.release-note {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-note pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass Button */
|
||||||
|
.glass-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-btn.small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-btn.primary {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon styles */
|
||||||
|
.header-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import {
|
|
||||||
NCard, NButton, NSpace, NTag, NGrid, NGi, NEmpty, NSpin, NIcon,
|
|
||||||
NAlert, NSelect, useMessage, useDialog
|
|
||||||
} from 'naive-ui'
|
|
||||||
import { ArrowBackOutline, CloudDownloadOutline, RefreshOutline, RocketOutline } from '@vicons/ionicons5'
|
|
||||||
import {
|
|
||||||
getVersionInfo, checkServerUpdate, checkClientUpdate, applyServerUpdate, applyClientUpdate,
|
|
||||||
getClients, type UpdateInfo, type VersionInfo
|
|
||||||
} from '../api'
|
|
||||||
import type { ClientStatus } from '../types'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
const versionInfo = ref<VersionInfo | null>(null)
|
|
||||||
const serverUpdate = ref<UpdateInfo | null>(null)
|
|
||||||
const clientUpdate = ref<UpdateInfo | null>(null)
|
|
||||||
const clients = ref<ClientStatus[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const checkingServer = ref(false)
|
|
||||||
const checkingClient = ref(false)
|
|
||||||
const updatingServer = ref(false)
|
|
||||||
const selectedClientId = ref('')
|
|
||||||
|
|
||||||
const onlineClients = computed(() => clients.value.filter(c => c.online))
|
|
||||||
|
|
||||||
const loadVersionInfo = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await getVersionInfo()
|
|
||||||
versionInfo.value = data
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load version info', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadClients = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await getClients()
|
|
||||||
clients.value = data || []
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load clients', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckServerUpdate = async () => {
|
|
||||||
checkingServer.value = true
|
|
||||||
try {
|
|
||||||
const { data } = await checkServerUpdate()
|
|
||||||
serverUpdate.value = data
|
|
||||||
if (data.available) {
|
|
||||||
message.success('发现新版本: ' + data.latest)
|
|
||||||
} else {
|
|
||||||
message.info('已是最新版本')
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.response?.data || '检查更新失败')
|
|
||||||
} finally {
|
|
||||||
checkingServer.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckClientUpdate = async () => {
|
|
||||||
checkingClient.value = true
|
|
||||||
try {
|
|
||||||
const { data } = await checkClientUpdate()
|
|
||||||
clientUpdate.value = data
|
|
||||||
if (data.download_url) {
|
|
||||||
message.success('找到客户端更新包: ' + data.latest)
|
|
||||||
} else {
|
|
||||||
message.warning('未找到对应平台的更新包')
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.response?.data || '检查更新失败')
|
|
||||||
} finally {
|
|
||||||
checkingClient.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApplyServerUpdate = () => {
|
|
||||||
if (!serverUpdate.value?.download_url) {
|
|
||||||
message.error('没有可用的下载链接')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认更新服务端',
|
|
||||||
content: `即将更新服务端到 ${serverUpdate.value.latest},更新后服务器将自动重启。确定要继续吗?`,
|
|
||||||
positiveText: '更新并重启',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
updatingServer.value = true
|
|
||||||
try {
|
|
||||||
await applyServerUpdate(serverUpdate.value!.download_url)
|
|
||||||
message.success('更新已开始,服务器将在几秒后重启')
|
|
||||||
// 显示倒计时或等待
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload()
|
|
||||||
}, 5000)
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.response?.data || '更新失败')
|
|
||||||
updatingServer.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApplyClientUpdate = async () => {
|
|
||||||
if (!selectedClientId.value) {
|
|
||||||
message.warning('请选择要更新的客户端')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clientUpdate.value?.download_url) {
|
|
||||||
message.error('没有可用的下载链接')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientName = onlineClients.value.find(c => c.id === selectedClientId.value)?.nickname || selectedClientId.value
|
|
||||||
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认更新客户端',
|
|
||||||
content: `即将更新客户端 "${clientName}" 到 ${clientUpdate.value.latest},更新后客户端将自动重启。确定要继续吗?`,
|
|
||||||
positiveText: '更新',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await applyClientUpdate(selectedClientId.value, clientUpdate.value!.download_url)
|
|
||||||
message.success(`更新命令已发送到客户端 ${clientName}`)
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.response?.data || '更新失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 B'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([loadVersionInfo(), loadClients()])
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="update-view">
|
|
||||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
|
||||||
<div>
|
|
||||||
<h2 style="margin: 0 0 8px 0;">系统更新</h2>
|
|
||||||
<p style="margin: 0; color: #666;">检查并应用服务端和客户端更新</p>
|
|
||||||
</div>
|
|
||||||
<n-button quaternary @click="router.push('/')">
|
|
||||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
|
||||||
返回首页
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
|
|
||||||
<n-spin :show="loading">
|
|
||||||
<!-- 当前版本信息 -->
|
|
||||||
<n-card title="当前版本" style="margin-bottom: 16px;">
|
|
||||||
<n-grid v-if="versionInfo" :cols="6" :x-gap="16" responsive="screen" cols-s="2" cols-m="3">
|
|
||||||
<n-gi>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">版本号</span>
|
|
||||||
<span class="value">{{ versionInfo.version }}</span>
|
|
||||||
</div>
|
|
||||||
</n-gi>
|
|
||||||
<n-gi>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Git 提交</span>
|
|
||||||
<span class="value">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
</n-gi>
|
|
||||||
<n-gi>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">构建时间</span>
|
|
||||||
<span class="value">{{ versionInfo.build_time || 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
</n-gi>
|
|
||||||
<n-gi>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Go 版本</span>
|
|
||||||
<span class="value">{{ versionInfo.go_version }}</span>
|
|
||||||
</div>
|
|
||||||
</n-gi>
|
|
||||||
<n-gi>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">操作系统</span>
|
|
||||||
<span class="value">{{ versionInfo.os }}</span>
|
|
||||||
</div>
|
|
||||||
</n-gi>
|
|
||||||
<n-gi>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">架构</span>
|
|
||||||
<span class="value">{{ versionInfo.arch }}</span>
|
|
||||||
</div>
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
<n-empty v-else description="加载中..." />
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-grid :cols="2" :x-gap="16" responsive="screen" cols-s="1">
|
|
||||||
<!-- 服务端更新 -->
|
|
||||||
<n-gi>
|
|
||||||
<n-card title="服务端更新">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button size="small" :loading="checkingServer" @click="handleCheckServerUpdate">
|
|
||||||
<template #icon><n-icon><RefreshOutline /></n-icon></template>
|
|
||||||
检查更新
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-empty v-if="!serverUpdate" description="点击检查更新按钮查看是否有新版本" />
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<n-alert v-if="serverUpdate.available" type="success" style="margin-bottom: 16px;">
|
|
||||||
发现新版本 {{ serverUpdate.latest }},当前版本 {{ serverUpdate.current }}
|
|
||||||
</n-alert>
|
|
||||||
<n-alert v-else type="info" style="margin-bottom: 16px;">
|
|
||||||
当前已是最新版本 {{ serverUpdate.current }}
|
|
||||||
</n-alert>
|
|
||||||
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<div v-if="serverUpdate.download_url">
|
|
||||||
<p style="margin: 0 0 8px 0; color: #666;">
|
|
||||||
下载文件: {{ serverUpdate.asset_name }}
|
|
||||||
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</n-tag>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="serverUpdate.release_note" style="max-height: 150px; overflow-y: auto;">
|
|
||||||
<p style="margin: 0 0 4px 0; color: #666; font-size: 12px;">更新日志:</p>
|
|
||||||
<pre style="margin: 0; white-space: pre-wrap; font-size: 12px; color: #333;">{{ serverUpdate.release_note }}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-button
|
|
||||||
v-if="serverUpdate.available && serverUpdate.download_url"
|
|
||||||
type="primary"
|
|
||||||
:loading="updatingServer"
|
|
||||||
@click="handleApplyServerUpdate"
|
|
||||||
>
|
|
||||||
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
|
|
||||||
下载并更新服务端
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</n-card>
|
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<!-- 客户端更新 -->
|
|
||||||
<n-gi>
|
|
||||||
<n-card title="客户端更新">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button size="small" :loading="checkingClient" @click="handleCheckClientUpdate">
|
|
||||||
<template #icon><n-icon><RefreshOutline /></n-icon></template>
|
|
||||||
检查更新
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-empty v-if="!clientUpdate" description="点击检查更新按钮查看客户端更新" />
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<div v-if="clientUpdate.download_url">
|
|
||||||
<p style="margin: 0 0 8px 0; color: #666;">
|
|
||||||
最新版本: {{ clientUpdate.latest }}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 0 0 8px 0; color: #666;">
|
|
||||||
下载文件: {{ clientUpdate.asset_name }}
|
|
||||||
<n-tag size="small" style="margin-left: 8px;">{{ formatBytes(clientUpdate.asset_size) }}</n-tag>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-empty v-if="onlineClients.length === 0" description="没有在线的客户端" />
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<n-select
|
|
||||||
v-model:value="selectedClientId"
|
|
||||||
placeholder="选择要更新的客户端"
|
|
||||||
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="!selectedClientId || !clientUpdate.download_url"
|
|
||||||
@click="handleApplyClientUpdate"
|
|
||||||
>
|
|
||||||
<template #icon><n-icon><RocketOutline /></n-icon></template>
|
|
||||||
推送更新到客户端
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</n-card>
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
</n-spin>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.info-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item .label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item .value {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user