|
|
@@ -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>
|