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

3
.gitignore vendored
View File

@@ -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

View File

@@ -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 })

View File

@@ -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;

View File

@@ -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_IDpull-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

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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})
}

View File

@@ -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

View File

@@ -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,