feat(live): 按用户名禁言、后台右侧管控栏、前台抖音式布局与禁言 Toast
Made-with: Cursor
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -58,6 +58,3 @@ web/promotion/视频发布/**/*.webp
|
|||||||
# PPT 解压临时目录与压缩包副本(仅保留 .pptx 源文件即可)
|
# PPT 解压临时目录与压缩包副本(仅保留 .pptx 源文件即可)
|
||||||
web/promotion/_pptx_extract/
|
web/promotion/_pptx_extract/
|
||||||
web/promotion/_pptx.zip
|
web/promotion/_pptx.zip
|
||||||
|
|
||||||
# yh_nginx 启动脚本生成的 conf(宿主机挂载目录,勿提交)
|
|
||||||
nginx/runtime-confd/default.conf
|
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
|
|||||||
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
||||||
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
||||||
|
|
||||||
// 直播发言管控(按 IP)
|
// 直播发言管控(全体 / IP / 用户名)
|
||||||
export const getLiveModeration = () => request.get('/admin/live/moderation')
|
export const getLiveModeration = () => request.get('/admin/live/moderation')
|
||||||
export const setLiveMuteAll = (enabled) => request.put('/admin/live/moderation/mute-all', { enabled })
|
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 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,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="live-broadcast">
|
<div class="live-broadcast live-broadcast--split">
|
||||||
|
<div class="live-broadcast-main">
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>官网视频直播(WebRTC)</span>
|
<span>官网视频直播(WebRTC)</span>
|
||||||
@@ -100,65 +101,95 @@
|
|||||||
></video>
|
></video>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-card class="moderation-card" style="margin-top: 16px">
|
<aside class="live-broadcast-side" aria-label="观众与发言管控">
|
||||||
|
<el-card class="moderation-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="moderation-head">
|
<div class="moderation-head">
|
||||||
<span>发言管控(按 IP)</span>
|
<span>观众与发言管控</span>
|
||||||
<el-button size="small" @click="loadModeration">刷新</el-button>
|
<el-button size="small" @click="loadModeration">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="moderation-actions">
|
<div class="moderation-actions moderation-actions--stack">
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="muteAll"
|
v-model="muteAll"
|
||||||
active-text="全体禁言"
|
active-text="全体禁言"
|
||||||
inactive-text="允许发言"
|
inactive-text="允许发言"
|
||||||
@change="toggleMuteAll"
|
@change="toggleMuteAll"
|
||||||
/>
|
/>
|
||||||
<el-input v-model.trim="manualIP" placeholder="手动输入 IP(如 1.2.3.4)" style="width: 260px" />
|
<div class="moderation-inline">
|
||||||
<el-button type="warning" plain @click="setManualIPMute(true)">禁言该IP</el-button>
|
<el-input v-model.trim="manualUsername" placeholder="按用户名禁言/解禁" style="width: 100%" />
|
||||||
<el-button @click="setManualIPMute(false)">解禁该IP</el-button>
|
<el-button type="warning" plain @click="setManualUserMute(true)">禁言用户</el-button>
|
||||||
|
<el-button @click="setManualUserMute(false)">解禁用户</el-button>
|
||||||
</div>
|
</div>
|
||||||
<p class="moderation-hint">
|
<div class="moderation-inline">
|
||||||
同 IP 在 {{ moderationRate.window_ms }}ms 内最多 {{ moderationRate.max_hits }} 次发送,超出会本地限频(优先保障实时)。
|
<el-input v-model.trim="manualIP" placeholder="按 IP 禁言/解禁" style="width: 100%" />
|
||||||
</p>
|
<el-button type="warning" plain @click="setManualIPMute(true)">禁言 IP</el-button>
|
||||||
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe style="width: 100%">
|
<el-button @click="setManualIPMute(false)">解禁 IP</el-button>
|
||||||
<el-table-column prop="ip" label="IP" min-width="180" />
|
</div>
|
||||||
<el-table-column prop="count" label="在线连接数" width="100" />
|
</div>
|
||||||
<el-table-column label="状态" width="100">
|
<p v-if="mutedUsernames.length" class="muted-names-line">
|
||||||
<template #default="{ row }">
|
已禁用户(归一化):
|
||||||
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
|
<el-tag v-for="u in mutedUsernames" :key="u" size="small" type="warning" class="muted-name-tag">
|
||||||
{{ row.muted ? '已禁言' : '正常' }}
|
{{ u }}
|
||||||
</el-tag>
|
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180">
|
<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 #default="{ row }">
|
||||||
<el-button v-if="!row.muted" link type="warning" @click="toggleIP(row.ip, true)">禁言</el-button>
|
<template v-if="row.username">
|
||||||
<el-button v-else link type="primary" @click="toggleIP(row.ip, false)">解禁</el-button>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<h4 class="moderation-subtitle">在线用户会话</h4>
|
<h4 class="moderation-subtitle">按 IP 聚合</h4>
|
||||||
<el-table v-loading="moderationLoading" :data="onlineUsers" stripe style="width: 100%">
|
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe size="small" class="moderation-table">
|
||||||
<el-table-column prop="channel" label="通道" width="96" />
|
<el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column prop="username" label="用户(脱敏)" min-width="120" />
|
<el-table-column prop="count" label="连接" width="64" />
|
||||||
<el-table-column prop="ip" label="IP" min-width="160" />
|
<el-table-column label="状态" width="72">
|
||||||
<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 }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
|
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
|
||||||
{{ row.muted ? '是' : '否' }}
|
{{ row.muted ? '禁' : '常' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -167,7 +198,7 @@ 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 } from '../../api/admin'
|
import { getLiveModeration, setLiveMuteAll, setLiveMuteIP, setLiveMuteUser } from '../../api/admin'
|
||||||
import { startPublishing } from '../../utils/liveWebRTC'
|
import { startPublishing } from '../../utils/liveWebRTC'
|
||||||
|
|
||||||
function liveStatusUrl() {
|
function liveStatusUrl() {
|
||||||
@@ -195,6 +226,8 @@ const muteAll = ref(false)
|
|||||||
const onlineIPs = ref([])
|
const onlineIPs = ref([])
|
||||||
const onlineUsers = ref([])
|
const onlineUsers = ref([])
|
||||||
const manualIP = ref('')
|
const manualIP = ref('')
|
||||||
|
const manualUsername = 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<'camera'|'screen_only'|'screen_pip'>} */
|
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
|
||||||
@@ -277,6 +310,7 @@ async function loadModeration() {
|
|||||||
muteAll.value = !!res.mute_all
|
muteAll.value = !!res.mute_all
|
||||||
onlineIPs.value = Array.isArray(res.online_ips) ? res.online_ips : []
|
onlineIPs.value = Array.isArray(res.online_ips) ? res.online_ips : []
|
||||||
onlineUsers.value = Array.isArray(res.online_users) ? res.online_users : []
|
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 }
|
moderationRate.value = res.rate_limit || { window_ms: 3000, max_hits: 10 }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(e?.response?.data?.error || '加载发言管控失败')
|
ElMessage.error(e?.response?.data?.error || '加载发言管控失败')
|
||||||
@@ -325,6 +359,26 @@ async function setManualIPMute(enabled) {
|
|||||||
await toggleIP(manualIP.value, 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) {
|
function applyPreview(payload) {
|
||||||
const { layout, main, screen, cam } = payload || {}
|
const { layout, main, screen, cam } = payload || {}
|
||||||
previewLayout.value = layout || 'camera'
|
previewLayout.value = layout || 'camera'
|
||||||
@@ -526,7 +580,30 @@ onBeforeRouteLeave(() => {
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
.moderation-card {
|
.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 {
|
.moderation-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 将 web/promotion/视频发布 导入到 data/uploads + MongoDB site_assets(无需后台手动上传)
|
# 将 web/promotion/视频发布 导入到 data/uploads + MongoDB site_assets(无需后台手动上传)
|
||||||
# 部署时若已在 server/.env 设置 YH_IMPORT_PROMOTION_SITE_ID,pull-and-restart/restart 会在 compose up 后自动执行等效逻辑(Docker 内 go run,见 run-promotion-import-on-deploy.sh)。
|
# 依赖:server/.env 中 MONGODB_URI、MONGODB_DB(与 API 一致);本机可连 Mongo
|
||||||
# 本脚本:在宿主机直接 go run,依赖 server/.env 中 MONGODB_URI 能连上 Mongo(若 Mongo 仅在 compose 内网,请用自动导入或 docker 网络)
|
|
||||||
#
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# ./scripts/import-promotion-to-api.sh -site=你的站点MongoID
|
# ./scripts/import-promotion-to-api.sh -site=你的站点MongoID
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ func main() {
|
|||||||
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
|
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
|
||||||
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
|
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
|
||||||
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
|
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
|
||||||
|
admin.PUT("/live/moderation/mute-user", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteUser)
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
|
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
|
||||||
|
|||||||
@@ -62,8 +62,10 @@ func handleDanmakuWS(c *gin.Context) {
|
|||||||
claims, tokenOK := handlers.ParseSiteClaims(c.Query("token"))
|
claims, tokenOK := handlers.ParseSiteClaims(c.Query("token"))
|
||||||
canSend := tokenOK
|
canSend := tokenOK
|
||||||
fromDisplay := "***"
|
fromDisplay := "***"
|
||||||
|
fullUsername := ""
|
||||||
if tokenOK && claims != nil {
|
if tokenOK && claims != nil {
|
||||||
fromDisplay = handlers.MaskSiteUsernameForDanmaku(claims.Username)
|
fromDisplay = handlers.MaskSiteUsernameForDanmaku(claims.Username)
|
||||||
|
fullUsername = strings.TrimSpace(claims.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
@@ -72,7 +74,7 @@ func handleDanmakuWS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
ws.SetReadLimit(4096)
|
ws.SetReadLimit(4096)
|
||||||
clientIP := c.ClientIP()
|
clientIP := c.ClientIP()
|
||||||
sessionID := RegisterOnlineSession("danmaku", clientIP, fromDisplay)
|
sessionID := RegisterOnlineSession("danmaku", clientIP, fullUsername)
|
||||||
|
|
||||||
danmakuClientsMu.Lock()
|
danmakuClientsMu.Lock()
|
||||||
danmakuClients[ws] = clientIP
|
danmakuClients[ws] = clientIP
|
||||||
@@ -95,11 +97,11 @@ func handleDanmakuWS(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
TouchOnlineSession(sessionID)
|
TouchOnlineSession(sessionID)
|
||||||
if IsMutedForIP(clientIP) {
|
if IsMutedForSend(clientIP, fullUsername) {
|
||||||
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"code": "muted",
|
"code": "muted",
|
||||||
"message": "当前已被禁言",
|
"message": "您已被禁言,暂时无法发弹幕或送礼物",
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package weblive
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +13,7 @@ var (
|
|||||||
modMu sync.RWMutex
|
modMu sync.RWMutex
|
||||||
muteAll bool
|
muteAll bool
|
||||||
mutedIP = make(map[string]bool)
|
mutedIP = make(map[string]bool)
|
||||||
|
mutedUsers = make(map[string]bool) // key: normMuteUsername
|
||||||
ipWindow = make(map[string][]int64) // 每个 IP 最近窗口内的发送时间戳(ms)
|
ipWindow = make(map[string][]int64) // 每个 IP 最近窗口内的发送时间戳(ms)
|
||||||
onlineMap = make(map[string]*onlineSession)
|
onlineMap = make(map[string]*onlineSession)
|
||||||
seq uint64
|
seq uint64
|
||||||
@@ -20,6 +22,7 @@ var (
|
|||||||
type ModerationSnapshot struct {
|
type ModerationSnapshot struct {
|
||||||
MuteAll bool `json:"mute_all"`
|
MuteAll bool `json:"mute_all"`
|
||||||
MutedIPs []string `json:"muted_ips"`
|
MutedIPs []string `json:"muted_ips"`
|
||||||
|
MutedUsernames []string `json:"muted_usernames"`
|
||||||
OnlineIPs []IPOnlineItem `json:"online_ips"`
|
OnlineIPs []IPOnlineItem `json:"online_ips"`
|
||||||
OnlineUsers []OnlineUserItem `json:"online_users"`
|
OnlineUsers []OnlineUserItem `json:"online_users"`
|
||||||
RateLimit struct {
|
RateLimit struct {
|
||||||
@@ -73,6 +76,25 @@ func SetIPMuted(ip string, enabled bool) {
|
|||||||
modMu.Unlock()
|
modMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normMuteUsername(u string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserMuted 按登录用户名禁言(弹幕/礼物);与 IP 禁言、全体禁言叠加。
|
||||||
|
func SetUserMuted(username string, enabled bool) {
|
||||||
|
key := normMuteUsername(username)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modMu.Lock()
|
||||||
|
if enabled {
|
||||||
|
mutedUsers[key] = true
|
||||||
|
} else {
|
||||||
|
delete(mutedUsers, key)
|
||||||
|
}
|
||||||
|
modMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ipSendWindowMs = 3000
|
ipSendWindowMs = 3000
|
||||||
ipSendMaxHits = 10
|
ipSendMaxHits = 10
|
||||||
@@ -93,6 +115,22 @@ func IsMutedForIP(ip string) bool {
|
|||||||
return mutedIP[ip]
|
return mutedIP[ip]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMutedForSend 发弹幕/礼物前:全体禁言、IP 禁言、或已登录用户名被禁。
|
||||||
|
func IsMutedForSend(ip, username string) bool {
|
||||||
|
modMu.RLock()
|
||||||
|
defer modMu.RUnlock()
|
||||||
|
if muteAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if mutedIP[ip] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if k := normMuteUsername(username); k != "" && mutedUsers[k] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func ModerationStateSnapshot() ModerationSnapshot {
|
func ModerationStateSnapshot() ModerationSnapshot {
|
||||||
modMu.RLock()
|
modMu.RLock()
|
||||||
muteAllNow := muteAll
|
muteAllNow := muteAll
|
||||||
@@ -100,8 +138,13 @@ func ModerationStateSnapshot() ModerationSnapshot {
|
|||||||
for ip := range mutedIP {
|
for ip := range mutedIP {
|
||||||
muted = append(muted, ip)
|
muted = append(muted, ip)
|
||||||
}
|
}
|
||||||
|
mutedNames := make([]string, 0, len(mutedUsers))
|
||||||
|
for u := range mutedUsers {
|
||||||
|
mutedNames = append(mutedNames, u)
|
||||||
|
}
|
||||||
modMu.RUnlock()
|
modMu.RUnlock()
|
||||||
sort.Strings(muted)
|
sort.Strings(muted)
|
||||||
|
sort.Strings(mutedNames)
|
||||||
|
|
||||||
counts := onlineIPCountsLocked()
|
counts := onlineIPCountsLocked()
|
||||||
online := make([]IPOnlineItem, 0, len(counts))
|
online := make([]IPOnlineItem, 0, len(counts))
|
||||||
@@ -215,7 +258,7 @@ func onlineUsersLocked() []OnlineUserItem {
|
|||||||
ConnectedAt: s.Connected.Format(time.RFC3339),
|
ConnectedAt: s.Connected.Format(time.RFC3339),
|
||||||
OnlineSec: int64(now.Sub(s.Connected).Seconds()),
|
OnlineSec: int64(now.Sub(s.Connected).Seconds()),
|
||||||
IdleSec: int64(now.Sub(s.LastAt).Seconds()),
|
IdleSec: int64(now.Sub(s.LastAt).Seconds()),
|
||||||
Muted: mutedIP[s.IP] || muteAll,
|
Muted: IsMutedForSend(s.IP, s.Username),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
|||||||
@@ -40,3 +40,21 @@ func PutLiveMuteIP(c *gin.Context) {
|
|||||||
SetIPMuted(ip, body.Enabled)
|
SetIPMuted(ip, body.Enabled)
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PutLiveMuteUser(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := strings.TrimSpace(body.Username)
|
||||||
|
if u == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SetUserMuted(u, body.Enabled)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ func handleViewerWS(c *gin.Context, h *Hub) {
|
|||||||
}
|
}
|
||||||
vid := uuid.New().String()
|
vid := uuid.New().String()
|
||||||
vs := &viewerSession{id: vid, ws: ws}
|
vs := &viewerSession{id: vid, ws: ws}
|
||||||
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "***")
|
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "")
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.viewers[vid] = vs
|
h.viewers[vid] = vs
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="live-watch-status">{{ watchStatus }}</p>
|
<p class="live-watch-status">{{ watchStatus }}</p>
|
||||||
<div ref="liveStageRef" class="live-stage">
|
<div ref="liveStageRef" class="live-stage">
|
||||||
|
<div class="live-stage-inner">
|
||||||
<div class="live-stage-media">
|
<div class="live-stage-media">
|
||||||
<div class="live-video-wrap">
|
<div class="live-video-wrap">
|
||||||
<video
|
<video
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="live-stage-footer">
|
<aside class="live-room-side" aria-label="互动">
|
||||||
<div class="live-dm-auth-row">
|
<div class="live-dm-auth-row">
|
||||||
<template v-if="dmLoggedIn">
|
<template v-if="dmLoggedIn">
|
||||||
<span class="live-dm-user">已登录:{{ dmDisplayName }}</span>
|
<span class="live-dm-user">已登录:{{ dmDisplayName }}</span>
|
||||||
@@ -128,22 +129,10 @@
|
|||||||
<button type="button" class="live-dm-send" :disabled="!dmLoggedIn" @click="sendDm">发送</button>
|
<button type="button" class="live-dm-send" :disabled="!dmLoggedIn" @click="sendDm">发送</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="dmHint" class="live-dm-hint">{{ dmHint }}</p>
|
<p v-if="dmHint" class="live-dm-hint">{{ dmHint }}</p>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div v-if="dmToast" class="live-dm-toast" role="status">{{ dmToast }}</div>
|
||||||
|
|
||||||
<section class="live-block live-block--divider" aria-label="外链直播间">
|
|
||||||
<h2 class="live-block-title">外部直播间</h2>
|
|
||||||
<div class="live-room-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="live-room-btn live-room-btn--primary"
|
|
||||||
:disabled="!enterUrl"
|
|
||||||
@click="goLiveRoom"
|
|
||||||
>
|
|
||||||
进入外部直播间
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +152,6 @@ const stageFullscreen = ref(false)
|
|||||||
/** 与 video.volume / muted 同步(浏览器自动播放可能先静音) */
|
/** 与 video.volume / muted 同步(浏览器自动播放可能先静音) */
|
||||||
const volumePercent = ref(100)
|
const volumePercent = ref(100)
|
||||||
const displayMuted = ref(false)
|
const displayMuted = ref(false)
|
||||||
const rawLiveUrl = ref('')
|
|
||||||
const pageTitle = ref('视频直播')
|
const pageTitle = ref('视频直播')
|
||||||
const watchStatus = ref('正在检测本站直播…')
|
const watchStatus = ref('正在检测本站直播…')
|
||||||
const qualityOptions = LIVE_QUALITY_OPTIONS
|
const qualityOptions = LIVE_QUALITY_OPTIONS
|
||||||
@@ -190,7 +178,8 @@ const dmDisplayName = computed(() => {
|
|||||||
return getSiteDmUsername() || '用户'
|
return getSiteDmUsername() || '用户'
|
||||||
})
|
})
|
||||||
|
|
||||||
const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
|
const dmToast = ref('')
|
||||||
|
let dmToastTimer = null
|
||||||
|
|
||||||
let viewSession = null
|
let viewSession = null
|
||||||
|
|
||||||
@@ -259,14 +248,14 @@ watch(captureQualityPref, (v) => {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
function normalizeOutboundUrl(u) {
|
function showDmToast(msg) {
|
||||||
const s = (u || '').trim()
|
const text = typeof msg === 'string' && msg.trim() ? msg.trim() : '提示'
|
||||||
if (!s) return ''
|
dmToast.value = text
|
||||||
if (/^https?:\/\//i.test(s)) return s
|
if (dmToastTimer) clearTimeout(dmToastTimer)
|
||||||
if (s.startsWith('/') && !s.startsWith('//')) {
|
dmToastTimer = window.setTimeout(() => {
|
||||||
return `${window.location.origin}${s}`
|
dmToast.value = ''
|
||||||
}
|
dmToastTimer = null
|
||||||
return `https://${s}`
|
}, 4200)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHomepage() {
|
async function loadHomepage() {
|
||||||
@@ -275,7 +264,6 @@ async function loadHomepage() {
|
|||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (typeof json.live_room_url === 'string') rawLiveUrl.value = json.live_room_url
|
|
||||||
if (typeof json.live_room_title === 'string' && json.live_room_title.trim()) {
|
if (typeof json.live_room_title === 'string' && json.live_room_title.trim()) {
|
||||||
pageTitle.value = json.live_room_title.trim()
|
pageTitle.value = json.live_room_title.trim()
|
||||||
}
|
}
|
||||||
@@ -283,12 +271,6 @@ async function loadHomepage() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goLiveRoom() {
|
|
||||||
const target = normalizeOutboundUrl(enterUrl.value)
|
|
||||||
if (!target) return
|
|
||||||
window.location.href = target
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncVolumeUIFromVideo() {
|
function syncVolumeUIFromVideo() {
|
||||||
const v = watchVideoRef.value
|
const v = watchVideoRef.value
|
||||||
if (!v) return
|
if (!v) return
|
||||||
@@ -333,7 +315,7 @@ function syncStageFullscreenFlag() {
|
|||||||
d.fullscreenElement === el || d.webkitFullscreenElement === el
|
d.fullscreenElement === el || d.webkitFullscreenElement === el
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 整页舞台全屏:含画面 + 底部登录与发弹幕(非仅 video,避免全屏后看不到输入框) */
|
/** 全屏含右侧互动栏,避免全屏后无法发弹幕 */
|
||||||
function toggleStageFullscreen() {
|
function toggleStageFullscreen() {
|
||||||
const stage = liveStageRef.value
|
const stage = liveStageRef.value
|
||||||
const v = watchVideoRef.value
|
const v = watchVideoRef.value
|
||||||
@@ -536,6 +518,10 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('fullscreenchange', syncStageFullscreenFlag)
|
document.removeEventListener('fullscreenchange', syncStageFullscreenFlag)
|
||||||
document.removeEventListener('webkitfullscreenchange', syncStageFullscreenFlag)
|
document.removeEventListener('webkitfullscreenchange', syncStageFullscreenFlag)
|
||||||
document.removeEventListener('visibilitychange', onPageVisibilityPlay)
|
document.removeEventListener('visibilitychange', onPageVisibilityPlay)
|
||||||
|
if (dmToastTimer) {
|
||||||
|
clearTimeout(dmToastTimer)
|
||||||
|
dmToastTimer = null
|
||||||
|
}
|
||||||
dmIntentionalClose = true
|
dmIntentionalClose = true
|
||||||
dmSendQueue.length = 0
|
dmSendQueue.length = 0
|
||||||
if (dmReconnectTimer) {
|
if (dmReconnectTimer) {
|
||||||
@@ -576,7 +562,7 @@ onUnmounted(() => {
|
|||||||
color: #00d4ff;
|
color: #00d4ff;
|
||||||
}
|
}
|
||||||
.live-room-main {
|
.live-room-main {
|
||||||
max-width: min(960px, 100%);
|
max-width: min(1180px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 12px 32px;
|
padding: 0 12px 32px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -639,9 +625,69 @@ onUnmounted(() => {
|
|||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
}
|
}
|
||||||
.live-stage {
|
.live-stage {
|
||||||
max-width: min(920px, 100%);
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
.live-stage-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.live-stage-media {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.live-room-side {
|
||||||
|
width: min(300px, 32vw);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 12px 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.38);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.live-room-side .live-dm-auth-row,
|
||||||
|
.live-room-side .live-gift-row,
|
||||||
|
.live-room-side .live-dm-bar,
|
||||||
|
.live-room-side .live-dm-hint {
|
||||||
|
max-width: none;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.live-room-side .live-gift-hint-muted {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.live-stage-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.live-room-side {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.live-dm-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: max(24px, env(safe-area-inset-bottom, 0px));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
max-width: min(92vw, 420px);
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(180, 40, 40, 0.92);
|
||||||
|
border: 1px solid rgba(255, 200, 200, 0.35);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.live-video-wrap {
|
.live-video-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -664,13 +710,31 @@ onUnmounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.live-stage:fullscreen .live-stage-media,
|
.live-stage:fullscreen .live-stage-inner,
|
||||||
.live-stage:-webkit-full-screen .live-stage-media {
|
.live-stage:-webkit-full-screen .live-stage-inner {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.live-stage:fullscreen .live-stage-media,
|
||||||
|
.live-stage:-webkit-full-screen .live-stage-media {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.live-stage:fullscreen .live-room-side,
|
||||||
|
.live-stage:-webkit-full-screen .live-room-side {
|
||||||
|
width: min(280px, 36vw);
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
.live-stage:fullscreen .live-video-wrap,
|
.live-stage:fullscreen .live-video-wrap,
|
||||||
.live-stage:-webkit-full-screen .live-video-wrap {
|
.live-stage:-webkit-full-screen .live-video-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -696,11 +760,6 @@ onUnmounted(() => {
|
|||||||
aspect-ratio: unset;
|
aspect-ratio: unset;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.live-stage:fullscreen .live-stage-footer,
|
|
||||||
.live-stage:-webkit-full-screen .live-stage-footer {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding-top: 6px;
|
|
||||||
}
|
|
||||||
.live-stage:fullscreen .live-dm-auth-row,
|
.live-stage:fullscreen .live-dm-auth-row,
|
||||||
.live-stage:fullscreen .live-gift-row,
|
.live-stage:fullscreen .live-gift-row,
|
||||||
.live-stage:fullscreen .live-dm-bar,
|
.live-stage:fullscreen .live-dm-bar,
|
||||||
|
|||||||
Reference in New Issue
Block a user