|
|
@@ -727,6 +727,20 @@ const socketCheckTimer = ref<NodeJS.Timeout | null>(null)
|
|
|
const connectionWarnTimer = ref<NodeJS.Timeout | null>(null)
|
|
|
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分钟)
|
|
|
const SYNC_DATA_MAX_AGE = 40 * 60 * 1000 // 40分钟 = 40 * 60 * 1000毫秒
|
|
|
|
|
|
@@ -4279,6 +4293,24 @@ onMounted(() => {
|
|
|
providerSocket.value?.forceReconnect()
|
|
|
}
|
|
|
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 更可靠)
|
|
|
window.addEventListener('pagehide', () => {
|
|
|
@@ -4291,6 +4323,8 @@ onMounted(() => {
|
|
|
// beforeunload: handlePageUnload,
|
|
|
visibilitychange: handleVisibilityChange,
|
|
|
pageshow: handlePageShow,
|
|
|
+ globalError: handleGlobalError,
|
|
|
+ unhandledrejection: handleUnhandledRejection,
|
|
|
pagehide: handlePageUnload
|
|
|
}
|
|
|
})
|
|
|
@@ -4354,6 +4388,8 @@ onUnmounted(() => {
|
|
|
// window.removeEventListener('beforeunload', handlers.beforeunload)
|
|
|
document.removeEventListener('visibilitychange', handlers.visibilitychange)
|
|
|
window.removeEventListener('pageshow', handlers.pageshow)
|
|
|
+ window.removeEventListener('error', handlers.globalError)
|
|
|
+ window.removeEventListener('unhandledrejection', handlers.unhandledrejection)
|
|
|
window.removeEventListener('pagehide', handlers.pagehide)
|
|
|
delete (window as any).__pptistStudentUnloadHandlers
|
|
|
}
|
|
|
@@ -4684,58 +4720,79 @@ const createWebSocketConnection = async (type = 1) => {
|
|
|
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) => {
|
|
|
console.log('👉 WebSocket 连接关闭(将自动重连):', event)
|
|
|
})
|
|
|
|
|
|
// ★ 所有消息处理走 raw-message,不再依赖 observe(observe 因服务器累积 DELETE 标记不触发)
|
|
|
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 {
|
|
|
- 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}`)
|
|
|
}
|
|
|
})
|
|
|
|