直播后台:新增按 IP 发言管控与在线会话视图。
支持全体禁言、单 IP 禁言/解禁与同 IP 本地限频,同时在开播页展示在线会话明细并补充实时优先码率策略,兼顾实时性与并发承载。 Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user