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