|
@@ -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
|
|
|
|
|
+}
|