Browse Source

fix: 修复 iPad Safari 下 WebSocket 连接异常和同步问题

针对 iPad Safari 浏览器的兼容性问题,新增了主动请求同步和强制重建连接的方法。在页面从后台恢复或 bfcache 加载时,自动触发同步或重连逻辑。优化了 socket 连接状态检查,区分连接状态和同步状态,避免假连接导致的同步失败。同时添加了连接超时和同步超时的自动重试机制,提升了学生端的使用体验。
lsc 1 week ago
parent
commit
2cfa9c5474
2 changed files with 164 additions and 26 deletions
  1. 38 0
      src/views/Student/CustomWebsocketProvider.ts
  2. 126 26
      src/views/Student/index.vue

+ 38 - 0
src/views/Student/CustomWebsocketProvider.ts

@@ -299,6 +299,44 @@ export class YjsRedisProvider extends Observable<string> {
     }
     }
   }
   }
 
 
+  /** 主动请求一次全量同步(iPad Safari 从后台恢复后补回错过的状态) */
+  public requestSync(): void {
+    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+      const encoder = encoding.createEncoder()
+      encoding.writeVarUint(encoder, messageSync)
+      syncProtocol.writeSyncStep1(encoder, this.doc)
+      this.ws.send(encoding.toUint8Array(encoder))
+    }
+  }
+
+  /**
+   * 强制重建连接
+   * iPad Safari 回到前台时,socket 可能处于"假 OPEN"状态(readyState 正常但收不到任何数据),
+   * 仅靠 onclose 不会触发重连,必须主动关闭后重建
+   */
+  public forceReconnect(): void {
+    this.synced = false
+    this.wsconnected = false
+    this.wsconnecting = false
+    if (this._resyncIntervalId) {
+      clearInterval(this._resyncIntervalId)
+      this._resyncIntervalId = null
+    }
+    const oldWs = this.ws
+    this.ws = null
+    if (oldWs) {
+      // 必须先解绑旧连接的处理器:否则旧连接异步触发 onclose 时会把 this.ws 置为 null,
+      // 覆盖掉即将建立的新连接
+      oldWs.onopen = null
+      oldWs.onmessage = null
+      oldWs.onclose = null
+      oldWs.onerror = null
+      try { oldWs.close() } catch (e) { /* ignore */ }
+    }
+    this.shouldConnect = true
+    this.connect()
+  }
+
   public disconnect(): void {
   public disconnect(): void {
     this.shouldConnect = false
     this.shouldConnect = false
     if (this._resyncIntervalId) {
     if (this._resyncIntervalId) {

+ 126 - 26
src/views/Student/index.vue

@@ -723,6 +723,9 @@ const authToken = ref<string | null>(null)
 const authTokenUpdateTimer = ref<NodeJS.Timeout | null>(null)
 const authTokenUpdateTimer = ref<NodeJS.Timeout | null>(null)
 const heartbeatTimer = ref<NodeJS.Timeout | null>(null)
 const heartbeatTimer = ref<NodeJS.Timeout | null>(null)
 const socketCheckTimer = ref<NodeJS.Timeout | null>(null)
 const socketCheckTimer = ref<NodeJS.Timeout | null>(null)
+// 连接超时提醒定时器 / 是否已提示过连接异常(避免重复弹窗)
+const connectionWarnTimer = ref<NodeJS.Timeout | null>(null)
+const socketWarned = ref(false)
 
 
 // 同步数据最大保留时间(40分钟)
 // 同步数据最大保留时间(40分钟)
 const SYNC_DATA_MAX_AGE = 40 * 60 * 1000 // 40分钟 = 40 * 60 * 1000毫秒
 const SYNC_DATA_MAX_AGE = 40 * 60 * 1000 // 40分钟 = 40 * 60 * 1000毫秒
@@ -1140,6 +1143,10 @@ const goToSlide = (index: number) => {
   }
   }
   else {
   else {
     console.warn('goToSlide: 无效的索引:', index)
     console.warn('goToSlide: 无效的索引:', index)
+    // 跟随模式下老师页数与本地页数不一致时,明确提示而不是静默失败
+    if (props.type == '2' && isFollowModeActive.value) {
+      message.warning(`未找到第 ${Number(index) + 1} 页,请刷新页面重新同步`)
+    }
   }
   }
 }
 }
 
 
@@ -2868,7 +2875,7 @@ const getCourseDetail = async () => {
   try {
   try {
     const res = await api.getCourseDetail(props.courseid as string)
     const res = await api.getCourseDetail(props.courseid as string)
     const opRes1 = await api.selectPPTOp({
     const opRes1 = await api.selectPPTOp({
-      id: props.courseid + '-' + props.cid,
+      id: props.courseid, //  + '-' + props.cid
       type: '1',
       type: '1',
       index: 0
       index: 0
     })
     })
@@ -3724,8 +3731,20 @@ const getMessages = (msgObj: any) => {
   console.log('message', msgObj)
   console.log('message', msgObj)
   
   
   // 处理幻灯片切换消息
   // 处理幻灯片切换消息
-  if (props.type == '2' && msgObj.type === 'slideIndex' && isFollowModeActive.value) {
-    goToSlide(msgObj.slideIndex)
+  if (props.type == '2' && msgObj.type === 'slideIndex') {
+    if (isFollowModeActive.value) {
+      goToSlide(msgObj.slideIndex)
+    }
+    else {
+      // sopen → selectCourseSLook 是异步请求,重放时 slideIndex 可能先到,
+      // 此刻跟随模式还没打开,延迟补一次,避免刷新后第一页不跟随
+      const targetIndex = msgObj.slideIndex
+      setTimeout(() => {
+        if (props.type == '2' && isFollowModeActive.value) {
+          goToSlide(targetIndex)
+        }
+      }, 1000)
+    }
   }
   }
   
   
   // 处理跟随模式状态变化
   // 处理跟随模式状态变化
@@ -4224,15 +4243,42 @@ onMounted(() => {
   
   
   // visibilitychange 事件(适用于 iframe 嵌套场景,当外层页面返回时触发)
   // visibilitychange 事件(适用于 iframe 嵌套场景,当外层页面返回时触发)
   const handleVisibilityChange = () => {
   const handleVisibilityChange = () => {
-    if (document.hidden && isCreator.value) {
-      // 页面被隐藏时,清空所有同步状态
-      clearAllSyncStates()
-      if (timerIndicator.value.visible && isCreator.value) {
-        sendMessage({ type: 'timer_stop', courseid: props.courseid, isCreator: isCreator.value ? 'T' : 'F' })
+    const provider = providerSocket.value
+    if (document.hidden) {
+      if (isCreator.value) {
+        // 页面被隐藏时,清空所有同步状态
+        clearAllSyncStates()
+        if (timerIndicator.value.visible) {
+          sendMessage({ type: 'timer_stop', courseid: props.courseid, isCreator: isCreator.value ? 'T' : 'F' })
+        }
+      }
+      return
+    }
+
+    // ★ iPad Safari 从后台切回前台:socket 常处于"假 OPEN"(readyState 正常但收不到数据)
+    //   或不满足权限导致挂起,这里主动补一次同步,必要时强制重建连接
+    if (provider) {
+      if (provider.ws && provider.ws.readyState === WebSocket.OPEN) {
+        console.log('🔁 页面恢复前台,主动请求同步')
+        message.warning('🔁 页面恢复前台,主动请求同步')
+        provider.requestSync()
+      }
+      else {
+        console.log('🔁 页面恢复前台,socket 已断开,强制重连')
+        message.warning('🔁 页面恢复前台,socket 已断开,强制重连')
+        provider.forceReconnect()
       }
       }
     }
     }
   }
   }
   document.addEventListener('visibilitychange', handleVisibilityChange)
   document.addEventListener('visibilitychange', handleVisibilityChange)
+
+  // bfcache 恢复(iPad Safari 后退/切换应用后从缓存恢复页面时触发)
+  const handlePageShow = (event: PageTransitionEvent) => {
+    if (!event.persisted) return
+    console.log('🔁 bfcache 恢复,强制重建 WebSocket')
+    providerSocket.value?.forceReconnect()
+  }
+  window.addEventListener('pageshow', handlePageShow)
   
   
   // pagehide 事件(作为补充,某些浏览器中比 beforeunload 更可靠)
   // pagehide 事件(作为补充,某些浏览器中比 beforeunload 更可靠)
   window.addEventListener('pagehide', () => {
   window.addEventListener('pagehide', () => {
@@ -4244,6 +4290,7 @@ onMounted(() => {
   ;(window as any).__pptistStudentUnloadHandlers = {
   ;(window as any).__pptistStudentUnloadHandlers = {
     // beforeunload: handlePageUnload,
     // beforeunload: handlePageUnload,
     visibilitychange: handleVisibilityChange,
     visibilitychange: handleVisibilityChange,
+    pageshow: handlePageShow,
     pagehide: handlePageUnload
     pagehide: handlePageUnload
   }
   }
 })
 })
@@ -4284,6 +4331,12 @@ onUnmounted(() => {
     socketCheckTimer.value = null
     socketCheckTimer.value = null
   }
   }
 
 
+  // 清理连接超时提醒定时器
+  if (connectionWarnTimer.value) {
+    clearTimeout(connectionWarnTimer.value)
+    connectionWarnTimer.value = null
+  }
+
   // if (providerSocket.value) {
   // if (providerSocket.value) {
   //   providerSocket.value.destroy()
   //   providerSocket.value.destroy()
   //   providerSocket.value = null
   //   providerSocket.value = null
@@ -4300,6 +4353,7 @@ onUnmounted(() => {
     const handlers = (window as any).__pptistStudentUnloadHandlers
     const handlers = (window as any).__pptistStudentUnloadHandlers
     // window.removeEventListener('beforeunload', handlers.beforeunload)
     // window.removeEventListener('beforeunload', handlers.beforeunload)
     document.removeEventListener('visibilitychange', handlers.visibilitychange)
     document.removeEventListener('visibilitychange', handlers.visibilitychange)
+    window.removeEventListener('pageshow', handlers.pageshow)
     window.removeEventListener('pagehide', handlers.pagehide)
     window.removeEventListener('pagehide', handlers.pagehide)
     delete (window as any).__pptistStudentUnloadHandlers
     delete (window as any).__pptistStudentUnloadHandlers
   }
   }
@@ -4468,7 +4522,7 @@ const createWebSocketConnection = async (type = 1) => {
     docSocket.value.gc = true
     docSocket.value.gc = true
     providerSocket.value = new YjsRedisProvider(
     providerSocket.value = new YjsRedisProvider(
       api.yweb_socket,
       api.yweb_socket,
-      'PPT' + props.courseid,
+      'PPT' + props.courseid + props.cid,
       docSocket.value,
       docSocket.value,
       {
       {
         params: { yauth: authToken.value },
         params: { yauth: authToken.value },
@@ -4518,6 +4572,25 @@ const createWebSocketConnection = async (type = 1) => {
     authTokenUpdateTimer.value = setTimeout(updateAuthToken, 30 * 60 * 1000) as unknown as NodeJS.Timeout
     authTokenUpdateTimer.value = setTimeout(updateAuthToken, 30 * 60 * 1000) as unknown as NodeJS.Timeout
     // authTokenUpdateTimer.value = setTimeout(updateAuthToken, 10 * 1000) as unknown as NodeJS.Timeout
     // authTokenUpdateTimer.value = setTimeout(updateAuthToken, 10 * 1000) as unknown as NodeJS.Timeout
 
 
+    // ★ iPad Safari 兼容:首次建连可能被系统推迟或静默挂起,超时后主动提示并重建
+    if (connectionWarnTimer.value) clearTimeout(connectionWarnTimer.value)
+    connectionWarnTimer.value = setTimeout(() => {
+      if (connectionStatus.value !== 'connected') {
+        console.warn('👉 WebSocket 建连超时,强制重连')
+        message.warning('实时连接超时,正在自动重试…')
+        providerSocket.value?.forceReconnect()
+      }
+    }, 12000) as unknown as NodeJS.Timeout
+
+    // ★ 同步超时提醒:连接成功但服务器状态迟迟未同步,学生端会表现为"不跟随"
+    setTimeout(() => {
+      const p = providerSocket.value
+      if (p && p.ws && p.ws.readyState === WebSocket.OPEN && !p.synced) {
+        console.warn('👉 已连接但未收到同步状态,主动请求同步')
+        p.requestSync()
+      }
+    }, 15000)
+
     providerSocket.value.on('status', (event: any) => {
     providerSocket.value.on('status', (event: any) => {
       console.log('👉 WebSocket状态:', event.status)
       console.log('👉 WebSocket状态:', event.status)
       
       
@@ -4526,6 +4599,11 @@ const createWebSocketConnection = async (type = 1) => {
         connectionStatus.value = 'connected'
         connectionStatus.value = 'connected'
         isConnecting.value = false
         isConnecting.value = false
         reconnectAttempts.value = 0 // 重置重连次数
         reconnectAttempts.value = 0 // 重置重连次数
+        socketWarned.value = false
+        if (connectionWarnTimer.value) {
+          clearTimeout(connectionWarnTimer.value)
+          connectionWarnTimer.value = null
+        }
         
         
         // 清理重连定时器
         // 清理重连定时器
         if (reconnectTimer.value) {
         if (reconnectTimer.value) {
@@ -4617,8 +4695,6 @@ const createWebSocketConnection = async (type = 1) => {
       const { subType, objs } = parsed
       const { subType, objs } = parsed
       // subType===1 是 syncStep2(服务器历史全量重放),其中的 timer_* 是旧消息,不能执行也不能打日志
       // subType===1 是 syncStep2(服务器历史全量重放),其中的 timer_* 是旧消息,不能执行也不能打日志
       const isReplay = subType === 1
       const isReplay = subType === 1
-      const timerTypes = ['timer_start', 'timer_pause', 'timer_reset', 'timer_stop', 'timer_finish', 'timer_update']
-      const writingBoardTypes = ['writing_board_update', 'writing_board_close', 'writing_board_blackboard']
       // 过滤出有效业务消息:不带业务字段的、自己发的、历史重放的 timer_* 全部剔除
       // 过滤出有效业务消息:不带业务字段的、自己发的、历史重放的 timer_* 全部剔除
       const validObjs = objs.filter((obj: Record<string, any>) => {
       const validObjs = objs.filter((obj: Record<string, any>) => {
         if (!obj.type && obj.slideIndex === undefined) return false
         if (!obj.type && obj.slideIndex === undefined) return false
@@ -4626,11 +4702,21 @@ const createWebSocketConnection = async (type = 1) => {
         if (isReplay && typeof obj.type === 'string' && obj.type.startsWith('timer_')) return false
         if (isReplay && typeof obj.type === 'string' && obj.type.startsWith('timer_')) return false
         return true
         return true
       })
       })
-      // 一次 update 可能带多个 struct(同一 Map 多次 set),只取最后一条处理
-      const lastObj = validObjs[validObjs.length - 1]
-      if (lastObj) {
-        console.log('📨 解析业务消息:', lastObj)
-        getMessages(lastObj)
+      // 按类型去重:同一类消息只保留最后一条(latest 与 slideIndex 是两条独立通道,
+      // 一次 syncStep2 重放会同时带上它们,不能只留全局最后一条,否则 sopen 被丢弃,
+      // 学生 isFollowModeActive 一直为 false → 之后所有 slideIndex 都被忽略,表现为"不跟随")
+      const lastByType = new Map<string, Record<string, any>>()
+      for (const obj of validObjs) {
+        const key = obj.type === 'slideIndex' ? 'slideIndex' : (obj.type || 'other')
+        lastByType.set(key, obj)
+      }
+      // slideIndex 必须最后执行(依赖 sopen 先打开跟随模式)
+      const orderedObjs = [...lastByType.entries()]
+        .sort(([a], [b]) => (a === 'slideIndex' ? 1 : 0) - (b === 'slideIndex' ? 1 : 0))
+        .map(([, v]) => v)
+      for (const obj of orderedObjs) {
+        console.log('📨 解析业务消息:', obj)
+        getMessages(obj)
       }
       }
 
 
       // ★ 消息处理后检查特殊消息数组长度,超出则清理(替代 observe 里的清理逻辑)
       // ★ 消息处理后检查特殊消息数组长度,超出则清理(替代 observe 里的清理逻辑)
@@ -4687,7 +4773,7 @@ const createWebSocketConnection = async (type = 1) => {
   }
   }
 
 
   // 启动 socket 连接检查定时器
   // 启动 socket 连接检查定时器
-  // startSocketCheckTimer()
+  startSocketCheckTimer()
 }
 }
 
 
 // ✅ 提取业务初始化逻辑
 // ✅ 提取业务初始化逻辑
@@ -4767,17 +4853,31 @@ const startSocketCheckTimer = () => {
     socketCheckTimer.value = null
     socketCheckTimer.value = null
   }
   }
   
   
-  // 每10秒检查一次 socket 连接状态
+  // 每10秒检查一次连接状态
+  // ★ iPad Safari 的 socket 可能"假 OPEN"(readyState=1 但收不到任何数据),
+  //   仅看 readyState 不够,还要看同步状态 synced
   socketCheckTimer.value = setInterval(() => {
   socketCheckTimer.value = setInterval(() => {
-    if (providerSocket.value) {
-      // 直接检查 providerSocket 的连接状态
-      // WebsocketProvider 有一个 connected 属性来表示连接状态
-      const isConnected = (providerSocket.value as any).ws.readyState
-      console.log('🔍 定时器检查 socket 连接状态:', isConnected)
-      if (isConnected !== 1) {
-        console.log('🔍 定时器检查发现 socket 未连接,执行重连')
-        createWebSocketConnection(2)
+    const provider = providerSocket.value
+    if (!provider || !docSocket.value) return
+
+    const readyState = provider.ws ? provider.ws.readyState : WebSocket.CLOSED
+    console.log('🔍 定时器检查 socket 连接状态:', readyState, 'synced:', provider.synced)
+
+    if (readyState !== WebSocket.OPEN) {
+      console.log('🔍 定时器检查发现 socket 未连接,执行重连')
+      provider.forceReconnect()
+      // 首次异常时提示,避免反复弹窗
+      if (!socketWarned.value) {
+        socketWarned.value = true
+        message.warning('实时连接已断开,正在自动重连…')
       }
       }
+      return
+    }
+
+    if (!provider.synced) {
+      console.log('🔍 定时器检查发现未完成同步,主动请求同步')
+      message.warning('🔍定时器检查发现未完成同步,主动请求同步')
+      provider.requestSync()
     }
     }
   }, 10000) as unknown as NodeJS.Timeout
   }, 10000) as unknown as NodeJS.Timeout
 }
 }