xw 13 годин тому
батько
коміт
98bbdd5272

+ 11 - 1
src/router/index.js

@@ -6,6 +6,7 @@ Vue.use(Router)
 /* Layout */
 import Layout from '@/layout'
 import LiveConsole from "@/views/live/liveConsole/index.vue";
+import LiveBehaviorScreen from "@/views/live/liveConsole/LiveBehaviorScreen.vue";
 
 
 /**
@@ -236,7 +237,16 @@ export const constantRoutes = [
     name: 'LiveConsole',
     component: LiveConsole, // 直接渲染目标组件,无侧边栏
     meta: {
-      isIndependentPage: true // 标记为“独立页”
+      isIndependentPage: true // 标记为"独立页"
+    }
+  },
+  // 直播行为实时大屏页面(独立页面,不需要登录)
+  {
+    path: '/live/screen-behavior',
+    name: 'LiveBehaviorScreen',
+    component: LiveBehaviorScreen,
+    meta: {
+      isIndependentPage: true // 标记为独立页
     }
   },
   // 直播配置页路由

+ 17 - 0
src/views/live/live/index.vue

@@ -301,6 +301,13 @@
             @click="handleManage(scope.row)"
             >进入直播间</el-button
           >
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-monitor"
+            @click="handleBehaviorScreen(scope.row)"
+            >大屏展示</el-button
+          >
           <el-button
             size="mini"
             type="text"
@@ -1214,6 +1221,16 @@ export default {
       }).href;
       window.open(routeUrl, "_blank");
     },
+    // 打开行为大屏页面
+    handleBehaviorScreen(row) {
+      const routeUrl = this.$router.resolve({
+        path: '/live/screen-behavior',
+        query: {
+          liveId: row.liveId
+        }
+      }).href;
+      window.open(routeUrl, "_blank");
+    },
     // 查看二维码图片
     handleCheckCode(row) {
       // 先校验图片地址是否存在

+ 694 - 0
src/views/live/liveConsole/LiveBehaviorScreen.vue

@@ -0,0 +1,694 @@
+<template>
+  <div class="behavior-screen">
+    <!-- 顶部标题栏 -->
+    <div class="screen-header">
+      <div class="header-title">
+        <h1>直播行为实时大屏</h1>
+        <div class="live-info">
+          <span class="live-id">直播间ID: {{ liveId || '未连接' }}</span>
+          <span class="connection-status" :class="{ 'connected': isConnected, 'disconnected': !isConnected }">
+            {{ isConnected ? '已连接 🟢' : '未连接 🔴' }}
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 行为列表容器 -->
+    <div class="behavior-container">
+      <!-- 空数据提示 -->
+      <div v-if="behaviorList.length === 0" class="empty-data">
+        <div class="empty-icon">📊</div>
+        <div class="empty-text">暂无用户行为数据</div>
+        <div class="empty-status">
+          <span>连接状态:{{ isConnected ? '已连接 🟢' : '连接中...' }}</span>
+        </div>
+        <div class="empty-tip">等待用户行为推送...</div>
+      </div>
+
+      <!-- 行为列表 -->
+      <div v-else class="behavior-list">
+        <transition-group name="behavior-fade" tag="div">
+          <div
+            v-for="(behavior, index) in behaviorList"
+            :key="behavior.id"
+            class="behavior-item"
+            :class="getBehaviorClass(behavior.behaviorDesc)"
+            :style="{ animationDelay: `${index * 0.05}s` }"
+          >
+            <div class="behavior-user">
+              <span class="user-id">{{ formatUserId(behavior.userId) }}</span>
+            </div>
+            <div class="behavior-desc">
+              <span class="desc-icon">{{ getBehaviorIcon(behavior.behaviorDesc) }}</span>
+              <span class="desc-text">{{ behavior.behaviorDesc }}</span>
+            </div>
+            <div class="behavior-time">
+              {{ formatTime(behavior.behaviorTime) }}
+            </div>
+            <div v-if="behavior.resourceId" class="behavior-resource">
+              资源ID: {{ behavior.resourceId }}
+            </div>
+          </div>
+        </transition-group>
+      </div>
+    </div>
+
+    <!-- 统计信息 -->
+    <div class="stats-footer">
+      <div class="stat-item">
+        <span class="stat-label">实时在线</span>
+        <span class="stat-value">{{ onlineCount }}</span>
+      </div>
+      <div class="stat-item">
+        <span class="stat-label">行为总数</span>
+        <span class="stat-value">{{ totalBehaviorCount }}</span>
+      </div>
+      <div class="stat-item">
+        <span class="stat-label">最近更新</span>
+        <span class="stat-value">{{ lastUpdateTime }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import CryptoJS from 'crypto-js'
+
+export default {
+  name: 'LiveBehaviorScreen',
+  data() {
+    return {
+      liveId: null, // 直播间ID
+      websocket: null, // WebSocket连接
+      isConnected: false, // 连接状态
+      behaviorList: [], // 行为列表
+      maxBehaviorCount: 100, // 最多展示100条
+      heartbeatTimer: null, // 心跳定时器
+      reconnectTimer: null, // 重连定时器
+      isManualClose: false, // 是否手动关闭
+      onlineCount: 0, // 在线人数
+      totalBehaviorCount: 0, // 行为总数
+      lastUpdateTime: '--:--:--', // 最后更新时间
+      wsUrl: 'ws.moonxiang.com' // WebSocket域名
+    }
+  },
+  mounted() {
+    // 从URL参数获取liveId
+    this.liveId = this.$route.query.liveId || null
+    
+    if (!this.liveId) {
+      this.$message.error('缺少直播间ID参数')
+      return
+    }
+
+    // 初始化WebSocket连接
+    this.connectWebSocket()
+  },
+  beforeDestroy() {
+    // 组件销毁前关闭连接
+    this.closeWebSocket()
+  },
+  methods: {
+    /**
+     * 建立WebSocket连接
+     */
+    connectWebSocket() {
+      try {
+        // 生成签名参数
+        const timestamp = new Date().getTime()
+        const userId = -1 // 大屏固定使用-1
+        const userType = 2 // 大屏标识
+        
+        // 生成HmacSHA256签名
+        const signature = CryptoJS.HmacSHA256(
+          CryptoJS.enc.Utf8.parse(`${this.liveId}${userId}${userType}${timestamp}`),
+          CryptoJS.enc.Utf8.parse(String(timestamp))
+        ).toString(CryptoJS.enc.Hex)
+
+        // 构建WebSocket URL
+        const wsUrl = `wss://${this.wsUrl}/ws/app/webSocket?liveId=${this.liveId}&userId=${userId}&userType=${userType}&timestamp=${timestamp}&signature=${signature}`
+        
+        console.log('连接WebSocket:', wsUrl)
+        
+        // 创建WebSocket连接
+        this.websocket = new WebSocket(wsUrl)
+        
+        // 连接打开
+        this.websocket.onopen = this.onWebSocketOpen
+        
+        // 接收消息
+        this.websocket.onmessage = this.onWebSocketMessage
+        
+        // 连接错误
+        this.websocket.onerror = this.onWebSocketError
+        
+        // 连接关闭
+        this.websocket.onclose = this.onWebSocketClose
+        
+      } catch (error) {
+        console.error('WebSocket连接失败:', error)
+        this.$message.error('WebSocket连接失败')
+      }
+    },
+
+    /**
+     * WebSocket连接打开
+     */
+    onWebSocketOpen() {
+      console.log('WebSocket连接成功')
+      this.isConnected = true
+      this.$message.success('大屏连接成功')
+      
+      // 清除重连定时器
+      if (this.reconnectTimer) {
+        clearTimeout(this.reconnectTimer)
+        this.reconnectTimer = null
+      }
+      
+      // 启动心跳
+      this.startHeartbeat()
+    },
+
+    /**
+     * WebSocket接收消息
+     */
+    onWebSocketMessage(event) {
+      try {
+        const response = JSON.parse(event.data)
+        
+        // 只处理行为轨迹批量推送消息
+        if (response.data?.cmd === 'behaviorTrackBatch') {
+          const behaviors = JSON.parse(response.data.data)
+          this.handleBehaviorData(behaviors)
+        }
+        
+        // 处理在线人数更新
+        if (response.data?.cmd === 'onlineCount') {
+          this.onlineCount = response.data.data || 0
+        }
+        
+      } catch (error) {
+        console.error('解析WebSocket消息失败:', error)
+      }
+    },
+
+    /**
+     * WebSocket连接错误
+     */
+    onWebSocketError(error) {
+      console.error('WebSocket错误:', error)
+      this.isConnected = false
+      
+      // 3秒后重连
+      this.scheduleReconnect()
+    },
+
+    /**
+     * WebSocket连接关闭
+     */
+    onWebSocketClose() {
+      console.log('WebSocket连接关闭')
+      this.isConnected = false
+      
+      // 停止心跳
+      this.stopHeartbeat()
+      
+      // 如果不是手动关闭,则重连
+      if (!this.isManualClose) {
+        this.scheduleReconnect()
+      }
+    },
+
+    /**
+     * 处理行为数据
+     */
+    handleBehaviorData(behaviors) {
+      if (!Array.isArray(behaviors) || behaviors.length === 0) {
+        return
+      }
+
+      // 为每个行为添加唯一ID
+      const newBehaviors = behaviors.map(behavior => ({
+        ...behavior,
+        id: `${behavior.userId}_${behavior.behaviorTime}_${Math.random()}`
+      }))
+
+      // 将新行为添加到列表顶部
+      this.behaviorList.unshift(...newBehaviors)
+
+      // 限制最多100条
+      if (this.behaviorList.length > this.maxBehaviorCount) {
+        this.behaviorList = this.behaviorList.slice(0, this.maxBehaviorCount)
+      }
+
+      // 更新统计信息
+      this.totalBehaviorCount += newBehaviors.length
+      this.lastUpdateTime = this.formatTime(Date.now())
+    },
+
+    /**
+     * 启动心跳
+     */
+    startHeartbeat() {
+      this.stopHeartbeat() // 先清除旧的定时器
+      
+      this.heartbeatTimer = setInterval(() => {
+        if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
+          this.websocket.send(JSON.stringify({
+            cmd: 'heartbeat',
+            liveId: this.liveId,
+            userId: -1,
+            userType: 2
+          }))
+          console.log('发送心跳')
+        }
+      }, 30000) // 每30秒发送一次
+    },
+
+    /**
+     * 停止心跳
+     */
+    stopHeartbeat() {
+      if (this.heartbeatTimer) {
+        clearInterval(this.heartbeatTimer)
+        this.heartbeatTimer = null
+      }
+    },
+
+    /**
+     * 调度重连
+     */
+    scheduleReconnect() {
+      if (this.reconnectTimer) {
+        return // 避免重复重连
+      }
+      
+      console.log('3秒后重连...')
+      this.$message.warning('连接断开,3秒后重连...')
+      
+      this.reconnectTimer = setTimeout(() => {
+        this.reconnectTimer = null
+        this.connectWebSocket()
+      }, 3000)
+    },
+
+    /**
+     * 关闭WebSocket连接
+     */
+    closeWebSocket() {
+      this.isManualClose = true
+      this.stopHeartbeat()
+      
+      if (this.reconnectTimer) {
+        clearTimeout(this.reconnectTimer)
+        this.reconnectTimer = null
+      }
+      
+      if (this.websocket) {
+        this.websocket.close()
+        this.websocket = null
+      }
+    },
+
+    /**
+     * 获取行为类型对应的CSS类
+     */
+    getBehaviorClass(behaviorDesc) {
+      const typeMap = {
+        '点赞': 'behavior-like',
+        '发送消息': 'behavior-message',
+        '点击商品': 'behavior-goods',
+        '下单': 'behavior-order',
+        '领取红包': 'behavior-redpacket',
+        '参与抽奖': 'behavior-lottery',
+        '领取优惠券': 'behavior-coupon',
+        '进入直播间': 'behavior-enter',
+        '离开直播间': 'behavior-leave'
+      }
+      return typeMap[behaviorDesc] || ''
+    },
+
+    /**
+     * 获取行为类型对应的图标
+     */
+    getBehaviorIcon(behaviorDesc) {
+      const iconMap = {
+        '点赞': '❤️',
+        '发送消息': '💬',
+        '点击商品': '🛍️',
+        '下单': '✅',
+        '领取红包': '🧧',
+        '参与抽奖': '🎲',
+        '领取优惠券': '🎟️',
+        '进入直播间': '👋',
+        '离开直播间': '👋'
+      }
+      return iconMap[behaviorDesc] || '📌'
+    },
+
+    /**
+     * 格式化用户ID(过长时截取)
+     */
+    formatUserId(userId) {
+      if (!userId) return '--'
+      const userIdStr = String(userId)
+      if (userIdStr.length > 10) {
+        return `${userIdStr.substring(0, 5)}...${userIdStr.substring(userIdStr.length - 4)}`
+      }
+      return userIdStr
+    },
+
+    /**
+     * 格式化时间为 HH:mm:ss
+     */
+    formatTime(timestamp) {
+      if (!timestamp) return '--:--:--'
+      
+      const date = new Date(timestamp)
+      const hours = String(date.getHours()).padStart(2, '0')
+      const minutes = String(date.getMinutes()).padStart(2, '0')
+      const seconds = String(date.getSeconds()).padStart(2, '0')
+      
+      return `${hours}:${minutes}:${seconds}`
+    }
+  }
+}
+</script>
+
+<style scoped>
+/* 全局样式 */
+.behavior-screen {
+  width: 100vw;
+  height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  color: #e0e0e0;
+  font-family: 'Microsoft YaHei', Arial, sans-serif;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+/* 顶部标题栏 */
+.screen-header {
+  background: rgba(255, 255, 255, 0.05);
+  backdrop-filter: blur(10px);
+  border-bottom: 2px solid rgba(83, 82, 237, 0.3);
+  padding: 20px 40px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+}
+
+.header-title {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.header-title h1 {
+  font-size: 36px;
+  font-weight: bold;
+  margin: 0;
+  background: linear-gradient(90deg, #5352ed, #3742fa);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  text-shadow: 0 0 30px rgba(83, 82, 237, 0.5);
+}
+
+.live-info {
+  display: flex;
+  gap: 30px;
+  align-items: center;
+  font-size: 18px;
+}
+
+.live-id {
+  color: #70a1ff;
+  font-weight: 600;
+}
+
+.connection-status {
+  padding: 8px 16px;
+  border-radius: 20px;
+  font-weight: 600;
+  transition: all 0.3s ease;
+}
+
+.connection-status.connected {
+  background: rgba(46, 213, 115, 0.2);
+  color: #2ed573;
+  box-shadow: 0 0 15px rgba(46, 213, 115, 0.3);
+}
+
+.connection-status.disconnected {
+  background: rgba(255, 71, 87, 0.2);
+  color: #ff4757;
+}
+
+/* 行为容器 */
+.behavior-container {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20px 40px;
+  position: relative;
+}
+
+/* 自定义滚动条 */
+.behavior-container::-webkit-scrollbar {
+  width: 8px;
+}
+
+.behavior-container::-webkit-scrollbar-track {
+  background: rgba(255, 255, 255, 0.05);
+  border-radius: 4px;
+}
+
+.behavior-container::-webkit-scrollbar-thumb {
+  background: rgba(83, 82, 237, 0.5);
+  border-radius: 4px;
+}
+
+.behavior-container::-webkit-scrollbar-thumb:hover {
+  background: rgba(83, 82, 237, 0.8);
+}
+
+/* 空数据提示 */
+.empty-data {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  text-align: center;
+}
+
+.empty-icon {
+  font-size: 80px;
+  margin-bottom: 20px;
+  animation: float 3s ease-in-out infinite;
+}
+
+@keyframes float {
+  0%, 100% {
+    transform: translateY(0);
+  }
+  50% {
+    transform: translateY(-20px);
+  }
+}
+
+.empty-text {
+  font-size: 28px;
+  color: #a0a0a0;
+  margin-bottom: 15px;
+}
+
+.empty-status {
+  font-size: 20px;
+  color: #70a1ff;
+  margin-bottom: 10px;
+}
+
+.empty-tip {
+  font-size: 18px;
+  color: #808080;
+}
+
+/* 行为列表 */
+.behavior-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+/* 行为项 */
+.behavior-item {
+  background: rgba(255, 255, 255, 0.05);
+  backdrop-filter: blur(10px);
+  border-radius: 12px;
+  padding: 20px 30px;
+  display: flex;
+  align-items: center;
+  gap: 30px;
+  border-left: 4px solid transparent;
+  transition: all 0.3s ease;
+  animation: slideIn 0.5s ease-out;
+}
+
+@keyframes slideIn {
+  from {
+    opacity: 0;
+    transform: translateX(-50px);
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0);
+  }
+}
+
+.behavior-item:hover {
+  background: rgba(255, 255, 255, 0.1);
+  transform: translateX(5px);
+}
+
+/* 行为类型样式 */
+.behavior-like {
+  border-left-color: #ff4757;
+}
+
+.behavior-message {
+  border-left-color: #5352ed;
+}
+
+.behavior-goods {
+  border-left-color: #ffa502;
+}
+
+.behavior-order {
+  border-left-color: #2ed573;
+}
+
+.behavior-redpacket {
+  border-left-color: #ffd700;
+}
+
+.behavior-lottery {
+  border-left-color: #a55eea;
+}
+
+.behavior-coupon {
+  border-left-color: #ff6b81;
+}
+
+.behavior-enter {
+  border-left-color: #70a1ff;
+}
+
+.behavior-leave {
+  border-left-color: #747d8c;
+}
+
+/* 用户信息 */
+.behavior-user {
+  min-width: 150px;
+}
+
+.user-id {
+  font-size: 20px;
+  font-weight: 600;
+  color: #70a1ff;
+}
+
+/* 行为描述 */
+.behavior-desc {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.desc-icon {
+  font-size: 28px;
+}
+
+.desc-text {
+  font-size: 22px;
+  font-weight: 600;
+  color: #e0e0e0;
+}
+
+/* 时间 */
+.behavior-time {
+  font-size: 20px;
+  color: #a0a0a0;
+  font-family: 'Consolas', monospace;
+  min-width: 100px;
+  text-align: right;
+}
+
+/* 资源ID */
+.behavior-resource {
+  font-size: 16px;
+  color: #808080;
+  min-width: 120px;
+  text-align: right;
+}
+
+/* 淡入淡出动画 */
+.behavior-fade-enter-active {
+  animation: fadeIn 0.5s ease-out;
+}
+
+.behavior-fade-leave-active {
+  animation: fadeOut 0.3s ease-in;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes fadeOut {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
+
+/* 统计信息底栏 */
+.stats-footer {
+  background: rgba(255, 255, 255, 0.05);
+  backdrop-filter: blur(10px);
+  border-top: 2px solid rgba(83, 82, 237, 0.3);
+  padding: 20px 40px;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
+}
+
+.stat-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+}
+
+.stat-label {
+  font-size: 16px;
+  color: #a0a0a0;
+}
+
+.stat-value {
+  font-size: 28px;
+  font-weight: bold;
+  color: #5352ed;
+  font-family: 'Consolas', monospace;
+}
+</style>