1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-03-21 09:13:10 +00:00
Classworks/src/components/TimeCard.vue
copilot-swe-agent[bot] 1bc9d2f67e feat: Add 12-hour clock format support to TimeCard
Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ZeroCatDev/Classworks/sessions/5b22f5d2-515c-4a87-84f5-18ef897ede79
2026-03-21 07:45:06 +00:00

1764 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-card
class="time-card"
elevation="2"
border
rounded="xl"
height="100%"
style="cursor: pointer"
@click="showFullscreen = true"
>
<v-card-text
class="pa-6 d-flex flex-column"
style="height: 100%"
>
<div class="d-flex align-center" style="gap: 16px;">
<!-- 左侧时间显示 -->
<div class="flex-grow-1">
<div
class="time-display"
:style="timeStyle"
>
{{ timeString }}<span
class="seconds-text"
:style="secondsStyle"
>{{ secondsString }}</span><span
v-if="use12hClock"
class="ampm-text"
:style="secondsStyle"
> {{ amPmString }}</span>
</div>
<div
class="date-line mt-3"
:style="dateStyle"
>
{{ dateString }} {{ weekdayString }} {{ periodOfDay }}
</div>
</div>
<!-- 右侧环境噪音状态 -->
<div
v-if="noiseEnabled"
class="d-flex flex-column align-center justify-center"
style="min-width: 80px;"
@click.stop="onNoiseClick"
>
<!-- 无麦克风权限提示 -->
<template v-if="micPermissionState === 'denied'">
<v-icon
color="error"
size="24"
>
mdi-microphone-off
</v-icon>
<div
class="text-caption text-error mt-1"
style="white-space: nowrap; font-size: 10px;"
>
权限被拒绝
</div>
</template>
<template v-else-if="micPermissionState === 'unavailable'">
<v-icon
color="warning"
size="24"
>
mdi-microphone-question
</v-icon>
<div
class="text-caption text-warning mt-1"
style="white-space: nowrap; font-size: 10px;"
>
无麦克风
</div>
</template>
<template v-else>
<div
class="noise-side-db font-weight-bold"
:class="`text-${noiseDbColor}`"
:style="{ fontSize: `${fontSize * 0.9}px`, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }"
>
{{ noiseDisplayDb }}
</div>
<div
class="text-caption mt-1"
:class="`text-${noiseDbColor}`"
style="white-space: nowrap; font-size: 11px;"
>
{{ noiseStatusText }}
</div>
<div
v-if="!noiseMonitoring"
class="text-caption text-medium-emphasis mt-1"
style="font-size: 10px; cursor: pointer;"
>
点击开启
</div>
</template>
</div>
</div>
</v-card-text>
<!-- 噪音详情对话框 -->
<noise-monitor-detail
v-if="noiseEnabled"
v-model="showNoiseDetail"
:status="noiseStatus"
:current-db="noiseDisplayDb"
:current-dbfs="noiseCurrentDbfs"
:noise-level="noiseStatusText"
:db-color="noiseDbColor"
:current-score="noiseScore"
:score-detail="noiseScoreDetail"
:ring-buffer="noiseRingBuffer"
:last-slice="noiseLastSlice"
:history="noiseHistory"
:is-monitoring="noiseMonitoring"
:mic-permission-state="micPermissionState"
:session-active="noiseSessionActive"
:session-data="noiseSessionData"
:report-meta="noiseReportMeta"
:selected-date="noiseSelectedDate"
:date-reports="noiseCurrentDateReports"
@start="startNoise"
@stop="stopNoise"
@clear-history="clearNoiseHistory"
@select-date="onSelectReportDate"
@clear-date-reports="onClearDateReports"
@clear-all-reports="onClearAllReports"
/>
<!-- 麦克风权限引导弹框 -->
<v-dialog
v-model="showMicPermissionDialog"
max-width="480"
persistent
>
<v-card rounded="xl">
<div class="text-center pt-8 pb-2">
<v-avatar
color="primary"
size="72"
>
<v-icon
icon="mdi-microphone-outline"
size="36"
/>
</v-avatar>
</div>
<v-card-title class="text-center text-h6 pt-4">
开启环境噪音监测
</v-card-title>
<v-card-text class="text-body-2 text-medium-emphasis px-6">
<p class="mb-3">
该功能可以实时监测教室环境噪音帮助营造安静的学习氛围
</p>
<div class="d-flex align-start mb-2">
<v-icon
icon="mdi-chart-line"
size="18"
color="primary"
class="mr-2 mt-1 flex-shrink-0"
/>
<span>实时显示环境分贝数与噪音等级评估</span>
</div>
<div class="d-flex align-start mb-2">
<v-icon
icon="mdi-clock-check-outline"
size="18"
color="primary"
class="mr-2 mt-1 flex-shrink-0"
/>
<span>在晚自习时段自动记录并生成统计报告</span>
</div>
<div class="d-flex align-start mb-4">
<v-icon
icon="mdi-shield-check-outline"
size="18"
color="primary"
class="mr-2 mt-1 flex-shrink-0"
/>
<span>音频数据仅在本地处理不会上传或存储录音</span>
</div>
<v-alert
type="info"
variant="tonal"
density="compact"
class="mb-2"
>
需要授予麦克风权限才能使用此功能浏览器将弹出权限请求请点击允许
</v-alert>
</v-card-text>
<v-divider />
<v-card-text class="px-6 py-3">
<v-list
density="compact"
class="pa-0"
>
<v-list-item class="px-0">
<template #prepend>
<v-icon
icon="mdi-microphone"
class="mr-3"
/>
</template>
<v-list-item-title>启用噪音监测</v-list-item-title>
<v-list-item-subtitle class="text-caption">
关闭后将不再提醒
</v-list-item-subtitle>
<template #append>
<v-switch
:model-value="noiseEnabled"
hide-details
density="comfortable"
@update:model-value="onPermissionDialogToggle"
/>
</template>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="px-6 pb-5">
<v-btn
variant="text"
@click="dismissMicPermission"
>
暂不开启
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-microphone"
:disabled="!noiseEnabled"
@click="grantMicPermission"
>
授权并开始
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
<!-- 全屏时间弹框 -->
<v-dialog
v-model="showFullscreen"
fullscreen
:scrim="false"
persistent
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><span
v-if="use12hClock"
class="fullscreen-seconds"
> {{ amPmString }}</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="countdownEndedDialog"
max-width="480"
persistent
>
<v-card rounded="xl">
<v-card-title class="d-flex align-center justify-center pt-6">
<v-icon
color="error"
size="32"
class="mr-2"
icon="mdi-alarm"
/>
时间到
</v-card-title>
<v-card-text class="text-center pb-2">
<div
class="text-h4 font-weight-bold my-4"
style="font-variant-numeric: tabular-nums;"
>
{{ formatCountdownTotal(countdownTotal) }}
</div>
<div class="text-body-1 text-medium-emphasis">
设定的倒计时已结束
</div>
<div
v-if="overtimeElapsed > 0"
class="mt-4"
>
<v-chip
color="error"
variant="tonal"
size="large"
prepend-icon="mdi-clock-alert-outline"
>
已超时 {{ overtimeDisplay }}
</v-chip>
</div>
</v-card-text>
<v-card-actions class="justify-center pb-6">
<v-btn
color="primary"
variant="tonal"
size="large"
rounded="xl"
prepend-icon="mdi-check"
@click="dismissCountdownDialog"
>
知道了
</v-btn>
</v-card-actions>
</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-item>
<template #prepend>
<v-icon
class="mr-3"
icon="mdi-clock-time-six-outline"
/>
</template>
<v-list-item-title>12 小时制</v-list-item-title>
<v-list-item-subtitle> 12 小时制AM/PM显示时间</v-list-item-subtitle>
<template #append>
<v-switch
:model-value="use12hClock"
hide-details
density="comfortable"
@update:model-value="setUse12hClock"
/>
</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, getSetting, setSetting } from '@/utils/settings'
import { playSound, defaultSingleSound } from '@/utils/soundList'
import { noiseService } from '@wydev/noise-core'
import { defineAsyncComponent } from 'vue'
import dataProvider from '@/utils/dataProvider'
const NoiseMonitorDetail = defineAsyncComponent(() =>
import('@/components/NoiseMonitorDetail.vue')
)
// 时间字体大小比例(卡片场景)
const TIME_FONT_RATIO = 2.0
const SECONDS_FONT_RATIO = 0.9
const DATE_FONT_RATIO = 0.6
export default {
name: 'TimeCard',
components: { NoiseMonitorDetail },
data() {
return {
now: new Date(),
timer: null,
unwatch: null,
fontSize: 28,
showFullscreen: false,
showSettings: false,
timeCardEnabled: true,
use12hClock: false,
// 全屏模式切换
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 },
],
// 倒计时结束弹框
countdownEndedDialog: false,
overtimeElapsed: 0,
overtimeTimer: null,
overtimeLastTick: null,
// 秒表
stopwatchRunning: false,
stopwatchElapsed: 0, // 已走毫秒
stopwatchTimer: null,
stopwatchLastTick: null,
laps: [],
lastLapElapsed: 0,
// 噪音监测
noiseEnabled: false,
noiseMonitoring: false,
noiseStatus: 'initializing',
noiseCurrentDbfs: -100,
noiseCurrentDisplayDb: 0,
noiseSmoothedDb: 0, // 防抖后的平滑值
noiseScore: null,
noiseScoreDetail: null,
noiseRingBuffer: [],
noiseLastSlice: null,
noiseHistory: [],
noiseUnsubscribe: null,
showNoiseDetail: false,
// 晚自习会话
noiseSessionConfig: null, // KV存储的配置
noiseSessionActive: false,
noiseSessionData: null, // 当前会话数据
noiseSessionCheckTimer: null,
// 按日期存储的报告系统
noiseReportMeta: {}, // { dates: { '2026-03-07': { count, avgScore, sessions } } }
noiseSelectedDate: '', // 当前查看的日期 YYYY-MM-DD
noiseCurrentDateReports: [], // 当前日期的报告列表
// 麦克风权限引导
showMicPermissionDialog: false,
micPermissionState: '', // 'granted' | 'prompt' | 'denied' | 'unavailable'
}
},
computed: {
timeString() {
const hours = this.now.getHours()
const m = String(this.now.getMinutes()).padStart(2, '0')
if (this.use12hClock) {
const h12 = hours % 12 || 12
return `${h12}:${m}`
}
return `${String(hours).padStart(2, '0')}:${m}`
},
amPmString() {
return this.now.getHours() < 12 ? 'AM' : 'PM'
},
secondsString() {
return `:${String(this.now.getSeconds()).padStart(2, '0')}`
},
dateString() {
const y = this.now.getFullYear()
const m = this.now.getMonth() + 1
const d = this.now.getDate()
return `${y}${m}${d}`
},
weekdayString() {
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return days[this.now.getDay()]
},
periodOfDay() {
const h = this.now.getHours()
if (h < 6) return '凌晨'
if (h < 8) return '早晨'
if (h < 11) return '上午'
if (h < 13) return '中午'
if (h < 17) return '下午'
if (h < 19) return '傍晚'
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)
},
timeStyle() {
return {
'font-size': `${this.fontSize * TIME_FONT_RATIO}px`,
'font-weight': '700',
'line-height': '1',
'letter-spacing': '4px',
'font-variant-numeric': 'tabular-nums',
}
},
secondsStyle() {
return {
'font-size': `${this.fontSize * SECONDS_FONT_RATIO}px`,
'font-variant-numeric': 'tabular-nums',
'vertical-align': 'baseline',
'margin-left': '4px',
'opacity': '0.6',
}
},
dateStyle() {
return {
'font-size': `${this.fontSize * DATE_FONT_RATIO}px`,
'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
},
overtimeDisplay() {
const totalSec = Math.floor(this.overtimeElapsed / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) {
return `${h}小时${m}${s}`
}
if (m > 0) {
return `${m}${s}`
}
return `${s}`
},
// 秒表 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')}`
},
// ===== 噪音监测 computed =====
noiseDisplayDb() {
if (!this.noiseMonitoring || this.noiseStatus !== 'active') return '--'
return Math.round(this.noiseSmoothedDb)
},
noiseDbColor() {
const db = typeof this.noiseDisplayDb === 'number' ? this.noiseDisplayDb : 0
if (db < 40) return 'success'
if (db < 55) return 'light-green'
if (db < 70) return 'warning'
if (db < 85) return 'orange'
return 'error'
},
noiseIconColor() {
if (!this.noiseMonitoring) return 'grey'
return this.noiseDbColor
},
noiseStatusText() {
if (!this.noiseMonitoring || this.noiseStatus !== 'active') return '未监测'
const db = typeof this.noiseDisplayDb === 'number' ? this.noiseDisplayDb : 0
if (db < 30) return '极其安静'
if (db < 40) return '非常安静'
if (db < 50) return '安静'
if (db < 60) return '正常交谈'
if (db < 70) return '较为嘈杂'
if (db < 80) return '嘈杂'
if (db < 90) return '非常嘈杂'
return '极度嘈杂'
},
},
watch: {
showFullscreen(val) {
if (val) {
this.handleKeydown = (e) => {
if (e.key === 'Escape') {
if (this.showSettings) {
this.showSettings = false
} else if (this.countdownEndedDialog) {
this.dismissCountdownDialog()
}
// 不关闭全屏弹框,阻止默认行为
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()
}
},
},
async mounted() {
this.loadSettings()
this.startTimer()
this.unwatch = watchSettings(() => {
this.loadSettings()
})
// 噪音: 加载历史 & 自动启动(带权限检测)
this.noiseEnabled = getSetting('noiseMonitor.enabled')
if (this.noiseEnabled) {
this.noiseHistory = noiseService.getHistory()
// 检查麦克风权限状态
const micState = await this.checkMicPermission()
this.micPermissionState = micState
if (micState === 'granted') {
// 已授权 → 正常流程
this.loadNoiseSessionConfig().then(() => {
this.startSessionCheck()
if (getSetting('noiseMonitor.autoStart') && !this.noiseMonitoring) {
this.startNoise()
}
})
} else if (micState === 'prompt') {
// 未授权也未拒绝 → 看是否已跳过引导
if (!getSetting('noiseMonitor.permissionDismissed')) {
this.showMicPermissionDialog = true
}
}
// denied / unavailable → 不自动启动micPermissionState 已记录
}
},
beforeUnmount() {
this.stopTimer()
this.clearCountdownTimer()
this.clearStopwatchTimer()
this.clearToolbarTimer()
this.dismissCountdownDialog()
this.stopNoise()
this.stopSessionCheck()
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')
this.use12hClock = getSetting('timeCard.use12h')
this.noiseEnabled = getSetting('noiseMonitor.enabled')
},
setTimeCardEnabled(val) {
this.timeCardEnabled = val
setSetting('timeCard.enabled', val)
},
setUse12hClock(val) {
this.use12hClock = val
setSetting('timeCard.use12h', val)
},
startTimer() {
this.timer = setInterval(() => {
this.now = new Date()
}, 1000)
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer)
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)
this.showCountdownEndedDialog()
}
},
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()
this.dismissCountdownDialog()
},
showCountdownEndedDialog() {
this.countdownEndedDialog = true
this.overtimeElapsed = 0
this.overtimeLastTick = Date.now()
this.overtimeTimer = setInterval(() => {
const now = Date.now()
this.overtimeElapsed += now - this.overtimeLastTick
this.overtimeLastTick = now
}, 200)
},
dismissCountdownDialog() {
this.countdownEndedDialog = false
this.overtimeElapsed = 0
if (this.overtimeTimer) {
clearInterval(this.overtimeTimer)
this.overtimeTimer = null
}
},
formatCountdownTotal(ms) {
const totalSec = Math.round(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const parts = []
if (h > 0) parts.push(`${h}小时`)
if (m > 0) parts.push(`${m}分钟`)
if (s > 0) parts.push(`${s}`)
return parts.join('') || '0秒'
},
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')}`
},
// ===== 麦克风权限引导 =====
async checkMicPermission() {
try {
// 先检查是否有麦克风硬件
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
const devices = await navigator.mediaDevices.enumerateDevices()
const hasMic = devices.some(d => d.kind === 'audioinput')
if (!hasMic) return 'unavailable'
}
if (!navigator.permissions || !navigator.permissions.query) return 'granted'
const result = await navigator.permissions.query({ name: 'microphone' })
return result.state // 'granted' | 'prompt' | 'denied'
} catch {
// Firefox 等部分浏览器不支持 query microphone视为 prompt
return 'prompt'
}
},
async grantMicPermission() {
this.showMicPermissionDialog = false
// 先加载配置和会话
await this.loadNoiseSessionConfig()
this.startSessionCheck()
// 调用 startNoise 触发浏览器权限弹框
await this.startNoise()
// 重新检查状态
this.micPermissionState = await this.checkMicPermission()
},
dismissMicPermission() {
this.showMicPermissionDialog = false
setSetting('noiseMonitor.permissionDismissed', true)
},
onPermissionDialogToggle(val) {
this.noiseEnabled = val
setSetting('noiseMonitor.enabled', val)
if (!val) {
// 用户在弹框中关闭了功能
this.showMicPermissionDialog = false
setSetting('noiseMonitor.permissionDismissed', true)
}
},
// ===== 噪音监测 methods =====
async startNoise() {
try {
await noiseService.start()
this.noiseMonitoring = true
this.noiseUnsubscribe = noiseService.subscribe((snapshot) => {
this.noiseStatus = snapshot.status
this.noiseCurrentDbfs = snapshot.currentDbfs
this.noiseCurrentDisplayDb = snapshot.currentDisplayDb
// 防抖平滑: 指数移动平均
const alpha = 0.25
const rawDb = snapshot.currentDisplayDb
this.noiseSmoothedDb = this.noiseSmoothedDb === 0
? rawDb
: this.noiseSmoothedDb * (1 - alpha) + rawDb * alpha
this.noiseRingBuffer = snapshot.ringBuffer || []
this.noiseLastSlice = snapshot.lastSlice || null
this.noiseScore = snapshot.currentScore ?? null
this.noiseScoreDetail = snapshot.currentScoreDetail ?? null
this.noiseHistory = noiseService.getHistory()
// 会话数据采集
if (this.noiseSessionActive && this.noiseSessionData) {
this.collectSessionSample(snapshot)
}
})
} catch (e) {
console.error('噪音监测启动失败:', e)
this.noiseStatus = 'error'
}
},
stopNoise() {
if (this.noiseUnsubscribe) {
this.noiseUnsubscribe()
this.noiseUnsubscribe = null
}
if (this.noiseMonitoring) {
noiseService.stop()
}
this.noiseMonitoring = false
this.noiseStatus = 'initializing'
this.noiseSmoothedDb = 0
this.noiseScore = null
this.noiseScoreDetail = null
},
clearNoiseHistory() {
noiseService.clearHistory()
this.noiseHistory = []
},
onNoiseClick() {
if (this.micPermissionState === 'denied' || this.micPermissionState === 'unavailable') {
this.showNoiseDetail = true
return
}
if (!this.noiseMonitoring) {
this.startNoise()
} else {
this.showNoiseDetail = true
}
},
// ===== 晚自习会话管理 =====
async loadNoiseSessionConfig() {
try {
const res = await dataProvider.loadData('noise-session-config')
const data = res?.data || res
if (data && data.sessions) {
this.noiseSessionConfig = data
} else {
// 默认配置
this.noiseSessionConfig = {
sessions: [
{ name: '第1节晚自习', start: '19:20', duration: 70, enabled: true },
{ name: '第2节晚自习', start: '20:20', duration: 110, enabled: true },
],
alertThresholdDb: 55,
}
}
// 加载报告
await this.loadSessionReports()
} catch (e) {
console.error('加载噪音会话配置失败:', e)
this.noiseSessionConfig = {
sessions: [
{ name: '第1节晚自习', start: '19:20', duration: 70, enabled: true },
{ name: '第2节晚自习', start: '20:20', duration: 110, enabled: true },
],
alertThresholdDb: 55,
}
}
},
async saveNoiseSessionConfig() {
try {
await dataProvider.saveData('noise-session-config', this.noiseSessionConfig)
} catch (e) {
console.error('保存噪音会话配置失败:', e)
}
},
// ===== 按日期的报告存储 =====
formatDateKey(ts) {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
},
async loadReportMeta() {
try {
const res = await dataProvider.loadData('noise-reports-meta')
const data = res?.data || res
if (data && data.dates) {
// 清理超过 30 天的元数据
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - 30)
const cutoffKey = this.formatDateKey(cutoffDate.getTime())
const cleaned = {}
for (const [key, val] of Object.entries(data.dates)) {
if (key >= cutoffKey) cleaned[key] = val
}
this.noiseReportMeta = { dates: cleaned }
} else {
this.noiseReportMeta = { dates: {} }
}
} catch {
this.noiseReportMeta = { dates: {} }
}
},
async saveReportMeta() {
try {
await dataProvider.saveData('noise-reports-meta', this.noiseReportMeta)
} catch {
console.error('保存报告元数据失败')
}
},
async loadReportsForDate(dateStr) {
this.noiseSelectedDate = dateStr
try {
const res = await dataProvider.loadData(`noise-reports-${dateStr}`)
const data = res?.data || res
this.noiseCurrentDateReports = Array.isArray(data) ? data : []
} catch {
this.noiseCurrentDateReports = []
}
},
async saveReportToDate(report) {
const dateStr = this.formatDateKey(report.startTime)
// 加载当天已有报告
let existing = []
try {
const res = await dataProvider.loadData(`noise-reports-${dateStr}`)
const data = res?.data || res
if (Array.isArray(data)) existing = data
} catch { /* empty */ }
existing.push(report)
await dataProvider.saveData(`noise-reports-${dateStr}`, existing)
// 更新元数据
if (!this.noiseReportMeta.dates) this.noiseReportMeta.dates = {}
const scores = existing.map(r => r.score)
this.noiseReportMeta.dates[dateStr] = {
count: existing.length,
avgScore: Math.round(scores.reduce((a, b) => a + b, 0) / scores.length),
sessions: existing.map(r => r.sessionName),
lastUpdated: Date.now(),
}
await this.saveReportMeta()
// 如果当前正在查看该日期,刷新
if (this.noiseSelectedDate === dateStr) {
this.noiseCurrentDateReports = existing
}
},
async loadSessionReports() {
await this.loadReportMeta()
// 默认加载今天的报告
const today = this.formatDateKey(Date.now())
await this.loadReportsForDate(today)
},
startSessionCheck() {
// 每 30 秒检查是否在晚自习时间段
this.checkSessionTime()
this.noiseSessionCheckTimer = setInterval(() => {
this.checkSessionTime()
}, 30000)
},
stopSessionCheck() {
if (this.noiseSessionCheckTimer) {
clearInterval(this.noiseSessionCheckTimer)
this.noiseSessionCheckTimer = null
}
},
checkSessionTime() {
if (!this.noiseSessionConfig?.sessions) return
const now = new Date()
const nowMinutes = now.getHours() * 60 + now.getMinutes()
const activeSession = this.noiseSessionConfig.sessions.find(s => {
if (!s.enabled) return false
const [h, m] = s.start.split(':').map(Number)
const startMin = h * 60 + m
const endMin = startMin + (s.duration || 70)
return nowMinutes >= startMin && nowMinutes < endMin
})
if (activeSession && !this.noiseSessionActive) {
// 进入晚自习时段,自动开始
this.beginSession(activeSession)
} else if (!activeSession && this.noiseSessionActive) {
// 离开晚自习时段,自动结束并保存
this.endSession()
}
},
beginSession(session) {
this.noiseSessionActive = true
this.noiseSessionData = {
sessionName: session.name,
startTime: Date.now(),
endTime: null,
samples: [], // { t, db }
slices: [],
alertThresholdDb: this.noiseSessionConfig.alertThresholdDb || 55,
}
// 如果还没在监测,自动开始
if (!this.noiseMonitoring) {
this.startNoise()
}
},
endSession() {
if (!this.noiseSessionData) {
this.noiseSessionActive = false
return
}
this.noiseSessionData.endTime = Date.now()
this.noiseSessionData.slices = [...this.noiseHistory]
// 生成报告并按日期保存
const report = this.generateSessionReport(this.noiseSessionData)
this.saveReportToDate(report)
this.noiseSessionActive = false
this.noiseSessionData = null
},
collectSessionSample(snapshot) {
if (!this.noiseSessionData) return
const db = snapshot.currentDisplayDb
if (typeof db === 'number' && db > 0) {
// 每 2 秒采样一次(通过间隔控制)
const samples = this.noiseSessionData.samples
const lastT = samples.length > 0 ? samples[samples.length - 1].t : 0
if (Date.now() - lastT >= 2000) {
samples.push({ t: Date.now(), db: Math.round(db * 10) / 10 })
}
}
},
generateSessionReport(data) {
const samples = data.samples
const dbs = samples.map(s => s.db)
const threshold = data.alertThresholdDb
const duration = data.endTime - data.startTime
if (dbs.length === 0) {
return {
sessionName: data.sessionName,
startTime: data.startTime,
endTime: data.endTime,
duration,
avgDb: 0, maxDb: 0, score: 100,
overThresholdDuration: 0, overThresholdRatio: 0,
segmentCount: 0, samples: [],
scorePenalties: { sustained: 0, time: 0, segment: 0 },
}
}
// 统计
const avgDb = Math.round(dbs.reduce((a, b) => a + b, 0) / dbs.length * 10) / 10
const maxDb = Math.round(Math.max(...dbs) * 10) / 10
// 超阈时长
let overCount = 0
dbs.forEach(d => { if (d > threshold) overCount++ })
const overThresholdRatio = overCount / dbs.length
const overThresholdDuration = Math.round(overThresholdRatio * duration / 1000)
// 打断次数(超阈片段)
let segmentCount = 0
let inSegment = false
dbs.forEach(d => {
if (d > threshold && !inSegment) { segmentCount++; inSegment = true }
if (d <= threshold) inSegment = false
})
// 评分
const sustainedPenalty = Math.min(40, Math.max(0, (avgDb - threshold) / 30 * 40))
const timePenalty = Math.min(30, overThresholdRatio * 30)
const segmentPenalty = Math.min(30, (segmentCount / Math.max(1, duration / 60000) / 6) * 30)
const score = Math.max(0, Math.round(100 - sustainedPenalty - timePenalty - segmentPenalty))
return {
sessionName: data.sessionName,
startTime: data.startTime,
endTime: data.endTime,
duration,
avgDb, maxDb, score,
overThresholdDuration,
overThresholdRatio: Math.round(overThresholdRatio * 1000) / 10,
segmentCount,
samples: samples.length > 500 ? this.downsampleArray(samples, 500) : samples,
scorePenalties: {
sustained: Math.round(sustainedPenalty),
time: Math.round(timePenalty),
segment: Math.round(segmentPenalty),
},
alertThresholdDb: threshold,
}
},
downsampleArray(arr, targetLen) {
const step = arr.length / targetLen
const result = []
for (let i = 0; i < targetLen; i++) {
result.push(arr[Math.floor(i * step)])
}
return result
},
async onSelectReportDate(dateStr) {
await this.loadReportsForDate(dateStr)
},
async onClearDateReports(dateStr) {
try {
await dataProvider.saveData(`noise-reports-${dateStr}`, [])
} catch { /* empty */ }
// 更新元数据
if (this.noiseReportMeta.dates) {
delete this.noiseReportMeta.dates[dateStr]
await this.saveReportMeta()
}
if (this.noiseSelectedDate === dateStr) {
this.noiseCurrentDateReports = []
}
},
async onClearAllReports() {
// 清空所有日期的报告
if (this.noiseReportMeta.dates) {
for (const dateStr of Object.keys(this.noiseReportMeta.dates)) {
try {
await dataProvider.saveData(`noise-reports-${dateStr}`, [])
} catch { /* empty */ }
}
}
this.noiseReportMeta = { dates: {} }
await this.saveReportMeta()
this.noiseCurrentDateReports = []
},
},
}
</script>
<style scoped>
.time-card {
transition: all 0.3s ease;
overflow: hidden;
}
.time-card:hover {
transform: translateY(-2px);
}
.time-display {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
white-space: nowrap;
}
.seconds-text {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
.date-line {
opacity: 0.75;
letter-spacing: 1px;
}
/* 噪音侧栏 */
.noise-side {
padding: 8px 12px;
border-radius: 12px;
background: rgba(var(--v-theme-surface-variant), 0.25);
transition: background 0.2s ease;
cursor: pointer;
}
.noise-side:hover {
background: rgba(var(--v-theme-surface-variant), 0.5);
}
.noise-side-db {
line-height: 1;
font-variant-numeric: tabular-nums;
}
/* 全屏样式 */
.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>