mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-03-21 09:13:10 +00:00
Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com> Agent-Logs-Url: https://github.com/ZeroCatDev/Classworks/sessions/5b22f5d2-515c-4a87-84f5-18ef897ede79
1764 lines
54 KiB
Vue
1764 lines
54 KiB
Vue
<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>
|