1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-03-21 09:13:10 +00:00

添加全屏时间卡片功能,支持时钟、倒计时和秒表模式,优化用户交互体验

This commit is contained in:
Sunwuyuan 2026-03-07 08:09:15 +08:00
parent c4b95aede2
commit f1838a891b
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64

View File

@ -5,9 +5,11 @@
border
rounded="xl"
height="100%"
style="cursor: pointer"
@click="showFullscreen = true"
>
<v-card-text
class="pa-6 d-flex flex-column "
class="pa-6 d-flex flex-column"
style="height: 100%"
>
<!-- 时间显示 -->
@ -30,12 +32,407 @@
</div>
</v-card-text>
</v-card>
<!-- 全屏时间弹框 -->
<v-dialog
v-model="showFullscreen"
fullscreen
:scrim="false"
transition="dialog-bottom-transition"
>
<v-card
class="fullscreen-time-card d-flex flex-column"
@mousemove="showToolbar"
@touchstart="showToolbar"
>
<!-- 顶部分页导航 (自动隐藏) -->
<Transition name="toolbar-fade">
<div
v-show="toolbarVisible"
class="fullscreen-toolbar"
>
<v-tabs
v-model="fullscreenMode"
density="comfortable"
color="primary"
align-tabs="center"
class="fullscreen-tabs"
>
<v-tab value="clock">
<v-icon
start
icon="mdi-clock-outline"
/>
时钟
</v-tab>
<v-tab value="countdown">
<v-icon
start
icon="mdi-timer-sand"
/>
倒计时
</v-tab>
<v-tab value="stopwatch">
<v-icon
start
icon="mdi-timer-outline"
/>
秒表
</v-tab>
</v-tabs>
</div>
</Transition>
<!-- 主体内容区 -->
<div class="fullscreen-time-body flex-grow-1 d-flex flex-column align-center justify-center">
<v-tabs-window
v-model="fullscreenMode"
class="fullscreen-tabs-window"
>
<!-- ========= 时钟模式 ========= -->
<v-tabs-window-item value="clock">
<div class="d-flex flex-column align-center justify-center">
<div class="fullscreen-time-display">
{{ timeString }}<span class="fullscreen-seconds">{{ secondsString }}</span>
</div>
<div class="fullscreen-date-line mt-6">
{{ dateString }} {{ weekdayString }} {{ periodOfDay }}
</div>
<div class="fullscreen-progress mt-10">
<div class="text-caption text-medium-emphasis mb-1">
今日已过 {{ dayProgressPercent }}%
</div>
<v-progress-linear
:model-value="dayProgressPercent"
color="primary"
height="6"
rounded
style="max-width: 400px; width: 80vw"
/>
</div>
<div class="fullscreen-extra mt-8 text-medium-emphasis d-flex ga-8">
<div class="text-center">
<div class="text-h6 font-weight-bold">
{{ dayOfYear }}
</div>
<div class="text-caption">
今年第几天
</div>
</div>
<div class="text-center">
<div class="text-h6 font-weight-bold">
{{ weekOfYear }}
</div>
<div class="text-caption">
今年第几周
</div>
</div>
<div class="text-center">
<div class="text-h6 font-weight-bold">
{{ daysLeftInYear }}
</div>
<div class="text-caption">
距离新年
</div>
</div>
</div>
</div>
</v-tabs-window-item>
<!-- ========= 倒计时模式 ========= -->
<v-tabs-window-item value="countdown">
<div class="d-flex flex-column align-center justify-center">
<!-- 未开始选择倒计时时间 -->
<template v-if="!countdownRunning && countdownRemaining <= 0">
<div class="countdown-setup d-flex align-center ga-4">
<div class="text-center">
<v-btn
icon="mdi-chevron-up"
variant="text"
size="small"
@click="countdownHours = Math.min(countdownHours + 1, 99)"
/>
<div class="countdown-digit">
{{ String(countdownHours).padStart(2, '0') }}
</div>
<v-btn
icon="mdi-chevron-down"
variant="text"
size="small"
@click="countdownHours = Math.max(countdownHours - 1, 0)"
/>
<div class="text-caption text-medium-emphasis">
</div>
</div>
<div class="countdown-sep">
:
</div>
<div class="text-center">
<v-btn
icon="mdi-chevron-up"
variant="text"
size="small"
@click="countdownMinutes = Math.min(countdownMinutes + 1, 59)"
/>
<div class="countdown-digit">
{{ String(countdownMinutes).padStart(2, '0') }}
</div>
<v-btn
icon="mdi-chevron-down"
variant="text"
size="small"
@click="countdownMinutes = Math.max(countdownMinutes - 1, 0)"
/>
<div class="text-caption text-medium-emphasis">
</div>
</div>
<div class="countdown-sep">
:
</div>
<div class="text-center">
<v-btn
icon="mdi-chevron-up"
variant="text"
size="small"
@click="countdownSeconds = Math.min(countdownSeconds + 1, 59)"
/>
<div class="countdown-digit">
{{ String(countdownSeconds).padStart(2, '0') }}
</div>
<v-btn
icon="mdi-chevron-down"
variant="text"
size="small"
@click="countdownSeconds = Math.max(countdownSeconds - 1, 0)"
/>
<div class="text-caption text-medium-emphasis">
</div>
</div>
</div>
<!-- 快捷按钮 -->
<div class="mt-8 d-flex ga-3 flex-wrap justify-center">
<v-btn
v-for="preset in countdownPresets"
:key="preset.label"
variant="tonal"
rounded="xl"
@click="applyCountdownPreset(preset)"
>
{{ preset.label }}
</v-btn>
</div>
<div class="mt-8">
<v-btn
color="primary"
size="x-large"
rounded="xl"
:disabled="countdownTotalSetSeconds <= 0"
prepend-icon="mdi-play"
@click="startCountdown"
>
开始
</v-btn>
</div>
</template>
<!-- 运行中/暂停 -->
<template v-else>
<div
class="fullscreen-time-display"
:class="{ 'countdown-ended': countdownRemaining <= 0 && !countdownRunning }"
>
{{ countdownDisplay }}
</div>
<div class="fullscreen-date-line mt-4 text-medium-emphasis">
{{ countdownRunning ? '倒计时进行中' : (countdownRemaining <= 0 ? '时间到!' : '已暂停') }}
</div>
<!-- 进度 -->
<v-progress-linear
:model-value="countdownProgressPercent"
:color="countdownRemaining <= 0 ? 'error' : 'primary'"
class="mt-8"
height="6"
rounded
style="max-width: 400px; width: 80vw"
/>
<div class="mt-8 d-flex ga-3">
<v-btn
v-if="countdownRemaining > 0"
:icon="countdownRunning ? 'mdi-pause' : 'mdi-play'"
:color="countdownRunning ? 'warning' : 'primary'"
size="x-large"
variant="tonal"
@click="toggleCountdown"
/>
<v-btn
icon="mdi-stop"
color="error"
size="x-large"
variant="tonal"
@click="resetCountdown"
/>
</div>
</template>
</div>
</v-tabs-window-item>
<!-- ========= 秒表模式 ========= -->
<v-tabs-window-item value="stopwatch">
<div class="d-flex flex-column align-center justify-center">
<div class="fullscreen-time-display">
{{ stopwatchDisplay }}
</div>
<div class="fullscreen-date-line mt-4 text-medium-emphasis">
{{ stopwatchRunning ? '计时中' : (stopwatchElapsed > 0 ? '已暂停' : '秒表') }}
</div>
<div class="mt-8 d-flex ga-3">
<v-btn
:icon="stopwatchRunning ? 'mdi-pause' : 'mdi-play'"
:color="stopwatchRunning ? 'warning' : 'primary'"
size="x-large"
variant="tonal"
@click="toggleStopwatch"
/>
<v-btn
v-if="stopwatchRunning"
icon="mdi-flag"
color="info"
size="x-large"
variant="tonal"
@click="addLap"
/>
<v-btn
v-if="!stopwatchRunning && stopwatchElapsed > 0"
icon="mdi-stop"
color="error"
size="x-large"
variant="tonal"
@click="resetStopwatch"
/>
</div>
<!-- 计次记录 -->
<v-slide-y-transition>
<div
v-if="laps.length > 0"
class="stopwatch-laps mt-6"
>
<v-table
density="compact"
class="stopwatch-laps-table"
>
<thead>
<tr>
<th>
#
</th>
<th>
计次
</th>
<th>
总计
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(lap, idx) in laps"
:key="idx"
>
<td>
{{ laps.length - idx }}
</td>
<td>
{{ formatMs(lap.split) }}
</td>
<td>
{{ formatMs(lap.total) }}
</td>
</tr>
</tbody>
</v-table>
</div>
</v-slide-y-transition>
</div>
</v-tabs-window-item>
</v-tabs-window>
</div>
<!-- 右下角按钮组 -->
<div class="fullscreen-actions">
<v-btn
icon="mdi-cog"
variant="text"
size="large"
@click.stop="showSettings = true"
/>
<v-btn
icon="mdi-close"
variant="text"
size="large"
class="ml-2"
@click="showFullscreen = false"
/>
</div>
</v-card>
</v-dialog>
<!-- 设置弹框 -->
<v-dialog
v-model="showSettings"
max-width="420"
:scrim="true"
>
<v-card rounded="xl">
<v-card-title class="d-flex align-center">
<v-icon
class="mr-2"
icon="mdi-cog"
/>
时间卡片设置
</v-card-title>
<v-card-text>
<v-list>
<v-list-item>
<template #prepend>
<v-icon
class="mr-3"
icon="mdi-clock-outline"
/>
</template>
<v-list-item-title>显示时间卡片</v-list-item-title>
<v-list-item-subtitle>在首页显示时间卡片刷新后生效</v-list-item-subtitle>
<template #append>
<v-switch
:model-value="timeCardEnabled"
hide-details
density="comfortable"
@update:model-value="setTimeCardEnabled"
/>
</template>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
@click="showSettings = false"
>
完成
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { SettingsManager, watchSettings } from '@/utils/settings'
import { SettingsManager, watchSettings, getSetting, setSetting } from '@/utils/settings'
import { playSound, defaultSingleSound } from '@/utils/soundList'
//
//
const TIME_FONT_RATIO = 2.0
const SECONDS_FONT_RATIO = 0.9
const DATE_FONT_RATIO = 0.6
@ -48,6 +445,39 @@ export default {
timer: null,
unwatch: null,
fontSize: 28,
showFullscreen: false,
showSettings: false,
timeCardEnabled: true,
//
fullscreenMode: 'clock',
//
toolbarVisible: true,
toolbarTimer: null,
//
countdownHours: 0,
countdownMinutes: 5,
countdownSeconds: 0,
countdownRunning: false,
countdownRemaining: 0, //
countdownTotal: 0, //
countdownTimer: null,
countdownLastTick: null,
countdownPresets: [
{ label: '1 分钟', h: 0, m: 1, s: 0 },
{ label: '3 分钟', h: 0, m: 3, s: 0 },
{ label: '5 分钟', h: 0, m: 5, s: 0 },
{ label: '10 分钟', h: 0, m: 10, s: 0 },
{ label: '15 分钟', h: 0, m: 15, s: 0 },
{ label: '30 分钟', h: 0, m: 30, s: 0 },
{ label: '1 小时', h: 1, m: 0, s: 0 },
],
//
stopwatchRunning: false,
stopwatchElapsed: 0, //
stopwatchTimer: null,
stopwatchLastTick: null,
laps: [],
lastLapElapsed: 0,
}
},
computed: {
@ -80,6 +510,31 @@ export default {
if (h < 22) return '晚上'
return '深夜'
},
dayProgressPercent() {
const h = this.now.getHours()
const m = this.now.getMinutes()
const s = this.now.getSeconds()
const totalSeconds = h * 3600 + m * 60 + s
return ((totalSeconds / 86400) * 100).toFixed(1)
},
dayOfYear() {
const start = new Date(this.now.getFullYear(), 0, 0)
const diff = this.now - start
const oneDay = 1000 * 60 * 60 * 24
return Math.floor(diff / oneDay)
},
weekOfYear() {
const d = new Date(Date.UTC(this.now.getFullYear(), this.now.getMonth(), this.now.getDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
},
daysLeftInYear() {
const endOfYear = new Date(this.now.getFullYear(), 11, 31)
const diff = endOfYear - this.now
return Math.ceil(diff / (1000 * 60 * 60 * 24))
},
timeStyle() {
return {
'font-size': `${this.fontSize * TIME_FONT_RATIO}px`,
@ -104,6 +559,62 @@ export default {
'letter-spacing': '1px',
}
},
// computed
countdownTotalSetSeconds() {
return this.countdownHours * 3600 + this.countdownMinutes * 60 + this.countdownSeconds
},
countdownDisplay() {
const totalSec = Math.max(0, Math.ceil(this.countdownRemaining / 1000))
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
},
countdownProgressPercent() {
if (this.countdownTotal <= 0) return 0
return ((this.countdownTotal - this.countdownRemaining) / this.countdownTotal) * 100
},
// computed
stopwatchDisplay() {
const ms = this.stopwatchElapsed
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const centis = Math.floor((ms % 1000) / 10)
if (h > 0) {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
}
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
},
},
watch: {
showFullscreen(val) {
if (val) {
this.handleKeydown = (e) => {
if (e.key === 'Escape') {
if (this.showSettings) {
this.showSettings = false
} else {
this.showFullscreen = false
}
e.preventDefault()
e.stopPropagation()
}
}
window.addEventListener('keydown', this.handleKeydown, true)
this.showToolbar()
} else {
if (this.handleKeydown) {
window.removeEventListener('keydown', this.handleKeydown, true)
this.handleKeydown = null
}
this.clearToolbarTimer()
}
},
},
mounted() {
this.loadSettings()
@ -114,16 +625,26 @@ export default {
},
beforeUnmount() {
this.stopTimer()
this.clearCountdownTimer()
this.clearStopwatchTimer()
this.clearToolbarTimer()
if (this.unwatch) {
this.unwatch()
}
if (this.handleKeydown) {
window.removeEventListener('keydown', this.handleKeydown, true)
}
},
methods: {
loadSettings() {
this.fontSize = SettingsManager.getSetting('font.size')
this.timeCardEnabled = getSetting('timeCard.enabled')
},
setTimeCardEnabled(val) {
this.timeCardEnabled = val
setSetting('timeCard.enabled', val)
},
startTimer() {
//
this.timer = setInterval(() => {
this.now = new Date()
}, 1000)
@ -134,6 +655,118 @@ export default {
this.timer = null
}
},
// ---- ----
showToolbar() {
this.toolbarVisible = true
this.clearToolbarTimer()
this.toolbarTimer = setTimeout(() => {
this.toolbarVisible = false
}, 3000)
},
clearToolbarTimer() {
if (this.toolbarTimer) {
clearTimeout(this.toolbarTimer)
this.toolbarTimer = null
}
},
// ---- ----
applyCountdownPreset(preset) {
this.countdownHours = preset.h
this.countdownMinutes = preset.m
this.countdownSeconds = preset.s
},
startCountdown() {
const totalMs = this.countdownTotalSetSeconds * 1000
if (totalMs <= 0) return
this.countdownTotal = totalMs
this.countdownRemaining = totalMs
this.countdownRunning = true
this.countdownLastTick = Date.now()
this.countdownTimer = setInterval(() => {
this.tickCountdown()
}, 50)
},
tickCountdown() {
const now = Date.now()
const delta = now - this.countdownLastTick
this.countdownLastTick = now
this.countdownRemaining = Math.max(0, this.countdownRemaining - delta)
if (this.countdownRemaining <= 0) {
this.countdownRunning = false
this.clearCountdownTimer()
playSound(defaultSingleSound)
}
},
toggleCountdown() {
if (this.countdownRunning) {
this.countdownRunning = false
this.clearCountdownTimer()
} else {
this.countdownRunning = true
this.countdownLastTick = Date.now()
this.countdownTimer = setInterval(() => {
this.tickCountdown()
}, 50)
}
},
resetCountdown() {
this.countdownRunning = false
this.countdownRemaining = 0
this.countdownTotal = 0
this.clearCountdownTimer()
},
clearCountdownTimer() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
this.countdownTimer = null
}
},
// ---- ----
toggleStopwatch() {
if (this.stopwatchRunning) {
this.stopwatchRunning = false
this.clearStopwatchTimer()
} else {
this.stopwatchRunning = true
this.stopwatchLastTick = Date.now()
this.stopwatchTimer = setInterval(() => {
this.tickStopwatch()
}, 30)
}
},
tickStopwatch() {
const now = Date.now()
this.stopwatchElapsed += now - this.stopwatchLastTick
this.stopwatchLastTick = now
},
addLap() {
const split = this.stopwatchElapsed - this.lastLapElapsed
this.laps.unshift({ split, total: this.stopwatchElapsed })
this.lastLapElapsed = this.stopwatchElapsed
},
resetStopwatch() {
this.stopwatchRunning = false
this.stopwatchElapsed = 0
this.lastLapElapsed = 0
this.laps = []
this.clearStopwatchTimer()
},
clearStopwatchTimer() {
if (this.stopwatchTimer) {
clearInterval(this.stopwatchTimer)
this.stopwatchTimer = null
}
},
formatMs(ms) {
const totalSec = Math.floor(ms / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
const centis = Math.floor((ms % 1000) / 10)
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
},
},
}
</script>
@ -161,4 +794,147 @@ export default {
opacity: 0.75;
letter-spacing: 1px;
}
/* 全屏样式 */
.fullscreen-time-card {
position: relative;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* 顶部工具栏 */
.fullscreen-toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
justify-content: center;
padding-top: 16px;
}
.fullscreen-tabs {
background: transparent;
border-radius: 16px;
}
.toolbar-fade-enter-active,
.toolbar-fade-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.toolbar-fade-enter-from,
.toolbar-fade-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.fullscreen-tabs-window {
width: 100%;
}
.fullscreen-time-body {
user-select: none;
padding: 0 24px;
}
.fullscreen-time-display {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: clamp(4rem, 15vw, 12rem);
font-weight: 700;
line-height: 1;
letter-spacing: 8px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.fullscreen-seconds {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.45em;
vertical-align: baseline;
margin-left: 4px;
opacity: 0.5;
}
.fullscreen-date-line {
font-size: clamp(1rem, 3vw, 2.2rem);
opacity: 0.7;
letter-spacing: 2px;
}
.fullscreen-progress {
display: flex;
flex-direction: column;
align-items: center;
}
.fullscreen-extra {
font-variant-numeric: tabular-nums;
}
.fullscreen-actions {
position: absolute;
bottom: 24px;
right: 24px;
display: flex;
align-items: center;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.fullscreen-actions:hover {
opacity: 1;
}
/* 倒计时设置 */
.countdown-setup {
user-select: none;
}
.countdown-digit {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: clamp(3rem, 10vw, 8rem);
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
min-width: 1.2em;
text-align: center;
}
.countdown-sep {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: clamp(3rem, 10vw, 8rem);
font-weight: 300;
line-height: 1;
opacity: 0.4;
padding-bottom: 1.8em;
}
.countdown-ended {
animation: pulse-red 1s ease-in-out infinite;
}
@keyframes pulse-red {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
/* 秒表计次列表 */
.stopwatch-laps {
max-height: 30vh;
overflow-y: auto;
width: min(90vw, 400px);
}
.stopwatch-laps-table {
background: transparent !important;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
</style>