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 源文件即可)
web/promotion/_pptx_extract/
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 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 })

View File

@@ -1,6 +1,7 @@
<template>
<div class="live-broadcast">
<el-card>
<div class="live-broadcast live-broadcast--split">
<div class="live-broadcast-main">
<el-card>
<template #header>
<span>官网视频直播WebRTC</span>
</template>
@@ -100,65 +101,95 @@
></video>
</div>
</el-card>
</div>
<el-card class="moderation-card" style="margin-top: 16px">
<template #header>
<div class="moderation-head">
<span>发言管控 IP</span>
<el-button size="small" @click="loadModeration">刷新</el-button>
<aside class="live-broadcast-side" aria-label="观众与发言管控">
<el-card class="moderation-card" shadow="never">
<template #header>
<div class="moderation-head">
<span>观众与发言管控</span>
<el-button size="small" @click="loadModeration">刷新</el-button>
</div>
</template>
<div class="moderation-actions moderation-actions--stack">
<el-switch
v-model="muteAll"
active-text="全体禁言"
inactive-text="允许发言"
@change="toggleMuteAll"
/>
<div class="moderation-inline">
<el-input v-model.trim="manualUsername" placeholder="按用户名禁言/解禁" style="width: 100%" />
<el-button type="warning" plain @click="setManualUserMute(true)">禁言用户</el-button>
<el-button @click="setManualUserMute(false)">解禁用户</el-button>
</div>
<div class="moderation-inline">
<el-input v-model.trim="manualIP" placeholder="按 IP 禁言/解禁" style="width: 100%" />
<el-button type="warning" plain @click="setManualIPMute(true)">禁言 IP</el-button>
<el-button @click="setManualIPMute(false)">解禁 IP</el-button>
</div>
</div>
</template>
<div class="moderation-actions">
<el-switch
v-model="muteAll"
active-text="全体禁言"
inactive-text="允许发言"
@change="toggleMuteAll"
/>
<el-input v-model.trim="manualIP" placeholder="手动输入 IP如 1.2.3.4" style="width: 260px" />
<el-button type="warning" plain @click="setManualIPMute(true)">禁言该IP</el-button>
<el-button @click="setManualIPMute(false)">解禁该IP</el-button>
</div>
<p class="moderation-hint">
IP {{ moderationRate.window_ms }}ms 内最多 {{ moderationRate.max_hits }} 次发送超出会本地限频优先保障实时
</p>
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe style="width: 100%">
<el-table-column prop="ip" label="IP" min-width="180" />
<el-table-column prop="count" label="在线连接数" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '已禁言' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button v-if="!row.muted" link type="warning" @click="toggleIP(row.ip, true)">禁言</el-button>
<el-button v-else link type="primary" @click="toggleIP(row.ip, false)">解禁</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="moderation-subtitle">在线用户会话</h4>
<el-table v-loading="moderationLoading" :data="onlineUsers" stripe style="width: 100%">
<el-table-column prop="channel" label="通道" width="96" />
<el-table-column prop="username" label="用户(脱敏)" min-width="120" />
<el-table-column prop="ip" label="IP" min-width="160" />
<el-table-column label="在线时长" width="110">
<template #default="{ row }">{{ formatSec(row.online_sec) }}</template>
</el-table-column>
<el-table-column label="空闲时长" width="110">
<template #default="{ row }">{{ formatSec(row.idle_sec) }}</template>
</el-table-column>
<el-table-column label="禁言" width="80">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '' : '' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<p v-if="mutedUsernames.length" class="muted-names-line">
已禁用户归一化
<el-tag v-for="u in mutedUsernames" :key="u" size="small" type="warning" class="muted-name-tag">
{{ u }}
</el-tag>
</p>
<p class="moderation-hint">
弹幕侧登记<strong>完整用户名</strong>便于对号入座未登录弹幕连接为游客 IP
{{ moderationRate.window_ms }}ms 内最多 {{ moderationRate.max_hits }} 次发送会限频
</p>
<h4 class="moderation-subtitle">在线会话</h4>
<el-table v-loading="moderationLoading" :data="onlineUsers" stripe size="small" class="moderation-table">
<el-table-column label="用户名" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
{{ formatSessionUsername(row) }}
</template>
</el-table-column>
<el-table-column prop="channel" label="通道" width="72" />
<el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
<el-table-column label="在线" width="72">
<template #default="{ row }">{{ formatSec(row.online_sec) }}</template>
</el-table-column>
<el-table-column label="操作" width="168" fixed="right">
<template #default="{ row }">
<template v-if="row.username">
<el-button link type="warning" size="small" @click="toggleUserMute(row.username, true)">
禁言
</el-button>
<el-button link type="primary" size="small" @click="toggleUserMute(row.username, false)">
解禁
</el-button>
</template>
<el-button v-if="!ipMuted(row.ip)" link type="warning" size="small" @click="toggleIP(row.ip, true)">
禁IP
</el-button>
<el-button v-else link type="primary" size="small" @click="toggleIP(row.ip, false)">解IP</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="moderation-subtitle"> IP 聚合</h4>
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe size="small" class="moderation-table">
<el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
<el-table-column prop="count" label="连接" width="64" />
<el-table-column label="状态" width="72">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '' : '' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.muted" link type="warning" size="small" @click="toggleIP(row.ip, true)">
禁言
</el-button>
<el-button v-else link type="primary" size="small" @click="toggleIP(row.ip, false)">解禁</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</aside>
</div>
</template>
@@ -167,7 +198,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { ElMessage } from 'element-plus'
import { getLiveModeration, setLiveMuteAll, setLiveMuteIP } from '../../api/admin'
import { getLiveModeration, setLiveMuteAll, setLiveMuteIP, setLiveMuteUser } from '../../api/admin'
import { startPublishing } from '../../utils/liveWebRTC'
function liveStatusUrl() {
@@ -195,6 +226,8 @@ const muteAll = ref(false)
const onlineIPs = ref([])
const onlineUsers = ref([])
const manualIP = ref('')
const manualUsername = ref('')
const mutedUsernames = ref([])
const moderationRate = ref({ window_ms: 3000, max_hits: 10 })
let moderationTimer = null
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
@@ -277,6 +310,7 @@ async function loadModeration() {
muteAll.value = !!res.mute_all
onlineIPs.value = Array.isArray(res.online_ips) ? res.online_ips : []
onlineUsers.value = Array.isArray(res.online_users) ? res.online_users : []
mutedUsernames.value = Array.isArray(res.muted_usernames) ? res.muted_usernames : []
moderationRate.value = res.rate_limit || { window_ms: 3000, max_hits: 10 }
} catch (e) {
ElMessage.error(e?.response?.data?.error || '加载发言管控失败')
@@ -325,6 +359,26 @@ async function setManualIPMute(enabled) {
await toggleIP(manualIP.value, enabled)
}
async function toggleUserMute(username, enabled) {
const u = (username || '').trim()
if (!u) return
try {
await setLiveMuteUser(u, enabled)
ElMessage.success(enabled ? `已禁言用户 ${u}` : `已解禁用户 ${u}`)
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
}
}
async function setManualUserMute(enabled) {
if (!manualUsername.value.trim()) {
ElMessage.warning('请先输入用户名')
return
}
await toggleUserMute(manualUsername.value.trim(), enabled)
}
function applyPreview(payload) {
const { layout, main, screen, cam } = payload || {}
previewLayout.value = layout || 'camera'
@@ -526,7 +580,30 @@ onBeforeRouteLeave(() => {
cursor: grabbing;
}
.moderation-card {
max-width: min(1200px, 100%);
max-width: 100%;
}
.moderation-actions--stack {
flex-direction: column;
align-items: stretch;
}
.moderation-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.muted-names-line {
margin: 0 0 8px;
font-size: 12px;
color: #606266;
line-height: 1.6;
}
.muted-name-tag {
margin: 2px 4px 2px 0;
}
.moderation-table {
width: 100%;
margin-bottom: 8px;
}
.moderation-head {
display: flex;

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env bash
# 将 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
# 本脚本:在宿主机直接 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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package weblive
import (
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -10,18 +11,20 @@ import (
var (
modMu sync.RWMutex
muteAll bool
mutedIP = make(map[string]bool)
ipWindow = make(map[string][]int64) // 每个 IP 最近窗口内的发送时间戳(ms)
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
)
type ModerationSnapshot struct {
MuteAll bool `json:"mute_all"`
MutedIPs []string `json:"muted_ips"`
OnlineIPs []IPOnlineItem `json:"online_ips"`
OnlineUsers []OnlineUserItem `json:"online_users"`
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 {
WindowMs int `json:"window_ms"`
MaxHits int `json:"max_hits"`
@@ -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 {

View File

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

View File

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

View File

@@ -20,130 +20,119 @@
</div>
<p class="live-watch-status">{{ watchStatus }}</p>
<div ref="liveStageRef" class="live-stage">
<div class="live-stage-media">
<div class="live-video-wrap">
<video
ref="watchVideoRef"
class="live-room-video live-room-video--watch live-room-video--fill"
playsinline
autoplay
@volumechange="syncVolumeUIFromVideo"
@loadeddata="onVideoMediaNudge"
@canplay="onVideoMediaNudge"
></video>
<div class="live-dm-layer" aria-hidden="true">
<div
v-for="d in dmItems"
:key="d.id"
class="live-dm-line"
:style="{ top: d.top + '%' }"
>
<span class="live-dm-from">{{ d.from }}</span>{{ d.text }}
<div class="live-stage-inner">
<div class="live-stage-media">
<div class="live-video-wrap">
<video
ref="watchVideoRef"
class="live-room-video live-room-video--watch live-room-video--fill"
playsinline
autoplay
@volumechange="syncVolumeUIFromVideo"
@loadeddata="onVideoMediaNudge"
@canplay="onVideoMediaNudge"
></video>
<div class="live-dm-layer" aria-hidden="true">
<div
v-for="d in dmItems"
:key="d.id"
class="live-dm-line"
:style="{ top: d.top + '%' }"
>
<span class="live-dm-from">{{ d.from }}</span>{{ d.text }}
</div>
</div>
<div class="live-gift-layer" aria-hidden="true">
<div
v-for="g in giftFxList"
:key="g.id"
class="live-gift-burst"
:class="[
'live-gift-burst--' + g.gift,
g.tier === 'big' ? 'live-gift-burst--mega' : ''
]"
:style="{ '--gift-x': g.offsetX + 'px' }"
>
<span class="live-gift-emoji">{{ g.emoji }}</span>
<span class="live-gift-from">{{ g.from }} 送出</span>
</div>
</div>
<div class="live-video-overlay" role="toolbar" aria-label="播放与音量">
<div class="live-video-overlay-inner">
<button type="button" class="live-overlay-btn" @click="toggleMute">
{{ displayMuted ? '开声音' : '静音' }}
</button>
<label class="live-vol-wrap">
<span class="live-vol-label">音量</span>
<input
class="live-vol-range"
type="range"
min="0"
max="100"
step="1"
:value="volumePercent"
aria-label="音量"
@input="onVolumeRangeInput"
/>
<span class="live-vol-value" aria-hidden="true">{{ volumePercent }}%</span>
</label>
<button type="button" class="live-overlay-btn" @click="toggleStageFullscreen">
{{ stageFullscreen ? '退出全屏' : '全屏' }}
</button>
</div>
</div>
</div>
<div class="live-gift-layer" aria-hidden="true">
<div
v-for="g in giftFxList"
:key="g.id"
class="live-gift-burst"
:class="[
'live-gift-burst--' + g.gift,
g.tier === 'big' ? 'live-gift-burst--mega' : ''
]"
:style="{ '--gift-x': g.offsetX + 'px' }"
>
<span class="live-gift-emoji">{{ g.emoji }}</span>
<span class="live-gift-from">{{ g.from }} 送出</span>
</div>
</div>
<aside class="live-room-side" aria-label="互动">
<div class="live-dm-auth-row">
<template v-if="dmLoggedIn">
<span class="live-dm-user">已登录{{ dmDisplayName }}</span>
<button type="button" class="live-dm-auth-btn" @click="logoutDmUser">退出</button>
</template>
<template v-else>
<router-link class="live-dm-auth-link" to="/live/login">登录发弹幕</router-link>
<span class="live-dm-auth-sep">·</span>
<router-link class="live-dm-auth-link" to="/live/register">注册</router-link>
</template>
</div>
<div class="live-video-overlay" role="toolbar" aria-label="播放与音量">
<div class="live-video-overlay-inner">
<button type="button" class="live-overlay-btn" @click="toggleMute">
{{ displayMuted ? '开声音' : '静音' }}
</button>
<label class="live-vol-wrap">
<span class="live-vol-label">音量</span>
<input
class="live-vol-range"
type="range"
min="0"
max="100"
step="1"
:value="volumePercent"
aria-label="音量"
@input="onVolumeRangeInput"
/>
<span class="live-vol-value" aria-hidden="true">{{ volumePercent }}%</span>
</label>
<button type="button" class="live-overlay-btn" @click="toggleStageFullscreen">
{{ stageFullscreen ? '退出全屏' : '全屏' }}
<div v-if="dmLoggedIn" class="live-gift-row live-gift-row--scroll" aria-label="礼物">
<span class="live-gift-hint">送礼</span>
<div class="live-gift-strip">
<button
v-for="g in LIVE_GIFTS"
:key="g.id"
type="button"
class="live-gift-chip"
:class="{ 'live-gift-chip--big': g.tier === 'big' }"
:title="g.label"
@click="sendGift(g.id)"
>
<span class="live-gift-chip-emoji" aria-hidden="true">{{ g.emoji }}</span>
<span class="live-gift-chip-label">{{ g.label }}</span>
</button>
</div>
</div>
</div>
</div>
<div class="live-stage-footer">
<div class="live-dm-auth-row">
<template v-if="dmLoggedIn">
<span class="live-dm-user">已登录{{ dmDisplayName }}</span>
<button type="button" class="live-dm-auth-btn" @click="logoutDmUser">退出</button>
</template>
<template v-else>
<router-link class="live-dm-auth-link" to="/live/login">登录发弹幕</router-link>
<span class="live-dm-auth-sep">·</span>
<router-link class="live-dm-auth-link" to="/live/register">注册</router-link>
</template>
</div>
<div v-if="dmLoggedIn" class="live-gift-row live-gift-row--scroll" aria-label="礼物">
<span class="live-gift-hint">送礼</span>
<div class="live-gift-strip">
<button
v-for="g in LIVE_GIFTS"
:key="g.id"
type="button"
class="live-gift-chip"
:class="{ 'live-gift-chip--big': g.tier === 'big' }"
:title="g.label"
@click="sendGift(g.id)"
>
<span class="live-gift-chip-emoji" aria-hidden="true">{{ g.emoji }}</span>
<span class="live-gift-chip-label">{{ g.label }}</span>
</button>
<div v-else class="live-gift-row">
<span class="live-gift-hint-muted">登录后可送礼物含火箭·跑车·飞机·嘉年华等</span>
</div>
</div>
<div v-else class="live-gift-row">
<span class="live-gift-hint-muted">登录后可送礼物含火箭·跑车·飞机·嘉年华等</span>
</div>
<div class="live-dm-bar">
<input
v-model="dmDraft"
class="live-dm-input"
type="text"
maxlength="120"
:placeholder="dmLoggedIn ? '发条弹幕…' : '登录后可发弹幕'"
autocomplete="off"
:disabled="!dmLoggedIn"
@keydown.enter.prevent="sendDm"
/>
<button type="button" class="live-dm-send" :disabled="!dmLoggedIn" @click="sendDm">发送</button>
</div>
<p v-if="dmHint" class="live-dm-hint">{{ dmHint }}</p>
<div class="live-dm-bar">
<input
v-model="dmDraft"
class="live-dm-input"
type="text"
maxlength="120"
:placeholder="dmLoggedIn ? '发条弹幕…' : '登录后可发弹幕'"
autocomplete="off"
:disabled="!dmLoggedIn"
@keydown.enter.prevent="sendDm"
/>
<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,