feat(admin): 开播页带宽观测;首页侧栏下载支持选择链接
Made-with: Cursor
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user