feat(ui): 重构应用布局和添加客户端更新功能
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 41s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m31s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m38s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m0s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m13s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m10s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m12s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m29s

- 将侧边栏菜单改为顶部标签页导航设计
- 添加客户端操作系统和架构信息显示
- 实现客户端自动更新检查和应用功能
- 添加底部页脚显示版本和GitHub链接
- 更新主题颜色为紫色渐变风格
- 优化首页和插件页面的UI布局结构
- 修改路由配置将更新页面重命名为设置页面
- 在认证协议中添加客户端平台信息字段
- 重构App.vue中的导航和状态管理逻辑
This commit is contained in:
Flik
2026-01-22 13:59:42 +08:00
parent 7d9ad44856
commit 23fa089608
19 changed files with 687 additions and 524 deletions

View File

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

View File

@@ -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 安装插件到客户端请求

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 认证响应

View File

@@ -1,46 +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, NLayout, NLayoutHeader, NLayoutContent, NLayoutFooter,
NButton, NIcon, NConfigProvider, NMessageProvider, NButton, NIcon, NConfigProvider, NMessageProvider,
NDialogProvider, NGlobalStyle, NDropdown, type GlobalThemeOverrides NDialogProvider, NGlobalStyle, NDropdown, NTabs, NTabPane,
type GlobalThemeOverrides
} from 'naive-ui' } from 'naive-ui'
import { import {
HomeOutline, ExtensionPuzzleOutline, LogOutOutline, PersonCircleOutline, LogoGithub
ServerOutline, MenuOutline, PersonCircleOutline
} 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 isLoginPage = computed(() => route.path === '/login') const isLoginPage = computed(() => route.path === '/login')
const menuOptions: MenuOption[] = [ // 当前激活的 Tab
{ const activeTab = computed(() => {
label: 'Dashboard', const path = route.path
key: '/', if (path === '/' || path === '/home') return 'home'
icon: () => h(NIcon, null, { default: () => h(HomeOutline) }) if (path.startsWith('/client')) return 'clients'
}, if (path === '/plugins') return 'plugins'
{ if (path === '/settings') return 'settings'
label: 'Plugins Store', return 'home'
key: '/plugins',
icon: () => h(NIcon, null, { default: () => h(ExtensionPuzzleOutline) })
}
]
const activeKey = computed(() => {
if (route.path.startsWith('/client/')) return '/'
return route.path
}) })
const handleMenuUpdate = (key: string) => { const handleTabChange = (tab: string) => {
router.push(key) if (tab === 'home') router.push('/')
else if (tab === 'clients') router.push('/')
else if (tab === 'plugins') router.push('/plugins')
else if (tab === 'settings') router.push('/settings')
} }
const fetchServerStatus = async () => { const fetchServerStatus = async () => {
@@ -54,14 +48,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 +75,23 @@ const logout = () => {
router.push('/login') router.push('/login')
} }
// User dropdown menu options const handleUserAction = (key: string) => {
const userDropdownOptions = [ if (key === 'logout') logout()
{
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,50 +101,31 @@ 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"> <n-layout v-if="!isLoginPage" class="main-layout" position="absolute">
<n-layout-sider <!-- 顶部导航栏 -->
bordered
collapse-mode="width"
:collapsed-width="64"
: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>
<n-menu
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:value="activeKey"
@update:value="handleMenuUpdate"
/>
<div v-if="!collapsed" class="server-status-card">
<div class="status-item">
<span class="label">Server:</span>
<span class="value">{{ serverInfo.bind_addr }}:{{ serverInfo.bind_port }}</span>
</div>
<div class="status-item">
<span class="label">Clients:</span>
<span class="value">{{ clientCount }}</span>
</div>
</div>
</n-layout-sider>
<n-layout>
<n-layout-header bordered class="header"> <n-layout-header bordered class="header">
<div class="header-content"> <div class="header-content">
<n-button quaternary circle size="large" @click="collapsed = !collapsed" class="mobile-toggle"> <div class="header-left">
<template #icon><n-icon><MenuOutline /></n-icon></template> <div class="logo">
</n-button> <span class="logo-text">GoTunnel</span>
</div>
<n-tabs
type="line"
:value="activeTab"
@update:value="handleTabChange"
class="nav-tabs"
>
<n-tab-pane name="home" tab="首页" />
<n-tab-pane name="clients" tab="客户端管理" />
<n-tab-pane name="plugins" tab="插件商店" />
<n-tab-pane name="settings" tab="系统设置" />
</n-tabs>
</div>
<div class="header-right"> <div class="header-right">
<n-dropdown :options="userDropdownOptions" @select="handleUserDropdown"> <n-dropdown
:options="[{ label: '退出登录', key: 'logout' }]"
@select="handleUserAction"
>
<n-button quaternary circle size="large"> <n-button quaternary circle size="large">
<template #icon> <template #icon>
<n-icon size="24"><PersonCircleOutline /></n-icon> <n-icon size="24"><PersonCircleOutline /></n-icon>
@@ -156,10 +135,30 @@ const themeOverrides: GlobalThemeOverrides = {
</div> </div>
</div> </div>
</n-layout-header> </n-layout-header>
<n-layout-content content-style="padding: 24px; background-color: #f0f2f5; min-height: calc(100vh - 64px);">
<!-- 主内容区 -->
<n-layout-content class="main-content">
<RouterView /> <RouterView />
</n-layout-content> </n-layout-content>
</n-layout>
<!-- 底部页脚 -->
<n-layout-footer bordered class="footer">
<div class="footer-content">
<div class="footer-left">
<span class="brand">GoTunnel</span>
<span class="version" v-if="version">v{{ version }}</span>
</div>
<div class="footer-center">
<a href="https://github.com/user/gotunnel" target="_blank" class="footer-link">
<n-icon size="16"><LogoGithub /></n-icon>
<span>GitHub</span>
</a>
</div>
<div class="footer-right">
<span>© 2024 Flik. MIT License</span>
</div>
</div>
</n-layout-footer>
</n-layout> </n-layout>
<RouterView v-else /> <RouterView v-else />
</n-message-provider> </n-message-provider>
@@ -170,31 +169,17 @@ const themeOverrides: GlobalThemeOverrides = {
<style scoped> <style scoped>
.main-layout { .main-layout {
height: 100vh; height: 100vh;
}
.logo-container {
height: 64px;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
gap: 12px;
border-bottom: 1px solid #efeff5;
overflow: hidden;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: #18a058;
white-space: nowrap;
} }
.header { .header {
height: 64px; height: 60px;
background: white; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 24px; padding: 0 24px;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
} }
.header-content { .header-content {
@@ -204,38 +189,117 @@ const themeOverrides: GlobalThemeOverrides = {
align-items: center; align-items: center;
} }
.server-status-card { .header-left {
position: absolute; display: flex;
bottom: 0; align-items: center;
width: 100%; gap: 32px;
padding: 20px;
background: #f0fdf4;
border-top: 1px solid #d1fae5;
} }
.status-item { .logo {
display: flex;
align-items: center;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: #ffffff;
}
.nav-tabs :deep(.n-tabs-tab) {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.nav-tabs :deep(.n-tabs-tab--active) {
color: #ffffff !important;
}
.nav-tabs :deep(.n-tabs-bar) {
background-color: #ffffff !important;
}
.header-right :deep(.n-button) {
color: rgba(255, 255, 255, 0.9);
}
.main-content {
flex: 1;
padding: 24px;
background-color: #f5f7fa;
overflow-y: auto;
}
.footer {
height: 48px;
background: #ffffff;
border-top: 1px solid #e5e7eb;
}
.footer-content {
height: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 8px; align-items: center;
padding: 0 24px;
font-size: 13px;
color: #6b7280;
}
.footer-left {
display: flex;
align-items: center;
gap: 12px;
}
.brand {
font-weight: 600;
color: #6366f1;
}
.version {
padding: 2px 8px;
background: #e0e7ff;
color: #4f46e5;
border-radius: 4px;
font-size: 12px; font-size: 12px;
} }
.status-item .label { .footer-center {
color: #64748b; display: flex;
gap: 16px;
} }
.status-item .value { .footer-link {
font-weight: 600; display: flex;
color: #0f172a; align-items: center;
gap: 4px;
color: #6b7280;
text-decoration: none;
transition: color 0.2s;
} }
.mobile-toggle { .footer-link:hover {
display: none; color: #6366f1;
}
.footer-right {
color: #9ca3af;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.mobile-toggle { .header {
display: inline-flex; padding: 0 12px;
}
.header-left {
gap: 16px;
}
.logo-text {
font-size: 16px;
}
.footer-content {
padding: 0 12px;
font-size: 12px;
} }
} }
</style> </style>

View File

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

View File

@@ -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
} }
// 服务器状态 // 服务器状态

View File

@@ -10,12 +10,13 @@ import {
import { import {
ArrowBackOutline, CreateOutline, TrashOutline, ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, AddOutline, StorefrontOutline, DocumentTextOutline, PushOutline, AddOutline, StorefrontOutline, DocumentTextOutline,
ExtensionPuzzleOutline, SettingsOutline, OpenOutline ExtensionPuzzleOutline, SettingsOutline, OpenOutline, CloudDownloadOutline, RefreshOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient, getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
getClientPluginConfig, updateClientPluginConfig, getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin,
checkClientUpdate, applyClientUpdate, type UpdateInfo
} from '../api' } from '../api'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types' import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
import LogViewer from '../components/LogViewer.vue' import LogViewer from '../components/LogViewer.vue'
@@ -34,6 +35,13 @@ const nickname = ref('')
const rules = ref<ProxyRule[]>([]) const rules = ref<ProxyRule[]>([])
const clientPlugins = ref<ClientPlugin[]>([]) const clientPlugins = ref<ClientPlugin[]>([])
const loading = ref(false) const loading = ref(false)
const clientOs = ref('')
const clientArch = ref('')
// 客户端更新相关
const clientUpdate = ref<UpdateInfo | null>(null)
const checkingUpdate = ref(false)
const updatingClient = ref(false)
// Rule Schemas // Rule Schemas
const pluginRuleSchemas = ref<RuleSchemasMap>({}) const pluginRuleSchemas = ref<RuleSchemasMap>({})
@@ -125,6 +133,8 @@ const loadClient = async () => {
nickname.value = data.nickname || '' nickname.value = data.nickname || ''
rules.value = data.rules || [] rules.value = data.rules || []
clientPlugins.value = data.plugins || [] clientPlugins.value = data.plugins || []
clientOs.value = data.os || ''
clientArch.value = data.arch || ''
} catch (e) { } catch (e) {
message.error('加载客户端信息失败') message.error('加载客户端信息失败')
console.error(e) console.error(e)
@@ -133,6 +143,57 @@ const loadClient = async () => {
} }
} }
// 客户端更新
const handleCheckClientUpdate = async () => {
if (!online.value) {
message.warning('客户端离线,无法检查更新')
return
}
if (!clientOs.value || !clientArch.value) {
message.warning('无法获取客户端平台信息')
return
}
checkingUpdate.value = true
try {
const { data } = await checkClientUpdate(clientOs.value, clientArch.value)
clientUpdate.value = data
if (data.download_url) {
message.success('找到客户端更新: ' + data.latest)
} else {
message.info('已是最新版本或未找到对应平台的更新包')
}
} catch (e: any) {
message.error(e.response?.data || '检查更新失败')
} finally {
checkingUpdate.value = false
}
}
const handleApplyClientUpdate = () => {
if (!clientUpdate.value?.download_url) {
message.error('没有可用的下载链接')
return
}
dialog.warning({
title: '确认更新客户端',
content: `即将更新客户端到 ${clientUpdate.value.latest},更新后客户端将自动重启。确定要继续吗?`,
positiveText: '更新',
negativeText: '取消',
onPositiveClick: async () => {
updatingClient.value = true
try {
await applyClientUpdate(clientId, clientUpdate.value!.download_url)
message.success('更新命令已发送,客户端将自动重启')
clientUpdate.value = null
} catch (e: any) {
message.error(e.response?.data || '更新失败')
} finally {
updatingClient.value = false
}
}
})
}
// Client Rename // Client Rename
const showRenameModal = ref(false) const showRenameModal = ref(false)
const renameValue = ref('') const renameValue = ref('')
@@ -479,6 +540,32 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<n-statistic label="插件数" :value="clientPlugins.length" /> <n-statistic label="插件数" :value="clientPlugins.length" />
</n-space> </n-space>
</n-card> </n-card>
<!-- 客户端更新 -->
<n-card title="客户端更新" bordered size="small">
<template #header-extra>
<n-button size="tiny" :loading="checkingUpdate" @click="handleCheckClientUpdate" :disabled="!online">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
检查
</n-button>
</template>
<div v-if="clientOs && clientArch" style="margin-bottom: 8px; font-size: 12px; color: #666;">
平台: {{ clientOs }}/{{ clientArch }}
</div>
<n-empty v-if="!clientUpdate" description="点击检查更新" size="small" />
<template v-else>
<div v-if="clientUpdate.download_url" style="font-size: 13px;">
<p style="margin: 0 0 8px 0; color: #10b981;">发现新版本 {{ clientUpdate.latest }}</p>
<n-button size="small" type="primary" :loading="updatingClient" @click="handleApplyClientUpdate">
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
更新
</n-button>
</div>
<div v-else style="font-size: 13px; color: #666;">
已是最新版本
</div>
</template>
</n-card>
</n-space> </n-space>
</n-grid-item> </n-grid-item>

View File

@@ -1,8 +1,7 @@
<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 { useRouter } from 'vue-router'
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty, NIcon } from 'naive-ui' import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty } 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'
@@ -18,7 +17,6 @@ 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
}) })
@@ -36,36 +34,24 @@ const viewClient = (id: string) => {
<template> <template>
<div class="home"> <div class="home">
<n-space justify="space-between" align="center" style="margin-bottom: 24px;"> <div class="page-header">
<div> <h2>首页</h2>
<h2 style="margin: 0 0 8px 0;">客户端管理</h2> <p>查看已连接的隧道客户端</p>
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</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;"> <n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;" responsive="screen" cols-s="1" cols-m="3">
<n-gi> <n-gi>
<n-card> <n-card class="stat-card">
<n-statistic label="总客户端" :value="clients.length" /> <n-statistic label="总客户端" :value="clients.length" />
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-card> <n-card class="stat-card">
<n-statistic label="在线客户端" :value="onlineClients" /> <n-statistic label="在线客户端" :value="onlineClients" />
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-card> <n-card class="stat-card">
<n-statistic label="总规则数" :value="totalRules" /> <n-statistic label="总规则数" :value="totalRules" />
</n-card> </n-card>
</n-gi> </n-gi>
@@ -75,13 +61,13 @@ const viewClient = (id: string) => {
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2"> <n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
<n-gi v-for="client in clients" :key="client.id"> <n-gi v-for="client in clients" :key="client.id">
<n-card hoverable style="cursor: pointer;" @click="viewClient(client.id)"> <n-card hoverable class="client-card" @click="viewClient(client.id)">
<n-space justify="space-between" align="center"> <n-space justify="space-between" align="center">
<div> <div>
<h3 style="margin: 0 0 4px 0;">{{ client.nickname || client.id }}</h3> <h3 class="client-name">{{ client.nickname || client.id }}</h3>
<p v-if="client.nickname" style="margin: 0 0 4px 0; color: #999; font-size: 12px;">{{ client.id }}</p> <p v-if="client.nickname" class="client-id">{{ client.id }}</p>
<p v-if="client.remote_addr && client.online" style="margin: 0 0 8px 0; color: #666; font-size: 12px;">IP: {{ client.remote_addr }}</p> <p v-if="client.remote_addr && client.online" class="client-ip">IP: {{ client.remote_addr }}</p>
<n-space> <n-space style="margin-top: 8px;">
<n-tag :type="client.online ? 'success' : 'default'" size="small"> <n-tag :type="client.online ? 'success' : 'default'" size="small">
{{ client.online ? '在线' : '离线' }} {{ client.online ? '在线' : '离线' }}
</n-tag> </n-tag>
@@ -95,3 +81,59 @@ const viewClient = (id: string) => {
</n-grid> </n-grid>
</div> </div>
</template> </template>
<style scoped>
.home {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
}
.page-header p {
margin: 0;
color: #6b7280;
}
.stat-card {
text-align: center;
}
.client-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.client-name {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.client-id {
margin: 0 0 4px 0;
color: #9ca3af;
font-size: 12px;
}
.client-ip {
margin: 0;
color: #6b7280;
font-size: 12px;
}
</style>

View File

@@ -1,19 +1,17 @@
<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 { import {
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage, NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage,
NSelect, NModal, NInput, NInputNumber NSelect, NModal, NInput, NInputNumber
} from 'naive-ui' } from 'naive-ui'
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline, SettingsOutline } from '@vicons/ionicons5' import { ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline, SettingsOutline } from '@vicons/ionicons5'
import { import {
getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins, getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins,
pushJSPluginToClient, getClients, installStorePlugin, updateJSPluginConfig, setJSPluginEnabled pushJSPluginToClient, getClients, installStorePlugin, updateJSPluginConfig, setJSPluginEnabled
} from '../api' } from '../api'
import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types' import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types'
const router = useRouter()
const message = useMessage() const message = useMessage()
const plugins = ref<PluginInfo[]>([]) const plugins = ref<PluginInfo[]>([])
const storePlugins = ref<StorePluginInfo[]>([]) const storePlugins = ref<StorePluginInfo[]>([])
@@ -290,16 +288,10 @@ onMounted(() => {
<template> <template>
<div class="plugins-view"> <div class="plugins-view">
<n-space justify="space-between" align="center" style="margin-bottom: 24px;"> <div class="page-header">
<div> <h2>插件管理</h2>
<h2 style="margin: 0 0 8px 0;">插件管理</h2> <p>管理已安装插件和浏览插件商店</p>
<p style="margin: 0; color: #666;">管理已安装插件和浏览插件商店</p>
</div> </div>
<n-button quaternary @click="router.push('/')">
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
返回首页
</n-button>
</n-space>
<n-tabs v-model:value="activeTab" type="line" @update:value="handleTabChange"> <n-tabs v-model:value="activeTab" type="line" @update:value="handleTabChange">
<!-- 已安装插件 --> <!-- 已安装插件 -->
@@ -574,3 +566,26 @@ onMounted(() => {
</n-modal> </n-modal>
</div> </div>
</template> </template>
<style scoped>
.plugins-view {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
}
.page-header p {
margin: 0;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
NCard, NButton, NSpace, NTag, NGrid, NGi, NEmpty, NSpin, NIcon,
NAlert, useMessage, useDialog
} from 'naive-ui'
import { CloudDownloadOutline, RefreshOutline, ServerOutline } from '@vicons/ionicons5'
import {
getVersionInfo, checkServerUpdate, applyServerUpdate,
type UpdateInfo, type VersionInfo
} from '../api'
const message = useMessage()
const dialog = useDialog()
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-view">
<div class="page-header">
<h2>系统设置</h2>
<p>管理服务端配置和系统更新</p>
</div>
<n-spin :show="loading">
<!-- 当前版本信息 -->
<n-card title="版本信息" class="settings-card">
<template #header-extra>
<n-icon size="20" color="#6366f1"><ServerOutline /></n-icon>
</template>
<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-card title="服务端更新" class="settings-card">
<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" class="release-note">
<p style="margin: 0 0 4px 0; color: #666; font-size: 12px;">更新日志:</p>
<pre>{{ 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-spin>
</div>
</template>
<style scoped>
.settings-view {
max-width: 900px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
}
.page-header p {
margin: 0;
color: #6b7280;
}
.settings-card {
margin-bottom: 16px;
}
.info-item {
display: flex;
flex-direction: column;
padding: 8px 0;
}
.info-item .label {
font-size: 12px;
color: #9ca3af;
margin-bottom: 4px;
}
.info-item .value {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.release-note {
max-height: 150px;
overflow-y: auto;
}
.release-note pre {
margin: 0;
white-space: pre-wrap;
font-size: 12px;
color: #374151;
background: #f9fafb;
padding: 12px;
border-radius: 6px;
}
</style>

View File

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