Browse Source

feat: 增加全局错误捕获与提示机制,优化WebSocket消息处理稳定性

为解决iPad Safari等无控制台环境下的问题,新增全局脚本错误、未捕获Promise异常的监听与去重提示;完善WebSocket消息处理全程的异常捕获,将错误转发到上层弹窗提示;同时优化旧消息数组清理逻辑,新增isResultArray消息的清理支持。
lsc 5 days ago
parent
commit
027f47bc3c
2 changed files with 123 additions and 48 deletions
  1. 26 8
      src/views/Student/CustomWebsocketProvider.ts
  2. 97 40
      src/views/Student/index.vue

+ 26 - 8
src/views/Student/CustomWebsocketProvider.ts

@@ -206,14 +206,29 @@ export class YjsRedisProvider extends Observable<string> {
     }
     }
 
 
     ws.onmessage = (event: MessageEvent) => {
     ws.onmessage = (event: MessageEvent) => {
-      if (event.data instanceof ArrayBuffer) {
-        this._readMessage(new Uint8Array(event.data))
-      }
-      else if (event.data instanceof Blob) {
-        event.data.arrayBuffer().then(buf => this._readMessage(new Uint8Array(buf)))
+      // ★ 前后全程错误捕获:任何消息处理异常都抛给上层(index.vue 监听后 message.warning)
+      try {
+        if (event.data instanceof ArrayBuffer) {
+          this._readMessage(new Uint8Array(event.data))
+        }
+        else if (event.data instanceof Blob) {
+          event.data.arrayBuffer()
+            .then(buf => {
+              try {
+                this._readMessage(new Uint8Array(buf))
+              }
+              catch (err) {
+                this.emit('ws-error', [{ stage: 'onmessage-blob', error: err }])
+              }
+            })
+            .catch(err => this.emit('ws-error', [{ stage: 'onmessage-blob-read', error: err }]))
+        }
+        else if (typeof event.data === 'string') {
+          this._readMessage(new TextEncoder().encode(event.data))
+        }
       }
       }
-      else if (typeof event.data === 'string') {
-        this._readMessage(new TextEncoder().encode(event.data))
+      catch (err) {
+        this.emit('ws-error', [{ stage: 'onmessage', error: err }])
       }
       }
     }
     }
 
 
@@ -331,7 +346,10 @@ export class YjsRedisProvider extends Observable<string> {
       oldWs.onmessage = null
       oldWs.onmessage = null
       oldWs.onclose = null
       oldWs.onclose = null
       oldWs.onerror = null
       oldWs.onerror = null
-      try { oldWs.close() } catch (e) { /* ignore */ }
+      try {
+        oldWs.close() 
+      }
+      catch (e) { /* ignore */ }
     }
     }
     this.shouldConnect = true
     this.shouldConnect = true
     this.connect()
     this.connect()

+ 97 - 40
src/views/Student/index.vue

@@ -727,6 +727,20 @@ const socketCheckTimer = ref<NodeJS.Timeout | null>(null)
 const connectionWarnTimer = ref<NodeJS.Timeout | null>(null)
 const connectionWarnTimer = ref<NodeJS.Timeout | null>(null)
 const socketWarned = ref(false)
 const socketWarned = ref(false)
 
 
+// 全局报错提示去重:同一条错误只弹一次,避免刷屏(iPad 上无法方便看控制台,直接弹窗暴露问题)
+const warnedErrorMessages = new Set<string>()
+const warnOnce = (msg: string) => {
+  if (!msg || warnedErrorMessages.has(msg)) return
+  warnedErrorMessages.add(msg)
+  message.warning(msg)
+}
+// 常见无害错误过滤(Safari 高频且不影响功能)
+const isHarmlessError = (msg: string) => {
+  return msg.includes('ResizeObserver loop')
+    || msg.includes('Script error')
+    || msg.includes('Load failed') // Safari fetch 失败的泛化提示,业务请求已有自己的错误提示
+}
+
 // 同步数据最大保留时间(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毫秒
 
 
@@ -4279,6 +4293,24 @@ onMounted(() => {
     providerSocket.value?.forceReconnect()
     providerSocket.value?.forceReconnect()
   }
   }
   window.addEventListener('pageshow', handlePageShow)
   window.addEventListener('pageshow', handlePageShow)
+
+  // ★ 全局错误监控:页面内所有脚本错误 / 未捕获的 Promise 异常都弹窗提示
+  //   (iPad Safari 上看不到控制台,报错只能以这种方式暴露)
+  const handleGlobalError = (event: ErrorEvent) => {
+    const msg = event.message || '未知脚本错误'
+    if (isHarmlessError(msg)) return
+    console.error('🌐 全局错误:', event.message, event.filename, event.lineno)
+    warnOnce(`页面报错: ${msg}(第${event.lineno || '?'}行)`)
+  }
+  const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
+    const reason: any = event.reason
+    const msg = reason?.message || String(reason) || '未处理的 Promise 异常'
+    if (isHarmlessError(msg)) return
+    console.error('🌐 未捕获的 Promise 异常:', reason)
+    warnOnce(`异步报错: ${msg}`)
+  }
+  window.addEventListener('error', handleGlobalError)
+  window.addEventListener('unhandledrejection', handleUnhandledRejection)
   
   
   // pagehide 事件(作为补充,某些浏览器中比 beforeunload 更可靠)
   // pagehide 事件(作为补充,某些浏览器中比 beforeunload 更可靠)
   window.addEventListener('pagehide', () => {
   window.addEventListener('pagehide', () => {
@@ -4291,6 +4323,8 @@ onMounted(() => {
     // beforeunload: handlePageUnload,
     // beforeunload: handlePageUnload,
     visibilitychange: handleVisibilityChange,
     visibilitychange: handleVisibilityChange,
     pageshow: handlePageShow,
     pageshow: handlePageShow,
+    globalError: handleGlobalError,
+    unhandledrejection: handleUnhandledRejection,
     pagehide: handlePageUnload
     pagehide: handlePageUnload
   }
   }
 })
 })
@@ -4354,6 +4388,8 @@ onUnmounted(() => {
     // 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('pageshow', handlers.pageshow)
+    window.removeEventListener('error', handlers.globalError)
+    window.removeEventListener('unhandledrejection', handlers.unhandledrejection)
     window.removeEventListener('pagehide', handlers.pagehide)
     window.removeEventListener('pagehide', handlers.pagehide)
     delete (window as any).__pptistStudentUnloadHandlers
     delete (window as any).__pptistStudentUnloadHandlers
   }
   }
@@ -4684,58 +4720,79 @@ const createWebSocketConnection = async (type = 1) => {
       console.error('👉 WebSocket 连接错误(将自动重连):', error)
       console.error('👉 WebSocket 连接错误(将自动重连):', error)
     })
     })
 
 
+    // ★ onmessage 处理过程中抛出的异常(provider 内部捕获后转发)
+    providerSocket.value.on('ws-error', (info: any) => {
+      const errMsg = info?.error?.message || String(info?.error) || '未知错误'
+      console.error(`👉 WS onmessage 处理出错(${info?.stage}):`, info?.error)
+      warnOnce(`消息接收出错(${info?.stage}): ${errMsg}`)
+    })
+
     providerSocket.value.on('connection-close', (event: any) => {
     providerSocket.value.on('connection-close', (event: any) => {
       console.log('👉 WebSocket 连接关闭(将自动重连):', event)
       console.log('👉 WebSocket 连接关闭(将自动重连):', event)
     })
     })
     
     
     // ★ 所有消息处理走 raw-message,不再依赖 observe(observe 因服务器累积 DELETE 标记不触发)
     // ★ 所有消息处理走 raw-message,不再依赖 observe(observe 因服务器累积 DELETE 标记不触发)
     providerSocket.value.on('raw-message', (data: Uint8Array) => {
     providerSocket.value.on('raw-message', (data: Uint8Array) => {
-      const parsed = parseYjsMessage(data)
-      if (!parsed || parsed.objs.length === 0) return
-      const { subType, objs } = parsed
-      // subType===1 是 syncStep2(服务器历史全量重放),其中的 timer_* 是旧消息,不能执行也不能打日志
-      const isReplay = subType === 1
-      // 过滤出有效业务消息:不带业务字段的、自己发的、历史重放的 timer_* 全部剔除
-      const validObjs = objs.filter((obj: Record<string, any>) => {
-        if (!obj.type && obj.slideIndex === undefined) return false
-        if (obj.mId && obj.mId === mId.value) return false
-        if (isReplay && typeof obj.type === 'string' && obj.type.startsWith('timer_')) return false
-        return true
-      })
-      // 按类型去重:同一类消息只保留最后一条(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 里的清理逻辑)
+      // ★ 前后全程错误捕获:解析/业务处理任何一步异常都弹窗提示(iPad 上看不到控制台)
       try {
       try {
-        if (yTimerMessages.value && yTimerMessages.value.length > 500 && isCreator.value) {
-          smartCleanupMessages(yTimerMessages.value, 500, 'timer', docSocket.value!)
+        const parsed = parseYjsMessage(data)
+        if (!parsed || parsed.objs.length === 0) return
+        const { subType, objs } = parsed
+        // subType===1 是 syncStep2(服务器历史全量重放),其中的 timer_* 是旧消息,不能执行也不能打日志
+        const isReplay = subType === 1
+        // 过滤出有效业务消息:不带业务字段的、自己发的、历史重放的 timer_* 全部剔除
+        const validObjs = objs.filter((obj: Record<string, any>) => {
+          if (!obj.type && obj.slideIndex === undefined) return false
+          if (obj.mId && obj.mId === mId.value) return false
+          if (isReplay && typeof obj.type === 'string' && obj.type.startsWith('timer_')) return false
+          return true
+        })
+        // 按类型去重:同一类消息只保留最后一条(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)
         }
         }
-        if (yLaserMessages.value && yLaserMessages.value.length > 500 && isCreator.value) {
-          smartCleanupMessages(yLaserMessages.value, 500, 'laser', docSocket.value!)
+        // 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)
+          try {
+            getMessages(obj)
+          }
+          catch (msgErr: any) {
+            // 单条消息处理失败不影响后续消息,但要提示出来
+            console.error('📨 消息处理出错:', msgErr, obj)
+            warnOnce(`消息处理出错(${obj?.type || 'unknown'}): ${msgErr?.message || msgErr}`)
+          }
         }
         }
-        if (yWritingBoardMessages.value && yWritingBoardMessages.value.length > 500 && isCreator.value) {
-          smartCleanupMessages(yWritingBoardMessages.value, 500, 'writingBoard', docSocket.value!)
+
+        // ★ 消息处理后检查特殊消息数组长度,超出则清理(替代 observe 里的清理逻辑)
+        try {
+          if (yTimerMessages.value && yTimerMessages.value.length > 500 && isCreator.value) {
+            smartCleanupMessages(yTimerMessages.value, 500, 'timer', docSocket.value!)
+          }
+          if (yLaserMessages.value && yLaserMessages.value.length > 500 && isCreator.value) {
+            smartCleanupMessages(yLaserMessages.value, 500, 'laser', docSocket.value!)
+          }
+          if (yWritingBoardMessages.value && yWritingBoardMessages.value.length > 500 && isCreator.value) {
+            smartCleanupMessages(yWritingBoardMessages.value, 500, 'writingBoard', docSocket.value!)
+          }
+          if (yIsResultArrayMessages.value && yIsResultArrayMessages.value.length > 1 && isCreator.value) {
+            smartCleanupMessages(yIsResultArrayMessages.value, 1, 'isResultArray', docSocket.value!)
+          }
         }
         }
-        if (yIsResultArrayMessages.value && yIsResultArrayMessages.value.length > 1 && isCreator.value) {
-          smartCleanupMessages(yIsResultArrayMessages.value, 1, 'isResultArray', docSocket.value!)
+        catch (cleanupErr) {
+          // 清理失败不影响消息处理
         }
         }
       }
       }
-      catch (cleanupErr) {
-        // 清理失败不影响消息处理
+      catch (err: any) {
+        console.error('📨 raw-message 处理失败:', err)
+        warnOnce(`消息解析出错: ${err?.message || err}`)
       }
       }
     })
     })