feat(live): 按用户名禁言、后台右侧管控栏、前台抖音式布局与禁言 Toast
Made-with: Cursor
This commit is contained in:
@@ -84,7 +84,9 @@ 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)
|
||||
// 直播发言管控(全体 / 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 })
|
||||
export const setLiveMuteUser = (username, enabled) =>
|
||||
request.put('/admin/live/moderation/mute-user', { username, enabled })
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user