登录发弹幕
+本站账号仅用于直播弹幕,与后台管理无关。
+ + +From 2e675bda512775a8112bf0e9708e208b2981e71a Mon Sep 17 00:00:00 2001
From: whm <973418690@qq.com>
Date: Thu, 26 Mar 2026 14:52:28 +0800
Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E6=92=AD=E5=BC=B9=E5=B9=95=EF=BC=9A?=
=?UTF-8?q?=E5=89=8D=E5=8F=B0=E6=B3=A8=E5=86=8C=E7=99=BB=E5=BD=95=E4=B8=8E?=
=?UTF-8?q?=20JWT=EF=BC=9BNginx=20=E7=BC=93=E8=A7=A3=E9=97=B4=E6=AD=87=205?=
=?UTF-8?q?02=EF=BC=9Bsite=5Fusers=20=E9=9B=86=E5=90=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Made-with: Cursor
---
MongoDB/create_collections.js | 7 ++
nginx/nginx.conf | 7 ++
nginx/yuheng.yuxindazhineng.com.conf | 4 +
server/.env.example | 3 +
server/handlers/site_auth.go | 178 +++++++++++++++++++++++++++
server/main.go | 4 +
server/models/site_user.go | 15 +++
server/pkg/schema/sync.go | 2 +
server/pkg/weblive/danmaku.go | 22 +++-
web/src/router/index.js | 12 ++
web/src/utils/liveWebRTC.js | 6 +-
web/src/utils/siteUserAuth.js | 35 ++++++
web/src/views/LiveLogin.vue | 175 ++++++++++++++++++++++++++
web/src/views/LiveRegister.vue | 175 ++++++++++++++++++++++++++
web/src/views/LiveRoom.vue | 91 +++++++++++++-
15 files changed, 730 insertions(+), 6 deletions(-)
create mode 100644 server/handlers/site_auth.go
create mode 100644 server/models/site_user.go
create mode 100644 web/src/utils/siteUserAuth.js
create mode 100644 web/src/views/LiveLogin.vue
create mode 100644 web/src/views/LiveRegister.vue
diff --git a/MongoDB/create_collections.js b/MongoDB/create_collections.js
index 322af10..3d60b54 100644
--- a/MongoDB/create_collections.js
+++ b/MongoDB/create_collections.js
@@ -81,4 +81,11 @@ if (!db.getCollectionNames().includes("role_permissions")) {
}
db.role_permissions.createIndex({ role_id: 1 }, { unique: true, name: "idx_role_id", background: true });
+// 11. site_users(前台直播弹幕账号,与后台 users 分离)
+if (!db.getCollectionNames().includes("site_users")) {
+ db.createCollection("site_users");
+ print("已创建集合: site_users");
+}
+db.site_users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true });
+
print("集合与索引处理完成。");
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 473cf4e..f9efefa 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -37,6 +37,10 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_connect_timeout 75s;
+ proxy_send_timeout 75s;
+ proxy_read_timeout 75s;
+ proxy_buffering off;
}
location /admin/ {
@@ -55,5 +59,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_connect_timeout 75s;
+ proxy_send_timeout 75s;
+ proxy_read_timeout 75s;
}
}
diff --git a/nginx/yuheng.yuxindazhineng.com.conf b/nginx/yuheng.yuxindazhineng.com.conf
index 67d0e23..1670d00 100644
--- a/nginx/yuheng.yuxindazhineng.com.conf
+++ b/nginx/yuheng.yuxindazhineng.com.conf
@@ -56,5 +56,9 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_connect_timeout 75s;
+ proxy_send_timeout 75s;
+ proxy_read_timeout 75s;
+ proxy_buffering off;
}
}
diff --git a/server/.env.example b/server/.env.example
index 42acb80..d66e3cd 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -9,6 +9,9 @@ GIN_MODE=release
# 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/)
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
+# 前台直播弹幕 JWT 签名密钥(生产环境务必自定义;不设置则使用内置默认值)
+# SITE_JWT_SECRET=your-long-random-secret
+
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
# SKIP_PROMOTION_TRANSCODE=1
diff --git a/server/handlers/site_auth.go b/server/handlers/site_auth.go
new file mode 100644
index 0000000..bb331d6
--- /dev/null
+++ b/server/handlers/site_auth.go
@@ -0,0 +1,178 @@
+package handlers
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "yh_web/server/config"
+ "yh_web/server/models"
+ "yh_web/server/utils"
+
+ "github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+)
+
+const siteJWTExpire = 30 * 24 * time.Hour
+
+// SiteClaims 前台 JWT(仅弹幕等轻量场景,勿与后台 Claims 混用)
+type SiteClaims struct {
+ UserID string `json:"user_id"`
+ Username string `json:"username"`
+ jwt.RegisteredClaims
+}
+
+func siteJWTSigningKey() []byte {
+ s := strings.TrimSpace(os.Getenv("SITE_JWT_SECRET"))
+ if s == "" {
+ s = "yh_web_site_dm_jwt_change_in_production"
+ }
+ return []byte(s)
+}
+
+// ParseSiteClaims 解析前台 JWT,失败返回 nil,false
+func ParseSiteClaims(tokenStr string) (*SiteClaims, bool) {
+ tokenStr = strings.TrimSpace(tokenStr)
+ if len(tokenStr) > 7 && strings.EqualFold(tokenStr[:7], "bearer ") {
+ tokenStr = strings.TrimSpace(tokenStr[7:])
+ }
+ if tokenStr == "" {
+ return nil, false
+ }
+ var claims SiteClaims
+ t, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
+ return siteJWTSigningKey(), nil
+ })
+ if err != nil || !t.Valid {
+ return nil, false
+ }
+ return &claims, true
+}
+
+// SiteDanmakuTokenValid 弹幕发送权限:有效的前台 JWT
+func SiteDanmakuTokenValid(tokenStr string) bool {
+ _, ok := ParseSiteClaims(tokenStr)
+ return ok
+}
+
+type siteRegisterInput struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+}
+
+type siteLoginInput struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+}
+
+func siteUsersColl() *mongo.Collection {
+ return config.GetDB(config.DBName).Collection("site_users")
+}
+
+// WebSiteRegister POST /api/web/site/register — 仅用于前台直播弹幕账号
+func WebSiteRegister(c *gin.Context) {
+ if config.MongoClient == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
+ return
+ }
+ var input siteRegisterInput
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
+ return
+ }
+ u := strings.TrimSpace(input.Username)
+ if utf8.RuneCountInString(u) < 2 || utf8.RuneCountInString(u) > 32 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "用户名为 2~32 个字符"})
+ return
+ }
+ if len(input.Password) < 6 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "密码至少 6 位"})
+ return
+ }
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
+ defer cancel()
+ coll := siteUsersColl()
+ var existing models.SiteUser
+ err := coll.FindOne(ctx, bson.M{"username": u}).Decode(&existing)
+ if err == nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "该用户名已被注册"})
+ return
+ }
+ if !errors.Is(err, mongo.ErrNoDocuments) {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
+ return
+ }
+ doc := models.SiteUser{
+ Username: u,
+ PasswordHash: utils.HashPassword(input.Password),
+ CreatedAt: time.Now().UTC(),
+ }
+ res, err := coll.InsertOne(ctx, doc)
+ if err != nil {
+ if mongo.IsDuplicateKeyError(err) {
+ c.JSON(http.StatusConflict, gin.H{"error": "该用户名已被注册"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
+ return
+ }
+ id, _ := res.InsertedID.(bson.ObjectID)
+ token, err := issueSiteToken(id.Hex(), u)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "签发登录凭证失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"token": token, "username": u})
+}
+
+// WebSiteLogin POST /api/web/site/login
+func WebSiteLogin(c *gin.Context) {
+ if config.MongoClient == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
+ return
+ }
+ var input siteLoginInput
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
+ return
+ }
+ u := strings.TrimSpace(input.Username)
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
+ defer cancel()
+ var user models.SiteUser
+ err := siteUsersColl().FindOne(ctx, bson.M{"username": u}).Decode(&user)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
+ return
+ }
+ if utils.HashPassword(input.Password) != user.PasswordHash {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
+ return
+ }
+ token, err := issueSiteToken(user.ID.Hex(), user.Username)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "签发登录凭证失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"token": token, "username": user.Username})
+}
+
+func issueSiteToken(userID, username string) (string, error) {
+ claims := SiteClaims{
+ UserID: userID,
+ Username: username,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(siteJWTExpire)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ Subject: userID,
+ },
+ }
+ t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return t.SignedString(siteJWTSigningKey())
+}
diff --git a/server/main.go b/server/main.go
index 41fc6c9..50af5fc 100644
--- a/server/main.go
+++ b/server/main.go
@@ -210,6 +210,10 @@ func main() {
r.GET("/api/web/routes", handlers.GetWebRoutes)
r.GET("/api/web/page", handlers.GetWebPageByPath)
+ // 前台直播弹幕账号(与后台 users 无关;需 MongoDB)
+ r.POST("/api/web/site/register", handlers.WebSiteRegister)
+ r.POST("/api/web/site/login", handlers.WebSiteLogin)
+
// 前台 API 路由组
web := r.Group("/api/web")
{
diff --git a/server/models/site_user.go b/server/models/site_user.go
new file mode 100644
index 0000000..e907231
--- /dev/null
+++ b/server/models/site_user.go
@@ -0,0 +1,15 @@
+package models
+
+import (
+ "time"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// SiteUser 前台用户(目前仅用于直播弹幕身份,与后台 users 集合分离)
+type SiteUser struct {
+ ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
+ Username string `bson:"username" json:"username"`
+ PasswordHash string `bson:"password_hash" json:"-"`
+ CreatedAt time.Time `bson:"created_at" json:"created_at"`
+}
diff --git a/server/pkg/schema/sync.go b/server/pkg/schema/sync.go
index 071a714..5053c26 100644
--- a/server/pkg/schema/sync.go
+++ b/server/pkg/schema/sync.go
@@ -28,6 +28,7 @@ var requiredCollections = map[string][]indexSpec{
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
"system_config": {},
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
+ "site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}},
}
type indexSpec struct {
@@ -48,6 +49,7 @@ var tableDDL = map[string]string{
"messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';",
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
+ "site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';",
}
// CollectionInfo 线上集合信息(名称、文档数、索引)
diff --git a/server/pkg/weblive/danmaku.go b/server/pkg/weblive/danmaku.go
index e2ccef2..4d9c149 100644
--- a/server/pkg/weblive/danmaku.go
+++ b/server/pkg/weblive/danmaku.go
@@ -7,6 +7,8 @@ import (
"time"
"unicode/utf8"
+ "yh_web/server/handlers"
+
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
@@ -18,8 +20,18 @@ var (
danmakuClients = make(map[*websocket.Conn]struct{})
)
-// handleDanmakuWS 弹幕:客户端发 JSON {"text":"..."},服务端立刻向所有连接广播 {"type":"dm","text","ts"},不落库
+func writeDanmakuJSON(ws *websocket.Conn, v any) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return ws.WriteMessage(websocket.TextMessage, b)
+}
+
+// handleDanmakuWS 弹幕:收 JSON {"text":"..."};未带有效 token 仅可收广播不可发。广播 {"type":"dm","text","ts"},不落库
func handleDanmakuWS(c *gin.Context) {
+ canSend := handlers.SiteDanmakuTokenValid(c.Query("token"))
+
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
@@ -45,6 +57,14 @@ func handleDanmakuWS(c *gin.Context) {
if mt != websocket.TextMessage {
continue
}
+ if !canSend {
+ _ = writeDanmakuJSON(ws, map[string]interface{}{
+ "type": "error",
+ "code": "login_required",
+ "message": "请先登录或注册后再发弹幕",
+ })
+ continue
+ }
text := extractDanmakuText(payload)
if text == "" {
continue
diff --git a/web/src/router/index.js b/web/src/router/index.js
index 3024d0e..1b8a891 100644
--- a/web/src/router/index.js
+++ b/web/src/router/index.js
@@ -23,6 +23,18 @@ const routes = [
component: () => import('../views/LiveRoom.vue'),
meta: { title: '直播' }
},
+ {
+ path: '/live/login',
+ name: 'LiveLogin',
+ component: () => import('../views/LiveLogin.vue'),
+ meta: { title: '登录' }
+ },
+ {
+ path: '/live/register',
+ name: 'LiveRegister',
+ component: () => import('../views/LiveRegister.vue'),
+ meta: { title: '注册' }
+ },
{
path: '/index',
redirect: '/'
diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js
index bf65924..e794f5e 100644
--- a/web/src/utils/liveWebRTC.js
+++ b/web/src/utils/liveWebRTC.js
@@ -16,8 +16,10 @@ export function liveInfoURL() {
return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info'
}
-export function liveDanmakuWsURL() {
- const path = '/api/web/live/danmaku/ws'
+/** @param {string} [token] 前台弹幕 JWT,缺省则仅收广播不可发 */
+export function liveDanmakuWsURL(token) {
+ const q = token ? `?token=${encodeURIComponent(token)}` : ''
+ const path = `/api/web/live/danmaku/ws${q}`
if (apiBase) {
const base = apiBase.replace(/\/$/, '')
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
diff --git a/web/src/utils/siteUserAuth.js b/web/src/utils/siteUserAuth.js
new file mode 100644
index 0000000..a8f4ad8
--- /dev/null
+++ b/web/src/utils/siteUserAuth.js
@@ -0,0 +1,35 @@
+/** 前台直播弹幕账号(localStorage,与后台管理 JWT 无关) */
+const TOKEN_KEY = 'yh_site_dm_token'
+const USER_KEY = 'yh_site_dm_username'
+
+export function getSiteDmToken() {
+ try {
+ return localStorage.getItem(TOKEN_KEY) || ''
+ } catch {
+ return ''
+ }
+}
+
+export function getSiteDmUsername() {
+ try {
+ return localStorage.getItem(USER_KEY) || ''
+ } catch {
+ return ''
+ }
+}
+
+export function setSiteDmSession(token, username) {
+ try {
+ if (token) localStorage.setItem(TOKEN_KEY, token)
+ else localStorage.removeItem(TOKEN_KEY)
+ if (username) localStorage.setItem(USER_KEY, username)
+ else localStorage.removeItem(USER_KEY)
+ } catch (_) {}
+}
+
+export function clearSiteDmSession() {
+ try {
+ localStorage.removeItem(TOKEN_KEY)
+ localStorage.removeItem(USER_KEY)
+ } catch (_) {}
+}
diff --git a/web/src/views/LiveLogin.vue b/web/src/views/LiveLogin.vue
new file mode 100644
index 0000000..c0acc44
--- /dev/null
+++ b/web/src/views/LiveLogin.vue
@@ -0,0 +1,175 @@
+
+ 本站账号仅用于直播弹幕,与后台管理无关。 注册后仅用于在本站直播页发送弹幕,暂无其他功能。登录发弹幕
+ 注册发弹幕
+
{{ dmHint }}
@@ -77,6 +89,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { apiBase } from '../config' import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality' import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC' +import { getSiteDmToken, getSiteDmUsername, clearSiteDmSession } from '../utils/siteUserAuth' const watchVideoRef = ref(null) const rawLiveUrl = ref('') @@ -95,6 +108,16 @@ let dmReconnectAttempt = 0 /** 先于 close 旧连接递增,避免主动换线时 onclose 误触发重连 */ let dmGen = 0 const dmSendQueue = [] +/** 递增以触发登录态 computed(同页退出后刷新 UI) */ +const dmAuthRev = ref(0) +const dmLoggedIn = computed(() => { + dmAuthRev.value + return !!getSiteDmToken() +}) +const dmDisplayName = computed(() => { + dmAuthRev.value + return getSiteDmUsername() || '用户' +}) const enterUrl = computed(() => (rawLiveUrl.value || '').trim()) @@ -218,7 +241,7 @@ function connectDanmaku() { } catch (_) {} dmWs = null } - const url = liveDanmakuWsURL() + const url = liveDanmakuWsURL(getSiteDmToken()) dmHint.value = dmReconnectAttempt > 0 ? `弹幕重连中(约 ${dmReconnectAttempt} 次失败后重试)…` : '弹幕通道连接中…' try { @@ -250,6 +273,10 @@ function connectDanmaku() { } if (j.type === 'dm' && typeof j.text === 'string' && j.text) { pushDmLine(j.text) + return + } + if (j.type === 'error' && j.code === 'login_required') { + dmHint.value = typeof j.message === 'string' ? j.message : '请先登录后再发弹幕' } } dmWs.onclose = () => { @@ -262,9 +289,20 @@ function connectDanmaku() { } } +function logoutDmUser() { + clearSiteDmSession() + dmAuthRev.value += 1 + dmSendQueue.length = 0 + connectDanmaku() +} + function sendDm() { const t = dmDraft.value.trim() if (!t) return + if (!getSiteDmToken()) { + dmHint.value = '发弹幕请先登录或注册' + return + } dmDraft.value = '' if (dmWs && dmWs.readyState === WebSocket.OPEN) { try { @@ -429,6 +467,45 @@ onUnmounted(() => { transform: translateX(-105%); } } +.live-dm-auth-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px 14px; + max-width: 480px; + margin: 12px auto 0; + font-size: 13px; + color: rgba(255, 255, 255, 0.7); +} +.live-dm-user { + color: rgba(255, 255, 255, 0.85); +} +.live-dm-auth-link { + color: #00d4ff; + text-decoration: none; + font-weight: 600; +} +.live-dm-auth-link:hover { + text-decoration: underline; +} +.live-dm-auth-sep { + color: rgba(255, 255, 255, 0.35); + user-select: none; +} +.live-dm-auth-btn { + padding: 4px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.85); + font-size: 12px; + cursor: pointer; +} +.live-dm-auth-btn:hover { + border-color: rgba(0, 212, 255, 0.5); + color: #00d4ff; +} .live-dm-bar { display: flex; gap: 10px; @@ -450,6 +527,10 @@ onUnmounted(() => { .live-dm-input::placeholder { color: rgba(255, 255, 255, 0.35); } +.live-dm-input:disabled { + opacity: 0.55; + cursor: not-allowed; +} .live-dm-send { flex-shrink: 0; padding: 10px 18px; @@ -464,6 +545,10 @@ onUnmounted(() => { .live-dm-send:hover { background: rgba(0, 212, 255, 0.25); } +.live-dm-send:disabled { + opacity: 0.45; + cursor: not-allowed; +} .live-dm-hint { max-width: 480px; margin: 10px auto 0;