feat(live): 按用户名禁言、后台右侧管控栏、前台抖音式布局与禁言 Toast

Made-with: Cursor
This commit is contained in:
whm
2026-03-27 09:11:39 +08:00
parent fe8d5a34cc
commit 435fbfd47e
10 changed files with 419 additions and 221 deletions

View File

@@ -1,6 +1,7 @@
<template>
<div class="live-broadcast">
<el-card>
<div class="live-broadcast live-broadcast--split">
<div class="live-broadcast-main">
<el-card>
<template #header>
<span>官网视频直播WebRTC</span>
</template>
@@ -100,65 +101,95 @@
></video>
</div>
</el-card>
</div>
<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>
<aside class="live-broadcast-side" 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>
</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>
<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>
</template>
@@ -167,7 +198,7 @@ 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 { getLiveModeration, setLiveMuteAll, setLiveMuteIP, setLiveMuteUser } from '../../api/admin'
import { startPublishing } from '../../utils/liveWebRTC'
function liveStatusUrl() {
@@ -195,6 +226,8 @@ 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<'camera'|'screen_only'|'screen_pip'>} */
@@ -277,6 +310,7 @@ async function loadModeration() {
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 || '加载发言管控失败')
@@ -325,6 +359,26 @@ async function setManualIPMute(enabled) {
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'
@@ -526,7 +580,30 @@ onBeforeRouteLeave(() => {
cursor: grabbing;
}
.moderation-card {
max-width: min(1200px, 100%);
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;