mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-03-21 09:13:10 +00:00
feat: add NoiseMonitorDetail component for environmental noise monitoring with real-time metrics, reports, and configuration settings
This commit is contained in:
parent
4cd948ff63
commit
9f95ca19cb
@ -16,6 +16,7 @@
|
||||
"@microsoft/clarity": "^1.0.2",
|
||||
"@sentry/vue": "^10.36.0",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@wydev/noise-core": "^0.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"idb": "^8.0.3",
|
||||
"js-base64": "^3.7.8",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -23,6 +23,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0(vue@3.5.25(typescript@5.9.3))
|
||||
'@wydev/noise-core':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
@ -1469,6 +1472,9 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@wydev/noise-core@0.1.0':
|
||||
resolution: {integrity: sha512-gHPDPasN8Iy3lHjv/3yhev3rpkMUd/o4iIQQKAKwK2Sewqu76dXMxqCsw9gGEf5ZZBf0YhwClsS7lcDlRo0Qxw==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@ -5083,6 +5089,10 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.25(typescript@5.9.3)
|
||||
|
||||
'@wydev/noise-core@0.1.0':
|
||||
dependencies:
|
||||
uuid: 13.0.0
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
284
src/components/NoiseMonitorCard.vue
Normal file
284
src/components/NoiseMonitorCard.vue
Normal file
@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<v-card
|
||||
class="noise-monitor-card"
|
||||
elevation="2"
|
||||
border
|
||||
rounded="xl"
|
||||
height="100%"
|
||||
style="cursor: pointer"
|
||||
@click="showDetail = true"
|
||||
>
|
||||
<v-card-text class="pa-5 d-flex flex-column" style="height: 100%">
|
||||
<!-- 顶部标题行 -->
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon
|
||||
:color="statusColor"
|
||||
class="mr-2"
|
||||
size="20"
|
||||
>
|
||||
mdi-microphone
|
||||
</v-icon>
|
||||
<span class="text-subtitle-2 font-weight-medium text-medium-emphasis">
|
||||
环境噪音监测
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
:color="statusColor"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- 分贝显示 -->
|
||||
<div class="noise-db-display d-flex align-center justify-center flex-grow-1">
|
||||
<div class="text-center">
|
||||
<div class="d-flex align-end justify-center">
|
||||
<span
|
||||
class="noise-db-value font-weight-bold"
|
||||
:style="{ color: `rgb(var(--v-theme-${dbColor}))`, fontSize: dbFontSize }"
|
||||
>
|
||||
{{ currentDb }}
|
||||
</span>
|
||||
<span class="text-caption text-medium-emphasis ml-1 mb-1">dB</span>
|
||||
</div>
|
||||
<div class="noise-level-label text-caption mt-1" :class="`text-${dbColor}`">
|
||||
{{ noiseLevel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部迷你波形 + 分数 -->
|
||||
<div class="d-flex align-center mt-2">
|
||||
<!-- 迷你波形条 -->
|
||||
<div class="noise-mini-bars d-flex align-end" style="height: 20px; gap: 2px; flex: 1;">
|
||||
<div
|
||||
v-for="(val, i) in miniBarValues"
|
||||
:key="i"
|
||||
class="noise-mini-bar"
|
||||
:style="{
|
||||
height: `${val}%`,
|
||||
backgroundColor: `rgb(var(--v-theme-${barColor(val)}))`,
|
||||
flex: 1,
|
||||
borderRadius: '2px',
|
||||
transition: 'height 0.15s ease',
|
||||
minWidth: '3px',
|
||||
maxWidth: '6px',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前评分 -->
|
||||
<div v-if="currentScore !== null" class="ml-3 text-center">
|
||||
<div
|
||||
class="font-weight-bold text-subtitle-1"
|
||||
:class="`text-${scoreColor}`"
|
||||
>
|
||||
{{ currentScore }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis" style="font-size: 10px; line-height: 1;">
|
||||
评分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<noise-monitor-detail
|
||||
v-model="showDetail"
|
||||
:status="status"
|
||||
:current-db="currentDb"
|
||||
:current-dbfs="currentDbfs"
|
||||
:noise-level="noiseLevel"
|
||||
:db-color="dbColor"
|
||||
:current-score="currentScore"
|
||||
:score-detail="scoreDetail"
|
||||
:ring-buffer="ringBuffer"
|
||||
:last-slice="lastSlice"
|
||||
:history="history"
|
||||
:is-monitoring="isMonitoring"
|
||||
@start="startMonitoring"
|
||||
@stop="stopMonitoring"
|
||||
@calibrate="handleCalibrate"
|
||||
@clear-history="handleClearHistory"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { noiseService } from '@wydev/noise-core'
|
||||
|
||||
const NoiseMonitorDetail = defineAsyncComponent(() =>
|
||||
import('@/components/NoiseMonitorDetail.vue')
|
||||
)
|
||||
|
||||
// 最近 N 个采样用于迷你波形
|
||||
const MINI_BAR_COUNT = 16
|
||||
|
||||
export default {
|
||||
name: 'NoiseMonitorCard',
|
||||
components: { NoiseMonitorDetail },
|
||||
data() {
|
||||
return {
|
||||
showDetail: false,
|
||||
isMonitoring: false,
|
||||
status: 'initializing',
|
||||
currentDbfs: -100,
|
||||
currentDisplayDb: 0,
|
||||
currentScore: null,
|
||||
scoreDetail: null,
|
||||
ringBuffer: [],
|
||||
lastSlice: null,
|
||||
history: [],
|
||||
unsubscribe: null,
|
||||
recentDbValues: new Array(MINI_BAR_COUNT).fill(0),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentDb() {
|
||||
if (!this.isMonitoring || this.status !== 'active') return '--'
|
||||
return Math.round(this.currentDisplayDb)
|
||||
},
|
||||
statusColor() {
|
||||
if (this.status === 'active') return 'success'
|
||||
if (this.status === 'paused') return 'warning'
|
||||
if (this.status === 'permission-denied' || this.status === 'error') return 'error'
|
||||
return 'grey'
|
||||
},
|
||||
statusLabel() {
|
||||
const map = {
|
||||
initializing: '就绪',
|
||||
active: '监测中',
|
||||
paused: '已暂停',
|
||||
'permission-denied': '无权限',
|
||||
error: '错误',
|
||||
}
|
||||
return this.isMonitoring ? (map[this.status] || '未知') : '未启动'
|
||||
},
|
||||
dbColor() {
|
||||
const db = typeof this.currentDb === 'number' ? this.currentDb : 0
|
||||
if (db < 40) return 'success'
|
||||
if (db < 55) return 'light-green'
|
||||
if (db < 70) return 'warning'
|
||||
if (db < 85) return 'orange'
|
||||
return 'error'
|
||||
},
|
||||
dbFontSize() {
|
||||
return '2.5rem'
|
||||
},
|
||||
noiseLevel() {
|
||||
const db = typeof this.currentDb === 'number' ? this.currentDb : 0
|
||||
if (!this.isMonitoring || this.status !== 'active') return '等待监测'
|
||||
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 '极度嘈杂'
|
||||
},
|
||||
scoreColor() {
|
||||
if (this.currentScore === null) return 'grey'
|
||||
if (this.currentScore >= 80) return 'success'
|
||||
if (this.currentScore >= 60) return 'warning'
|
||||
return 'error'
|
||||
},
|
||||
miniBarValues() {
|
||||
return this.recentDbValues.map(db => {
|
||||
// 将 dB 值 (0-100) 映射到 5%-100% 高度
|
||||
return Math.max(5, Math.min(100, db))
|
||||
})
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.history = noiseService.getHistory()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async startMonitoring() {
|
||||
try {
|
||||
await noiseService.start()
|
||||
this.isMonitoring = true
|
||||
this.unsubscribe = noiseService.subscribe((snapshot) => {
|
||||
this.status = snapshot.status
|
||||
this.currentDbfs = snapshot.currentDbfs
|
||||
this.currentDisplayDb = snapshot.currentDisplayDb
|
||||
this.ringBuffer = snapshot.ringBuffer || []
|
||||
this.lastSlice = snapshot.lastSlice || null
|
||||
this.currentScore = snapshot.currentScore ?? null
|
||||
this.scoreDetail = snapshot.currentScoreDetail ?? null
|
||||
|
||||
// 更新迷你波形数据
|
||||
const dbVal = Math.max(0, Math.min(100, this.currentDisplayDb))
|
||||
this.recentDbValues.push(dbVal)
|
||||
if (this.recentDbValues.length > MINI_BAR_COUNT) {
|
||||
this.recentDbValues.shift()
|
||||
}
|
||||
|
||||
// 更新历史
|
||||
this.history = noiseService.getHistory()
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('噪音监测启动失败:', e)
|
||||
this.status = 'error'
|
||||
}
|
||||
},
|
||||
stopMonitoring() {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe()
|
||||
this.unsubscribe = null
|
||||
}
|
||||
noiseService.stop()
|
||||
this.isMonitoring = false
|
||||
this.status = 'initializing'
|
||||
this.recentDbValues = new Array(MINI_BAR_COUNT).fill(0)
|
||||
this.currentScore = null
|
||||
this.scoreDetail = null
|
||||
},
|
||||
handleCalibrate(targetDb) {
|
||||
noiseService.calibrate(targetDb, (success, msg) => {
|
||||
console.log(success ? '校准成功' : `校准失败: ${msg}`)
|
||||
})
|
||||
},
|
||||
handleClearHistory() {
|
||||
noiseService.clearHistory()
|
||||
this.history = []
|
||||
},
|
||||
barColor(val) {
|
||||
if (val < 30) return 'success'
|
||||
if (val < 55) return 'light-green'
|
||||
if (val < 70) return 'warning'
|
||||
if (val < 85) return 'orange'
|
||||
return 'error'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.noise-monitor-card {
|
||||
overflow: hidden;
|
||||
|
||||
.noise-db-value {
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.noise-level-label {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.noise-mini-bars {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1392
src/components/NoiseMonitorDetail.vue
Normal file
1392
src/components/NoiseMonitorDetail.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,9 @@
|
||||
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"
|
||||
@ -22,15 +24,77 @@
|
||||
:style="secondsStyle"
|
||||
>{{ secondsString }}</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"
|
||||
>
|
||||
|
||||
<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>
|
||||
</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"
|
||||
:session-config="noiseSessionConfig"
|
||||
:session-active="noiseSessionActive"
|
||||
:session-data="noiseSessionData"
|
||||
:report-meta="noiseReportMeta"
|
||||
:selected-date="noiseSelectedDate"
|
||||
:date-reports="noiseCurrentDateReports"
|
||||
@start="startNoise"
|
||||
@stop="stopNoise"
|
||||
@calibrate="calibrateNoise"
|
||||
@clear-history="clearNoiseHistory"
|
||||
@save-config="onSaveSessionConfig"
|
||||
@select-date="onSelectReportDate"
|
||||
@clear-date-reports="onClearDateReports"
|
||||
@clear-all-reports="onClearAllReports"
|
||||
/>
|
||||
</v-card>
|
||||
|
||||
<!-- 全屏时间弹框 -->
|
||||
@ -487,6 +551,13 @@
|
||||
<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
|
||||
@ -495,6 +566,7 @@ const DATE_FONT_RATIO = 0.6
|
||||
|
||||
export default {
|
||||
name: 'TimeCard',
|
||||
components: { NoiseMonitorDetail },
|
||||
data() {
|
||||
return {
|
||||
now: new Date(),
|
||||
@ -539,6 +611,29 @@ export default {
|
||||
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: [], // 当前日期的报告列表
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -647,6 +742,35 @@ export default {
|
||||
}
|
||||
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) {
|
||||
@ -680,6 +804,15 @@ export default {
|
||||
this.unwatch = watchSettings(() => {
|
||||
this.loadSettings()
|
||||
})
|
||||
// 噪音: 加载历史 & 自动启动
|
||||
this.noiseEnabled = getSetting('noiseMonitor.enabled')
|
||||
if (this.noiseEnabled) {
|
||||
this.noiseHistory = noiseService.getHistory()
|
||||
// 先加载配置,再启动会话检查(自动检测当前是否在晚自习时段并自动开始)
|
||||
this.loadNoiseSessionConfig().then(() => {
|
||||
this.startSessionCheck()
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopTimer()
|
||||
@ -687,6 +820,8 @@ export default {
|
||||
this.clearStopwatchTimer()
|
||||
this.clearToolbarTimer()
|
||||
this.dismissCountdownDialog()
|
||||
this.stopNoise()
|
||||
this.stopSessionCheck()
|
||||
if (this.unwatch) {
|
||||
this.unwatch()
|
||||
}
|
||||
@ -698,6 +833,7 @@ export default {
|
||||
loadSettings() {
|
||||
this.fontSize = SettingsManager.getSetting('font.size')
|
||||
this.timeCardEnabled = getSetting('timeCard.enabled')
|
||||
this.noiseEnabled = getSetting('noiseMonitor.enabled')
|
||||
},
|
||||
setTimeCardEnabled(val) {
|
||||
this.timeCardEnabled = val
|
||||
@ -857,6 +993,353 @@ export default {
|
||||
const centis = Math.floor((ms % 1000) / 10)
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
|
||||
},
|
||||
// ===== 噪音监测 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
|
||||
},
|
||||
calibrateNoise(targetDb) {
|
||||
noiseService.calibrate(targetDb, (success, msg) => {
|
||||
console.log(success ? '校准成功' : `校准失败: ${msg}`)
|
||||
})
|
||||
},
|
||||
clearNoiseHistory() {
|
||||
noiseService.clearHistory()
|
||||
this.noiseHistory = []
|
||||
},
|
||||
onNoiseClick() {
|
||||
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
|
||||
},
|
||||
onSaveSessionConfig(config) {
|
||||
this.noiseSessionConfig = config
|
||||
this.saveNoiseSessionConfig()
|
||||
},
|
||||
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>
|
||||
@ -885,6 +1368,22 @@ export default {
|
||||
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;
|
||||
|
||||
@ -7,10 +7,11 @@
|
||||
color="error"
|
||||
size="large"
|
||||
@click="$emit('upload')"
|
||||
rounded="xl"
|
||||
>
|
||||
上传
|
||||
</v-btn>
|
||||
<v-btn v-else color="success" size="large" @click="$emit('show-sync-message')">
|
||||
<v-btn v-else color="success" size="large" @click="$emit('show-sync-message')" rounded="xl">
|
||||
同步完成
|
||||
</v-btn>
|
||||
<v-btn
|
||||
|
||||
@ -126,6 +126,7 @@
|
||||
border
|
||||
class="glow-track"
|
||||
height="100%"
|
||||
rounded="xl"
|
||||
@click="handleCardClick('dialog', item.key)"
|
||||
@mousemove="handleMouseMove"
|
||||
@touchmove="handleTouchMove"
|
||||
@ -185,6 +186,7 @@
|
||||
v-for="subject in unusedSubjects"
|
||||
:key="subject.name"
|
||||
border
|
||||
rounded="xl"
|
||||
class="empty-subject-card"
|
||||
@click="handleCardClick('dialog', subject.name)"
|
||||
>
|
||||
|
||||
@ -100,6 +100,20 @@ const settingsDefinitions = {
|
||||
icon: "mdi-card-outline",
|
||||
},
|
||||
|
||||
// 噪音监测设置
|
||||
"noiseMonitor.enabled": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "启用环境噪音监测卡片",
|
||||
icon: "mdi-microphone",
|
||||
},
|
||||
"noiseMonitor.autoStart": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "打开页面时自动开始监测",
|
||||
icon: "mdi-play-circle-outline",
|
||||
},
|
||||
|
||||
// 时间卡片设置
|
||||
"timeCard.enabled": {
|
||||
type: "boolean",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user