Files
web/admin/src/views/sites/LiveBroadcast.vue

828 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="live-broadcast">
<el-card class="live-broadcast-card">
<template #header>
<span>官网视频直播WebRTC</span>
</template>
<p class="status">{{ status }}</p>
<p v-if="session" class="viewer-row">
<el-tag type="info" effect="plain">当前观看人数{{ viewerCount }}</el-tag>
</p>
<div class="form-block">
<div class="field-row">
<span class="field-label">画面来源</span>
<el-radio-group v-model="captureMode" :disabled="!token || switchingCapture">
<el-radio-button value="camera">仅摄像头</el-radio-button>
<el-radio-button value="screen_only">仅共享屏幕</el-radio-button>
<el-radio-button value="screen_pip">共享屏幕 + 摄像头</el-radio-button>
</el-radio-group>
</div>
<div class="field-row">
<span class="field-label">摄像头</span>
<el-select
v-model="selectedCameraId"
placeholder="默认摄像头"
clearable
filterable
style="width: 100%; max-width: 360px"
:disabled="!token || switchingCapture"
>
<el-option label="系统默认" value="" />
<el-option
v-for="d in videoInputs"
:key="d.deviceId"
:label="d.label || `摄像头 ${d.deviceId.slice(0, 8)}…`"
:value="d.deviceId"
/>
</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
铺满共享整屏时尽量勿把本管理页选进画面以免套娃
</p>
<div v-if="session" class="field-row">
<el-button
type="primary"
plain
:disabled="!token || switchingCapture"
:loading="switchingCapture"
@click="applyCaptureSwitch"
>
应用切换不切断直播
</el-button>
</div>
</div>
<div class="actions">
<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-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'"
ref="previewMainRef"
class="preview-main"
playsinline
muted
autoplay
></video>
<video
v-show="previewLayout === 'screen_pip'"
ref="previewScreenRef"
class="preview-main preview-main--fill"
playsinline
muted
autoplay
></video>
<video
v-show="previewLayout === 'screen_pip'"
ref="previewCamRef"
class="preview-pip-drag"
:style="pipStyle"
playsinline
muted
autoplay
title="拖动调整小窗位置(观众端同步)"
@pointerdown.prevent="onPipPointerDown"
></video>
</div>
<aside class="live-moderation-aside" aria-label="观众与发言管控">
<el-card class="moderation-card" shadow="never">
<template #header>
<div class="moderation-head">
<span>观众与发言管控</span>
<el-button size="small" @click="loadModeration">刷新</el-button>
</div>
</template>
<div class="moderation-actions moderation-actions--stack">
<el-switch
v-model="muteAll"
active-text="全体禁言"
inactive-text="允许发言"
@change="toggleMuteAll"
/>
<div class="moderation-inline">
<el-input v-model.trim="manualUsername" placeholder="按用户名禁言/解禁" style="width: 100%" />
<el-button type="warning" plain @click="setManualUserMute(true)">禁言用户</el-button>
<el-button @click="setManualUserMute(false)">解禁用户</el-button>
</div>
<div class="moderation-inline">
<el-input v-model.trim="manualIP" placeholder="按 IP 禁言/解禁" style="width: 100%" />
<el-button type="warning" plain @click="setManualIPMute(true)">禁言 IP</el-button>
<el-button @click="setManualIPMute(false)">解禁 IP</el-button>
</div>
</div>
<p v-if="mutedUsernames.length" class="muted-names-line">
已禁用户归一化
<el-tag v-for="u in mutedUsernames" :key="u" size="small" type="warning" class="muted-name-tag">
{{ u }}
</el-tag>
</p>
<p class="moderation-hint">
弹幕侧登记<strong>完整用户名</strong>便于对号入座未登录弹幕连接为游客 IP
{{ moderationRate.window_ms }}ms 内最多 {{ moderationRate.max_hits }} 次发送会限频
</p>
<h4 class="moderation-subtitle">在线会话</h4>
<el-table v-loading="moderationLoading" :data="onlineUsers" stripe size="small" class="moderation-table">
<el-table-column label="用户名" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
{{ formatSessionUsername(row) }}
</template>
</el-table-column>
<el-table-column prop="channel" label="通道" width="72" />
<el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
<el-table-column label="在线" width="72">
<template #default="{ row }">{{ formatSec(row.online_sec) }}</template>
</el-table-column>
<el-table-column label="操作" width="168" fixed="right">
<template #default="{ row }">
<template v-if="row.username">
<el-button link type="warning" size="small" @click="toggleUserMute(row.username, true)">
禁言
</el-button>
<el-button link type="primary" size="small" @click="toggleUserMute(row.username, false)">
解禁
</el-button>
</template>
<el-button v-if="!ipMuted(row.ip)" link type="warning" size="small" @click="toggleIP(row.ip, true)">
禁IP
</el-button>
<el-button v-else link type="primary" size="small" @click="toggleIP(row.ip, false)">解IP</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="moderation-subtitle"> IP 聚合</h4>
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe size="small" class="moderation-table">
<el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
<el-table-column prop="count" label="连接" width="64" />
<el-table-column label="状态" width="72">
<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="100" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.muted" link type="warning" size="small" @click="toggleIP(row.ip, true)">
禁言
</el-button>
<el-button v-else link type="primary" size="small" @click="toggleIP(row.ip, false)">解禁</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</aside>
</div>
</el-card>
</div>
</template>
<script setup>
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,
getStats,
setLiveMuteAll,
setLiveMuteIP,
setLiveMuteUser
} from '../../api/admin'
import { startPublishing } from '../../utils/liveWebRTC'
function liveStatusUrl() {
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
return base ? `${base}/api/web/live/status` : '/api/web/live/status'
}
const authStore = useAuthStore()
const token = computed(() => authStore.getToken() || '')
const previewWrapRef = ref(null)
const previewMainRef = ref(null)
const previewScreenRef = ref(null)
const previewCamRef = ref(null)
const status = ref('就绪')
const session = ref(null)
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 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')
/** 与推流画布 1280×720 一致的归一化小窗矩形(左上 + 宽高0~1 */
const pipNorm = ref(defaultPipNorm())
function defaultPipNorm() {
const nw = 0.24
const nh = 0.24
return {
nx: 1 - nw - 10 / 1280,
ny: 1 - nh - 10 / 720,
nw,
nh
}
}
const pipStyle = computed(() => ({
left: `${pipNorm.value.nx * 100}%`,
top: `${pipNorm.value.ny * 100}%`,
width: `${pipNorm.value.nw * 100}%`,
height: `${pipNorm.value.nh * 100}%`
}))
let pipDragging = false
let pipDragStart = { cx: 0, cy: 0, nx: 0, ny: 0 }
function clamp01(v, lo, hi) {
return Math.min(hi, Math.max(lo, v))
}
function onPipPointerDown(e) {
if (previewLayout.value !== 'screen_pip') return
pipDragging = true
pipDragStart.cx = e.clientX
pipDragStart.cy = e.clientY
pipDragStart.nx = pipNorm.value.nx
pipDragStart.ny = pipNorm.value.ny
try {
e.target.setPointerCapture(e.pointerId)
} catch (_) {}
window.addEventListener('pointermove', onPipPointerMove)
window.addEventListener('pointerup', onPipPointerUp, { once: true })
window.addEventListener('pointercancel', onPipPointerUp, { once: true })
}
function onPipPointerMove(e) {
if (!pipDragging || !previewWrapRef.value) return
const rect = previewWrapRef.value.getBoundingClientRect()
if (rect.width < 1 || rect.height < 1) return
const dx = (e.clientX - pipDragStart.cx) / rect.width
const dy = (e.clientY - pipDragStart.cy) / rect.height
const { nw, nh } = pipNorm.value
pipNorm.value = {
...pipNorm.value,
nx: clamp01(pipDragStart.nx + dx, 0, 1 - nw),
ny: clamp01(pipDragStart.ny + dy, 0, 1 - nh)
}
}
function onPipPointerUp() {
pipDragging = false
window.removeEventListener('pointermove', onPipPointerMove)
}
async function refreshVideoDevices() {
try {
if (!navigator.mediaDevices?.enumerateDevices) return
const list = await navigator.mediaDevices.enumerateDevices()
videoInputs.value = list.filter((d) => d.kind === 'videoinput')
} 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 : []
mutedUsernames.value = Array.isArray(res.muted_usernames) ? res.muted_usernames : []
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)
}
async function toggleUserMute(username, enabled) {
const u = (username || '').trim()
if (!u) return
try {
await setLiveMuteUser(u, enabled)
ElMessage.success(enabled ? `已禁言用户 ${u}` : `已解禁用户 ${u}`)
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
}
}
async function setManualUserMute(enabled) {
if (!manualUsername.value.trim()) {
ElMessage.warning('请先输入用户名')
return
}
await toggleUserMute(manualUsername.value.trim(), enabled)
}
function applyPreview(payload) {
const { layout, main, screen, cam } = payload || {}
previewLayout.value = layout || 'camera'
if (previewLayout.value === 'screen_pip') {
if (previewMainRef.value) previewMainRef.value.srcObject = null
if (previewScreenRef.value) previewScreenRef.value.srcObject = screen || null
if (previewCamRef.value) previewCamRef.value.srcObject = cam || null
} else {
if (previewScreenRef.value) previewScreenRef.value.srcObject = null
if (previewCamRef.value) previewCamRef.value.srcObject = null
if (previewMainRef.value) previewMainRef.value.srcObject = main || null
}
}
function clearPreview() {
previewLayout.value = 'camera'
pipNorm.value = defaultPipNorm()
if (previewMainRef.value) previewMainRef.value.srcObject = null
if (previewScreenRef.value) previewScreenRef.value.srcObject = null
if (previewCamRef.value) previewCamRef.value.srcObject = null
}
async function fetchViewerCount() {
try {
const r = await fetch(liveStatusUrl(), { cache: 'no-store' })
if (!r.ok) return
const j = await r.json()
if (typeof j.viewers === 'number') viewerCount.value = j.viewers
} catch (_) {}
}
function startViewerPoll() {
stopViewerPoll()
fetchViewerCount()
viewerPollTimer = window.setInterval(fetchViewerCount, 2500)
}
function stopViewerPoll() {
if (viewerPollTimer != null) {
clearInterval(viewerPollTimer)
viewerPollTimer = null
}
}
watch(session, (s) => {
if (s) {
startViewerPoll()
} else {
stopViewerPoll()
viewerCount.value = 0
}
})
async function applyCaptureSwitch() {
if (!session.value?.switchMode) return
switchingCapture.value = true
try {
await session.value.switchMode(captureMode.value, selectedCameraId.value || '')
} finally {
switchingCapture.value = false
}
}
function start() {
if (!token.value) {
status.value = '请先登录'
return
}
status.value = '正在连接…'
const { stop, switchMode } = startPublishing({
token: token.value,
captureMode: captureMode.value,
videoDeviceId: selectedCameraId.value || '',
bitrateProfile: bitrateProfile.value,
onStatus: (s) => {
status.value = s
},
onLocalStream: applyPreview,
onActiveModeChange: (m) => {
captureMode.value = m
},
getPipRect: () => ({ ...pipNorm.value })
})
session.value = { stop, switchMode }
}
function stop() {
session.value?.stop()
session.value = null
clearPreview()
status.value = '已停止'
}
function onBeforeUnload() {
session.value?.stop()
}
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)
fetchBandwidth()
bwPollTimer = window.setInterval(fetchBandwidth, 8000)
})
watch(bitrateProfile, (v) => {
try {
localStorage.setItem('yh_live_bitrate_profile', v)
} catch (_) {}
})
onUnmounted(() => {
stopViewerPoll()
if (moderationTimer != null) {
clearInterval(moderationTimer)
moderationTimer = null
}
if (bwPollTimer != null) {
clearInterval(bwPollTimer)
bwPollTimer = null
}
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener('pointermove', onPipPointerMove)
})
onBeforeRouteLeave(() => {
stop()
})
</script>
<style scoped>
.live-broadcast {
max-width: min(1680px, 100%);
}
/* 右侧列:上为带宽卡片、下为观众管控;左侧为预览 */
.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 {
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: start;
}
/* 仅小屏/手机再上下堆叠 */
@media (max-width: 768px) {
.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 {
grid-column: 1;
grid-row: 3;
max-height: none;
position: static;
overflow-y: visible;
}
}
.status {
color: #409eff;
margin-bottom: 14px;
min-height: 1.5em;
}
.viewer-row {
margin: -6px 0 14px;
}
.form-block {
margin-bottom: 16px;
}
.field-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.field-label {
font-size: 14px;
color: #606266;
min-width: 72px;
}
.hint-live {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.5;
color: #909399;
max-width: 720px;
}
.actions {
margin-bottom: 16px;
}
.preview-wrap {
position: relative;
min-width: 0;
width: 100%;
max-width: none;
}
.preview-main {
display: block;
width: 100%;
min-height: 320px;
max-height: min(85vh, 900px);
border-radius: 8px;
background: #000;
object-fit: contain;
aspect-ratio: 16 / 9;
}
.preview-main--fill {
object-fit: fill;
}
.preview-pip-drag {
position: absolute;
box-sizing: border-box;
border-radius: 8px;
border: 3px solid #409eff;
background: #000;
object-fit: cover;
cursor: grab;
touch-action: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
z-index: 2;
}
.preview-pip-drag:active {
cursor: grabbing;
}
.moderation-card {
max-width: 100%;
}
.moderation-actions--stack {
flex-direction: column;
align-items: stretch;
}
.moderation-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.muted-names-line {
margin: 0 0 8px;
font-size: 12px;
color: #606266;
line-height: 1.6;
}
.muted-name-tag {
margin: 2px 4px 2px 0;
}
.moderation-table {
width: 100%;
margin-bottom: 8px;
}
.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>