Refactor web console UI
This commit is contained in:
@@ -1,653 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getClients, getTrafficStats, getTrafficHourly, type TrafficRecord } from '../api'
|
||||
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 k = 1024
|
||||
const sizes: string[] = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
return {
|
||||
value: parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toString(),
|
||||
unit: sizes[i] as string
|
||||
value: (bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1),
|
||||
unit: units[index] ?? 'B',
|
||||
}
|
||||
}
|
||||
|
||||
// 加载流量统计
|
||||
const loadTrafficStats = async () => {
|
||||
const loadDashboard = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getTrafficStats()
|
||||
traffic24h.value = data.traffic_24h
|
||||
trafficTotal.value = data.traffic_total
|
||||
} catch (e) {
|
||||
console.error('Failed to load traffic stats', e)
|
||||
}
|
||||
}
|
||||
const [{ data: clientData }, { data: statsData }, { data: hourlyData }] = await Promise.all([
|
||||
getClients(),
|
||||
getTrafficStats(),
|
||||
getTrafficHourly(),
|
||||
])
|
||||
|
||||
// 加载每小时流量
|
||||
const loadTrafficHourly = async () => {
|
||||
try {
|
||||
const { data } = await getTrafficHourly()
|
||||
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
|
||||
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
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load hourly traffic', e)
|
||||
|
||||
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 loadClients = async () => {
|
||||
try {
|
||||
const { data } = await getClients()
|
||||
clients.value = data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load clients', e)
|
||||
}
|
||||
}
|
||||
|
||||
const onlineClients = computed(() => {
|
||||
return clients.value.filter(client => client.online).length
|
||||
})
|
||||
|
||||
const totalRules = computed(() => {
|
||||
return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0)
|
||||
})
|
||||
|
||||
// 格式化后的流量统计
|
||||
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' })
|
||||
|
||||
// Chart helpers
|
||||
const maxTraffic = computed(() => {
|
||||
const max = Math.max(
|
||||
...trafficHistory.value.map(d => Math.max(d.inbound, d.outbound))
|
||||
)
|
||||
return max || 100
|
||||
})
|
||||
|
||||
const getBarHeight = (value: number) => {
|
||||
return (value / maxTraffic.value) * 100
|
||||
}
|
||||
|
||||
// 格式化时间戳为小时
|
||||
const formatHour = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.getHours().toString().padStart(2, '0') + ':00'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadClients()
|
||||
loadTrafficStats()
|
||||
loadTrafficHourly()
|
||||
})
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- Animated background particles -->
|
||||
<div class="particles">
|
||||
<div class="particle particle-1"></div>
|
||||
<div class="particle particle-2"></div>
|
||||
<div class="particle particle-3"></div>
|
||||
<div class="particle particle-4"></div>
|
||||
<div class="particle particle-5"></div>
|
||||
<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>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="dashboard-content">
|
||||
<!-- Header -->
|
||||
<div class="dashboard-header">
|
||||
<h1 class="dashboard-title">仪表盘</h1>
|
||||
<p class="dashboard-subtitle">监控隧道连接和流量状态</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid">
|
||||
<!-- 24H Traffic Combined -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large traffic-24h">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">24H 出站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formatted24hOutbound.value }}</span>
|
||||
<span class="stat-unit">{{ formatted24hOutbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">24H 入站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formatted24hInbound.value }}</span>
|
||||
<span class="stat-unit">{{ formatted24hInbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Traffic Combined -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large traffic-total">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">总出站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formattedTotalOutbound.value }}</span>
|
||||
<span class="stat-unit">{{ formattedTotalOutbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">总入站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formattedTotalInbound.value }}</span>
|
||||
<span class="stat-unit">{{ formattedTotalInbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Count -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large clients">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">在线客户端</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number online">{{ onlineClients }}</span>
|
||||
<span class="stat-unit">个</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">总客户端</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ clients.length }}</span>
|
||||
<span class="stat-unit">个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="online-indicator" :class="{ active: onlineClients > 0 }">
|
||||
<span class="pulse"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules Count -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large rules">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row single">
|
||||
<span class="stat-title">代理规则</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ totalRules }}</span>
|
||||
<span class="stat-unit">条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traffic Chart Section -->
|
||||
<div class="chart-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">24小时流量趋势</h2>
|
||||
<div class="chart-legend">
|
||||
<span class="legend-item inbound"><span class="legend-dot"></span>入站</span>
|
||||
<span class="legend-item outbound"><span class="legend-dot"></span>出站</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card glass-card">
|
||||
<div class="chart-container">
|
||||
<div class="chart-bars">
|
||||
<div v-for="(data, index) in trafficHistory" :key="index" class="bar-group">
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div>
|
||||
<div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-label">{{ formatHour(data.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Container */
|
||||
.dashboard-container {
|
||||
min-height: calc(100vh - 116px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 动画背景粒子 */
|
||||
.particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.15;
|
||||
filter: blur(60px);
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.particle-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--color-accent);
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.particle-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #8b5cf6;
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.particle-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: var(--color-info);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.particle-4 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--color-success);
|
||||
bottom: 20%;
|
||||
right: 20%;
|
||||
animation-delay: -15s;
|
||||
}
|
||||
|
||||
.particle-5 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: #ec4899;
|
||||
top: 30%;
|
||||
left: 10%;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, -30px) scale(1.05); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
75% { transform: translate(-30px, -20px) scale(1.02); }
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.dashboard-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.traffic-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Glass stat card - 毛玻璃效果 */
|
||||
.glass-stat {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
.traffic-pill {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* 卡片顶部高光 */
|
||||
.glass-stat::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.glass-stat:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Large Stat icon */
|
||||
.stat-icon-large {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stat-icon-large.traffic-24h {
|
||||
background: var(--gradient-accent);
|
||||
}
|
||||
|
||||
.stat-icon-large.traffic-total {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
.stat-icon-large.clients {
|
||||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||
}
|
||||
|
||||
.stat-icon-large.rules {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Stat details */
|
||||
.stat-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-row.single {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-data {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.stat-number.online {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Online indicator with pulse */
|
||||
.online-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.online-indicator .pulse {
|
||||
.traffic-pill span {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.online-indicator.active .pulse {
|
||||
background: var(--color-success);
|
||||
animation: pulse-animation 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 8px var(--color-success-glow);
|
||||
}
|
||||
|
||||
@keyframes pulse-animation {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--color-success-glow); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
/* Chart Section */
|
||||
.chart-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
.traffic-pill strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.legend-item.inbound .legend-dot {
|
||||
background: #8b5cf6;
|
||||
.traffic-chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.legend-item.outbound .legend-dot {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Chart Card */
|
||||
.chart-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bar-group {
|
||||
flex: 1;
|
||||
.traffic-chart__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bar-wrapper {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
.traffic-chart__bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
align-items: end;
|
||||
gap: 4px;
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s ease;
|
||||
min-height: 4px;
|
||||
border-radius: 999px 999px 6px 6px;
|
||||
}
|
||||
|
||||
.bar.inbound {
|
||||
background: #8b5cf6;
|
||||
.bar--inbound {
|
||||
background: linear-gradient(180deg, rgba(6, 182, 212, 0.95), rgba(6, 182, 212, 0.25));
|
||||
}
|
||||
|
||||
.bar.outbound {
|
||||
background: var(--color-accent);
|
||||
.bar--outbound {
|
||||
background: linear-gradient(180deg, rgba(59, 130, 246, 0.95), rgba(59, 130, 246, 0.25));
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-hint {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
.traffic-chart__label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Glass card base - 毛玻璃效果 */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
.client-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.glass-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%);
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user