Browse Source

feat(student): 优化计时器同步逻辑与状态管理

新增 ParsedYjsMessage 接口规范消息解析结果
修改 parseYjsMessage 返回结构化结果而非数组
替换 timerStateMap.clear() 为写入完整关闭状态
新增 sync 兜底恢复逻辑同步计时器与激光笔状态
重构计时器本地计时逻辑,改用锚点时间计算进度
修复计时器刷新后无法正确恢复状态的问题
优化历史重放消息过滤逻辑,避免执行旧消息
lsc 1 week ago
parent
commit
bae68464e3
2 changed files with 179 additions and 84 deletions
  1. 167 78
      src/views/Student/index.vue
  2. 12 6
      src/views/Student/parseYjsMessage.ts

+ 167 - 78
src/views/Student/index.vue

@@ -1542,10 +1542,18 @@ const clearAllSyncStates = () => {
         if (writingBoardMessagesArray) {
           writingBoardMessagesArray.delete(0, writingBoardMessagesArray.length)
         }
-        // 清空计时器状态
+        // 关闭计时器状态(不用 clear(),避免 DELETE 标记毒化 Map)
         const timerStateMap = docSocket.value?.getMap?.('timerState')
         if (timerStateMap) {
-          timerStateMap.clear()
+          timerStateMap.set('visible', false)
+          timerStateMap.set('isCountdown', false)
+          timerStateMap.set('status', 'stopped')
+          timerStateMap.set('startAt', null)
+          timerStateMap.set('durationSec', null)
+          timerStateMap.set('elapsedBaseSec', null)
+          timerStateMap.set('remainingBaseSec', null)
+          timerStateMap.set('finished', true)
+          timerStateMap.set('stopped', true)
         }
         // 清空激光笔状态
         const laserStateMap = docSocket.value?.getMap?.('laserState')
@@ -3445,10 +3453,18 @@ const messageInit = () => {
       if (writingBoardMessagesArray) {
         writingBoardMessagesArray.delete(0, writingBoardMessagesArray.length)
       }
-      // 清空计时器状态
+      // 关闭计时器状态(不用 clear(),避免 DELETE 标记毒化 Map)
       const timerStateMap = docSocket.value?.getMap?.('timerState')
       if (timerStateMap) {
-        timerStateMap.clear()
+        timerStateMap.set('visible', false)
+        timerStateMap.set('isCountdown', false)
+        timerStateMap.set('status', 'stopped')
+        timerStateMap.set('startAt', null)
+        timerStateMap.set('durationSec', null)
+        timerStateMap.set('elapsedBaseSec', null)
+        timerStateMap.set('remainingBaseSec', null)
+        timerStateMap.set('finished', true)
+        timerStateMap.set('stopped', true)
       }
       // 清空激光笔状态
       const laserStateMap = docSocket.value?.getMap?.('laserState')
@@ -4502,6 +4518,36 @@ const createWebSocketConnection = async (type = 1) => {
       // }
     })
 
+    // ★ sync 完成后的兜底恢复:observe 在 syncStep2 阶段可能不触发,
+    //   必须在这里显式用 Map 快照恢复计时器/激光笔状态,并追平最新消息
+    providerSocket.value.on('sync', () => {
+      try {
+        if (!docSocket.value) return
+        // 计时器状态(学生刷新后靠这里恢复"正在执行"的倒计时)
+        const timerMap = docSocket.value.getMap('timerState')
+        yTimerState.value = timerMap
+        applyTimerStateSnapshot(timerMap.toJSON())
+        // 激光笔状态
+        const laserMap = docSocket.value.getMap('laserState')
+        yLaserState.value = laserMap
+        applyLaserStateSnapshot(laserMap.toJSON())
+        // 追平最新一条消息(latest / slideIndex)
+        if (yMessage.value) {
+          const latest = yMessage.value.get('latest')
+          if (latest && latest.mId !== mId.value) {
+            getMessages(latest)
+          }
+          const slideIndex = yMessage.value.get('slideIndex')
+          if (slideIndex && slideIndex.mId !== mId.value) {
+            getMessages(slideIndex)
+          }
+        }
+      }
+      catch (e) {
+        console.warn('sync 兜底恢复失败', e)
+      }
+    })
+
     // ✅ 只记录日志,不干预状态
     providerSocket.value.on('connection-error', (error: any) => {
       console.error('👉 WebSocket 连接错误(将自动重连):', error)
@@ -4513,8 +4559,11 @@ const createWebSocketConnection = async (type = 1) => {
     
     // ★ 所有消息处理走 raw-message,不再依赖 observe(observe 因服务器累积 DELETE 标记不触发)
     providerSocket.value.on('raw-message', (data: Uint8Array) => {
-      const objs = parseYjsMessage(data)
-      if (!objs || objs.length === 0) return
+      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']
       for (const obj of objs) {
@@ -4522,10 +4571,21 @@ const createWebSocketConnection = async (type = 1) => {
         if (!obj.type && obj.slideIndex === undefined) continue
         // mId 过滤,跳过自己发的
         if (obj.mId && obj.mId === mId.value) continue
+        // 历史重放的计时器消息直接跳过(学生刷新时服务器会把旧的 timer_stop 一起重放)
+        if (isReplay && typeof obj.type === 'string' && obj.type.startsWith('timer_')) continue
 
         console.log('📨 解析业务消息:', obj)
-        if (!timerTypes.includes(obj.type) && !writingBoardTypes.includes(obj.type)) {
+        // 简化判断逻辑:writingBoard 类型永远跳过 getMessages;
+        // timer 类型仅在非创建人时才走 getMessages;其余类型一律走 getMessages
+        const isWritingBoard = writingBoardTypes.includes(obj.type)
+        const isTimer = timerTypes.includes(obj.type)
+        if (isTimer && !isCreator.value) {
+          getMessages(obj)
+          return
+        }
+        if (!isWritingBoard) {
           getMessages(obj)
+          return
         }
       }
 
@@ -4720,32 +4780,52 @@ const getTimerIndicatorRight = () => {
   return 65
 }
 
-// 计时器本地更新
-const startLocalTick = (isCountdown: boolean) => {
+// 停止本地计时 tick
+const stopLocalTick = () => {
   if (timerInterval.value) {
     clearInterval(timerInterval.value)
     timerInterval.value = null
   }
-  timerInterval.value = setInterval(() => {
+}
+
+// 计时器本地更新:以 startAt(anchorTs)为锚点,用真实时间差重算进度
+// - 不再用「每秒 ±1」累加,避免漂移与卡顿
+// - 250ms 轮询,但只在整秒值变化时才写入响应式数据,UI 依然每秒跳一次
+// - 页面被切到后台再切回来、或学生刷新后,都能立刻追到正确的进度
+// @param anchorTs 计时锚点时间戳(一般是 startAt 的时刻)
+// @param baseSec  锚点时刻的基线秒数:倒计时=该时刻的剩余秒数,秒表=该时刻已计秒数
+const startLocalTick = (isCountdown: boolean, anchorTs: number, baseSec: number) => {
+  stopLocalTick()
+  if (Number.isNaN(anchorTs)) return
+
+  const base = Math.max(Number(baseSec) || 0, 0)
+
+  const compute = () => {
+    const delta = Math.max(Math.floor((Date.now() - anchorTs) / 1000), 0)
     if (isCountdown) {
-      if (timerIndicator.value.remainingSec !== null) {
-        const newRemaining = (timerIndicator.value.remainingSec as number) - 1
-        timerIndicator.value.remainingSec = Math.max(newRemaining, 0)
-        // 时间到了,标记为完成但保持显示
-        if (newRemaining <= 0) {
+      const remaining = Math.max(base - delta, 0)
+      if (timerIndicator.value.remainingSec !== remaining) {
+        timerIndicator.value.remainingSec = remaining
+      }
+      // 倒计时结束:标记完成但保持可见,并停止 tick
+      if (remaining <= 0) {
+        if (!timerIndicator.value.finished) {
           timerIndicator.value.finished = true
-          timerIndicator.value.remainingSec = 0
-          // 保持 visible 为 true,不隐藏
           timerIndicator.value.visible = true
         }
+        stopLocalTick()
       }
     }
     else {
-      if (timerIndicator.value.elapsedSec !== null) {
-        timerIndicator.value.elapsedSec = (timerIndicator.value.elapsedSec as number) + 1
+      const elapsed = base + delta
+      if (timerIndicator.value.elapsedSec !== elapsed) {
+        timerIndicator.value.elapsedSec = elapsed
       }
     }
-  }, 1000) as unknown as number
+  }
+
+  compute()
+  timerInterval.value = setInterval(compute, 250) as unknown as number
 }
 
 // CountdownTimer 事件(仅创建人触发发送)
@@ -4757,7 +4837,8 @@ const onTimerStart = (payload: { isCountdown: boolean; startAt: string; duration
   timerIndicator.value.elapsedSec = payload.isCountdown ? null : 0
   timerIndicator.value.remainingSec = payload.isCountdown ? (payload.durationSec || 0) : null
   timerIndicator.value.finished = false
-  startLocalTick(payload.isCountdown)
+  // 以 startAt 为锚点、durationSec 为基线启动本地计时(刷新后也能接着跑)
+  startLocalTick(payload.isCountdown, new Date(payload.startAt).getTime(), payload.isCountdown ? (payload.durationSec || 0) : 0)
   if (isCreator.value) {
     sendMessage({ type: 'timer_start', courseid: props.courseid, payload: JSON.stringify(payload) })
     // 持久化状态到 YMap(带运行状态与基线)
@@ -4859,8 +4940,8 @@ const onTimerUpdate = (payload: { durationSec: number }) => {
     timerIndicator.value.remainingSec = payload.durationSec
     timerIndicator.value.finished = false
     
-    // 重新开始本地计时
-    startLocalTick(true)
+    // 重新开始本地计时(锚点=新的 startAt,基线=新的时长)
+    startLocalTick(true, new Date(newStartAt).getTime(), payload.durationSec)
     
     // 更新 YMap 状态
     const snap = getTimerState()
@@ -4905,7 +4986,8 @@ const applyTimerStart = (payload: { isCountdown: boolean; startAt: string; durat
     timerIndicator.value.remainingSec = null
   }
   timerIndicator.value.finished = false
-  startLocalTick(payload.isCountdown)
+  // 以 startAt 为锚点、durationSec 为基线启动本地计时(@param baseSec:倒计时=剩余秒,秒表=0)
+  startLocalTick(payload.isCountdown, startTs, payload.isCountdown ? (payload.durationSec || 0) : 0)
 }
 const applyTimerPause = () => {
   if (timerInterval.value) {
@@ -4949,8 +5031,8 @@ const applyTimerUpdate = (payload: { durationSec: number; startAt?: string }) =>
     timerIndicator.value.remainingSec = payload.durationSec
     timerIndicator.value.finished = false
     
-    // 重新开始本地计时
-    startLocalTick(true)
+    // 重新开始本地计时(锚点=新的 startAt,基线=新的时长)
+    startLocalTick(true, new Date(newStartAt).getTime(), payload.durationSec)
   }
 }
 
@@ -4980,74 +5062,71 @@ const applyLaserStateSnapshot = (snap: any) => {
 
 // YMap 状态应用
 const applyTimerStateSnapshot = (snap: any) => {
-  if (!snap || !snap.visible) {
+  console.log('⏱️ applyTimerStateSnapshot:', JSON.stringify(snap))
+  const status = snap?.status as string | undefined
+
+  // ① 空快照 / 明确不可见 / 已停止 → 关闭计时器
+  //   老师刷新或清空计时器后,学生会通过该 Map 快照同步关闭
+  if (!snap || snap.visible !== true || status === 'stopped') {
+    stopLocalTick()
+    timerIndicator.value = { visible: false, isCountdown: false, startAt: null, durationSec: null, elapsedSec: null, remainingSec: null, finished: false }
     return
   }
+
   const isCountdown = !!snap.isCountdown
-  const status = snap.status as string | undefined
-  const startAt = snap.startAt as string
+  const startAt = (snap.startAt as string) || null
+  const startTs = startAt ? new Date(startAt).getTime() : NaN
   const durationSec = isCountdown ? Number(snap.durationSec || 0) : null
   const finished = !!snap.finished
   const elapsedBaseSec = snap.elapsedBaseSec != null ? Number(snap.elapsedBaseSec) : null
   const remainingBaseSec = snap.remainingBaseSec != null ? Number(snap.remainingBaseSec) : null
+
   timerIndicator.value.visible = true
   timerIndicator.value.isCountdown = isCountdown
   timerIndicator.value.startAt = startAt
   timerIndicator.value.durationSec = durationSec
-  const startTs = new Date(startAt).getTime()
-  const nowTs = Date.now()
-  if (isCountdown) {
-    if (status === 'paused') {
-      timerIndicator.value.remainingSec = Math.max(remainingBaseSec || 0, 0)
+
+  // ② 缺少锚点时间 → 无法推算进度,只展示基线值并停止 tick
+  if (Number.isNaN(startTs)) {
+    stopLocalTick()
+    if (isCountdown) {
+      timerIndicator.value.remainingSec = Math.max(remainingBaseSec ?? durationSec ?? 0, 0)
       timerIndicator.value.elapsedSec = null
-      timerIndicator.value.finished = !!finished || (timerIndicator.value.remainingSec as number) <= 0
-      if (timerInterval.value) {
-        clearInterval(timerInterval.value); timerInterval.value = null 
-      }
-      return
-    }
-    const base = remainingBaseSec != null ? remainingBaseSec : (durationSec || 0)
-    const elapsed = Math.floor((nowTs - startTs) / 1000)
-    timerIndicator.value.remainingSec = Math.max(base - elapsed, 0)
-    timerIndicator.value.elapsedSec = null
-    if (finished || (timerIndicator.value.remainingSec as number) <= 0) {
-      timerIndicator.value.finished = true
-      timerIndicator.value.remainingSec = 0
-      // 保持 visible 为 true,时间到了也不消失
-      timerIndicator.value.visible = true
-      if (timerInterval.value) {
-        clearInterval(timerInterval.value); timerInterval.value = null 
-      }
     }
     else {
-      timerIndicator.value.finished = false
-      startLocalTick(true)
-    }
-  }
-  else {
-    if (status === 'paused') {
-      timerIndicator.value.elapsedSec = Math.max(elapsedBaseSec || 0, 0)
+      timerIndicator.value.elapsedSec = Math.max(elapsedBaseSec ?? 0, 0)
       timerIndicator.value.remainingSec = null
-      timerIndicator.value.finished = !!finished
-      if (timerInterval.value) {
-        clearInterval(timerInterval.value); timerInterval.value = null 
-      }
-      return
     }
-    const base = elapsedBaseSec != null ? elapsedBaseSec : 0
-    const elapsed = Math.floor((nowTs - startTs) / 1000)
-    timerIndicator.value.elapsedSec = base + elapsed
-    timerIndicator.value.remainingSec = null
-    if (finished) {
-      if (timerInterval.value) {
-        clearInterval(timerInterval.value); timerInterval.value = null 
-      }
-      timerIndicator.value.finished = true
+    timerIndicator.value.finished = finished
+    return
+  }
+
+  // ③ 暂停态:显示基线值,停止 tick
+  if (status === 'paused') {
+    stopLocalTick()
+    if (isCountdown) {
+      timerIndicator.value.remainingSec = Math.max(remainingBaseSec ?? 0, 0)
+      timerIndicator.value.elapsedSec = null
+      timerIndicator.value.finished = finished || (timerIndicator.value.remainingSec as number) <= 0
     }
     else {
-      timerIndicator.value.finished = false
-      startLocalTick(false)
+      timerIndicator.value.elapsedSec = Math.max(elapsedBaseSec ?? 0, 0)
+      timerIndicator.value.remainingSec = null
+      timerIndicator.value.finished = finished
     }
+    return
+  }
+
+  // ④ 运行态:以 startAt 为锚点、基线为起点,用真实时间重算 → 学生刷新后可"接着跑"
+  if (isCountdown) {
+    timerIndicator.value.elapsedSec = null
+    timerIndicator.value.finished = false
+    startLocalTick(true, startTs, remainingBaseSec != null ? remainingBaseSec : (durationSec || 0))
+  }
+  else {
+    timerIndicator.value.remainingSec = null
+    timerIndicator.value.finished = false
+    startLocalTick(false, startTs, elapsedBaseSec != null ? elapsedBaseSec : 0)
   }
 }
 
@@ -5056,17 +5135,27 @@ const getTimerState = () => {
   if (!yTimerState.value) return {}
   return yTimerState.value.toJSON()
 }
+// 写入完整快照;不再强制 visible:true(关闭计时器时需要写 visible:false)
 const setTimerState = (state: any) => {
   if (!yTimerState.value) return
   docSocket.value?.transact(() => {
     Object.entries(state).forEach(([k, v]) => yTimerState.value.set(k, v as any))
-    yTimerState.value.set('visible', true)
   })
 }
+// ★ 不使用 Map.clear()(会产生永久 DELETE 标记、毒化后续读取),
+//   改为写入一份「已关闭」的完整快照,学生端 observe 收到后即可关闭计时器
 const clearTimerState = () => {
   if (!yTimerState.value) return
-  docSocket.value?.transact(() => {
-    yTimerState.value.clear()
+  setTimerState({
+    visible: false,
+    isCountdown: false,
+    status: 'stopped',
+    startAt: null,
+    durationSec: null,
+    elapsedBaseSec: null,
+    remainingBaseSec: null,
+    finished: true,
+    stopped: true,
   })
 }
 </script>

+ 12 - 6
src/views/Student/parseYjsMessage.ts

@@ -82,17 +82,23 @@ function parseAllContentAny(updateBytes: Uint8Array): Record<string, any>[] {
   return results
 }
 
+export interface ParsedYjsMessage {
+  /** Sync 子类型:1 = Step2(服务器历史全量重放),2 = Update(实时增量) */
+  subType: number
+  /** 解析出的业务对象(可能多个,对应一次 update 里的多个 struct) */
+  objs: Record<string, any>[]
+}
+
 /**
  * 从一条完整的 WebSocket 原始消息里解析出所有用户数据
  * 支持 messageType=0 (Sync),subType=1(Step2) 或 2(Update)
- * 返回解析出的对象数组(可能有多个,对应一次 update 里的多个 struct)
  */
-export function parseYjsMessage(rawMessage: Uint8Array): Record<string, any>[] {
-  if (!rawMessage || rawMessage.length < 3) return []
-  if (rawMessage[0] !== 0) return [] // 只处理 Sync 消息
+export function parseYjsMessage(rawMessage: Uint8Array): ParsedYjsMessage | null {
+  if (!rawMessage || rawMessage.length < 3) return null
+  if (rawMessage[0] !== 0) return null // 只处理 Sync 消息
 
   const subType = rawMessage[1]
-  if (subType !== 1 && subType !== 2) return []
+  if (subType !== 1 && subType !== 2) return null
 
   // 跳过 [msgType][subType],读 updateLen
   const state = { pos: 2 }
@@ -101,5 +107,5 @@ export function parseYjsMessage(rawMessage: Uint8Array): Record<string, any>[] {
   const updateEnd = Math.min(updateStart + updateLen, rawMessage.length)
   const updateBytes = rawMessage.subarray(updateStart, updateEnd)
 
-  return parseAllContentAny(updateBytes)
+  return { subType, objs: parseAllContentAny(updateBytes) }
 }