828 lines
24 KiB
Vue
828 lines
24 KiB
Vue
<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>
|