|
|
@@ -723,23 +723,6 @@ const authToken = ref<string | null>(null)
|
|
|
const authTokenUpdateTimer = ref<NodeJS.Timeout | null>(null)
|
|
|
const heartbeatTimer = ref<NodeJS.Timeout | null>(null)
|
|
|
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毫秒
|
|
|
@@ -1157,10 +1140,6 @@ const goToSlide = (index: number) => {
|
|
|
}
|
|
|
else {
|
|
|
console.warn('goToSlide: 无效的索引:', index)
|
|
|
- // 跟随模式下老师页数与本地页数不一致时,明确提示而不是静默失败
|
|
|
- if (props.type == '2' && isFollowModeActive.value) {
|
|
|
- message.warning(`未找到第 ${Number(index) + 1} 页,请刷新页面重新同步`)
|
|
|
- }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2889,7 +2868,7 @@ const getCourseDetail = async () => {
|
|
|
try {
|
|
|
const res = await api.getCourseDetail(props.courseid as string)
|
|
|
const opRes1 = await api.selectPPTOp({
|
|
|
- id: props.courseid, // + '-' + props.cid
|
|
|
+ id: props.courseid + '-' + props.cid,
|
|
|
type: '1',
|
|
|
index: 0
|
|
|
})
|
|
|
@@ -3745,20 +3724,8 @@ const getMessages = (msgObj: any) => {
|
|
|
console.log('message', msgObj)
|
|
|
|
|
|
// 处理幻灯片切换消息
|
|
|
- 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)
|
|
|
- }
|
|
|
+ if (props.type == '2' && msgObj.type === 'slideIndex' && isFollowModeActive.value) {
|
|
|
+ goToSlide(msgObj.slideIndex)
|
|
|
}
|
|
|
|
|
|
// 处理跟随模式状态变化
|
|
|
@@ -4257,60 +4224,15 @@ onMounted(() => {
|
|
|
|
|
|
// visibilitychange 事件(适用于 iframe 嵌套场景,当外层页面返回时触发)
|
|
|
const handleVisibilityChange = () => {
|
|
|
- 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()
|
|
|
+ if (document.hidden && isCreator.value) {
|
|
|
+ // 页面被隐藏时,清空所有同步状态
|
|
|
+ clearAllSyncStates()
|
|
|
+ if (timerIndicator.value.visible && isCreator.value) {
|
|
|
+ sendMessage({ type: 'timer_stop', courseid: props.courseid, isCreator: isCreator.value ? 'T' : 'F' })
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
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)
|
|
|
-
|
|
|
- // ★ 全局错误监控:页面内所有脚本错误 / 未捕获的 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', () => {
|
|
|
@@ -4322,9 +4244,6 @@ onMounted(() => {
|
|
|
;(window as any).__pptistStudentUnloadHandlers = {
|
|
|
// beforeunload: handlePageUnload,
|
|
|
visibilitychange: handleVisibilityChange,
|
|
|
- pageshow: handlePageShow,
|
|
|
- globalError: handleGlobalError,
|
|
|
- unhandledrejection: handleUnhandledRejection,
|
|
|
pagehide: handlePageUnload
|
|
|
}
|
|
|
})
|
|
|
@@ -4365,12 +4284,6 @@ onUnmounted(() => {
|
|
|
socketCheckTimer.value = null
|
|
|
}
|
|
|
|
|
|
- // 清理连接超时提醒定时器
|
|
|
- if (connectionWarnTimer.value) {
|
|
|
- clearTimeout(connectionWarnTimer.value)
|
|
|
- connectionWarnTimer.value = null
|
|
|
- }
|
|
|
-
|
|
|
// if (providerSocket.value) {
|
|
|
// providerSocket.value.destroy()
|
|
|
// providerSocket.value = null
|
|
|
@@ -4387,9 +4300,6 @@ onUnmounted(() => {
|
|
|
const handlers = (window as any).__pptistStudentUnloadHandlers
|
|
|
// 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
|
|
|
}
|
|
|
@@ -4608,25 +4518,6 @@ const createWebSocketConnection = async (type = 1) => {
|
|
|
authTokenUpdateTimer.value = setTimeout(updateAuthToken, 30 * 60 * 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) => {
|
|
|
console.log('👉 WebSocket状态:', event.status)
|
|
|
|
|
|
@@ -4635,11 +4526,6 @@ const createWebSocketConnection = async (type = 1) => {
|
|
|
connectionStatus.value = 'connected'
|
|
|
isConnecting.value = false
|
|
|
reconnectAttempts.value = 0 // 重置重连次数
|
|
|
- socketWarned.value = false
|
|
|
- if (connectionWarnTimer.value) {
|
|
|
- clearTimeout(connectionWarnTimer.value)
|
|
|
- connectionWarnTimer.value = null
|
|
|
- }
|
|
|
|
|
|
// 清理重连定时器
|
|
|
if (reconnectTimer.value) {
|
|
|
@@ -4720,79 +4606,53 @@ 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) => {
|
|
|
- // ★ 前后全程错误捕获:解析/业务处理任何一步异常都弹窗提示(iPad 上看不到控制台)
|
|
|
+ const parsed = parseYjsMessage(data)
|
|
|
+ if (!parsed || parsed.objs.length === 0) return
|
|
|
+ const { subType, objs } = parsed
|
|
|
+ // subType===1 是 syncStep2(服务器历史全量重放),其中的 timer_* 是旧消息,不能执行也不能打日志
|
|
|
+ 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']
|
|
|
+ // 按类型去重:同一类消息只保留最后一条(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 里的清理逻辑)
|
|
|
try {
|
|
|
- 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 (yTimerMessages.value && yTimerMessages.value.length > 500 && isCreator.value) {
|
|
|
+ smartCleanupMessages(yTimerMessages.value, 500, 'timer', 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 (yLaserMessages.value && yLaserMessages.value.length > 500 && isCreator.value) {
|
|
|
+ smartCleanupMessages(yLaserMessages.value, 500, 'laser', 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 (yWritingBoardMessages.value && yWritingBoardMessages.value.length > 500 && isCreator.value) {
|
|
|
+ smartCleanupMessages(yWritingBoardMessages.value, 500, 'writingBoard', docSocket.value!)
|
|
|
}
|
|
|
- catch (cleanupErr) {
|
|
|
- // 清理失败不影响消息处理
|
|
|
+ if (yIsResultArrayMessages.value && yIsResultArrayMessages.value.length > 1 && isCreator.value) {
|
|
|
+ smartCleanupMessages(yIsResultArrayMessages.value, 1, 'isResultArray', docSocket.value!)
|
|
|
}
|
|
|
}
|
|
|
- catch (err: any) {
|
|
|
- console.error('📨 raw-message 处理失败:', err)
|
|
|
- warnOnce(`消息解析出错: ${err?.message || err}`)
|
|
|
+ catch (cleanupErr) {
|
|
|
+ // 清理失败不影响消息处理
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -4830,7 +4690,7 @@ const createWebSocketConnection = async (type = 1) => {
|
|
|
}
|
|
|
|
|
|
// 启动 socket 连接检查定时器
|
|
|
- startSocketCheckTimer()
|
|
|
+ // startSocketCheckTimer()
|
|
|
}
|
|
|
|
|
|
// ✅ 提取业务初始化逻辑
|
|
|
@@ -4910,31 +4770,17 @@ const startSocketCheckTimer = () => {
|
|
|
socketCheckTimer.value = null
|
|
|
}
|
|
|
|
|
|
- // 每10秒检查一次连接状态
|
|
|
- // ★ iPad Safari 的 socket 可能"假 OPEN"(readyState=1 但收不到任何数据),
|
|
|
- // 仅看 readyState 不够,还要看同步状态 synced
|
|
|
+ // 每10秒检查一次 socket 连接状态
|
|
|
socketCheckTimer.value = setInterval(() => {
|
|
|
- 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('实时连接已断开,正在自动重连…')
|
|
|
+ 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)
|
|
|
}
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (!provider.synced) {
|
|
|
- console.log('🔍 定时器检查发现未完成同步,主动请求同步')
|
|
|
- message.warning('🔍定时器检查发现未完成同步,主动请求同步')
|
|
|
- provider.requestSync()
|
|
|
}
|
|
|
}, 10000) as unknown as NodeJS.Timeout
|
|
|
}
|