Files
GoTunnel/web/src/views/HomeView.vue
2026-03-19 20:21:05 +08:00

325 lines
9.1 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getClients, getTrafficHourly, getTrafficStats, type TrafficRecord } from '../api'
import type { ClientStatus } from '../types'
import MetricCard from '../components/MetricCard.vue'
import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
const clients = ref<ClientStatus[]>([])
const traffic24h = ref({ inbound: 0, outbound: 0 })
const trafficTotal = ref({ inbound: 0, outbound: 0 })
const trafficHistory = ref<TrafficRecord[]>([])
const loading = ref(true)
const formatBytes = (bytes: number): { value: string; unit: string } => {
if (bytes === 0) return { value: '0', unit: 'B' }
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return {
value: (bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1),
unit: units[index] ?? 'B',
}
}
const loadDashboard = async () => {
loading.value = true
try {
const [{ data: clientData }, { data: statsData }, { data: hourlyData }] = await Promise.all([
getClients(),
getTrafficStats(),
getTrafficHourly(),
])
clients.value = clientData || []
traffic24h.value = statsData.traffic_24h
trafficTotal.value = statsData.traffic_total
const records = hourlyData.records || []
if (records.length) {
trafficHistory.value = records.slice(-12)
return
}
const now = new Date()
trafficHistory.value = Array.from({ length: 12 }, (_, index) => {
const slot = new Date(now.getTime() - (11 - index) * 3600 * 1000)
return {
timestamp: Math.floor(slot.getTime() / 1000),
inbound: 0,
outbound: 0,
}
})
} catch (error) {
console.error('Failed to load dashboard', error)
} finally {
loading.value = false
}
}
const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
const totalRules = computed(() => clients.value.reduce((sum, client) => sum + (client.rule_count || 0), 0))
const topClients = computed(() => [...clients.value].sort((a, b) => Number(b.online) - Number(a.online)).slice(0, 6))
const chartMax = computed(() => Math.max(...trafficHistory.value.flatMap((item) => [item.inbound, item.outbound]), 1))
const formatted24hInbound = computed(() => formatBytes(traffic24h.value.inbound))
const formatted24hOutbound = computed(() => formatBytes(traffic24h.value.outbound))
const formattedTotalInbound = computed(() => formatBytes(trafficTotal.value.inbound))
const formattedTotalOutbound = computed(() => formatBytes(trafficTotal.value.outbound))
const formatHour = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
onMounted(loadDashboard)
</script>
<template>
<PageShell title="控制台" eyebrow="Overview" subtitle="统一查看连接状态、流量趋势与客户端健康情况,减少页面层级并突出关键数据。">
<template #actions>
<button class="glass-btn" @click="loadDashboard">{{ loading ? '刷新中...' : '刷新数据' }}</button>
</template>
<template #metrics>
<MetricCard label="在线客户端" :value="onlineClients" :hint="`离线 ${offlineClients} 台`" tone="success" />
<MetricCard label="代理规则" :value="totalRules" hint="全部客户端规则总数" />
<MetricCard
label="24H 出站"
:value="formatted24hOutbound.value"
:hint="formatted24hOutbound.unit"
tone="info"
/>
<MetricCard
label="总入站"
:value="formattedTotalInbound.value"
:hint="formattedTotalInbound.unit"
tone="warning"
/>
</template>
<div class="dashboard-grid">
<SectionCard title="流量趋势" description="近 12 小时入站 / 出站流量概览。">
<div class="traffic-summary">
<div class="traffic-pill">
<span>24H 入站</span>
<strong>{{ formatted24hInbound.value }} {{ formatted24hInbound.unit }}</strong>
</div>
<div class="traffic-pill">
<span>24H 出站</span>
<strong>{{ formatted24hOutbound.value }} {{ formatted24hOutbound.unit }}</strong>
</div>
<div class="traffic-pill">
<span>总出站</span>
<strong>{{ formattedTotalOutbound.value }} {{ formattedTotalOutbound.unit }}</strong>
</div>
</div>
<div class="traffic-chart">
<div v-for="item in trafficHistory" :key="item.timestamp" class="traffic-chart__item">
<div class="traffic-chart__bars">
<span class="bar bar--inbound" :style="{ height: `${(item.inbound / chartMax) * 100}%` }"></span>
<span class="bar bar--outbound" :style="{ height: `${(item.outbound / chartMax) * 100}%` }"></span>
</div>
<span class="traffic-chart__label">{{ formatHour(item.timestamp) }}</span>
</div>
</div>
</SectionCard>
<SectionCard title="客户端概况" description="优先展示在线客户端,并保留连接来源与规则数量。">
<div v-if="topClients.length" class="client-list">
<article v-for="client in topClients" :key="client.id" class="client-row">
<div>
<div class="client-row__title">
<span class="client-dot" :class="{ online: client.online }"></span>
<strong>{{ client.nickname || client.id }}</strong>
</div>
<p>{{ client.remote_addr || '等待连接地址' }}</p>
</div>
<div class="client-row__meta">
<span>{{ client.rule_count || 0 }} 条规则</span>
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
</div>
</article>
</div>
<div v-else class="empty-state">暂无客户端数据</div>
</SectionCard>
</div>
</PageShell>
</template>
<style scoped>
.dashboard-grid {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
}
.traffic-summary {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.traffic-pill {
padding: 14px 16px;
border-radius: 16px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
}
.traffic-pill span {
display: block;
color: var(--color-text-secondary);
font-size: 12px;
}
.traffic-pill strong {
display: block;
margin-top: 8px;
color: var(--color-text-primary);
font-size: 18px;
}
.traffic-chart {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px;
align-items: end;
min-height: 240px;
}
.traffic-chart__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.traffic-chart__bars {
display: flex;
align-items: end;
gap: 4px;
height: 200px;
width: 100%;
}
.bar {
flex: 1;
min-height: 4px;
border-radius: 999px 999px 6px 6px;
}
.bar--inbound {
background: linear-gradient(180deg, rgba(6, 182, 212, 0.95), rgba(6, 182, 212, 0.25));
}
.bar--outbound {
background: linear-gradient(180deg, rgba(59, 130, 246, 0.95), rgba(59, 130, 246, 0.25));
}
.traffic-chart__label {
font-size: 11px;
color: var(--color-text-muted);
}
.client-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.client-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 16px;
border-radius: 16px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
}
.client-row__title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.client-row p {
color: var(--color-text-secondary);
font-size: 13px;
}
.client-row__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
color: var(--color-text-secondary);
font-size: 13px;
}
.client-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--color-error);
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.08);
}
.client-dot.online {
background: var(--color-success);
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.1);
}
.state-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid transparent;
}
.state-pill.online {
color: var(--color-success);
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.2);
}
.state-pill.offline {
color: var(--color-text-secondary);
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.2);
}
.empty-state {
padding: 36px 16px;
text-align: center;
color: var(--color-text-secondary);
background: var(--glass-bg-light);
border: 1px dashed var(--color-border);
border-radius: 16px;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.traffic-chart {
overflow-x: auto;
padding-bottom: 8px;
}
.traffic-chart__bars {
width: 28px;
}
.client-row {
flex-direction: column;
}
.client-row__meta {
align-items: flex-start;
}
}
</style>