|
|
@@ -723,6 +723,9 @@ 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)
|
|
|
|
|
|
// 同步数据最大保留时间(40分钟)
|
|
|
const SYNC_DATA_MAX_AGE = 40 * 60 * 1000 // 40分钟 = 40 * 60 * 1000毫秒
|
|
|
@@ -1140,6 +1143,10 @@ const goToSlide = (index: number) => {
|
|
|
}
|
|
|
else {
|
|
|
console.warn('goToSlide: 无效的索引:', index)
|
|
|
+ // 跟随模式下老师页数与本地页数不一致时,明确提示而不是静默失败
|
|
|
+ if (props.type == '2' && isFollowModeActive.value) {
|
|
|
+ message.warning(`未找到第 ${Number(index) + 1} 页,请刷新页面重新同步`)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2868,7 +2875,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
|
|
|
})
|
|
|
@@ -3724,8 +3731,20 @@ const getMessages = (msgObj: any) => {
|
|
|
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 嵌套场景,当外层页面返回时触发)
|
|
|
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)
|
|
|
+
|
|
|
+ // bfcache 恢复(iPad Safari 后退/切换应用后从缓存恢复页面时触发)
|
|
|
+ const handlePageShow = (event: PageTransitionEvent) => {
|
|
|
+ if (!event.persisted) return
|
|
|
+ console.log('🔁 bfcache 恢复,强制重建 WebSocket')
|
|
|
+ providerSocket.value?.forceReconnect()
|
|
|
+ }
|
|
|
+ window.addEventListener('pageshow', handlePageShow)
|
|
|
|
|
|
// pagehide 事件(作为补充,某些浏览器中比 beforeunload 更可靠)
|
|
|
window.addEventListener('pagehide', () => {
|
|
|
@@ -4244,6 +4290,7 @@ onMounted(() => {
|
|
|
;(window as any).__pptistStudentUnloadHandlers = {
|
|
|
// beforeunload: handlePageUnload,
|
|
|
visibilitychange: handleVisibilityChange,
|
|
|
+ pageshow: handlePageShow,
|
|
|
pagehide: handlePageUnload
|
|
|
}
|
|
|
})
|
|
|
@@ -4284,6 +4331,12 @@ onUnmounted(() => {
|
|
|
socketCheckTimer.value = null
|
|
|
}
|
|
|
|
|
|
+ // 清理连接超时提醒定时器
|
|
|
+ if (connectionWarnTimer.value) {
|
|
|
+ clearTimeout(connectionWarnTimer.value)
|
|
|
+ connectionWarnTimer.value = null
|
|
|
+ }
|
|
|
+
|
|
|
// if (providerSocket.value) {
|
|
|
// providerSocket.value.destroy()
|
|
|
// providerSocket.value = null
|
|
|
@@ -4300,6 +4353,7 @@ onUnmounted(() => {
|
|
|
const handlers = (window as any).__pptistStudentUnloadHandlers
|
|
|
// window.removeEventListener('beforeunload', handlers.beforeunload)
|
|
|
document.removeEventListener('visibilitychange', handlers.visibilitychange)
|
|
|
+ window.removeEventListener('pageshow', handlers.pageshow)
|
|
|
window.removeEventListener('pagehide', handlers.pagehide)
|
|
|
delete (window as any).__pptistStudentUnloadHandlers
|
|
|
}
|
|
|
@@ -4468,7 +4522,7 @@ const createWebSocketConnection = async (type = 1) => {
|
|
|
docSocket.value.gc = true
|
|
|
providerSocket.value = new YjsRedisProvider(
|
|
|
api.yweb_socket,
|
|
|
- 'PPT' + props.courseid,
|
|
|
+ 'PPT' + props.courseid + props.cid,
|
|
|
docSocket.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, 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)
|
|
|
|
|
|
@@ -4526,6 +4599,11 @@ 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) {
|
|
|
@@ -4617,8 +4695,6 @@ const createWebSocketConnection = async (type = 1) => {
|
|
|
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']
|
|
|
// 过滤出有效业务消息:不带业务字段的、自己发的、历史重放的 timer_* 全部剔除
|
|
|
const validObjs = objs.filter((obj: Record<string, any>) => {
|
|
|
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
|
|
|
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 里的清理逻辑)
|
|
|
@@ -4687,7 +4773,7 @@ const createWebSocketConnection = async (type = 1) => {
|
|
|
}
|
|
|
|
|
|
// 启动 socket 连接检查定时器
|
|
|
- // startSocketCheckTimer()
|
|
|
+ startSocketCheckTimer()
|
|
|
}
|
|
|
|
|
|
// ✅ 提取业务初始化逻辑
|
|
|
@@ -4767,17 +4853,31 @@ const startSocketCheckTimer = () => {
|
|
|
socketCheckTimer.value = null
|
|
|
}
|
|
|
|
|
|
- // 每10秒检查一次 socket 连接状态
|
|
|
+ // 每10秒检查一次连接状态
|
|
|
+ // ★ iPad Safari 的 socket 可能"假 OPEN"(readyState=1 但收不到任何数据),
|
|
|
+ // 仅看 readyState 不够,还要看同步状态 synced
|
|
|
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
|
|
|
}
|