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
- 在App.vue中新增服务端更新模态框和相关功能 - 添加applyServerUpdate API调用和更新确认对话框 - 实现客户端版本信息显示和更新检测功能 - 添加系统状态监控包括CPU、内存和磁盘使用情况 - 新增getClientSystemStats API接口获取客户端系统统计信息 - 更新go.mod和go.sum文件添加必要的依赖包 - 优化UI界面样式和交互体验
630 lines
14 KiB
Vue
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>
|