Files
web/admin/src/views/Dashboard.vue

259 lines
7.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="dashboard" v-loading="loading" element-loading-text="加载中...">
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.users }}</div>
<div class="stat-label">用户数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.workspaces }}</div>
<div class="stat-label">工作空间</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.conversations }}</div>
<div class="stat-label">对话数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.messages }}</div>
<div class="stat-label">消息数</div>
</el-card>
</el-col>
</el-row>
<el-card v-if="bandwidth" class="bw-card" style="margin-top: 20px">
<template #header>
<div class="bw-header">
<span>应用带宽观测</span>
<el-text type="info" size="small">{{ bwUpdatedAt }}</el-text>
</div>
</template>
<el-alert type="info" :closable="false" show-icon class="bw-tip">
以下为<strong> Go 进程</strong>统计的 HTTP 请求/响应字节量用于粗估负载若前面还有 Nginx/CDN<strong>公网出口带宽</strong>可能更高WebSocket如直播信令升级后的流量可能未完全计入
</el-alert>
<el-row :gutter="16" class="bw-row">
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label">出站累计用户下载为主</div>
<div class="bw-value">{{ formatBytes(bandwidth.bytes_out_total) }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label">入站累计上传/POST</div>
<div class="bw-value">{{ formatBytes(bandwidth.bytes_in_total) }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label"> 60 秒出站 · Mbps</div>
<div class="bw-value accent">{{ bandwidth.recent_egress_mbps }}</div>
<div class="bw-sub">{{ formatBytes(bandwidth.bytes_out_last_60s) }} / 60s</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label">自启动平均出站 · Mbps</div>
<div class="bw-value accent">{{ bandwidth.avg_egress_mbps }}</div>
<div class="bw-sub">运行 {{ formatUptime(bandwidth.uptime_seconds) }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="16" class="bw-row bw-row--second">
<el-col :xs="24" :sm="12">
<div class="bw-metric bw-metric--inline">
<span class="bw-label"> 60 秒入站约 Mbps</span>
<span class="bw-value-inline">{{ bandwidth.recent_ingress_mbps }}</span>
<span class="bw-sub">{{ formatBytes(bandwidth.bytes_in_last_60s) }} / 60s</span>
</div>
</el-col>
<el-col :xs="24" :sm="12">
<div class="bw-metric bw-metric--inline">
<span class="bw-label">自启动平均入站 · Mbps</span>
<span class="bw-value-inline">{{ bandwidth.avg_ingress_mbps }}</span>
</div>
</el-col>
</el-row>
</el-card>
<el-card style="margin-top: 20px">
<template #header>
<span>快捷入口</span>
</template>
<el-space wrap>
<el-button type="primary" @click="$router.push('/users')">用户管理</el-button>
<el-button @click="$router.push('/workspaces')">工作空间</el-button>
<el-button @click="$router.push('/conversations')">对话管理</el-button>
</el-space>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
import { getStats } from '../api/admin'
const stats = reactive({
users: 0,
workspaces: 0,
conversations: 0,
messages: 0,
files: 0
})
const bandwidth = ref(null)
const bwFetchAt = ref(0)
const bwUpdatedAt = computed(() => {
if (!bwFetchAt.value) return ''
const d = new Date(bwFetchAt.value)
return `更新于 ${d.toLocaleTimeString()}`
})
const loading = ref(true)
function formatBytes(n) {
if (n == null || !Number.isFinite(n) || n < 0) return '—'
if (n < 1024) return `${n} B`
const u = ['KB', 'MB', 'GB', 'TB']
let v = n
let i = -1
do {
v /= 1024
i++
} while (v >= 1024 && i < u.length - 1)
return `${v < 10 ? v.toFixed(2) : v.toFixed(1)} ${u[i]}`
}
function formatUptime(sec) {
if (sec == null || !Number.isFinite(sec) || sec < 0) return '—'
const s = Math.floor(sec)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h} 小时 ${m}`
if (m > 0) return `${m}${r}`
return `${r}`
}
const fetchStats = async () => {
loading.value = true
try {
const res = await getStats()
const { bandwidth: bw, ...rest } = res
Object.assign(stats, rest)
if (bw && typeof bw === 'object') {
bandwidth.value = bw
bwFetchAt.value = Date.now()
}
} catch (e) {
console.error('获取统计失败:', e)
// 即使失败也显示 0不阻塞页面
} finally {
loading.value = false
}
}
let pollTimer = null
onMounted(() => {
fetchStats()
pollTimer = window.setInterval(() => {
getStats()
.then((res) => {
const { bandwidth: bw, ...rest } = res
Object.assign(stats, rest)
if (bw && typeof bw === 'object') {
bandwidth.value = bw
bwFetchAt.value = Date.now()
}
})
.catch(() => {})
}, 8000)
})
onUnmounted(() => {
if (pollTimer != null) {
clearInterval(pollTimer)
pollTimer = null
}
})
</script>
<style scoped>
.stat-card {
text-align: center;
padding: 20px 0;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #409eff;
}
.stat-label {
margin-top: 8px;
color: #666;
font-size: 14px;
}
.bw-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.bw-tip {
margin-bottom: 16px;
}
.bw-row {
margin-top: 0;
}
.bw-row--second {
margin-top: 12px;
}
.bw-metric {
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.bw-metric--inline {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
border-bottom: none;
padding: 8px 0;
}
.bw-label {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.bw-metric--inline .bw-label {
margin-bottom: 0;
}
.bw-value {
font-size: 22px;
font-weight: 600;
color: #303133;
}
.bw-value.accent {
color: #409eff;
}
.bw-value-inline {
font-size: 18px;
font-weight: 600;
color: #409eff;
}
.bw-sub {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>