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