直播后台:新增按 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

@@ -83,3 +83,8 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
}
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
// 直播发言管控(按 IP
export const getLiveModeration = () => request.get('/admin/live/moderation')
export const setLiveMuteAll = (enabled) => request.put('/admin/live/moderation/mute-all', { enabled })
export const setLiveMuteIP = (ip, enabled) => request.put('/admin/live/moderation/mute-ip', { ip, enabled })

View File

@@ -37,6 +37,20 @@ const QUALITY_MEDIA = {
}
}
// 码率上限kbps: 在实时性与并发之间取平衡
const QUALITY_VIDEO_MAX_KBPS = {
source: 1800,
high: 1400,
mid: 900,
low: 550
}
const BITRATE_PROFILE_MULTIPLIER = {
save: 0.78,
balanced: 1,
clarity: 1.2
}
function effectivePublishQualityKey() {
try {
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
@@ -45,6 +59,27 @@ function effectivePublishQualityKey() {
return 'source'
}
function targetVideoMaxBitrateBps(publishKey, bitrateProfile = 'balanced') {
const kbps = QUALITY_VIDEO_MAX_KBPS[publishKey] || QUALITY_VIDEO_MAX_KBPS.source
const m = BITRATE_PROFILE_MULTIPLIER[bitrateProfile] || BITRATE_PROFILE_MULTIPLIER.balanced
return Math.max(220, Math.round(kbps * m)) * 1000
}
async function applyVideoSenderPolicy(sender, publishKey, bitrateProfile) {
if (!sender) return
try {
const p = sender.getParameters ? sender.getParameters() : null
if (!p) return
if (!p.encodings || !p.encodings.length) p.encodings = [{}]
p.degradationPreference = 'maintain-framerate'
p.encodings[0].maxBitrate = targetVideoMaxBitrateBps(publishKey, bitrateProfile)
// 保留一定冗余,弱网抖动时更稳,避免一路拉满
p.encodings[0].maxFramerate =
publishKey === 'low' ? 20 : publishKey === 'mid' ? 24 : 30
await sender.setParameters(p)
} catch (_) {}
}
function liveWsURLPublish(token) {
const q = effectivePublishQualityKey()
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
@@ -132,6 +167,7 @@ export function startPublishing(opts = {}) {
token = '',
captureMode: initialMode = 'camera',
videoDeviceId: initialDeviceId = '',
bitrateProfile = 'balanced',
onStatus = () => {},
onLocalStream = () => {},
onActiveModeChange = () => {},
@@ -245,6 +281,9 @@ export function startPublishing(opts = {}) {
throw e
}
const previewVid = new MediaStream([sTr])
try {
sTr.contentHint = 'detail'
} catch (_) {}
onLocalStream({ layout: 'screen_only', main: previewVid })
return new MediaStream([sTr, ...micStream.getAudioTracks()])
}
@@ -309,6 +348,9 @@ export function startPublishing(opts = {}) {
cam.getTracks().forEach((t) => t.stop())
throw new Error('画布采集失败')
}
try {
outV.contentHint = 'detail'
} catch (_) {}
const mic = cam.getAudioTracks()
const publish = new MediaStream([outV, ...mic])
onLocalStream({
@@ -319,6 +361,10 @@ export function startPublishing(opts = {}) {
return publish
}
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
try {
const camV = s.getVideoTracks()[0]
if (camV) camV.contentHint = 'motion'
} catch (_) {}
onLocalStream({ layout: 'camera', main: s })
return s
}
@@ -359,6 +405,11 @@ export function startPublishing(opts = {}) {
stream.getTracks().forEach((t) => {
if (t.readyState === 'live') pc.addTrack(t, stream)
})
await applyVideoSenderPolicy(
pc.getSenders().find((s) => s.track?.kind === 'video'),
publishKey,
bitrateProfile
)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
@@ -410,6 +461,11 @@ export function startPublishing(opts = {}) {
} else if (aT) {
pc.addTrack(aT, stream)
}
await applyVideoSenderPolicy(
pc.getSenders().find((s) => s.track?.kind === 'video'),
publishKey,
bitrateProfile
)
const offer = await pc.createOffer({ iceRestart: false })
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })

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>