直播弹幕:前台注册登录与 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 });
|
||||
|
||||
// 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("集合与索引处理完成。");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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/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")
|
||||
{
|
||||
|
||||
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"}},
|
||||
"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 线上集合信息(名称、文档数、索引)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '/'
|
||||
|
||||
@@ -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:')
|
||||
|
||||
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>
|
||||
</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">
|
||||
<input
|
||||
v-model="dmDraft"
|
||||
class="live-dm-input"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="发条弹幕…"
|
||||
:placeholder="dmLoggedIn ? '发条弹幕…' : '登录后可发弹幕(仍可观看)'"
|
||||
autocomplete="off"
|
||||
:disabled="!dmLoggedIn"
|
||||
@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>
|
||||
<p v-if="dmHint" class="live-dm-hint">{{ dmHint }}</p>
|
||||
</section>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user