直播后台:新增按 IP 发言管控与在线会话视图。

支持全体禁言、单 IP 禁言/解禁与同 IP 本地限频,同时在开播页展示在线会话明细并补充实时优先码率策略,兼顾实时性与并发承载。

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 17:57:50 +08:00
parent 0da93fb1be
commit fe8d5a34cc
8 changed files with 535 additions and 2 deletions

View File

@@ -37,6 +37,19 @@
</el-select>
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
</div>
<div class="field-row">
<span class="field-label">码率策略</span>
<el-select
v-model="bitrateProfile"
style="width: 100%; max-width: 300px"
:disabled="!token || switchingCapture"
>
<el-option label="省流优先(更多并发)" value="save" />
<el-option label="均衡(推荐)" value="balanced" />
<el-option label="清晰优先(更占带宽)" value="clarity" />
</el-select>
<el-tag effect="plain" type="info">弱网建议省流/均衡</el-tag>
</div>
<p v-if="session" class="hint-live">
直播中可切换三种模式屏幕+摄像头下可拖动小窗观众画面与预览一致画布 16:9
铺满共享整屏时尽量勿把本管理页选进画面以免套娃
@@ -87,6 +100,65 @@
></video>
</div>
</el-card>
<el-card class="moderation-card" style="margin-top: 16px">
<template #header>
<div class="moderation-head">
<span>发言管控 IP</span>
<el-button size="small" @click="loadModeration">刷新</el-button>
</div>
</template>
<div class="moderation-actions">
<el-switch
v-model="muteAll"
active-text="全体禁言"
inactive-text="允许发言"
@change="toggleMuteAll"
/>
<el-input v-model.trim="manualIP" placeholder="手动输入 IP如 1.2.3.4" style="width: 260px" />
<el-button type="warning" plain @click="setManualIPMute(true)">禁言该IP</el-button>
<el-button @click="setManualIPMute(false)">解禁该IP</el-button>
</div>
<p class="moderation-hint">
IP {{ moderationRate.window_ms }}ms 内最多 {{ moderationRate.max_hits }} 次发送超出会本地限频优先保障实时
</p>
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe style="width: 100%">
<el-table-column prop="ip" label="IP" min-width="180" />
<el-table-column prop="count" label="在线连接数" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '已禁言' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button v-if="!row.muted" link type="warning" @click="toggleIP(row.ip, true)">禁言</el-button>
<el-button v-else link type="primary" @click="toggleIP(row.ip, false)">解禁</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="moderation-subtitle">在线用户会话</h4>
<el-table v-loading="moderationLoading" :data="onlineUsers" stripe style="width: 100%">
<el-table-column prop="channel" label="通道" width="96" />
<el-table-column prop="username" label="用户(脱敏)" min-width="120" />
<el-table-column prop="ip" label="IP" min-width="160" />
<el-table-column label="在线时长" width="110">
<template #default="{ row }">{{ formatSec(row.online_sec) }}</template>
</el-table-column>
<el-table-column label="空闲时长" width="110">
<template #default="{ row }">{{ formatSec(row.idle_sec) }}</template>
</el-table-column>
<el-table-column label="禁言" width="80">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
@@ -94,6 +166,8 @@
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 } from '../../api/admin'
import { startPublishing } from '../../utils/liveWebRTC'
function liveStatusUrl() {
@@ -113,8 +187,16 @@ const viewerCount = ref(0)
let viewerPollTimer = null
const captureMode = ref('camera')
const selectedCameraId = ref('')
const bitrateProfile = ref('balanced')
const videoInputs = ref([])
const switchingCapture = ref(false)
const moderationLoading = ref(false)
const muteAll = ref(false)
const onlineIPs = ref([])
const onlineUsers = ref([])
const manualIP = ref('')
const moderationRate = ref({ window_ms: 3000, max_hits: 10 })
let moderationTimer = null
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
const previewLayout = ref('camera')
@@ -188,6 +270,61 @@ async function refreshVideoDevices() {
} catch (_) {}
}
async function loadModeration() {
moderationLoading.value = true
try {
const res = await getLiveModeration()
muteAll.value = !!res.mute_all
onlineIPs.value = Array.isArray(res.online_ips) ? res.online_ips : []
onlineUsers.value = Array.isArray(res.online_users) ? res.online_users : []
moderationRate.value = res.rate_limit || { window_ms: 3000, max_hits: 10 }
} catch (e) {
ElMessage.error(e?.response?.data?.error || '加载发言管控失败')
} finally {
moderationLoading.value = false
}
}
function formatSec(s) {
const v = Math.max(0, Number(s) || 0)
if (v < 60) return `${v}s`
const m = Math.floor(v / 60)
const r = v % 60
if (m < 60) return `${m}m${r}s`
const h = Math.floor(m / 60)
const mm = m % 60
return `${h}h${mm}m`
}
async function toggleMuteAll(v) {
try {
await setLiveMuteAll(!!v)
ElMessage.success(v ? '已开启全体禁言' : '已关闭全体禁言')
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
muteAll.value = !v
}
}
async function toggleIP(ip, enabled) {
try {
await setLiveMuteIP(ip, enabled)
ElMessage.success(enabled ? `已禁言 ${ip}` : `已解禁 ${ip}`)
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
}
}
async function setManualIPMute(enabled) {
if (!manualIP.value) {
ElMessage.warning('请先输入 IP')
return
}
await toggleIP(manualIP.value, enabled)
}
function applyPreview(payload) {
const { layout, main, screen, cam } = payload || {}
previewLayout.value = layout || 'camera'
@@ -261,6 +398,7 @@ function start() {
token: token.value,
captureMode: captureMode.value,
videoDeviceId: selectedCameraId.value || '',
bitrateProfile: bitrateProfile.value,
onStatus: (s) => {
status.value = s
},
@@ -287,11 +425,27 @@ function onBeforeUnload() {
onMounted(() => {
document.title = '视频直播开播 - 管理后台'
window.addEventListener('beforeunload', onBeforeUnload)
try {
const v = localStorage.getItem('yh_live_bitrate_profile')
bitrateProfile.value = v === 'save' || v === 'clarity' ? v : 'balanced'
} catch (_) {}
refreshVideoDevices()
loadModeration()
moderationTimer = window.setInterval(loadModeration, 5000)
})
watch(bitrateProfile, (v) => {
try {
localStorage.setItem('yh_live_bitrate_profile', v)
} catch (_) {}
})
onUnmounted(() => {
stopViewerPoll()
if (moderationTimer != null) {
clearInterval(moderationTimer)
moderationTimer = null
}
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener('pointermove', onPipPointerMove)
})
@@ -371,4 +525,29 @@ onBeforeRouteLeave(() => {
.preview-pip-drag:active {
cursor: grabbing;
}
.moderation-card {
max-width: min(1200px, 100%);
}
.moderation-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.moderation-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.moderation-hint {
margin: 0 0 10px;
color: #909399;
font-size: 12px;
}
.moderation-subtitle {
margin: 16px 0 10px;
font-size: 14px;
color: #303133;
}
</style>