Files
GoTunnel/web/src/App.vue
Flik 9f13b0d4e9
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
feat(app): 添加服务端和客户端更新功能以及系统状态监控
- 在App.vue中新增服务端更新模态框和相关功能
- 添加applyServerUpdate API调用和更新确认对话框
- 实现客户端版本信息显示和更新检测功能
- 添加系统状态监控包括CPU、内存和磁盘使用情况
- 新增getClientSystemStats API接口获取客户端系统统计信息
- 更新go.mod和go.sum文件添加必要的依赖包
- 优化UI界面样式和交互体验
2026-01-22 21:32:03 +08:00

630 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import {
HomeOutline, DesktopOutline, SettingsOutline,
PersonCircleOutline, LogOutOutline, LogoGithub, ServerOutline, CheckmarkCircleOutline, ArrowUpCircleOutline, CloseOutline
} from '@vicons/ionicons5'
import { getServerStatus, getVersionInfo, checkServerUpdate, applyServerUpdate, removeToken, getToken, type UpdateInfo } from './api'
import { useToast } from './composables/useToast'
import { useConfirm } from './composables/useConfirm'
const router = useRouter()
const route = useRoute()
const message = useToast()
const dialog = useConfirm()
const serverInfo = ref({ bind_addr: '', bind_port: 0 })
const clientCount = ref(0)
const version = ref('')
const showUserMenu = ref(false)
const updateInfo = ref<UpdateInfo | null>(null)
const showUpdateModal = ref(false)
const updatingServer = ref(false)
const isLoginPage = computed(() => route.path === '/login')
const navItems = [
{ key: 'home', label: '首页', icon: HomeOutline, path: '/' },
{ key: 'clients', label: '客户端', icon: DesktopOutline, path: '/clients' },
{ key: 'settings', label: '设置', icon: SettingsOutline, path: '/settings' }
]
const activeNav = computed(() => {
const path = route.path
if (path === '/' || path === '/home') return 'home'
if (path === '/clients' || path.startsWith('/client/')) return 'clients'
if (path === '/settings') return 'settings'
return 'home'
})
const fetchServerStatus = async () => {
if (isLoginPage.value || !getToken()) return
try {
const { data } = await getServerStatus()
serverInfo.value = data.server
clientCount.value = data.client_count
} catch (e) {
console.error('Failed to get server status', e)
}
}
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)
}
}
const checkUpdate = async () => {
if (isLoginPage.value || !getToken()) return
try {
const { data } = await checkServerUpdate()
updateInfo.value = data
} catch (e) {
console.error('Failed to check update', e)
}
}
watch(() => route.path, (newPath, oldPath) => {
if (oldPath === '/login' && newPath !== '/login') {
fetchServerStatus()
fetchVersion()
checkUpdate()
}
})
onMounted(() => {
fetchServerStatus()
fetchVersion()
checkUpdate()
})
const logout = () => {
removeToken()
router.push('/login')
}
const toggleUserMenu = () => {
showUserMenu.value = !showUserMenu.value
}
const openUpdateModal = () => {
if (updateInfo.value) {
showUpdateModal.value = true
}
}
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]
}
const handleApplyServerUpdate = () => {
if (!updateInfo.value?.download_url) {
message.error('没有可用的下载链接')
return
}
dialog.warning({
title: '确认更新服务端',
content: `即将更新服务端到 ${updateInfo.value.latest},更新后服务器将自动重启。确定要继续吗?`,
positiveText: '更新并重启',
negativeText: '取消',
onPositiveClick: async () => {
updatingServer.value = true
try {
await applyServerUpdate(updateInfo.value!.download_url)
message.success('更新已开始,服务器将在几秒后重启')
showUpdateModal.value = false
setTimeout(() => {
window.location.reload()
}, 5000)
} catch (e: any) {
message.error(e.response?.data || '更新失败')
updatingServer.value = false
}
}
})
}
</script>
<template>
<div v-if="!isLoginPage" class="app-layout">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<span class="logo">GoTunnel</span>
</div>
<nav class="header-nav">
<router-link
v-for="item in navItems"
:key="item.key"
:to="item.path"
class="nav-item"
:class="{ active: activeNav === item.key }"
>
<component :is="item.icon" class="nav-icon" />
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="header-right">
<div class="user-menu" @click="toggleUserMenu">
<PersonCircleOutline class="user-icon" />
<div v-if="showUserMenu" class="user-dropdown" @click.stop>
<button class="dropdown-item" @click="logout">
<LogOutOutline class="dropdown-icon" />
<span>退出登录</span>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<RouterView />
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="footer-left">
<span class="brand">GoTunnel</span>
<div v-if="version" class="version-info">
<ServerOutline class="version-icon" />
<span class="version">v{{ version }}</span>
<span v-if="updateInfo" class="update-status" :class="{ latest: !updateInfo.available, 'has-update': updateInfo.available }" @click="openUpdateModal">
<template v-if="updateInfo.available">
<ArrowUpCircleOutline class="status-icon" />
<span>新版本 ({{ updateInfo.latest }})</span>
</template>
<template v-else>
<CheckmarkCircleOutline class="status-icon" />
<span>最新版本</span>
</template>
</span>
</div>
</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>
<!-- Update Modal -->
<div v-if="showUpdateModal" class="modal-overlay" @click.self="showUpdateModal = false">
<div class="update-modal">
<div class="modal-header">
<h3>系统更新</h3>
<button class="close-btn" @click="showUpdateModal = false">
<CloseOutline />
</button>
</div>
<div class="modal-body" v-if="updateInfo">
<div class="update-info-grid">
<div class="info-row">
<span class="info-label">当前版本</span>
<span class="info-value">{{ updateInfo.current }}</span>
</div>
<div class="info-row">
<span class="info-label">最新版本</span>
<span class="info-value highlight">{{ updateInfo.latest }}</span>
</div>
<div v-if="updateInfo.asset_name" class="info-row">
<span class="info-label">文件名</span>
<span class="info-value">{{ updateInfo.asset_name }}</span>
</div>
<div v-if="updateInfo.asset_size" class="info-row">
<span class="info-label">文件大小</span>
<span class="info-value">{{ formatBytes(updateInfo.asset_size) }}</span>
</div>
</div>
<div v-if="updateInfo.release_note" class="release-note">
<span class="note-label">更新日志</span>
<pre>{{ updateInfo.release_note }}</pre>
</div>
</div>
<div class="modal-footer">
<button class="modal-btn" @click="showUpdateModal = false">取消</button>
<button
v-if="updateInfo?.available && updateInfo?.download_url"
class="modal-btn primary"
:disabled="updatingServer"
@click="handleApplyServerUpdate"
>
{{ updatingServer ? '更新中...' : '立即更新' }}
</button>
</div>
</div>
</div>
</div>
<RouterView v-else />
</template>
<style scoped>
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4c1d95 60%, #581c87 100%);
}
/* Header */
.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;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-size: 20px;
font-weight: 700;
color: white;
}
/* Navigation */
.header-nav {
display: flex;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
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;
}
.nav-item:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.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;
top: 100%;
right: 0;
margin-top: 8px;
background: rgba(30, 27, 75, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px;
min-width: 140px;
}
.dropdown-item {
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;
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;
}
.version-info {
display: flex;
align-items: center;
gap: 6px;
}
.version-icon {
width: 14px;
height: 14px;
color: rgba(255, 255, 255, 0.5);
}
.update-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.update-status:hover {
opacity: 0.8;
}
.update-status.latest {
color: #34d399;
background: rgba(52, 211, 153, 0.15);
}
.update-status.has-update {
color: #fbbf24;
background: rgba(251, 191, 36, 0.15);
}
.status-icon {
width: 14px;
height: 14px;
}
.footer-link {
display: flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color 0.2s;
}
.footer-link:hover {
color: white;
}
.footer-icon {
width: 16px;
height: 16px;
}
.copyright {
color: rgba(255, 255, 255, 0.4);
}
/* Responsive */
@media (max-width: 768px) {
.app-header {
padding: 0 12px;
}
.header-nav {
display: none;
}
.app-footer {
padding: 0 12px;
}
.copyright {
display: none;
}
}
/* Update Modal */
.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;
}
.update-modal {
background: rgba(30, 27, 75, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: white;
}
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 4px;
display: flex;
transition: color 0.2s;
}
.close-btn:hover {
color: white;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.update-info-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.info-value {
color: white;
font-size: 13px;
font-weight: 500;
}
.info-value.highlight {
color: #34d399;
}
.release-note {
margin-top: 16px;
}
.note-label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 8px;
}
.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: 200px;
overflow-y: auto;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
color: white;
}
.modal-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
.modal-btn.primary {
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
border: none;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>