feat(admin): 开播页带宽观测;首页侧栏下载支持选择链接

Made-with: Cursor
This commit is contained in:
whm
2026-04-02 15:21:45 +08:00
parent f161ff0e4e
commit 03f5fbb41a
2 changed files with 203 additions and 24 deletions

View File

@@ -70,7 +70,39 @@
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
<el-button v-else type="danger" @click="stop">结束直播</el-button>
</div>
<div class="live-preview-row">
<div class="live-preview-layout">
<div class="live-bw-slot">
<el-card class="live-bw-panel" shadow="never">
<template #header>
<div class="live-bw-head">
<span>带宽使用情况</span>
<el-text v-if="bwUpdatedAt" type="info" size="small">{{ bwUpdatedAt }}</el-text>
</div>
</template>
<template v-if="bandwidth">
<div class="live-bw-metrics">
<div class="live-bw-line">
<span class="live-bw-label"> 60 秒出站</span>
<strong class="live-bw-strong">{{ bandwidth.recent_egress_mbps }} Mbps</strong>
<span class="live-bw-sub">{{ formatBwBytes(bandwidth.bytes_out_last_60s) }} / 60s</span>
</div>
<div class="live-bw-line">
<span class="live-bw-label"> 60 秒入站</span>
<strong class="live-bw-strong">{{ bandwidth.recent_ingress_mbps }} Mbps</strong>
<span class="live-bw-sub">{{ formatBwBytes(bandwidth.bytes_in_last_60s) }} / 60s</span>
</div>
<div class="live-bw-line live-bw-line--split">
<span class="live-bw-mini">平均出站 {{ bandwidth.avg_egress_mbps }} Mbps</span>
<span class="live-bw-mini">运行 {{ formatBwUptime(bandwidth.uptime_seconds) }}</span>
</div>
</div>
<p class="live-bw-footnote">
为本 Go 进程 HTTP 粗估前有 Nginx/CDN 时公网带宽可能更高
</p>
</template>
<el-text v-else type="info" size="small">统计加载中或暂不可用</el-text>
</el-card>
</div>
<div ref="previewWrapRef" class="preview-wrap">
<video
v-show="previewLayout !== 'screen_pip'"
@@ -197,7 +229,13 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { ElMessage } from 'element-plus'
import { getLiveModeration, setLiveMuteAll, setLiveMuteIP, setLiveMuteUser } from '../../api/admin'
import {
getLiveModeration,
getStats,
setLiveMuteAll,
setLiveMuteIP,
setLiveMuteUser
} from '../../api/admin'
import { startPublishing } from '../../utils/liveWebRTC'
function liveStatusUrl() {
@@ -229,6 +267,53 @@ const manualUsername = ref('')
const mutedUsernames = ref([])
const moderationRate = ref({ window_ms: 3000, max_hits: 10 })
let moderationTimer = null
/** @type {import('vue').Ref<Record<string, unknown> | null>} */
const bandwidth = ref(null)
const bwFetchAt = ref(0)
const bwUpdatedAt = computed(() => {
if (!bwFetchAt.value) return ''
return `更新于 ${new Date(bwFetchAt.value).toLocaleTimeString()}`
})
function formatBwBytes(n) {
if (n == null || !Number.isFinite(Number(n)) || Number(n) < 0) return '—'
const v = Number(n)
if (v < 1024) return `${v} B`
const u = ['KB', 'MB', 'GB', 'TB']
let x = v
let i = -1
do {
x /= 1024
i++
} while (x >= 1024 && i < u.length - 1)
return `${x < 10 ? x.toFixed(2) : x.toFixed(1)} ${u[i]}`
}
function formatBwUptime(sec) {
if (sec == null || !Number.isFinite(Number(sec)) || Number(sec) < 0) return '—'
const s = Math.floor(Number(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}`
}
async function fetchBandwidth() {
try {
const res = await getStats()
const bw = res?.bandwidth
if (bw && typeof bw === 'object') {
bandwidth.value = bw
bwFetchAt.value = Date.now()
}
} catch (_) {
/* 静默,不阻塞开播 */
}
}
let bwPollTimer = null
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
const previewLayout = ref('camera')
@@ -485,6 +570,8 @@ onMounted(() => {
refreshVideoDevices()
loadModeration()
moderationTimer = window.setInterval(loadModeration, 5000)
fetchBandwidth()
bwPollTimer = window.setInterval(fetchBandwidth, 8000)
})
watch(bitrateProfile, (v) => {
@@ -499,6 +586,10 @@ onUnmounted(() => {
clearInterval(moderationTimer)
moderationTimer = null
}
if (bwPollTimer != null) {
clearInterval(bwPollTimer)
bwPollTimer = null
}
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener('pointermove', onPipPointerMove)
})
@@ -512,37 +603,107 @@ onBeforeRouteLeave(() => {
.live-broadcast {
max-width: min(1680px, 100%);
}
/* 预览与观众管控同一行:画面在左、管控在右(勿在窄屏外误触发布局为上下) */
.live-preview-row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-start;
gap: 16px;
/* 右侧列:上为带宽卡片、下为观众管控;左侧为预览 */
.live-preview-layout {
display: grid;
grid-template-columns: 1fr clamp(280px, 34vw, 420px);
grid-template-rows: auto auto;
gap: 12px 16px;
margin-top: 4px;
width: 100%;
align-items: start;
}
.live-bw-slot {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.live-bw-panel :deep(.el-card__header) {
padding: 10px 14px;
}
.live-bw-panel :deep(.el-card__body) {
padding: 12px 14px 14px;
}
.live-bw-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
font-size: 14px;
font-weight: 600;
}
.live-bw-metrics {
display: flex;
flex-direction: column;
gap: 8px;
}
.live-bw-line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
font-size: 13px;
}
.live-bw-line--split {
justify-content: space-between;
color: #606266;
font-size: 12px;
}
.live-bw-label {
color: #606266;
min-width: 5.5em;
}
.live-bw-strong {
color: #409eff;
font-size: 15px;
}
.live-bw-sub {
color: #909399;
font-size: 12px;
}
.live-bw-mini {
font-size: 12px;
}
.live-bw-footnote {
margin: 10px 0 0;
font-size: 11px;
line-height: 1.45;
color: #909399;
}
.preview-wrap {
grid-column: 1;
grid-row: 2;
}
.live-moderation-aside {
flex: 0 0 auto;
width: clamp(280px, 34vw, 420px);
min-width: 260px;
grid-column: 2;
grid-row: 2;
width: 100%;
min-width: 0;
max-height: calc(100vh - 180px);
overflow-y: auto;
overflow-x: hidden;
position: sticky;
top: 8px;
align-self: stretch;
align-self: start;
}
/* 仅小屏/手机再上下堆叠,避免 1200px 以下管理后台侧栏占宽导致误变单列 */
/* 仅小屏/手机再上下堆叠 */
@media (max-width: 768px) {
.live-preview-row {
flex-direction: column;
flex-wrap: wrap;
.live-preview-layout {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.live-bw-slot {
grid-column: 1;
grid-row: 1;
}
.preview-wrap {
grid-column: 1;
grid-row: 2;
}
.live-moderation-aside {
flex: 1 1 auto;
width: 100%;
min-width: 0;
grid-column: 1;
grid-row: 3;
max-height: none;
position: static;
overflow-y: visible;
@@ -583,8 +744,8 @@ onBeforeRouteLeave(() => {
}
.preview-wrap {
position: relative;
flex: 1 1 0;
min-width: 0;
width: 100%;
max-width: none;
}
.preview-main {