Просмотр исходного кода

播报任务和播报方案修改为websocket推送

zmj 19 часов назад
Родитель
Сommit
2294db4b0c
10 измененных файлов с 906 добавлено и 57 удалено
  1. 126 0
      package-lock.json
  2. 2 0
      package.json
  3. 27 0
      src/App.vue
  4. 26 8
      src/components/IdlePlayer.vue
  5. 32 0
      src/main.js
  6. 137 26
      src/stores/screen.js
  7. 179 0
      src/stores/websocket.js
  8. 300 0
      src/utils/websocket.js
  9. 64 21
      src/views/idle/Index.vue
  10. 13 2
      vite.config.js

+ 126 - 0
package-lock.json

@@ -8,8 +8,10 @@
       "name": "robot-screen",
       "version": "1.0.0",
       "dependencies": {
+        "@stomp/stompjs": "^6.1.2",
         "axios": "^1.16.0",
         "pinia": "^2.1.7",
+        "sockjs-client": "^1.6.1",
         "vue": "^3.4.21",
         "vue-router": "^4.3.0"
       },
@@ -87,6 +89,11 @@
         "darwin"
       ]
     },
+    "node_modules/@stomp/stompjs": {
+      "version": "6.1.2",
+      "resolved": "https://registry.npmmirror.com/@stomp/stompjs/-/stompjs-6.1.2.tgz",
+      "integrity": "sha512-FHDTrIFM5Ospi4L3Xhj6v2+NzCVAeNDcBe95YjUWhWiRMrBF6uN3I7AUOlRgT6jU/2WQvvYK8ZaIxFfxFp+uHQ=="
+    },
     "node_modules/@types/estree": {
       "version": "1.0.8",
       "dev": true,
@@ -232,6 +239,14 @@
       "version": "3.2.3",
       "license": "MIT"
     },
+    "node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
     "node_modules/delayed-stream": {
       "version": "1.0.0",
       "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -697,6 +712,25 @@
       "version": "2.0.2",
       "license": "MIT"
     },
+    "node_modules/eventsource": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/eventsource/-/eventsource-2.0.2.tgz",
+      "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/faye-websocket": {
+      "version": "0.11.4",
+      "resolved": "https://registry.npmmirror.com/faye-websocket/-/faye-websocket-0.11.4.tgz",
+      "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
+      "dependencies": {
+        "websocket-driver": ">=0.5.1"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
     "node_modules/follow-redirects": {
       "version": "1.16.0",
       "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -833,6 +867,16 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/http-parser-js": {
+      "version": "0.5.10",
+      "resolved": "https://registry.npmmirror.com/http-parser-js/-/http-parser-js-0.5.10.tgz",
+      "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
     "node_modules/magic-string": {
       "version": "0.30.21",
       "license": "MIT",
@@ -867,6 +911,11 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+    },
     "node_modules/nanoid": {
       "version": "3.3.12",
       "funding": [
@@ -941,6 +990,16 @@
         "node": ">=10"
       }
     },
+    "node_modules/querystringify": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz",
+      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
+    },
+    "node_modules/requires-port": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz",
+      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
+    },
     "node_modules/rollup": {
       "version": "4.60.3",
       "dev": true,
@@ -1296,6 +1355,43 @@
         "win32"
       ]
     },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/sockjs-client": {
+      "version": "1.6.1",
+      "resolved": "https://registry.npmmirror.com/sockjs-client/-/sockjs-client-1.6.1.tgz",
+      "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==",
+      "dependencies": {
+        "debug": "^3.2.7",
+        "eventsource": "^2.0.2",
+        "faye-websocket": "^0.11.4",
+        "inherits": "^2.0.4",
+        "url-parse": "^1.5.10"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://tidelift.com/funding/github/npm/sockjs-client"
+      }
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "license": "BSD-3-Clause",
@@ -1303,6 +1399,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/url-parse": {
+      "version": "1.5.10",
+      "resolved": "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz",
+      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+      "dependencies": {
+        "querystringify": "^2.1.1",
+        "requires-port": "^1.0.0"
+      }
+    },
     "node_modules/vite": {
       "version": "5.4.21",
       "dev": true,
@@ -1416,6 +1521,27 @@
       "peerDependencies": {
         "vue": "^3.5.0"
       }
+    },
+    "node_modules/websocket-driver": {
+      "version": "0.7.4",
+      "resolved": "https://registry.npmmirror.com/websocket-driver/-/websocket-driver-0.7.4.tgz",
+      "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
+      "dependencies": {
+        "http-parser-js": ">=0.5.1",
+        "safe-buffer": ">=5.1.0",
+        "websocket-extensions": ">=0.1.1"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/websocket-extensions": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
+      "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
+      "engines": {
+        "node": ">=0.8.0"
+      }
     }
   }
 }

+ 2 - 0
package.json

@@ -9,8 +9,10 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@stomp/stompjs": "^6.1.2",
     "axios": "^1.16.0",
     "pinia": "^2.1.7",
+    "sockjs-client": "^1.6.1",
     "vue": "^3.4.21",
     "vue-router": "^4.3.0"
   },

+ 27 - 0
src/App.vue

@@ -2,11 +2,38 @@
   <router-view />
   <GlobalAlert />
   <BroadcastOverlay />
+  <!-- 隐藏的静音音频,用于解锁浏览器自动播放权限 -->
+  <audio ref="audioUnlocker" style="display:none" muted playsinline></audio>
 </template>
 
 <script setup>
 import GlobalAlert from '@/components/GlobalAlert.vue'
 import BroadcastOverlay from '@/components/BroadcastOverlay.vue'
+import { ref, onMounted } from 'vue'
+
+const audioUnlocker = ref(null)
+
+// 解锁浏览器音频自动播放权限
+const unlockAudio = () => {
+  if (audioUnlocker.value) {
+    audioUnlocker.value.play().then(() => {
+      console.log('[App] 音频播放权限已解锁')
+    }).catch(() => {
+      // 静默失败
+    })
+  }
+  // 移除监听,只需解锁一次
+  document.removeEventListener('click', unlockAudio)
+  document.removeEventListener('touchstart', unlockAudio)
+  document.removeEventListener('keydown', unlockAudio)
+}
+
+onMounted(() => {
+  // 监听用户首次交互来解锁音频权限
+  document.addEventListener('click', unlockAudio, { once: true })
+  document.addEventListener('touchstart', unlockAudio, { once: true })
+  document.addEventListener('keydown', unlockAudio, { once: true })
+})
 </script>
 
 <style>

+ 26 - 8
src/components/IdlePlayer.vue

@@ -364,22 +364,34 @@ const goNextMedia = () => {
 
 // 播放方案:图片定时切换
 let imageTimer = null
+let imageTimerMediaUrl = null  // Track which media URL the timer is for
+
+const startImageTimer = (force = false) => {
+  const media = currentMedia.value
+  if (!media || media.type !== 'image') return
+
+  const duration = media.duration || 8000
+  const mediaUrl = media.url
+
+  // Skip if timer already running for this URL (unless forced)
+  if (imageTimer && imageTimerMediaUrl === mediaUrl && !force) {
+    return
+  }
 
-const startImageTimer = () => {
   clearImageTimer()
-  if (!currentMedia.value || currentMedia.value.type !== 'image') return
-  const duration = currentMedia.value.duration || 8000
-  console.log('[Broadcast] 图片轮播定时器恢复', currentMedia.value?.duration)
   imageTimer = setTimeout(() => {
     goNextMedia()
     startImageTimer()
   }, duration)
+  imageTimerMediaUrl = mediaUrl
+  console.log('[Broadcast] 图片轮播定时器恢复', media.duration)
 }
 
 const clearImageTimer = () => {
   if (imageTimer) {
     clearTimeout(imageTimer)
     imageTimer = null
+    imageTimerMediaUrl = null
   }
 }
 
@@ -421,11 +433,17 @@ const onMediaError = () => {
 
 // 播放方案:监听 currentMedia 变化,重启图片定时器
 watch(() => currentMedia.value, (newVal) => {
-  if (!newVal) return
-  if (newVal.type === 'image') {
-    startImageTimer()
-  } else {
+  if (!newVal) {
+    clearImageTimer()
+    return
+  }
+  if (newVal.type !== 'image') {
     clearImageTimer()
+    return
+  }
+  // Only start timer if this is a different media URL
+  if (newVal.url !== imageTimerMediaUrl) {
+    startImageTimer()
   }
 })
 

+ 32 - 0
src/main.js

@@ -9,4 +9,36 @@ const pinia = createPinia()
 
 app.use(pinia)
 app.use(router)
+
+// WebSocket 初始化
+// 在 Vue 应用挂载后初始化 WebSocket 连接
+import { useWebSocketStore } from './stores/websocket'
+import { useScreenStore } from './stores/screen'
+
+// 等待 Pinia 初始化完成后初始化 WebSocket
+const initWebSocket = () => {
+  const wsStore = useWebSocketStore()
+  const screenStore = useScreenStore()
+
+  // 设置屏幕 Store 引用
+  wsStore.setScreenStore(screenStore)
+
+  // 监听 WebSocket 连接状态变化
+  wsStore.$subscribe((mutation, state) => {
+    screenStore.setWsConnected(state.isConnected)
+  })
+
+  // 初始化 WebSocket 连接
+  wsStore.init()
+  
+  console.log('[App] WebSocket 初始化完成')
+}
+
+// 组件挂载后初始化
 app.mount('#app')
+
+// 在 nextTick 后初始化 WebSocket,确保 Store 已就绪
+import { nextTick } from 'vue'
+nextTick(() => {
+  initWebSocket()
+})

+ 137 - 26
src/stores/screen.js

@@ -62,12 +62,18 @@ export const useScreenStore = defineStore('screen', () => {
   // 播报轮询定时器
   const broadcastPollingTimer = ref(null)
 
+  // 播放方案轮询定时器(降级方案)
+  const playPlanPollingTimer = ref(null)
+
+  // WebSocket 连接状态
+  const wsConnected = ref(false)
+
+  // 是否使用 WebSocket 模式
+  const useWebSocketMode = ref(true)
+
   // 是否正在播放播报音频
   const isBroadcastAudioPlaying = ref(false)
 
-  // 已完成播报的 key 集合(用于避免同一播报重复播放)
-  const finishedBroadcastKeys = ref(new Set())
-
   // 语音指令
   const latestCommand = ref(null)
 
@@ -101,12 +107,10 @@ export const useScreenStore = defineStore('screen', () => {
   const hasFault = computed(() => robotStatus.value.faultFlag)
   const networkOk = computed(() => robotStatus.value.networkStatus === 'online')
 
-  // 是否正在播报(有播报状态、audioUrl 有值、且未被标记为已完成
+  // 是否正在播报(有播报状态、audioUrl 有值)
   const isBroadcasting = computed(() => {
     const b = currentBroadcast.value
     if (!b || b.broadcasting !== true || !b.audioUrl) return false
-    const key = getBroadcastKey(b)
-    if (key && finishedBroadcastKeys.value.has(key)) return false
     return true
   })
 
@@ -249,6 +253,116 @@ export const useScreenStore = defineStore('screen', () => {
     }
   }
 
+  // ============================================
+  // WebSocket 驱动方法
+  // ============================================
+
+  /**
+   * 处理 WebSocket 推送的播放方案变化
+   * 由 websocket store 调用
+   */
+  function handlePlayPlanChanged(data) {
+    console.log('[Store] WebSocket 收到播放方案变化:', data)
+    
+    if (!data || data.enabled === false) {
+      // 方案禁用
+      handlePlayPlanDisabled()
+      return
+    }
+
+    const newVersion = data?.version || ''
+    
+    // 如果是首次加载,直接应用方案
+    if (!playPlanVersion.value) {
+      playPlan.value = data
+      playPlanVersion.value = newVersion
+      pendingPlayPlan.value = null
+      currentMediaIndex.value = 0
+      hasPlayPlan.value = !!(
+        data &&
+        data.enabled !== false &&
+        Array.isArray(data.items) &&
+        data.items.length > 0
+      )
+      console.log('[Store] WebSocket 首次加载播放方案')
+      return
+    }
+
+    // 后续更新:检查版本是否变化
+    const isVersionChanged = newVersion && playPlanVersion.value !== newVersion
+
+    if (isVersionChanged) {
+      // 版本变化,暂存新方案
+      pendingPlayPlan.value = data
+      console.log('[Store] WebSocket 播放方案版本变化,暂存待切换方案:', newVersion)
+    }
+  }
+
+  /**
+   * 处理 WebSocket 推送的播放方案禁用
+   */
+  function handlePlayPlanDisabled() {
+    console.log('[Store] WebSocket 收到播放方案禁用')
+    pendingPlayPlan.value = null
+    playPlan.value = null
+    playPlanVersion.value = ''
+    hasPlayPlan.value = false
+    currentMediaIndex.value = 0
+  }
+
+  /**
+   * 处理 WebSocket 推送的播报开始
+   */
+  function handleBroadcastStarted(data) {
+    console.log('[Store] WebSocket 收到播报开始:', data)
+    
+    if (!data) return
+
+    currentBroadcast.value = data
+  }
+
+  /**
+   * 处理 WebSocket 推送的播报结束
+   */
+  function handleBroadcastEnded() {
+    console.log('[Store] WebSocket 收到播报结束')
+    currentBroadcast.value = emptyBroadcast()
+  }
+
+  /**
+   * 设置 WebSocket 连接状态
+   */
+  function setWsConnected(connected) {
+    wsConnected.value = connected
+    console.log('[Store] WebSocket 连接状态:', connected ? '已连接' : '已断开')
+  }
+
+  /**
+   * 开始播放方案轮询(降级方案)
+   * 当 WebSocket 不可用时启用
+   */
+  function startPlayPlanPolling() {
+    if (playPlanPollingTimer.value) return
+    
+    // 立即请求一次
+    fetchPlayPlan()
+    
+    // 每 30 秒轮询一次(降级方案,频率较低)
+    playPlanPollingTimer.value = setInterval(() => {
+      fetchPlayPlan()
+    }, 30000)
+  }
+
+  /**
+   * 停止播放方案轮询
+   */
+  function stopPlayPlanPolling() {
+    if (playPlanPollingTimer.value) {
+      clearInterval(playPlanPollingTimer.value)
+      playPlanPollingTimer.value = null
+    }
+  }
+
   async function fetchBroadcastState() {
     try {
       const res = await api.getBroadcastState()
@@ -261,14 +375,14 @@ export const useScreenStore = defineStore('screen', () => {
 
   /**
    * 生成播报唯一 key
+   * 使用时间戳确保每次推送都是新的 key,配合后端频率控制
    */
   function getBroadcastKey(broadcast) {
     if (!broadcast) return ''
     return [
       broadcast.taskId || '',
       broadcast.contentId || '',
-      broadcast.version || '',
-      broadcast.audioUrl || ''
+      Date.now()  // 使用时间戳让每次都是新的 key
     ].join('_')
   }
 
@@ -296,27 +410,18 @@ export const useScreenStore = defineStore('screen', () => {
 
   /**
    * 获取当前播报状态(播报插播专用)
-   * 会过滤已完成播报,避免重复播放
    */
   async function fetchCurrentBroadcast() {
     try {
       const res = await api.getCurrentBroadcast()
       const data = res && res.data !== undefined ? res.data : res
-      const key = getBroadcastKey(data)
 
-      // 音频正在播放时,不清空已完成记录,避免状态被轮询错误清空
+      // 音频正在播放时,不清空当前播报状态
       if (isBroadcastAudioPlaying.value) {
         console.log('[Broadcast] 音频正在播放,忽略后端的 broadcasting=false 状态')
         return
       }
 
-      if (data?.broadcasting === true && key && finishedBroadcastKeys.value.has(key)) {
-        console.log('[Broadcast] 已播放完成的播报,本地忽略:', key)
-        return
-      }
-      if (data?.broadcasting === false) {
-        finishedBroadcastKeys.value.clear()
-      }
       currentBroadcast.value = data || emptyBroadcast()
       console.log('[Broadcast] 播报状态更新:', data)
     } catch (e) {
@@ -360,14 +465,10 @@ export const useScreenStore = defineStore('screen', () => {
   }
 
   /**
-   * 本地标记播报结束(用于 audio ended/error 后隐藏浮层并记录已播放
+   * 本地标记播报结束(用于 audio ended/error 后隐藏浮层)
    */
   function markBroadcastFinishedLocally() {
-    const key = getBroadcastKey(currentBroadcast.value)
-    if (key) {
-      finishedBroadcastKeys.value.add(key)
-      console.log('[Broadcast] 标记播报为已完成:', key)
-    }
+    console.log('[Broadcast] 标记播报为已完成')
     currentBroadcast.value = emptyBroadcast()
   }
 
@@ -454,8 +555,10 @@ export const useScreenStore = defineStore('screen', () => {
     broadcastState,
     currentBroadcast,
     broadcastPollingTimer,
+    playPlanPollingTimer,
+    wsConnected,
+    useWebSocketMode,
     isBroadcastAudioPlaying,
-    finishedBroadcastKeys,
     latestCommand,
     globalAlert,
     volume,
@@ -500,6 +603,14 @@ export const useScreenStore = defineStore('screen', () => {
     resumePlay,
     resetToIdle,
     toggleIdleMode,
-    resetForceMode
+    resetForceMode,
+    // WebSocket 驱动方法
+    handlePlayPlanChanged,
+    handlePlayPlanDisabled,
+    handleBroadcastStarted,
+    handleBroadcastEnded,
+    setWsConnected,
+    startPlayPlanPolling,
+    stopPlayPlanPolling
   }
 })

+ 179 - 0
src/stores/websocket.js

@@ -0,0 +1,179 @@
+/**
+ * WebSocket 连接状态管理 Store
+ * 
+ * 管理 WebSocket 连接的生命周期
+ * 订阅后端推送的消息并转发到其他 Store
+ */
+
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import ws, {
+  connect as wsConnect,
+  disconnect as wsDisconnect,
+  subscribePlayPlan,
+  subscribeBroadcast,
+  subscribeConnect,
+  unsubscribe,
+  isConnected as wsIsConnected,
+  ConnectionState
+} from '@/utils/websocket'
+
+export const useWebSocketStore = defineStore('websocket', () => {
+  // 连接状态
+  const connectionState = ref(ConnectionState.DISCONNECTED)
+
+  // 是否已连接
+  const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED)
+
+  // 是否正在重连
+  const isReconnecting = computed(() => connectionState.value === ConnectionState.RECONNECTING)
+
+  // 订阅ID
+  const playPlanSubscription = ref(null)
+  const broadcastSubscription = ref(null)
+  const connectSubscription = ref(null)
+
+  // 消息回调
+  let onPlayPlanChangeCallback = null
+  let onBroadcastStartCallback = null
+  let onBroadcastEndCallback = null
+
+  // 屏幕 Store 引用(避免循环引用)
+  let screenStore = null
+
+  function setScreenStore(store) {
+    screenStore = store
+  }
+
+  // 状态同步定时器(用于检测 WebSocket 底层连接与 store 状态不一致的情况)
+  let stateSyncTimer = null
+
+  function init() {
+    if (wsIsConnected()) {
+      console.log('[WS Store] WebSocket already connected')
+      connectionState.value = ConnectionState.CONNECTED
+      return
+    }
+
+    console.log('[WS Store] Initializing WebSocket connection...')
+    connectionState.value = ConnectionState.CONNECTING
+
+    wsConnect(() => {
+      console.log('[WS Store] WebSocket connected successfully')
+      connectionState.value = ConnectionState.CONNECTED
+      subscribeChannels()
+    })
+  }
+
+  function subscribeChannels() {
+    unsubscribeChannels()
+
+    playPlanSubscription.value = subscribePlayPlan((message) => {
+      handlePlayPlanMessage(message)
+    })
+
+    broadcastSubscription.value = subscribeBroadcast((message) => {
+      handleBroadcastMessage(message)
+    })
+
+    connectSubscription.value = subscribeConnect((message) => {
+      console.log('[WS Store] Received connect confirmation:', message)
+    })
+  }
+
+  function unsubscribeChannels() {
+    if (playPlanSubscription.value) {
+      unsubscribe(playPlanSubscription.value)
+      playPlanSubscription.value = null
+    }
+    if (broadcastSubscription.value) {
+      unsubscribe(broadcastSubscription.value)
+      broadcastSubscription.value = null
+    }
+    if (connectSubscription.value) {
+      unsubscribe(connectSubscription.value)
+      connectSubscription.value = null
+    }
+  }
+
+  function handlePlayPlanMessage(message) {
+    if (!message) return
+
+    const type = message.type
+    const data = message.data || {}
+
+    console.log('[WS Store] Received play plan message:', type, data)
+
+    if (type === 'play_plan_changed') {
+      if (onPlayPlanChangeCallback) {
+        onPlayPlanChangeCallback(data)
+      } else if (screenStore) {
+        screenStore.handlePlayPlanChanged(data)
+      }
+    } else if (type === 'play_plan_disabled') {
+      if (onPlayPlanChangeCallback) {
+        onPlayPlanChangeCallback({ enabled: false, items: [] })
+      } else if (screenStore) {
+        screenStore.handlePlayPlanDisabled()
+      }
+    }
+  }
+
+  function handleBroadcastMessage(message) {
+    if (!message) return
+
+    const type = message.type
+    const data = message.data || {}
+
+    console.log('[WS Store] Received broadcast message:', type, data)
+
+    if (type === 'broadcast_started') {
+      if (onBroadcastStartCallback) {
+        onBroadcastStartCallback(data)
+      } else if (screenStore) {
+        screenStore.handleBroadcastStarted(data)
+      }
+    } else if (type === 'broadcast_ended') {
+      if (onBroadcastEndCallback) {
+        onBroadcastEndCallback()
+      } else if (screenStore) {
+        screenStore.handleBroadcastEnded()
+      }
+    }
+  }
+
+  function disconnect() {
+    console.log('[WS Store] Disconnecting WebSocket')
+    unsubscribeChannels()
+    if (stateSyncTimer) {
+      clearInterval(stateSyncTimer)
+      stateSyncTimer = null
+    }
+    wsDisconnect()
+    connectionState.value = ConnectionState.DISCONNECTED
+  }
+
+  function setOnPlayPlanChange(callback) {
+    onPlayPlanChangeCallback = callback
+  }
+
+  function setOnBroadcastStart(callback) {
+    onBroadcastStartCallback = callback
+  }
+
+  function setOnBroadcastEnd(callback) {
+    onBroadcastEndCallback = callback
+  }
+
+  return {
+    connectionState,
+    isConnected,
+    isReconnecting,
+    init,
+    disconnect,
+    setScreenStore,
+    setOnPlayPlanChange,
+    setOnBroadcastStart,
+    setOnBroadcastEnd
+  }
+})

+ 300 - 0
src/utils/websocket.js

@@ -0,0 +1,300 @@
+/**
+ * WebSocket 客户端工具类
+ * 基于 STOMP over WebSocket 协议
+ * 用于接收后端推送的播放方案和播报状态
+ */
+
+import { ref } from "vue"
+
+// WebSocket 连接状态
+export const ConnectionState = {
+  DISCONNECTED: "disconnected",
+  CONNECTING: "connecting",
+  CONNECTED: "connected",
+  RECONNECTING: "reconnecting",
+  ERROR: "error"
+}
+
+// 单例模式
+let stompClient = null
+let socket = null
+
+// 连接配置
+const defaultConfig = {
+  brokerURL: getWebSocketURL(),
+  reconnectDelay: 3000,
+  maxReconnectAttempts: 5,
+  heartbeatIncoming: 10000,
+  heartbeatOutgoing: 10000,
+  debug: false
+}
+
+let config = { ...defaultConfig }
+
+// 连接状态
+const connectionState = ref(ConnectionState.DISCONNECTED)
+
+// 重连计数器
+let reconnectAttempts = 0
+let reconnectTimer = null
+let isFirstConnection = true
+
+// 订阅列表
+const subscriptions = new Map()
+
+function getWebSocketURL() {
+  if (import.meta.env.VITE_WS_URL) {
+    return import.meta.env.VITE_WS_URL
+  }
+  // SockJS 需要 http/https 协议,不是 ws/wss
+  const protocol = window.location.protocol === "https:" ? "https:" : "http:"
+  const host = window.location.host
+  return `${protocol}//${host}/ws/robot`
+}
+
+export function configure(options) {
+  config = { ...defaultConfig, ...options }
+}
+
+export function connect(onConnected) {
+  if (connectionState.value === ConnectionState.CONNECTED) {
+    console.log("[WebSocket] Already connected")
+    return
+  }
+
+  if (connectionState.value === ConnectionState.CONNECTING) {
+    console.log("[WebSocket] Connecting...")
+    return
+  }
+
+  connectionState.value = ConnectionState.CONNECTING
+  console.log("[WebSocket] Connecting...", config.brokerURL)
+
+  Promise.all([
+    import("sockjs-client"),
+    import("@stomp/stompjs")
+  ]).then(([SockJS, Stomp]) => {
+    // v6 API: Stomp is exported as default or Stomp property
+    const StompJS = Stomp.default || Stomp.Stomp
+    socket = new SockJS.default(config.brokerURL)
+    stompClient = StompJS.over(socket, {
+      heartbeatIncoming: config.heartbeatIncoming,
+      heartbeatOutgoing: config.heartbeatOutgoing,
+      debug: config.debug
+    })
+
+    stompClient.connect(
+      {},
+      (frame) => {
+        console.log("[WebSocket] Connected successfully")
+        connectionState.value = ConnectionState.CONNECTED
+        reconnectAttempts = 0
+        if (onConnected) {
+          onConnected()
+        }
+        // Only resubscribe on actual reconnect (not first connect)
+        // On first connect, subscriptions are created by subscribeChannels() called from onConnected callback
+        if (!isFirstConnection && subscriptions.size > 0) {
+          resubscribeAll()
+        }
+        isFirstConnection = false
+      },
+      (error) => {
+        console.warn("[WebSocket] Connection disconnected:", error)
+        handleDisconnect(error)
+      }
+    )
+
+    stompClient.onWebSocketClose = (event) => {
+      console.warn("[WebSocket] WebSocket closed", event)
+      handleDisconnect(event)
+    }
+
+    stompClient.onStompError = (event) => {
+      console.error("[WebSocket] STOMP error:", event)
+      handleDisconnect(event)
+    }
+  }).catch((error) => {
+    console.error("[WebSocket] Failed to load SockJS/STOMP:", error)
+    connectionState.value = ConnectionState.ERROR
+    scheduleReconnect()
+  })
+}
+
+export function disconnect() {
+  console.log("[WebSocket] Disconnecting")
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+  reconnectAttempts = 0
+  isFirstConnection = true  // Reset for next connection attempt
+  unsubscribeAll()
+  if (stompClient) {
+    try {
+      stompClient.disconnect()
+    } catch (e) {
+      console.warn("[WebSocket] Error on disconnect:", e)
+    }
+    stompClient = null
+  }
+  if (socket) {
+    socket.close()
+    socket = null
+  }
+  connectionState.value = ConnectionState.DISCONNECTED
+}
+
+function handleDisconnect(error) {
+  if (connectionState.value === ConnectionState.DISCONNECTED) {
+    return
+  }
+  connectionState.value = ConnectionState.ERROR
+  scheduleReconnect()
+}
+
+function scheduleReconnect() {
+  if (reconnectAttempts >= config.maxReconnectAttempts) {
+    console.error(`[WebSocket] Max reconnect attempts (${config.maxReconnectAttempts}) reached`)
+    connectionState.value = ConnectionState.ERROR
+    return
+  }
+
+  reconnectAttempts++
+  connectionState.value = ConnectionState.RECONNECTING
+  const delay = config.reconnectDelay * reconnectAttempts
+  console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`)
+
+  reconnectTimer = setTimeout(() => {
+    connect()
+  }, delay)
+}
+
+export function subscribe(destination, callback, id = null) {
+  if (!stompClient || connectionState.value !== ConnectionState.CONNECTED) {
+    console.warn("[WebSocket] Not connected, cannot subscribe:", destination)
+    return null
+  }
+
+  const subscriptionId = id || `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
+  console.log("[WebSocket] Subscribing:", destination, subscriptionId)
+
+  const subscription = stompClient.subscribe(destination, (message) => {
+    try {
+      const body = message.body ? JSON.parse(message.body) : null
+      if (config.debug) {
+        console.log("[WebSocket] Received:", destination, body)
+      }
+      callback(body, message)
+    } catch (e) {
+      console.error("[WebSocket] Parse error:", e)
+      callback(message.body, message)
+    }
+  }, { id: subscriptionId })
+
+  subscriptions.set(subscriptionId, { subscription, destination })
+  return subscriptionId
+}
+
+export function unsubscribe(subscriptionId) {
+  const sub = subscriptions.get(subscriptionId)
+  if (sub) {
+    try {
+      sub.subscription.unsubscribe()
+      console.log("[WebSocket] Unsubscribed:", sub.destination, subscriptionId)
+    } catch (e) {
+      console.warn("[WebSocket] Unsubscribe failed:", e)
+    }
+    subscriptions.delete(subscriptionId)
+  }
+}
+
+function unsubscribeAll() {
+  subscriptions.forEach((sub, id) => {
+    try {
+      sub.subscription.unsubscribe()
+    } catch (e) {
+      // Ignore
+    }
+  })
+  subscriptions.clear()
+}
+
+function resubscribeAll() {
+  subscriptions.forEach((sub, id) => {
+    const newSub = stompClient.subscribe(sub.destination, (message) => {
+      try {
+        const body = message.body ? JSON.parse(message.body) : null
+        if (config.debug) {
+          console.log("[WebSocket] Received:", sub.destination, body)
+        }
+      } catch (e) {
+        console.error("[WebSocket] Parse error:", e)
+      }
+    }, { id })
+    sub.subscription = newSub
+  })
+}
+
+export function send(destination, body = {}) {
+  if (!stompClient || connectionState.value !== ConnectionState.CONNECTED) {
+    console.warn("[WebSocket] Not connected, cannot send:", destination)
+    return false
+  }
+  try {
+    stompClient.send(destination, {}, JSON.stringify(body))
+    if (config.debug) {
+      console.log("[WebSocket] Sent:", destination, body)
+    }
+    return true
+  } catch (e) {
+    console.error("[WebSocket] Send failed:", e)
+    return false
+  }
+}
+
+export function getConnectionState() {
+  return connectionState.value
+}
+
+export function isConnected() {
+  return connectionState.value === ConnectionState.CONNECTED
+}
+
+export function sendHeartbeat() {
+  if (isConnected()) {
+    stompClient.send("/app/heartbeat", {}, JSON.stringify({
+      timestamp: Date.now(),
+      client: "screen"
+    }))
+  }
+}
+
+// 屏幕端专用订阅
+export function subscribePlayPlan(callback) {
+  return subscribe("/topic/screen/play-plan", callback, "play-plan")
+}
+
+export function subscribeBroadcast(callback) {
+  return subscribe("/topic/screen/broadcast", callback, "broadcast")
+}
+
+export function subscribeConnect(callback) {
+  return subscribe("/topic/screen/connect", callback, "connect")
+}
+
+export default {
+  connect,
+  disconnect,
+  subscribe,
+  unsubscribe,
+  send,
+  subscribePlayPlan,
+  subscribeBroadcast,
+  subscribeConnect,
+  sendHeartbeat,
+  getConnectionState,
+  isConnected,
+  configure,
+  ConnectionState
+}

+ 64 - 21
src/views/idle/Index.vue

@@ -6,38 +6,79 @@
 </template>
 
 <script setup>
-import { ref, onMounted, onUnmounted } from 'vue'
+import { ref, onMounted, onUnmounted, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import { useScreenStore } from '@/stores/screen'
+import { useWebSocketStore } from '@/stores/websocket'
 import IdlePlayer from '@/components/IdlePlayer.vue'
 import BroadcastOverlay from '@/components/BroadcastOverlay.vue'
 
 const router = useRouter()
 const screenStore = useScreenStore()
+const wsStore = useWebSocketStore()
 
-// 播放方案轮询定时器
-const playPlanPollingTimer = ref(null)
-const PLAY_PLAN_POLL_INTERVAL = 60000 // 60 秒轮询一次
+// 降级轮询定时器(WebSocket 不可用时使用)
+const fallbackPollingTimer = ref(null)
+const FALLBACK_POLL_INTERVAL = 30000 // 30 秒降级轮询
 
 const goToMenu = () => {
   router.push('/menu')
 }
 
-// 停止播放方案轮询
-const stopPlayPlanPolling = () => {
-  if (playPlanPollingTimer.value) {
-    clearInterval(playPlanPollingTimer.value)
-    playPlanPollingTimer.value = null
+/**
+ * 停止降级轮询
+ */
+const stopFallbackPolling = () => {
+  if (fallbackPollingTimer.value) {
+    clearInterval(fallbackPollingTimer.value)
+    fallbackPollingTimer.value = null
   }
 }
 
-// 开始播放方案轮询(待机页每 60 秒重新请求一次播放方案)
-const startPlayPlanPolling = () => {
-  stopPlayPlanPolling()
-  playPlanPollingTimer.value = setInterval(() => {
-    console.log('[Idle] 轮询播放方案...')
+/**
+ * 开始降级轮询(WebSocket 不可用时)
+ * 同时轮询播放方案和播报状态
+ */
+const startFallbackPolling = () => {
+  stopFallbackPolling()
+  // 立即请求一次
+  screenStore.fetchPlayPlan()
+  screenStore.fetchCurrentBroadcast()
+  
+  fallbackPollingTimer.value = setInterval(() => {
+    console.log('[Idle] 降级轮询...')
     screenStore.fetchPlayPlan()
-  }, PLAY_PLAN_POLL_INTERVAL)
+    screenStore.fetchCurrentBroadcast()
+  }, FALLBACK_POLL_INTERVAL)
+}
+
+/**
+ * 根据 WebSocket 连接状态决定是否启用降级轮询
+ * 使用防抖避免快速状态变化导致的重复处理
+ */
+let handleWsTimer = null
+const handleWsConnectionChange = (isConnected) => {
+  // 防抖:100ms 内的重复调用只执行一次
+  if (handleWsTimer) {
+    clearTimeout(handleWsTimer)
+  }
+  handleWsTimer = setTimeout(() => {
+    handleWsTimer = null
+    if (isConnected) {
+      // WebSocket 已连接,停止所有降级轮询
+      console.log('[Idle] WebSocket 已连接,停止降级轮询')
+      stopFallbackPolling()
+      // 停止 screenStore 的播报轮询(使用 WebSocket 推送)
+      screenStore.stopBroadcastPolling()
+    } else {
+      // WebSocket 断开,启用降级轮询
+      console.log('[Idle] WebSocket 断开,启用降级轮询')
+      // 播放方案轮询(30秒一次)
+      startFallbackPolling()
+      // 播报状态轮询(2秒一次,由 screenStore 统一管理)
+      screenStore.startBroadcastPolling()
+    }
+  }, 100)
 }
 
 onMounted(() => {
@@ -45,17 +86,19 @@ onMounted(() => {
   screenStore.fetchScreenTheme()
   screenStore.fetchPlayPlan()
   screenStore.fetchRobotStatus()
-  // 开始轮询播放方案
-  startPlayPlanPolling()
-  // 开始播报状态轮询(每 2 秒一次)
-  screenStore.startBroadcastPolling()
+  
+  // 监听 WebSocket 连接状态变化
+  watch(() => wsStore.isConnected, handleWsConnectionChange, { immediate: true })
 })
 
 onUnmounted(() => {
   // 组件销毁时停止轮询
-  stopPlayPlanPolling()
-  // 停止播报状态轮询
+  stopFallbackPolling()
   screenStore.stopBroadcastPolling()
+  if (handleWsTimer) {
+    clearTimeout(handleWsTimer)
+    handleWsTimer = null
+  }
 })
 </script>
 

+ 13 - 2
vite.config.js

@@ -9,16 +9,27 @@ export default defineConfig({
       '@': fileURLToPath(new URL('./src', import.meta.url))
     }
   },
+  define: {
+    // 修复 SockJS 客户端在 Vite 环境下的兼容性问题
+    global: 'globalThis'
+  },
   server: {
     port: 5173,
     host: true,
     proxy: {
       // 使用 /dev-api 前缀,与 RobotSpineWeb 保持一致
       '/dev-api': {
-        //target: 'http://192.168.0.30:8080',
-        target: 'http://localhost:8080',
+        target: 'http://192.168.0.30:8080',
+        //target: 'http://localhost:8080',
         changeOrigin: true,
         rewrite: (path) => path.replace(/^\/dev-api/, '')
+      },
+      // WebSocket 连接代理
+      '/ws': {
+        target: 'http://192.168.0.30:8080',
+        //target: 'http://localhost:8080',
+        changeOrigin: true,
+        ws: true
       }
     }
   },