feat(admin): 开播页带宽观测;首页侧栏下载支持选择链接
Made-with: Cursor
This commit is contained in:
@@ -55,10 +55,24 @@
|
|||||||
填写同域可下载的静态地址(需将安装包放到站点 <code>promotion/downloads/</code> 并部署)。前台为「Windows 版下载」「安卓版下载」直连,不跳转整页。
|
填写同域可下载的静态地址(需将安装包放到站点 <code>promotion/downloads/</code> 并部署)。前台为「Windows 版下载」「安卓版下载」直连,不跳转整页。
|
||||||
</p>
|
</p>
|
||||||
<el-form-item label="Windows 安装包">
|
<el-form-item label="Windows 安装包">
|
||||||
<el-input v-model="form.download_windows_url" placeholder="/promotion/downloads/yuheng-windows.zip" />
|
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
|
||||||
|
<el-input
|
||||||
|
v-model="form.download_windows_url"
|
||||||
|
placeholder="/promotion/downloads/yuheng-windows.zip"
|
||||||
|
style="flex: 1; min-width: 160px"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" link @click="openLinkPicker({ type: 'download_windows' })">选择链接</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="安卓安装包">
|
<el-form-item label="安卓安装包">
|
||||||
<el-input v-model="form.download_android_url" placeholder="/promotion/downloads/yuheng-android.apk" />
|
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
|
||||||
|
<el-input
|
||||||
|
v-model="form.download_android_url"
|
||||||
|
placeholder="/promotion/downloads/yuheng-android.apk"
|
||||||
|
style="flex: 1; min-width: 160px"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" link @click="openLinkPicker({ type: 'download_android' })">选择链接</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-divider content-position="left">直播(前台 /live)</el-divider>
|
<el-divider content-position="left">直播(前台 /live)</el-divider>
|
||||||
@@ -157,7 +171,7 @@ const saving = ref(false)
|
|||||||
const downloading = ref(false)
|
const downloading = ref(false)
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const linkPickerVisible = ref(false)
|
const linkPickerVisible = ref(false)
|
||||||
/** @type {import('vue').Ref<{ type: 'nav' | 'download' | 'platform'; index?: number }>} */
|
/** @type {import('vue').Ref<{ type: 'nav' | 'download' | 'download_windows' | 'download_android' | 'platform'; index?: number }>} */
|
||||||
const linkPickTarget = ref({ type: 'download' })
|
const linkPickTarget = ref({ type: 'download' })
|
||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
@@ -285,6 +299,10 @@ function onLinkPicked(url) {
|
|||||||
form.nav_links[t.index].url = url
|
form.nav_links[t.index].url = url
|
||||||
} else if (t.type === 'download') {
|
} else if (t.type === 'download') {
|
||||||
form.download_url = url
|
form.download_url = url
|
||||||
|
} else if (t.type === 'download_windows') {
|
||||||
|
form.download_windows_url = url
|
||||||
|
} else if (t.type === 'download_android') {
|
||||||
|
form.download_android_url = url
|
||||||
} else if (t.type === 'platform' && typeof t.index === 'number') {
|
} else if (t.type === 'platform' && typeof t.index === 'number') {
|
||||||
form.platforms[t.index].url = url
|
form.platforms[t.index].url = url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,39 @@
|
|||||||
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
||||||
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="live-preview-row">
|
<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">
|
<div ref="previewWrapRef" class="preview-wrap">
|
||||||
<video
|
<video
|
||||||
v-show="previewLayout !== 'screen_pip'"
|
v-show="previewLayout !== 'screen_pip'"
|
||||||
@@ -197,7 +229,13 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getLiveModeration, setLiveMuteAll, setLiveMuteIP, setLiveMuteUser } from '../../api/admin'
|
import {
|
||||||
|
getLiveModeration,
|
||||||
|
getStats,
|
||||||
|
setLiveMuteAll,
|
||||||
|
setLiveMuteIP,
|
||||||
|
setLiveMuteUser
|
||||||
|
} from '../../api/admin'
|
||||||
import { startPublishing } from '../../utils/liveWebRTC'
|
import { startPublishing } from '../../utils/liveWebRTC'
|
||||||
|
|
||||||
function liveStatusUrl() {
|
function liveStatusUrl() {
|
||||||
@@ -229,6 +267,53 @@ const manualUsername = ref('')
|
|||||||
const mutedUsernames = ref([])
|
const mutedUsernames = ref([])
|
||||||
const moderationRate = ref({ window_ms: 3000, max_hits: 10 })
|
const moderationRate = ref({ window_ms: 3000, max_hits: 10 })
|
||||||
let moderationTimer = null
|
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'>} */
|
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
|
||||||
const previewLayout = ref('camera')
|
const previewLayout = ref('camera')
|
||||||
|
|
||||||
@@ -485,6 +570,8 @@ onMounted(() => {
|
|||||||
refreshVideoDevices()
|
refreshVideoDevices()
|
||||||
loadModeration()
|
loadModeration()
|
||||||
moderationTimer = window.setInterval(loadModeration, 5000)
|
moderationTimer = window.setInterval(loadModeration, 5000)
|
||||||
|
fetchBandwidth()
|
||||||
|
bwPollTimer = window.setInterval(fetchBandwidth, 8000)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(bitrateProfile, (v) => {
|
watch(bitrateProfile, (v) => {
|
||||||
@@ -499,6 +586,10 @@ onUnmounted(() => {
|
|||||||
clearInterval(moderationTimer)
|
clearInterval(moderationTimer)
|
||||||
moderationTimer = null
|
moderationTimer = null
|
||||||
}
|
}
|
||||||
|
if (bwPollTimer != null) {
|
||||||
|
clearInterval(bwPollTimer)
|
||||||
|
bwPollTimer = null
|
||||||
|
}
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
window.removeEventListener('pointermove', onPipPointerMove)
|
window.removeEventListener('pointermove', onPipPointerMove)
|
||||||
})
|
})
|
||||||
@@ -512,37 +603,107 @@ onBeforeRouteLeave(() => {
|
|||||||
.live-broadcast {
|
.live-broadcast {
|
||||||
max-width: min(1680px, 100%);
|
max-width: min(1680px, 100%);
|
||||||
}
|
}
|
||||||
/* 预览与观众管控同一行:画面在左、管控在右(勿在窄屏外误触发布局为上下) */
|
/* 右侧列:上为带宽卡片、下为观众管控;左侧为预览 */
|
||||||
.live-preview-row {
|
.live-preview-layout {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: 1fr clamp(280px, 34vw, 420px);
|
||||||
flex-wrap: nowrap;
|
grid-template-rows: auto auto;
|
||||||
align-items: flex-start;
|
gap: 12px 16px;
|
||||||
gap: 16px;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
width: 100%;
|
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 {
|
.live-moderation-aside {
|
||||||
flex: 0 0 auto;
|
grid-column: 2;
|
||||||
width: clamp(280px, 34vw, 420px);
|
grid-row: 2;
|
||||||
min-width: 260px;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
max-height: calc(100vh - 180px);
|
max-height: calc(100vh - 180px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
align-self: stretch;
|
align-self: start;
|
||||||
}
|
}
|
||||||
/* 仅小屏/手机再上下堆叠,避免 1200px 以下管理后台侧栏占宽导致误变单列 */
|
/* 仅小屏/手机再上下堆叠 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.live-preview-row {
|
.live-preview-layout {
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
flex-wrap: wrap;
|
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 {
|
.live-moderation-aside {
|
||||||
flex: 1 1 auto;
|
grid-column: 1;
|
||||||
width: 100%;
|
grid-row: 3;
|
||||||
min-width: 0;
|
|
||||||
max-height: none;
|
max-height: none;
|
||||||
position: static;
|
position: static;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
@@ -583,8 +744,8 @@ onBeforeRouteLeave(() => {
|
|||||||
}
|
}
|
||||||
.preview-wrap {
|
.preview-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
.preview-main {
|
.preview-main {
|
||||||
|
|||||||
Reference in New Issue
Block a user