后台:修复直播开播页空白(补全 watch 导入);控制台展示应用带宽观测与 HTTP 流量统计

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 16:05:48 +08:00
parent e6ac5a107a
commit 0da93fb1be
6 changed files with 338 additions and 4 deletions

View File

@@ -27,6 +27,61 @@
</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>
@@ -41,7 +96,7 @@
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
import { getStats } from '../api/admin'
const stats = reactive({
@@ -52,13 +107,51 @@ const stats = reactive({
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()
Object.assign(stats, res)
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不阻塞页面
@@ -67,7 +160,30 @@ const fetchStats = async () => {
}
}
onMounted(fetchStats)
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>
@@ -85,4 +201,58 @@ onMounted(fetchStats)
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>

View File

@@ -91,7 +91,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { startPublishing } from '../../utils/liveWebRTC'