feat(app): 添加服务端和客户端更新功能以及系统状态监控
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
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界面样式和交互体验
This commit is contained in:
251
web/src/App.vue
251
web/src/App.vue
@@ -3,17 +3,23 @@ import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
HomeOutline, DesktopOutline, SettingsOutline,
|
||||
PersonCircleOutline, LogOutOutline, LogoGithub, ServerOutline, CheckmarkCircleOutline, ArrowUpCircleOutline
|
||||
PersonCircleOutline, LogOutOutline, LogoGithub, ServerOutline, CheckmarkCircleOutline, ArrowUpCircleOutline, CloseOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { getServerStatus, getVersionInfo, checkServerUpdate, removeToken, getToken, type UpdateInfo } from './api'
|
||||
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')
|
||||
|
||||
@@ -84,6 +90,48 @@ const logout = () => {
|
||||
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>
|
||||
@@ -130,7 +178,7 @@ const toggleUserMenu = () => {
|
||||
<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 }">
|
||||
<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>
|
||||
@@ -148,6 +196,53 @@ const toggleUserMenu = () => {
|
||||
</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>
|
||||
@@ -327,6 +422,12 @@ const toggleUserMenu = () => {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.update-status:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.update-status.latest {
|
||||
@@ -381,4 +482,148 @@ const toggleUserMenu = () => {
|
||||
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>
|
||||
|
||||
@@ -196,6 +196,19 @@ export interface TrafficRecord {
|
||||
export const getTrafficStats = () => get<TrafficStats>('/traffic/stats')
|
||||
export const getTrafficHourly = () => get<{ records: TrafficRecord[] }>('/traffic/hourly')
|
||||
|
||||
// 客户端系统状态
|
||||
export interface SystemStats {
|
||||
cpu_usage: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_usage: number
|
||||
disk_total: number
|
||||
disk_used: number
|
||||
disk_usage: number
|
||||
}
|
||||
|
||||
export const getClientSystemStats = (clientId: string) => get<SystemStats>(`/client/${clientId}/system-stats`)
|
||||
|
||||
// 服务器配置
|
||||
export interface ServerConfigInfo {
|
||||
bind_addr: string
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface ClientDetail {
|
||||
remote_addr?: string
|
||||
os?: string
|
||||
arch?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
// 服务器状态
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
|
||||
getClientPluginConfig, updateClientPluginConfig,
|
||||
getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin,
|
||||
checkClientUpdate, applyClientUpdate, type UpdateInfo
|
||||
checkClientUpdate, applyClientUpdate, getClientSystemStats, type UpdateInfo, type SystemStats
|
||||
} from '../api'
|
||||
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
|
||||
import LogViewer from '../components/LogViewer.vue'
|
||||
@@ -37,12 +37,17 @@ const clientPlugins = ref<ClientPlugin[]>([])
|
||||
const loading = ref(false)
|
||||
const clientOs = ref('')
|
||||
const clientArch = ref('')
|
||||
const clientVersion = ref('')
|
||||
|
||||
// 客户端更新相关
|
||||
const clientUpdate = ref<UpdateInfo | null>(null)
|
||||
const checkingUpdate = ref(false)
|
||||
const updatingClient = ref(false)
|
||||
|
||||
// 系统状态相关
|
||||
const systemStats = ref<SystemStats | null>(null)
|
||||
const loadingStats = ref(false)
|
||||
|
||||
// Rule Schemas
|
||||
const pluginRuleSchemas = ref<RuleSchemasMap>({})
|
||||
const loadRuleSchemas = async () => {
|
||||
@@ -103,6 +108,13 @@ const loadClient = async () => {
|
||||
clientPlugins.value = data.plugins || []
|
||||
clientOs.value = data.os || ''
|
||||
clientArch.value = data.arch || ''
|
||||
clientVersion.value = data.version || ''
|
||||
|
||||
// 如果客户端在线且有平台信息,自动检测更新
|
||||
if (data.online && data.os && data.arch) {
|
||||
autoCheckClientUpdate()
|
||||
loadSystemStats()
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('加载客户端信息失败')
|
||||
console.error(e)
|
||||
@@ -111,6 +123,39 @@ const loadClient = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动检测客户端更新(静默)
|
||||
const autoCheckClientUpdate = async () => {
|
||||
try {
|
||||
const { data } = await checkClientUpdate(clientOs.value, clientArch.value)
|
||||
clientUpdate.value = data
|
||||
} catch (e) {
|
||||
console.error('Auto check update failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载系统状态
|
||||
const loadSystemStats = async () => {
|
||||
if (!online.value) return
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const { data } = await getClientSystemStats(clientId)
|
||||
systemStats.value = data
|
||||
} catch (e) {
|
||||
console.error('Failed to load system stats', e)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化字节大小
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 客户端更新
|
||||
const handleCheckClientUpdate = async () => {
|
||||
if (!online.value) {
|
||||
@@ -473,11 +518,11 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="router.push('/')">
|
||||
<n-icon size="20"><ArrowBackOutline /></n-icon>
|
||||
<ArrowBackOutline class="btn-icon-lg" />
|
||||
</button>
|
||||
<h1 class="page-title">{{ nickname || clientId }}</h1>
|
||||
<button class="edit-btn" @click="openRenameModal">
|
||||
<n-icon size="16"><CreateOutline /></n-icon>
|
||||
<CreateOutline class="btn-icon" />
|
||||
</button>
|
||||
<span class="status-tag" :class="{ online }">
|
||||
{{ online ? '在线' : '离线' }}
|
||||
@@ -485,15 +530,15 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button v-if="online" class="glass-btn primary" @click="pushConfigToClient(clientId).then(() => message.success('已推送'))">
|
||||
<n-icon size="16"><PushOutline /></n-icon>
|
||||
<PushOutline class="btn-icon" />
|
||||
<span>推送配置</span>
|
||||
</button>
|
||||
<button class="glass-btn" @click="showLogViewer=true">
|
||||
<n-icon size="16"><DocumentTextOutline /></n-icon>
|
||||
<DocumentTextOutline class="btn-icon" />
|
||||
<span>日志</span>
|
||||
</button>
|
||||
<button class="glass-btn danger" @click="confirmDelete">
|
||||
<n-icon size="16"><TrashOutline /></n-icon>
|
||||
<TrashOutline class="btn-icon" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -517,6 +562,15 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
<span class="stat-label">远程 IP</span>
|
||||
<span class="stat-value">{{ remoteAddr || '-' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">客户端版本</span>
|
||||
<span class="stat-value">
|
||||
{{ clientVersion || '-' }}
|
||||
<span v-if="clientUpdate?.available" class="update-badge" @click="handleApplyClientUpdate">
|
||||
可更新
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">最后心跳</span>
|
||||
<span class="stat-value">{{ lastPing ? new Date(lastPing).toLocaleTimeString() : '-' }}</span>
|
||||
@@ -545,12 +599,57 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Stats Card -->
|
||||
<div class="glass-card" v-if="online">
|
||||
<div class="card-header">
|
||||
<h3>系统状态</h3>
|
||||
<button class="glass-btn tiny" :disabled="loadingStats" @click="loadSystemStats">
|
||||
<RefreshOutline class="btn-icon-sm" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="!systemStats" class="empty-hint">
|
||||
{{ loadingStats ? '加载中...' : '点击刷新获取状态' }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="system-stat-item">
|
||||
<span class="system-stat-label">CPU</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: systemStats.cpu_usage + '%' }"></div>
|
||||
</div>
|
||||
<span class="system-stat-value">{{ systemStats.cpu_usage.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="system-stat-item">
|
||||
<span class="system-stat-label">内存</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: systemStats.memory_usage + '%' }"></div>
|
||||
</div>
|
||||
<span class="system-stat-value">{{ systemStats.memory_usage.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="system-stat-detail">
|
||||
{{ formatBytes(systemStats.memory_used) }} / {{ formatBytes(systemStats.memory_total) }}
|
||||
</div>
|
||||
<div class="system-stat-item">
|
||||
<span class="system-stat-label">磁盘</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: systemStats.disk_usage + '%' }"></div>
|
||||
</div>
|
||||
<span class="system-stat-value">{{ systemStats.disk_usage.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="system-stat-detail">
|
||||
{{ formatBytes(systemStats.disk_used) }} / {{ formatBytes(systemStats.disk_total) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Card -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h3>客户端更新</h3>
|
||||
<button class="glass-btn tiny" :disabled="!online || checkingUpdate" @click="handleCheckClientUpdate">
|
||||
<n-icon size="14"><RefreshOutline /></n-icon>
|
||||
<RefreshOutline class="btn-icon-sm" />
|
||||
检查
|
||||
</button>
|
||||
</div>
|
||||
@@ -563,7 +662,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
<div v-if="clientUpdate.download_url" class="update-available">
|
||||
<p>发现新版本 {{ clientUpdate.latest }}</p>
|
||||
<button class="glass-btn primary small" :disabled="updatingClient" @click="handleApplyClientUpdate">
|
||||
<n-icon size="14"><CloudDownloadOutline /></n-icon>
|
||||
<CloudDownloadOutline class="btn-icon-sm" />
|
||||
更新
|
||||
</button>
|
||||
</div>
|
||||
@@ -580,7 +679,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
<div class="card-header">
|
||||
<h3>代理规则</h3>
|
||||
<button class="glass-btn primary small" @click="openCreateRule">
|
||||
<n-icon size="14"><AddOutline /></n-icon>
|
||||
<AddOutline class="btn-icon-sm" />
|
||||
添加规则
|
||||
</button>
|
||||
</div>
|
||||
@@ -757,7 +856,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
<div v-for="plugin in storePlugins" :key="plugin.name" class="store-plugin-card">
|
||||
<div class="store-plugin-header">
|
||||
<span class="store-plugin-name">{{ plugin.name }}</span>
|
||||
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||
<GlassTag>v{{ plugin.version }}</GlassTag>
|
||||
</div>
|
||||
<p class="store-plugin-desc">{{ plugin.description }}</p>
|
||||
<button class="glass-btn primary small full" @click="handleInstallStorePlugin(plugin)">
|
||||
@@ -1000,6 +1099,22 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.update-badge:hover {
|
||||
background: rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
/* Mini Stats */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
@@ -1335,6 +1450,16 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.btn-icon-lg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-icon-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.plugin-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -1345,4 +1470,49 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* System Stats */
|
||||
.system-stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.system-stat-label {
|
||||
width: 40px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #60a5fa, #a78bfa);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.system-stat-value {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.system-stat-detail {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-align: right;
|
||||
margin-bottom: 12px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
</style>
|
||||
@@ -37,7 +37,24 @@ const loadTrafficStats = async () => {
|
||||
const loadTrafficHourly = async () => {
|
||||
try {
|
||||
const { data } = await getTrafficHourly()
|
||||
trafficHistory.value = data.records || []
|
||||
const records = data.records || []
|
||||
// 如果没有数据,生成从当前时间开始的24小时空数据
|
||||
if (records.length === 0) {
|
||||
const now = new Date()
|
||||
const currentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours())
|
||||
const emptyRecords: TrafficRecord[] = []
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const ts = new Date(currentHour.getTime() - i * 3600 * 1000)
|
||||
emptyRecords.push({
|
||||
timestamp: Math.floor(ts.getTime() / 1000),
|
||||
inbound: 0,
|
||||
outbound: 0
|
||||
})
|
||||
}
|
||||
trafficHistory.value = emptyRecords
|
||||
} else {
|
||||
trafficHistory.value = records
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load hourly traffic', e)
|
||||
}
|
||||
@@ -106,8 +123,8 @@ onMounted(() => {
|
||||
<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>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">仪表盘</h1>
|
||||
<p class="text-white/70">监控隧道连接和流量状态</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
@@ -226,9 +243,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-hint" v-if="trafficHistory.length === 0">
|
||||
<span>暂无流量数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,25 +425,29 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
min-height: 48px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
line-height: 1.1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Client count special styling */
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { CloudDownloadOutline, RefreshOutline, ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5'
|
||||
import GlassTag from '../components/GlassTag.vue'
|
||||
import { ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import {
|
||||
getVersionInfo, checkServerUpdate, applyServerUpdate,
|
||||
getServerConfig, updateServerConfig,
|
||||
type UpdateInfo, type VersionInfo, type ServerConfigResponse
|
||||
getVersionInfo, getServerConfig, updateServerConfig,
|
||||
type VersionInfo, type ServerConfigResponse
|
||||
} 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 serverConfig = ref<ServerConfigResponse | null>(null)
|
||||
@@ -96,58 +89,6 @@ const handleSaveConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
loadServerConfig()
|
||||
@@ -301,50 +242,6 @@ onMounted(() => {
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user