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 源文件即可)
|
||||
web/promotion/_pptx_extract/
|
||||
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 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,5 +1,6 @@
|
||||
<template>
|
||||
<div class="live-broadcast">
|
||||
<div class="live-broadcast live-broadcast--split">
|
||||
<div class="live-broadcast-main">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>官网视频直播(WebRTC)</span>
|
||||
@@ -100,65 +101,95 @@
|
||||
></video>
|
||||
</div>
|
||||
</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>
|
||||
<div class="moderation-head">
|
||||
<span>发言管控(按 IP)</span>
|
||||
<span>观众与发言管控</span>
|
||||
<el-button size="small" @click="loadModeration">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="moderation-actions">
|
||||
<div class="moderation-actions moderation-actions--stack">
|
||||
<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 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>
|
||||
<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 ? '已禁言' : '正常' }}
|
||||
<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>
|
||||
<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 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 }">
|
||||
<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 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">在线用户会话</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">
|
||||
<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 ? '是' : '否' }}
|
||||
{{ 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;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将 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)。
|
||||
# 本脚本:在宿主机直接 go run,依赖 server/.env 中 MONGODB_URI 能连上 Mongo(若 Mongo 仅在 compose 内网,请用自动导入或 docker 网络)
|
||||
# 依赖:server/.env 中 MONGODB_URI、MONGODB_DB(与 API 一致);本机可连 Mongo
|
||||
#
|
||||
# 用法:
|
||||
# ./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.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-user", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteUser)
|
||||
|
||||
// 用户管理
|
||||
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"))
|
||||
canSend := tokenOK
|
||||
fromDisplay := "***"
|
||||
fullUsername := ""
|
||||
if tokenOK && claims != nil {
|
||||
fromDisplay = handlers.MaskSiteUsernameForDanmaku(claims.Username)
|
||||
fullUsername = strings.TrimSpace(claims.Username)
|
||||
}
|
||||
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
@@ -72,7 +74,7 @@ func handleDanmakuWS(c *gin.Context) {
|
||||
}
|
||||
ws.SetReadLimit(4096)
|
||||
clientIP := c.ClientIP()
|
||||
sessionID := RegisterOnlineSession("danmaku", clientIP, fromDisplay)
|
||||
sessionID := RegisterOnlineSession("danmaku", clientIP, fullUsername)
|
||||
|
||||
danmakuClientsMu.Lock()
|
||||
danmakuClients[ws] = clientIP
|
||||
@@ -95,11 +97,11 @@ func handleDanmakuWS(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
TouchOnlineSession(sessionID)
|
||||
if IsMutedForIP(clientIP) {
|
||||
if IsMutedForSend(clientIP, fullUsername) {
|
||||
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
||||
"type": "error",
|
||||
"code": "muted",
|
||||
"message": "当前已被禁言",
|
||||
"message": "您已被禁言,暂时无法发弹幕或送礼物",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package weblive
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -12,6 +13,7 @@ var (
|
||||
modMu sync.RWMutex
|
||||
muteAll bool
|
||||
mutedIP = make(map[string]bool)
|
||||
mutedUsers = make(map[string]bool) // key: normMuteUsername
|
||||
ipWindow = make(map[string][]int64) // 每个 IP 最近窗口内的发送时间戳(ms)
|
||||
onlineMap = make(map[string]*onlineSession)
|
||||
seq uint64
|
||||
@@ -20,6 +22,7 @@ var (
|
||||
type ModerationSnapshot struct {
|
||||
MuteAll bool `json:"mute_all"`
|
||||
MutedIPs []string `json:"muted_ips"`
|
||||
MutedUsernames []string `json:"muted_usernames"`
|
||||
OnlineIPs []IPOnlineItem `json:"online_ips"`
|
||||
OnlineUsers []OnlineUserItem `json:"online_users"`
|
||||
RateLimit struct {
|
||||
@@ -73,6 +76,25 @@ func SetIPMuted(ip string, enabled bool) {
|
||||
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 (
|
||||
ipSendWindowMs = 3000
|
||||
ipSendMaxHits = 10
|
||||
@@ -93,6 +115,22 @@ func IsMutedForIP(ip string) bool {
|
||||
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 {
|
||||
modMu.RLock()
|
||||
muteAllNow := muteAll
|
||||
@@ -100,8 +138,13 @@ func ModerationStateSnapshot() ModerationSnapshot {
|
||||
for ip := range mutedIP {
|
||||
muted = append(muted, ip)
|
||||
}
|
||||
mutedNames := make([]string, 0, len(mutedUsers))
|
||||
for u := range mutedUsers {
|
||||
mutedNames = append(mutedNames, u)
|
||||
}
|
||||
modMu.RUnlock()
|
||||
sort.Strings(muted)
|
||||
sort.Strings(mutedNames)
|
||||
|
||||
counts := onlineIPCountsLocked()
|
||||
online := make([]IPOnlineItem, 0, len(counts))
|
||||
@@ -215,7 +258,7 @@ func onlineUsersLocked() []OnlineUserItem {
|
||||
ConnectedAt: s.Connected.Format(time.RFC3339),
|
||||
OnlineSec: int64(now.Sub(s.Connected).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 {
|
||||
|
||||
@@ -40,3 +40,21 @@ func PutLiveMuteIP(c *gin.Context) {
|
||||
SetIPMuted(ip, body.Enabled)
|
||||
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()
|
||||
vs := &viewerSession{id: vid, ws: ws}
|
||||
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "***")
|
||||
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "")
|
||||
|
||||
h.mu.Lock()
|
||||
h.viewers[vid] = vs
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
</div>
|
||||
<p class="live-watch-status">{{ watchStatus }}</p>
|
||||
<div ref="liveStageRef" class="live-stage">
|
||||
<div class="live-stage-inner">
|
||||
<div class="live-stage-media">
|
||||
<div class="live-video-wrap">
|
||||
<video
|
||||
@@ -82,7 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-stage-footer">
|
||||
<aside class="live-room-side" aria-label="互动">
|
||||
<div class="live-dm-auth-row">
|
||||
<template v-if="dmLoggedIn">
|
||||
<span class="live-dm-user">已登录:{{ dmDisplayName }}</span>
|
||||
@@ -128,22 +129,10 @@
|
||||
<button type="button" class="live-dm-send" :disabled="!dmLoggedIn" @click="sendDm">发送</button>
|
||||
</div>
|
||||
<p v-if="dmHint" class="live-dm-hint">{{ dmHint }}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<div v-if="dmToast" class="live-dm-toast" role="status">{{ dmToast }}</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
@@ -163,7 +152,6 @@ const stageFullscreen = ref(false)
|
||||
/** 与 video.volume / muted 同步(浏览器自动播放可能先静音) */
|
||||
const volumePercent = ref(100)
|
||||
const displayMuted = ref(false)
|
||||
const rawLiveUrl = ref('')
|
||||
const pageTitle = ref('视频直播')
|
||||
const watchStatus = ref('正在检测本站直播…')
|
||||
const qualityOptions = LIVE_QUALITY_OPTIONS
|
||||
@@ -190,7 +178,8 @@ const dmDisplayName = computed(() => {
|
||||
return getSiteDmUsername() || '用户'
|
||||
})
|
||||
|
||||
const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
|
||||
const dmToast = ref('')
|
||||
let dmToastTimer = null
|
||||
|
||||
let viewSession = null
|
||||
|
||||
@@ -259,14 +248,14 @@ watch(captureQualityPref, (v) => {
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
function normalizeOutboundUrl(u) {
|
||||
const s = (u || '').trim()
|
||||
if (!s) return ''
|
||||
if (/^https?:\/\//i.test(s)) return s
|
||||
if (s.startsWith('/') && !s.startsWith('//')) {
|
||||
return `${window.location.origin}${s}`
|
||||
}
|
||||
return `https://${s}`
|
||||
function showDmToast(msg) {
|
||||
const text = typeof msg === 'string' && msg.trim() ? msg.trim() : '提示'
|
||||
dmToast.value = text
|
||||
if (dmToastTimer) clearTimeout(dmToastTimer)
|
||||
dmToastTimer = window.setTimeout(() => {
|
||||
dmToast.value = ''
|
||||
dmToastTimer = null
|
||||
}, 4200)
|
||||
}
|
||||
|
||||
async function loadHomepage() {
|
||||
@@ -275,7 +264,6 @@ async function loadHomepage() {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return
|
||||
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()) {
|
||||
pageTitle.value = json.live_room_title.trim()
|
||||
}
|
||||
@@ -283,12 +271,6 @@ async function loadHomepage() {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function goLiveRoom() {
|
||||
const target = normalizeOutboundUrl(enterUrl.value)
|
||||
if (!target) return
|
||||
window.location.href = target
|
||||
}
|
||||
|
||||
function syncVolumeUIFromVideo() {
|
||||
const v = watchVideoRef.value
|
||||
if (!v) return
|
||||
@@ -333,7 +315,7 @@ function syncStageFullscreenFlag() {
|
||||
d.fullscreenElement === el || d.webkitFullscreenElement === el
|
||||
}
|
||||
|
||||
/** 整页舞台全屏:含画面 + 底部登录与发弹幕(非仅 video,避免全屏后看不到输入框) */
|
||||
/** 全屏含右侧互动栏,避免全屏后无法发弹幕 */
|
||||
function toggleStageFullscreen() {
|
||||
const stage = liveStageRef.value
|
||||
const v = watchVideoRef.value
|
||||
@@ -536,6 +518,10 @@ onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', syncStageFullscreenFlag)
|
||||
document.removeEventListener('webkitfullscreenchange', syncStageFullscreenFlag)
|
||||
document.removeEventListener('visibilitychange', onPageVisibilityPlay)
|
||||
if (dmToastTimer) {
|
||||
clearTimeout(dmToastTimer)
|
||||
dmToastTimer = null
|
||||
}
|
||||
dmIntentionalClose = true
|
||||
dmSendQueue.length = 0
|
||||
if (dmReconnectTimer) {
|
||||
@@ -576,7 +562,7 @@ onUnmounted(() => {
|
||||
color: #00d4ff;
|
||||
}
|
||||
.live-room-main {
|
||||
max-width: min(960px, 100%);
|
||||
max-width: min(1180px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 0 12px 32px;
|
||||
box-sizing: border-box;
|
||||
@@ -639,9 +625,69 @@ onUnmounted(() => {
|
||||
min-height: 1.4em;
|
||||
}
|
||||
.live-stage {
|
||||
max-width: min(920px, 100%);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -664,13 +710,31 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.live-stage:fullscreen .live-stage-media,
|
||||
.live-stage:-webkit-full-screen .live-stage-media {
|
||||
.live-stage:fullscreen .live-stage-inner,
|
||||
.live-stage:-webkit-full-screen .live-stage-inner {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
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;
|
||||
}
|
||||
.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:-webkit-full-screen .live-video-wrap {
|
||||
flex: 1;
|
||||
@@ -696,11 +760,6 @@ onUnmounted(() => {
|
||||
aspect-ratio: unset;
|
||||
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-gift-row,
|
||||
.live-stage:fullscreen .live-dm-bar,
|
||||
|
||||
Reference in New Issue
Block a user