lsc vor 1 Monat
Ursprung
Commit
569093b39b
2 geänderte Dateien mit 491 neuen und 7 gelöschten Zeilen
  1. 23 4
      src/views/Screen/CountdownTimer.vue
  2. 468 3
      src/views/Student/index.vue

+ 23 - 4
src/views/Screen/CountdownTimer.vue

@@ -57,6 +57,11 @@ withDefaults(defineProps<{
 
 const emit = defineEmits<{
   (event: 'close'): void
+  (event: 'timer-start', payload: { isCountdown: boolean; startAt: string; durationSec?: number }): void
+  (event: 'timer-pause', payload: { pausedAt: string }): void
+  (event: 'timer-reset'): void
+  (event: 'timer-stop'): void
+  (event: 'timer-finish'): void
 }>()
 
 const timer = ref<number | null>(null)
@@ -71,7 +76,9 @@ const inputEditable = computed(() => {
 })
 
 const clearTimer = () => {
-  if (timer.value) clearInterval(timer.value)
+  if (timer.value !== null) {
+    clearInterval(timer.value)
+  }
 }
 
 onUnmounted(clearTimer)
@@ -79,6 +86,7 @@ onUnmounted(clearTimer)
 const pause = () => {
   clearTimer()
   inTiming.value = false
+  emit('timer-pause', { pausedAt: new Date().toISOString() })
 }
 
 const reset = () => {
@@ -87,20 +95,26 @@ const reset = () => {
   
   if (isCountdown.value) time.value = 600
   else time.value = 0
+  emit('timer-reset')
 }
 
 const start = () => {
   clearTimer()
 
   if (isCountdown.value) {
-    timer.value = setInterval(() => {
+    timer.value = window.setInterval(() => {
       time.value = time.value - 1
 
-      if (time.value <= 0) reset()
+      if (time.value <= 0) {
+        time.value = 0
+        clearTimer()
+        inTiming.value = false
+        emit('timer-finish')
+      }
     }, 1000)
   }
   else {
-    timer.value = setInterval(() => {
+    timer.value = window.setInterval(() => {
       time.value = time.value + 1
 
       if (time.value > 36000) pause()
@@ -108,6 +122,7 @@ const start = () => {
   }
 
   inTiming.value = true
+  emit('timer-start', { isCountdown: isCountdown.value, startAt: new Date().toISOString(), durationSec: isCountdown.value ? time.value : undefined })
 }
 
 const toggle = () => {
@@ -120,6 +135,10 @@ const toggleCountdown = () => {
   reset()
 }
 
+onUnmounted(() => {
+  emit('timer-stop')
+})
+
 const changeTime = (e: FocusEvent | KeyboardEvent, type: 'minute' | 'second') => {
   const inputRef = e.target as HTMLInputElement
   let value = inputRef.value

+ 468 - 3
src/views/Student/index.vue

@@ -147,7 +147,15 @@
         <WritingBoardTool :slideWidth="slideWidth" :slideHeight="slideHeight" v-if="writingBoardToolVisible"
           @close="writingBoardToolVisible = false" />
 
-        <CountdownTimer v-if="timerlVisible" @close="timerlVisible = false" />
+        <CountdownTimer 
+          v-if="timerlVisible" 
+          @close="timerlVisible = false" 
+          @timer-start="onTimerStart" 
+          @timer-pause="onTimerPause" 
+          @timer-reset="onTimerReset"
+          @timer-stop="onTimerStop"
+          @timer-finish="onTimerFinish"
+        />
 
         <div v-if="isFullscreen && (!isFollowModeActive || props.type == '1')" class="tools-right" :class="{ 'visible': rightToolsVisible }"
           @mouseleave="rightToolsVisible = false" @mouseenter="rightToolsVisible = true">
@@ -156,7 +164,7 @@
               1 }} / {{ slides.length }}</div>
             <IconWrite class="tool-btn" v-tooltip="'画笔工具'" @click="writingBoardToolVisible = true" />
             <IconMagic class="tool-btn" v-tooltip="'激光笔'" :class="{ 'active': laserPen }" @click="toggleLaserPen" />
-            <IconStopwatchStart class="tool-btn" v-tooltip="'计时器'" @click="timerlVisible = !timerlVisible" />
+            <IconStopwatchStart v-if="(props.type == '1' && courseDetail.userid == props.userid)" class="tool-btn" v-tooltip="'计时器'" @click="timerlVisible = !timerlVisible" />
             <IconOffScreenOne class="tool-btn" v-tooltip="'退出全屏'" @click="enterFullscreen" />
           </div>
         </div>
@@ -280,6 +288,25 @@
         </div>
       </div>
     </div>
+    <!-- 右上角计时状态指示器(块状样式) -->
+    <div 
+      v-if="timerIndicator.visible" 
+      class="timer-indicator" 
+      :class="{ 'countdown': timerIndicator.isCountdown, 'timeout': timerIndicator.isCountdown && timerIndicator.remainingSec !== null && timerIndicator.remainingSec <= 0 }"
+      :style="{ right: getTimerIndicatorRight() + 'px', top: isFullscreen ? '16px' : '12px' }"
+    >
+      <div class="blocks">
+        <template v-if="timerBlocksVisibility().showH">
+          <span class="block">{{ timerBlocks().h }}</span>
+          <span class="colon"></span>
+        </template>
+        <template v-if="timerBlocksVisibility().showM">
+          <span class="block">{{ timerBlocks().m }}</span>
+          <span class="colon"></span>
+        </template>
+        <span class="block">{{ timerBlocks().s }}</span>
+      </div>
+    </div>
   </div>
 
   <ShotWorkModal v-model:visible="visibleShot" :work="selectedWork" />
@@ -377,6 +404,18 @@ const slideThumbnailModelVisible = ref(false)
 const laserPen = ref(false)
 const answerTheResultRef = ref(null)
 
+// 计时状态指示器
+const timerIndicator = ref<{ visible: boolean; isCountdown: boolean; startAt: string | null; durationSec: number | null; elapsedSec: number | null; remainingSec: number | null; finished: boolean }>({
+  visible: false,
+  isCountdown: false,
+  startAt: null,
+  durationSec: null,
+  elapsedSec: null,
+  remainingSec: null,
+  finished: false,
+})
+const timerInterval = ref<number | null>(null)
+
 // 作业提交状态
 const isSubmitting = ref(false)
 
@@ -442,6 +481,7 @@ const unsubmittedStudents = computed(() => {
 
 const docSocket = ref<Y.Doc | null>(null)
 const yMessage = ref<any | null>(null)
+const yTimerState = ref<any | null>(null)
 const providerSocket = ref<WebsocketProvider | null>(null)
 const mId = ref<string | null>(null)
 
@@ -769,7 +809,6 @@ const handleFullscreenChange = () => {
     // 重置所有工具状态
     rightToolsVisible.value = false
     writingBoardToolVisible.value = false
-    timerlVisible.value = false
     slideThumbnailModelVisible.value = false
     laserPen.value = false
 
@@ -2009,6 +2048,18 @@ const messageInit = () => {
       })
     })
   }
+  // 初始化计时器状态 Map 并监听
+  if (docSocket.value && !yTimerState.value) {
+    yTimerState.value = docSocket.value.getMap('timerState')
+    // 初始状态同步(后加入用户会立即拿到当前 map 值)
+    const snapshot = yTimerState.value.toJSON()
+    applyTimerStateSnapshot(snapshot)
+    // 监听变化
+    yTimerState.value.observe((event: any) => {
+      const snap = yTimerState.value.toJSON()
+      applyTimerStateSnapshot(snap)
+    })
+  }
 }
 
 /**
@@ -2056,6 +2107,23 @@ const getMessages = (msgObj: any) => {
     }, 1000)
   }
 
+  // 计时器消息 - 学生与老师端实时显示
+  if (msgObj.type === 'timer_start' && msgObj.courseid === props.courseid) {
+    applyTimerStart(msgObj.payload)
+  }
+  if (msgObj.type === 'timer_pause' && msgObj.courseid === props.courseid) {
+    applyTimerPause()
+  }
+  if (msgObj.type === 'timer_reset' && msgObj.courseid === props.courseid) {
+    applyTimerReset()
+  }
+  if (msgObj.type === 'timer_stop' && msgObj.courseid === props.courseid) {
+    applyTimerStop()
+  }
+  if (msgObj.type === 'timer_finish' && msgObj.courseid === props.courseid) {
+    applyTimerFinish()
+  }
+
 }
 
 
@@ -2145,6 +2213,13 @@ onMounted(() => {
   if (api.yweb_socket) {
     createWebSocketConnection()
   }
+
+  // 创建人离开页面时,广播停止计时
+  window.addEventListener('beforeunload', () => {
+    if (isCreator.value && timerIndicator.value.visible) {
+      sendMessage({ type: 'timer_stop', courseid: props.courseid })
+    }
+  })
 })
 
 
@@ -2175,6 +2250,10 @@ onUnmounted(() => {
     delete (window as any).PPTistStudent
     console.log('PPTist Student View 已卸载,window.PPTistStudent 已清理')
   }
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
 })
 
 // 手动重连
@@ -2223,6 +2302,12 @@ const createWebSocketConnection = () => {
         
         mId.value = Math.random().toString(36).substr(2, 9)
         messageInit()
+        // 连接成功后,读取当前计时器状态(Map)
+        if (docSocket.value) {
+          yTimerState.value = docSocket.value.getMap('timerState')
+          const snapshot = yTimerState.value.toJSON()
+          applyTimerStateSnapshot(snapshot)
+        }
       }
       else if (event.status === 'disconnected') {
         console.log('👉 WebSocket连接断开')
@@ -2265,6 +2350,309 @@ const handleDisconnection = () => {
     message.error('网络连接异常,请检查网络后刷新页面')
   }
 }
+
+// 工具函数:格式化时间
+const formatTime = (totalSec: number) => {
+  const m = Math.floor(totalSec / 60)
+  const s = Math.floor(totalSec % 60)
+  return `${fillDigit(m, 2)}:${fillDigit(s, 2)}`
+}
+
+// 块状时间显示
+const timerBlocks = () => {
+  const total = timerIndicator.value.isCountdown
+    ? Math.max(timerIndicator.value.remainingSec || 0, 0)
+    : Math.max(timerIndicator.value.elapsedSec || 0, 0)
+  const h = Math.floor(total / 3600)
+  const m = Math.floor((total % 3600) / 60)
+  const s = Math.floor(total % 60)
+  return {
+    h: fillDigit(h, 2),
+    m: fillDigit(m, 2),
+    s: fillDigit(s, 2),
+  }
+}
+
+// 块可见性:< 60s 仅秒;< 1h 显示分秒;>=1h 显示时分秒
+const timerBlocksVisibility = () => {
+  const total = timerIndicator.value.isCountdown
+    ? Math.max(timerIndicator.value.remainingSec || 0, 0)
+    : Math.max(timerIndicator.value.elapsedSec || 0, 0)
+  return {
+    showH: total >= 3600,
+    showM: total >= 60,
+  }
+}
+
+// 根据布局避免遮挡右侧面板
+const getTimerIndicatorRight = () => {
+  if (isFullscreen.value) {
+    return 16
+  }
+  if (props.type === '1') {
+    // 右侧面板展开时向左让位
+    return workPanelCollapsed.value ? 65 : 420
+  }
+  return 65
+}
+
+// 计时器本地更新
+const startLocalTick = (isCountdown: boolean) => {
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+  timerInterval.value = setInterval(() => {
+    if (isCountdown) {
+      if (timerIndicator.value.remainingSec !== null) {
+        timerIndicator.value.remainingSec = (timerIndicator.value.remainingSec as number) - 1
+      }
+    }
+    else {
+      if (timerIndicator.value.elapsedSec !== null) {
+        timerIndicator.value.elapsedSec = (timerIndicator.value.elapsedSec as number) + 1
+      }
+    }
+  }, 1000) as unknown as number
+}
+
+// CountdownTimer 事件(仅创建人触发发送)
+const onTimerStart = (payload: { isCountdown: boolean; startAt: string; durationSec?: number }) => {
+  timerIndicator.value.visible = true
+  timerIndicator.value.isCountdown = payload.isCountdown
+  timerIndicator.value.startAt = payload.startAt
+  timerIndicator.value.durationSec = payload.isCountdown ? (payload.durationSec || 0) : null
+  timerIndicator.value.elapsedSec = payload.isCountdown ? null : 0
+  timerIndicator.value.remainingSec = payload.isCountdown ? (payload.durationSec || 0) : null
+  timerIndicator.value.finished = false
+  startLocalTick(payload.isCountdown)
+  if (isCreator.value) {
+    sendMessage({ type: 'timer_start', courseid: props.courseid, payload })
+    // 持久化状态到 YMap(带运行状态与基线)
+    const isCd = payload.isCountdown
+    const state: any = {
+      visible: true,
+      isCountdown: isCd,
+      status: 'running',
+      startAt: payload.startAt,
+      durationSec: isCd ? (payload.durationSec || 0) : null,
+      finished: false,
+      stopped: false,
+    }
+    if (isCd) {
+      state.remainingBaseSec = payload.durationSec || 0
+      state.elapsedBaseSec = null
+    }
+    else {
+      state.elapsedBaseSec = 0
+      state.remainingBaseSec = null
+    }
+    setTimerState(state)
+  }
+}
+const onTimerPause = () => {
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+  if (isCreator.value) {
+    sendMessage({ type: 'timer_pause', courseid: props.courseid })
+    // 将当前显示值作为基线写入,并标记暂停
+    const isCd = !!timerIndicator.value.isCountdown
+    const payload: any = {
+      ...getTimerState(),
+      status: 'paused',
+      pausedAt: new Date().toISOString(),
+      finished: !!timerIndicator.value.finished,
+      stopped: false,
+    }
+    if (isCd) {
+      payload.remainingBaseSec = Math.max(Number(timerIndicator.value.remainingSec || 0), 0)
+      payload.elapsedBaseSec = null
+    }
+    else {
+      payload.elapsedBaseSec = Math.max(Number(timerIndicator.value.elapsedSec || 0), 0)
+      payload.remainingBaseSec = null
+    }
+    setTimerState(payload)
+  }
+}
+const onTimerReset = () => {
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+  timerIndicator.value = { visible: false, isCountdown: false, startAt: null, durationSec: null, elapsedSec: null, remainingSec: null, finished: false }
+  if (isCreator.value) {
+    sendMessage({ type: 'timer_reset', courseid: props.courseid })
+    clearTimerState()
+  }
+}
+const onTimerStop = () => {
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+  timerIndicator.value = { visible: false, isCountdown: false, startAt: null, durationSec: null, elapsedSec: null, remainingSec: null, finished: false }
+  if (isCreator.value) {
+    sendMessage({ type: 'timer_stop', courseid: props.courseid })
+    clearTimerState()
+  }
+}
+const onTimerFinish = () => {
+  timerIndicator.value.finished = true
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+  if (isCreator.value) {
+    sendMessage({ type: 'timer_finish', courseid: props.courseid })
+    const snap = getTimerState()
+    setTimerState({ ...snap, status: 'finished', finished: true, stopped: true, remainingBaseSec: 0 })
+  }
+}
+
+// 消息应用(任意端)
+const applyTimerStart = (payload: { isCountdown: boolean; startAt: string; durationSec?: number }) => {
+  timerIndicator.value.visible = true
+  timerIndicator.value.isCountdown = payload.isCountdown
+  timerIndicator.value.startAt = payload.startAt
+  timerIndicator.value.durationSec = payload.isCountdown ? (payload.durationSec || 0) : null
+  // 以消息时间为基准纠正进度
+  const startTs = new Date(payload.startAt).getTime()
+  const nowTs = Date.now()
+  if (payload.isCountdown) {
+    const elapsed = Math.floor((nowTs - startTs) / 1000)
+    timerIndicator.value.remainingSec = Math.max((payload.durationSec || 0) - elapsed, 0)
+    timerIndicator.value.elapsedSec = null
+  }
+  else {
+    timerIndicator.value.elapsedSec = Math.floor((nowTs - startTs) / 1000)
+    timerIndicator.value.remainingSec = null
+  }
+  timerIndicator.value.finished = false
+  startLocalTick(payload.isCountdown)
+}
+const applyTimerPause = () => {
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+}
+const applyTimerReset = () => {
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+  timerIndicator.value = { visible: false, isCountdown: false, startAt: null, durationSec: null, elapsedSec: null, remainingSec: null, finished: false }
+}
+const applyTimerStop = () => {
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+  timerIndicator.value = { visible: false, isCountdown: false, startAt: null, durationSec: null, elapsedSec: null, remainingSec: null, finished: false }
+}
+const applyTimerFinish = () => {
+  timerIndicator.value.finished = true
+  if (timerIndicator.value.isCountdown) {
+    timerIndicator.value.remainingSec = 0
+  }
+  if (timerInterval.value) {
+    clearInterval(timerInterval.value)
+    timerInterval.value = null
+  }
+}
+
+// YMap 状态应用
+const applyTimerStateSnapshot = (snap: any) => {
+  if (!snap || !snap.visible) {
+    return
+  }
+  const isCountdown = !!snap.isCountdown
+  const status = snap.status as string | undefined
+  const startAt = snap.startAt as string
+  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)
+      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
+      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.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
+    }
+    else {
+      timerIndicator.value.finished = false
+      startLocalTick(false)
+    }
+  }
+}
+
+// 读写 YMap 工具
+const getTimerState = () => {
+  if (!yTimerState.value) return {}
+  return yTimerState.value.toJSON()
+}
+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)
+  })
+}
+const clearTimerState = () => {
+  if (!yTimerState.value) return
+  docSocket.value?.transact(() => {
+    yTimerState.value.clear()
+  })
+}
 </script>
 
 <style lang="scss" scoped>
@@ -2930,6 +3318,83 @@ const handleDisconnection = () => {
   }
 }
 
+// 右上角计时状态指示器样式
+.timer-indicator {
+  position: fixed;
+  z-index: 1000;
+  // background: rgba(0, 0, 0, 0.75);
+  color: #fff;
+  border-radius: 8px;
+  padding: 8px 10px;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  // border: 1px solid rgba(255, 255, 255, 0.15);
+
+  .label {
+    font-size: 12px;
+    opacity: .9;
+    margin-right: 2px;
+    white-space: nowrap;
+  }
+
+  .blocks {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+  .block {
+    min-width: 36px;
+    height: 28px;
+    padding: 0 8px;
+    border-radius: 6px;
+    background: #111;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-weight: 700;
+    font-size: 16px;
+    letter-spacing: 1px;
+  }
+  .colon {
+    position: relative;
+    width: 6px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+  }
+  .colon::before,
+  .colon::after {
+    content: '';
+    width: 4px;
+    height: 4px;
+    border-radius: 50%;
+    background: #000;
+    display: block;
+    opacity: .9;
+    position: absolute;
+    left: 0;
+  }
+  .colon::before { top: 4px; }
+  .colon::after { bottom: 4px; }
+
+  // 全屏尺寸略大
+  .pptist-student-viewer.fullscreen & .block {
+    min-width: 40px;
+    height: 30px;
+    font-size: 18px;
+  }
+
+  &.countdown .block {
+    background: #222;
+  }
+  &.timeout .block,
+  &.timeout .colon {
+    background: #ff4d4f;
+    color: #fff;
+  }
+}
+
 .viewport {
   position: relative;
   width: 100%;