直播弹幕:前台注册登录与 JWT;Nginx 缓解间歇 502;site_users 集合
Made-with: Cursor
This commit is contained in:
@@ -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 });
|
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("集合与索引处理完成。");
|
print("集合与索引处理完成。");
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_send_timeout 75s;
|
||||||
|
proxy_read_timeout 75s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /admin/ {
|
location /admin/ {
|
||||||
@@ -55,5 +59,8 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_send_timeout 75s;
|
||||||
|
proxy_read_timeout 75s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,5 +56,9 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_send_timeout 75s;
|
||||||
|
proxy_read_timeout 75s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ GIN_MODE=release
|
|||||||
# 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/)
|
# 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/)
|
||||||
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
|
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
|
||||||
|
|
||||||
|
# 前台直播弹幕 JWT 签名密钥(生产环境务必自定义;不设置则使用内置默认值)
|
||||||
|
# SITE_JWT_SECRET=your-long-random-secret
|
||||||
|
|
||||||
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
|
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
|
||||||
# SKIP_PROMOTION_TRANSCODE=1
|
# SKIP_PROMOTION_TRANSCODE=1
|
||||||
|
|
||||||
|
|||||||
178
server/handlers/site_auth.go
Normal file
178
server/handlers/site_auth.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
@@ -210,6 +210,10 @@ func main() {
|
|||||||
r.GET("/api/web/routes", handlers.GetWebRoutes)
|
r.GET("/api/web/routes", handlers.GetWebRoutes)
|
||||||
r.GET("/api/web/page", handlers.GetWebPageByPath)
|
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 路由组
|
// 前台 API 路由组
|
||||||
web := r.Group("/api/web")
|
web := r.Group("/api/web")
|
||||||
{
|
{
|
||||||
|
|||||||
15
server/models/site_user.go
Normal file
15
server/models/site_user.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ var requiredCollections = map[string][]indexSpec{
|
|||||||
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
|
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
|
||||||
"system_config": {},
|
"system_config": {},
|
||||||
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
"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 {
|
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='消息表';",
|
"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='文件表';",
|
"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='系统配置表';",
|
"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 线上集合信息(名称、文档数、索引)
|
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"yh_web/server/handlers"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@@ -18,8 +20,18 @@ var (
|
|||||||
danmakuClients = make(map[*websocket.Conn]struct{})
|
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) {
|
func handleDanmakuWS(c *gin.Context) {
|
||||||
|
canSend := handlers.SiteDanmakuTokenValid(c.Query("token"))
|
||||||
|
|
||||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -45,6 +57,14 @@ func handleDanmakuWS(c *gin.Context) {
|
|||||||
if mt != websocket.TextMessage {
|
if mt != websocket.TextMessage {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !canSend {
|
||||||
|
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
||||||
|
"type": "error",
|
||||||
|
"code": "login_required",
|
||||||
|
"message": "请先登录或注册后再发弹幕",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
text := extractDanmakuText(payload)
|
text := extractDanmakuText(payload)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ const routes = [
|
|||||||
component: () => import('../views/LiveRoom.vue'),
|
component: () => import('../views/LiveRoom.vue'),
|
||||||
meta: { title: '直播' }
|
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',
|
path: '/index',
|
||||||
redirect: '/'
|
redirect: '/'
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ export function liveInfoURL() {
|
|||||||
return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info'
|
return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function liveDanmakuWsURL() {
|
/** @param {string} [token] 前台弹幕 JWT,缺省则仅收广播不可发 */
|
||||||
const path = '/api/web/live/danmaku/ws'
|
export function liveDanmakuWsURL(token) {
|
||||||
|
const q = token ? `?token=${encodeURIComponent(token)}` : ''
|
||||||
|
const path = `/api/web/live/danmaku/ws${q}`
|
||||||
if (apiBase) {
|
if (apiBase) {
|
||||||
const base = apiBase.replace(/\/$/, '')
|
const base = apiBase.replace(/\/$/, '')
|
||||||
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
||||||
|
|||||||
35
web/src/utils/siteUserAuth.js
Normal file
35
web/src/utils/siteUserAuth.js
Normal file
@@ -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 (_) {}
|
||||||
|
}
|
||||||
175
web/src/views/LiveLogin.vue
Normal file
175
web/src/views/LiveLogin.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="live-auth">
|
||||||
|
<header class="live-auth-top">
|
||||||
|
<router-link to="/live" class="live-auth-back">← 返回直播</router-link>
|
||||||
|
</header>
|
||||||
|
<main class="live-auth-main">
|
||||||
|
<h1 class="live-auth-title">登录发弹幕</h1>
|
||||||
|
<p class="live-auth-desc">本站账号仅用于直播弹幕,与后台管理无关。</p>
|
||||||
|
<form class="live-auth-form" @submit.prevent="submit">
|
||||||
|
<label class="live-auth-label">
|
||||||
|
<span>用户名</span>
|
||||||
|
<input v-model.trim="username" class="live-auth-input" type="text" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
<label class="live-auth-label">
|
||||||
|
<span>密码</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
class="live-auth-input"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="err" class="live-auth-err">{{ err }}</p>
|
||||||
|
<button type="submit" class="live-auth-btn" :disabled="loading">
|
||||||
|
{{ loading ? '登录中…' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="live-auth-footer">
|
||||||
|
还没有账号?
|
||||||
|
<router-link to="/live/register" class="live-auth-link">注册</router-link>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { apiBase } from '../config'
|
||||||
|
import { setSiteDmSession } from '../utils/siteUserAuth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const err = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
err.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const url = apiBase ? `${apiBase}/api/web/site/login` : '/api/web/site/login'
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const j = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
err.value = j.error || `登录失败(${res.status})`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!j.token) {
|
||||||
|
err.value = '服务器未返回凭证'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSiteDmSession(j.token, j.username || username.value)
|
||||||
|
router.push('/live')
|
||||||
|
} catch {
|
||||||
|
err.value = '网络错误,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.live-auth {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px 20px 48px;
|
||||||
|
background: radial-gradient(ellipse at 30% 20%, rgba(74, 26, 107, 0.25) 0%, transparent 45%),
|
||||||
|
radial-gradient(ellipse at 70% 80%, rgba(30, 58, 95, 0.3) 0%, transparent 50%),
|
||||||
|
#0a0a12;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Noto Sans SC', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.live-auth-top {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
.live-auth-back {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.live-auth-back:hover {
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
.live-auth-main {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.live-auth-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.live-auth-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
.live-auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.live-auth-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.live-auth-input {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.35);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.live-auth-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
.live-auth-err {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
.live-auth-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #00d4ff, #ff2d95);
|
||||||
|
color: #0a0a12;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.live-auth-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.live-auth-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
.live-auth-link {
|
||||||
|
color: #00d4ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.live-auth-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
175
web/src/views/LiveRegister.vue
Normal file
175
web/src/views/LiveRegister.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="live-auth">
|
||||||
|
<header class="live-auth-top">
|
||||||
|
<router-link to="/live" class="live-auth-back">← 返回直播</router-link>
|
||||||
|
</header>
|
||||||
|
<main class="live-auth-main">
|
||||||
|
<h1 class="live-auth-title">注册发弹幕</h1>
|
||||||
|
<p class="live-auth-desc">注册后仅用于在本站直播页发送弹幕,暂无其他功能。</p>
|
||||||
|
<form class="live-auth-form" @submit.prevent="submit">
|
||||||
|
<label class="live-auth-label">
|
||||||
|
<span>用户名(2~32 字)</span>
|
||||||
|
<input v-model.trim="username" class="live-auth-input" type="text" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
<label class="live-auth-label">
|
||||||
|
<span>密码(至少 6 位)</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
class="live-auth-input"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="err" class="live-auth-err">{{ err }}</p>
|
||||||
|
<button type="submit" class="live-auth-btn" :disabled="loading">
|
||||||
|
{{ loading ? '注册中…' : '注册并登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="live-auth-footer">
|
||||||
|
已有账号?
|
||||||
|
<router-link to="/live/login" class="live-auth-link">登录</router-link>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { apiBase } from '../config'
|
||||||
|
import { setSiteDmSession } from '../utils/siteUserAuth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const err = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
err.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const url = apiBase ? `${apiBase}/api/web/site/register` : '/api/web/site/register'
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const j = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
err.value = j.error || `注册失败(${res.status})`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!j.token) {
|
||||||
|
err.value = '服务器未返回凭证'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSiteDmSession(j.token, j.username || username.value)
|
||||||
|
router.push('/live')
|
||||||
|
} catch {
|
||||||
|
err.value = '网络错误,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.live-auth {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px 20px 48px;
|
||||||
|
background: radial-gradient(ellipse at 30% 20%, rgba(74, 26, 107, 0.25) 0%, transparent 45%),
|
||||||
|
radial-gradient(ellipse at 70% 80%, rgba(30, 58, 95, 0.3) 0%, transparent 50%),
|
||||||
|
#0a0a12;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Noto Sans SC', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.live-auth-top {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
.live-auth-back {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.live-auth-back:hover {
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
.live-auth-main {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.live-auth-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.live-auth-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
.live-auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.live-auth-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.live-auth-input {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.35);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.live-auth-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
.live-auth-err {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
.live-auth-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #00d4ff, #ff2d95);
|
||||||
|
color: #0a0a12;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.live-auth-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.live-auth-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
.live-auth-link {
|
||||||
|
color: #00d4ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.live-auth-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -40,17 +40,29 @@
|
|||||||
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
|
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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-dm-bar">
|
<div class="live-dm-bar">
|
||||||
<input
|
<input
|
||||||
v-model="dmDraft"
|
v-model="dmDraft"
|
||||||
class="live-dm-input"
|
class="live-dm-input"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
placeholder="发条弹幕…"
|
:placeholder="dmLoggedIn ? '发条弹幕…' : '登录后可发弹幕(仍可观看)'"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
:disabled="!dmLoggedIn"
|
||||||
@keydown.enter.prevent="sendDm"
|
@keydown.enter.prevent="sendDm"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="live-dm-send" @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>
|
||||||
</section>
|
</section>
|
||||||
@@ -77,6 +89,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|||||||
import { apiBase } from '../config'
|
import { apiBase } from '../config'
|
||||||
import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
|
import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
|
||||||
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
||||||
|
import { getSiteDmToken, getSiteDmUsername, clearSiteDmSession } from '../utils/siteUserAuth'
|
||||||
|
|
||||||
const watchVideoRef = ref(null)
|
const watchVideoRef = ref(null)
|
||||||
const rawLiveUrl = ref('')
|
const rawLiveUrl = ref('')
|
||||||
@@ -95,6 +108,16 @@ let dmReconnectAttempt = 0
|
|||||||
/** 先于 close 旧连接递增,避免主动换线时 onclose 误触发重连 */
|
/** 先于 close 旧连接递增,避免主动换线时 onclose 误触发重连 */
|
||||||
let dmGen = 0
|
let dmGen = 0
|
||||||
const dmSendQueue = []
|
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())
|
const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
|
||||||
|
|
||||||
@@ -218,7 +241,7 @@ function connectDanmaku() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
dmWs = null
|
dmWs = null
|
||||||
}
|
}
|
||||||
const url = liveDanmakuWsURL()
|
const url = liveDanmakuWsURL(getSiteDmToken())
|
||||||
dmHint.value =
|
dmHint.value =
|
||||||
dmReconnectAttempt > 0 ? `弹幕重连中(约 ${dmReconnectAttempt} 次失败后重试)…` : '弹幕通道连接中…'
|
dmReconnectAttempt > 0 ? `弹幕重连中(约 ${dmReconnectAttempt} 次失败后重试)…` : '弹幕通道连接中…'
|
||||||
try {
|
try {
|
||||||
@@ -250,6 +273,10 @@ function connectDanmaku() {
|
|||||||
}
|
}
|
||||||
if (j.type === 'dm' && typeof j.text === 'string' && j.text) {
|
if (j.type === 'dm' && typeof j.text === 'string' && j.text) {
|
||||||
pushDmLine(j.text)
|
pushDmLine(j.text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (j.type === 'error' && j.code === 'login_required') {
|
||||||
|
dmHint.value = typeof j.message === 'string' ? j.message : '请先登录后再发弹幕'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dmWs.onclose = () => {
|
dmWs.onclose = () => {
|
||||||
@@ -262,9 +289,20 @@ function connectDanmaku() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logoutDmUser() {
|
||||||
|
clearSiteDmSession()
|
||||||
|
dmAuthRev.value += 1
|
||||||
|
dmSendQueue.length = 0
|
||||||
|
connectDanmaku()
|
||||||
|
}
|
||||||
|
|
||||||
function sendDm() {
|
function sendDm() {
|
||||||
const t = dmDraft.value.trim()
|
const t = dmDraft.value.trim()
|
||||||
if (!t) return
|
if (!t) return
|
||||||
|
if (!getSiteDmToken()) {
|
||||||
|
dmHint.value = '发弹幕请先登录或注册'
|
||||||
|
return
|
||||||
|
}
|
||||||
dmDraft.value = ''
|
dmDraft.value = ''
|
||||||
if (dmWs && dmWs.readyState === WebSocket.OPEN) {
|
if (dmWs && dmWs.readyState === WebSocket.OPEN) {
|
||||||
try {
|
try {
|
||||||
@@ -429,6 +467,45 @@ onUnmounted(() => {
|
|||||||
transform: translateX(-105%);
|
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 {
|
.live-dm-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -450,6 +527,10 @@ onUnmounted(() => {
|
|||||||
.live-dm-input::placeholder {
|
.live-dm-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.35);
|
color: rgba(255, 255, 255, 0.35);
|
||||||
}
|
}
|
||||||
|
.live-dm-input:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.live-dm-send {
|
.live-dm-send {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
@@ -464,6 +545,10 @@ onUnmounted(() => {
|
|||||||
.live-dm-send:hover {
|
.live-dm-send:hover {
|
||||||
background: rgba(0, 212, 255, 0.25);
|
background: rgba(0, 212, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
.live-dm-send:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.live-dm-hint {
|
.live-dm-hint {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 10px auto 0;
|
margin: 10px auto 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user