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

Compare commits

..

9 Commits

12 changed files with 3701 additions and 40 deletions

View File

@ -16,6 +16,7 @@
"@microsoft/clarity": "^1.0.2", "@microsoft/clarity": "^1.0.2",
"@sentry/vue": "^10.36.0", "@sentry/vue": "^10.36.0",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"@wydev/noise-core": "^0.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"js-base64": "^3.7.8", "js-base64": "^3.7.8",

10
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ importers:
'@vueuse/core': '@vueuse/core':
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0(vue@3.5.25(typescript@5.9.3)) version: 14.1.0(vue@3.5.25(typescript@5.9.3))
'@wydev/noise-core':
specifier: ^0.1.0
version: 0.1.0
axios: axios:
specifier: ^1.13.2 specifier: ^1.13.2
version: 1.13.2 version: 1.13.2
@ -1469,6 +1472,9 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.0 vue: ^3.5.0
'@wydev/noise-core@0.1.0':
resolution: {integrity: sha512-gHPDPasN8Iy3lHjv/3yhev3rpkMUd/o4iIQQKAKwK2Sewqu76dXMxqCsw9gGEf5ZZBf0YhwClsS7lcDlRo0Qxw==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@ -5083,6 +5089,10 @@ snapshots:
dependencies: dependencies:
vue: 3.5.25(typescript@5.9.3) 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): acorn-jsx@5.3.2(acorn@8.15.0):
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,10 +7,11 @@
color="error" color="error"
size="large" size="large"
@click="$emit('upload')" @click="$emit('upload')"
rounded="xl"
> >
上传 上传
</v-btn> </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>
<v-btn <v-btn

View File

@ -126,6 +126,7 @@
border border
class="glow-track" class="glow-track"
height="100%" height="100%"
rounded="xl"
@click="handleCardClick('dialog', item.key)" @click="handleCardClick('dialog', item.key)"
@mousemove="handleMouseMove" @mousemove="handleMouseMove"
@touchmove="handleTouchMove" @touchmove="handleTouchMove"
@ -185,6 +186,7 @@
v-for="subject in unusedSubjects" v-for="subject in unusedSubjects"
:key="subject.name" :key="subject.name"
border border
rounded="xl"
class="empty-subject-card" class="empty-subject-card"
@click="handleCardClick('dialog', subject.name)" @click="handleCardClick('dialog', subject.name)"
> >

View File

@ -127,14 +127,14 @@
</v-list-item> </v-list-item>
<v-list-item <v-list-item
append-icon="mdi-link" append-icon="mdi-link"
href="https://zerocat.houlangs.com" href="https://clock.qqhkx.com/"
target="_blank" target="_blank"
> >
<v-list-item-title> <v-list-item-title>
感谢 ZeroCat 社区的开发者们 感谢 沉浸式时钟
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
新一代开源编程社区 https://clock.qqhkx.com/
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item> </v-list-item>
<v-divider class="ma-1"></v-divider> <v-divider class="ma-1"></v-divider>

View File

@ -1,8 +1,35 @@
<template> <template>
<settings-card border icon="mdi-monitor" title="显示设置"> <settings-card border icon="mdi-monitor" title="显示设置">
<v-list> <v-list>
<setting-item :setting-key="'display.emptySubjectDisplay'"/> <v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-theme-light-dark"/>
</template>
<v-list-item-title>主题模式</v-list-item-title>
<v-list-item-subtitle>选择明亮或暗黑主题</v-list-item-subtitle>
<template #append>
<v-btn-toggle
v-model="localTheme"
color="primary"
density="comfortable"
>
<v-btn value="light">
<v-icon class="mr-2" icon="mdi-white-balance-sunny"/>
明亮
</v-btn>
<v-btn value="dark">
<v-icon class="mr-2" icon="mdi-moon-waning-crescent"/>
暗黑
</v-btn>
</v-btn-toggle>
</template>
</v-list-item>
<v-divider class="my-2"/>
<setting-item :setting-key="'timeCard.enabled'"/>
<v-divider class="my-2"/>
<setting-item :setting-key="'display.emptySubjectDisplay'"/>
<v-divider class="my-2"/> <v-divider class="my-2"/>
<setting-item :setting-key="'display.dynamicSort'"/> <setting-item :setting-key="'display.dynamicSort'"/>
@ -41,15 +68,29 @@
<script> <script>
import SettingsCard from '@/components/SettingsCard.vue'; import SettingsCard from '@/components/SettingsCard.vue';
import SettingItem from '@/components/settings/SettingItem.vue'; import SettingItem from '@/components/settings/SettingItem.vue';
import {getSetting, setSetting} from '@/utils/settings';
import {useTheme} from 'vuetify';
export default { export default {
name: 'DisplaySettingsCard', name: 'DisplaySettingsCard',
components: {SettingsCard, SettingItem}, components: {SettingsCard, SettingItem},
data() {
return {}; setup() {
const theme = useTheme();
return {theme};
}, },
data() {
return {
localTheme: getSetting('theme.mode'),
};
},
watch: {
localTheme(newValue) {
setSetting('theme.mode', newValue);
this.theme.global.name.value = newValue;
},
},
}; };
</script> </script>

View File

@ -0,0 +1,284 @@
<template>
<settings-card
border
icon="mdi-microphone"
title="噪音监测"
>
<v-list>
<setting-item :setting-key="'noiseMonitor.enabled'" />
<v-divider class="my-2" />
<setting-item :setting-key="'noiseMonitor.autoStart'" />
<v-divider class="my-2" />
<setting-item :setting-key="'noiseMonitor.permissionDismissed'" />
</v-list>
<v-divider class="mb-4" />
<!-- 晚自习时间段配置 -->
<div class="px-4 pb-4">
<div class="d-flex align-center mb-4">
<v-icon
class="mr-2"
color="teal"
>
mdi-clock-edit-outline
</v-icon>
<span class="text-subtitle-1 font-weight-bold">晚自习时间段</span>
<v-spacer />
<v-btn
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-plus"
@click="addSession"
>
添加时段
</v-btn>
</div>
<div class="text-caption text-medium-emphasis mb-4">
配置晚自习时间段后系统会在对应时段内自动开启噪音监测并记录统计报告时间段外不会长期记录
</div>
<v-skeleton-loader
v-if="sessionLoading"
type="card"
class="mb-4"
/>
<template v-else>
<div
v-for="(session, idx) in editSessions"
:key="idx"
class="mb-3"
>
<v-card
variant="outlined"
rounded="xl"
>
<v-card-text class="pa-4">
<div class="d-flex align-center ga-3 flex-wrap">
<v-text-field
v-model="session.name"
density="compact"
variant="outlined"
label="名称"
hide-details
style="max-width: 160px;"
/>
<v-menu
v-model="timePickerMenus[idx]"
:close-on-content-click="false"
location="bottom"
>
<template #activator="{ props: menuProps }">
<v-text-field
v-bind="menuProps"
:model-value="session.start"
density="compact"
variant="outlined"
label="开始时间"
readonly
hide-details
prepend-inner-icon="mdi-clock-outline"
style="max-width: 170px;"
/>
</template>
<v-time-picker
v-model="session.start"
color="primary"
format="24hr"
scrollable
@update:model-value="timePickerMenus[idx] = false"
/>
</v-menu>
<v-text-field
v-model.number="session.duration"
density="compact"
variant="outlined"
type="number"
label="时长"
suffix="分钟"
hide-details
style="max-width: 130px;"
:min="10"
:max="300"
/>
<span class="text-caption text-medium-emphasis">
{{ sessionEndTime(session) }}
</span>
<v-switch
v-model="session.enabled"
density="compact"
color="primary"
hide-details
label="启用"
/>
<v-btn
icon="mdi-delete"
color="error"
size="x-small"
variant="text"
@click="editSessions.splice(idx, 1)"
/>
</div>
</v-card-text>
</v-card>
</div>
<div
v-if="editSessions.length === 0"
class="text-center text-medium-emphasis py-4"
>
<v-icon class="mb-1">
mdi-clock-outline
</v-icon>
<div class="text-caption">
暂无时间段点击上方添加时段创建
</div>
</div>
</template>
<v-divider class="my-5" />
<!-- 监测参数 -->
<div class="d-flex align-center mb-4">
<v-icon
class="mr-2"
color="orange"
>
mdi-alert-decagram
</v-icon>
<span class="text-subtitle-1 font-weight-bold">监测参数</span>
</div>
<div class="d-flex align-center flex-wrap ga-4 mb-4">
<v-text-field
v-model.number="editAlertThreshold"
density="compact"
variant="outlined"
type="number"
label="噪音报警阈值"
suffix="dB"
hide-details
style="max-width: 200px;"
:min="30"
:max="90"
/>
</div>
<div class="d-flex justify-end ga-3 mb-2">
<v-btn
variant="text"
prepend-icon="mdi-restore"
@click="resetSessionConfig"
>
重置
</v-btn>
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-content-save"
:loading="sessionSaving"
@click="saveSessionConfig"
>
保存配置
</v-btn>
</div>
</div>
</settings-card>
</template>
<script>
import SettingsCard from '@/components/SettingsCard.vue';
import SettingItem from '@/components/settings/SettingItem.vue';
import dataProvider from '@/utils/dataProvider';
const DEFAULT_SESSION_CONFIG = {
sessions: [
{ name: '第1节晚自习', start: '19:20', duration: 70, enabled: true },
{ name: '第2节晚自习', start: '20:20', duration: 110, enabled: true },
],
alertThresholdDb: 55,
};
export default {
name: 'NoiseSettingsCard',
components: { SettingsCard, SettingItem },
data() {
return {
//
sessionLoading: true,
sessionSaving: false,
editSessions: [],
editAlertThreshold: 55,
timePickerMenus: {},
};
},
mounted() {
this.loadSessionConfig();
},
methods: {
// ===== =====
async loadSessionConfig() {
this.sessionLoading = true;
try {
const res = await dataProvider.loadData('noise-session-config');
const data = res?.data || res;
if (data && data.sessions) {
this.editSessions = JSON.parse(JSON.stringify(data.sessions));
this.editAlertThreshold = data.alertThresholdDb || 55;
} else {
this.resetSessionConfig();
}
} catch {
this.resetSessionConfig();
} finally {
this.sessionLoading = false;
}
},
async saveSessionConfig() {
this.sessionSaving = true;
try {
const config = {
sessions: this.editSessions,
alertThresholdDb: this.editAlertThreshold,
};
await dataProvider.saveData('noise-session-config', config);
} catch (e) {
console.error('保存自习配置失败:', e);
} finally {
this.sessionSaving = false;
}
},
resetSessionConfig() {
this.editSessions = JSON.parse(JSON.stringify(DEFAULT_SESSION_CONFIG.sessions));
this.editAlertThreshold = DEFAULT_SESSION_CONFIG.alertThresholdDb;
},
addSession() {
this.editSessions.push({
name: `${this.editSessions.length + 1}节晚自习`,
start: '19:00',
duration: 70,
enabled: true,
});
},
sessionEndTime(session) {
if (!session?.start || !session?.duration) return '--:--';
const [h, m] = session.start.split(':').map(Number);
const totalMin = h * 60 + m + (session.duration || 0);
const eh = Math.floor(totalMin / 60) % 24;
const em = totalMin % 60;
return `${String(eh).padStart(2, '0')}:${String(em).padStart(2, '0')}`;
},
},
};
</script>

View File

@ -174,12 +174,8 @@
/> />
</v-tabs-window-item> </v-tabs-window-item>
<v-tabs-window-item value="theme"> <v-tabs-window-item value="noise">
<theme-settings-card <noise-settings-card border />
:loading="loading.theme"
border
@saved="onSettingsSaved"
/>
</v-tabs-window-item> </v-tabs-window-item>
<v-tabs-window-item value="notification"> <v-tabs-window-item value="notification">
@ -262,7 +258,6 @@ import EditSettingsCard from "@/components/settings/cards/EditSettingsCard.vue";
import RefreshSettingsCard from "@/components/settings/cards/RefreshSettingsCard.vue"; import RefreshSettingsCard from "@/components/settings/cards/RefreshSettingsCard.vue";
import DisplaySettingsCard from "@/components/settings/cards/DisplaySettingsCard.vue"; import DisplaySettingsCard from "@/components/settings/cards/DisplaySettingsCard.vue";
import DataProviderSettingsCard from "@/components/settings/cards/DataProviderSettingsCard.vue"; import DataProviderSettingsCard from "@/components/settings/cards/DataProviderSettingsCard.vue";
import ThemeSettingsCard from "@/components/settings/cards/ThemeSettingsCard.vue";
import EchoChamberCard from "@/components/settings/cards/EchoChamberCard.vue"; import EchoChamberCard from "@/components/settings/cards/EchoChamberCard.vue";
import { import {
getSetting, getSetting,
@ -284,6 +279,7 @@ import SubjectManagementCard from "@/components/settings/cards/SubjectManagement
import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue"; import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue";
import HitokotoSettings from "@/components/HitokotoSettings.vue"; import HitokotoSettings from "@/components/HitokotoSettings.vue";
import NotificationSoundSettings from "@/components/settings/NotificationSoundSettings.vue"; import NotificationSoundSettings from "@/components/settings/NotificationSoundSettings.vue";
import NoiseSettingsCard from "@/components/settings/cards/NoiseSettingsCard.vue";
export default { export default {
name: "Settings", name: "Settings",
@ -298,7 +294,6 @@ export default {
TeacherListCard, TeacherListCard,
AboutCard, AboutCard,
DataProviderSettingsCard, DataProviderSettingsCard,
ThemeSettingsCard,
EchoChamberCard, EchoChamberCard,
SettingsExplorer, SettingsExplorer,
SettingsLinkGenerator, SettingsLinkGenerator,
@ -308,6 +303,7 @@ export default {
KvDatabaseCard, KvDatabaseCard,
HitokotoSettings, HitokotoSettings,
NotificationSoundSettings, NotificationSoundSettings,
NoiseSettingsCard,
}, },
setup() { setup() {
const {mobile} = useDisplay(); const {mobile} = useDisplay();
@ -425,9 +421,9 @@ export default {
value: "display", value: "display",
}, },
{ {
title: "主题", title: "噪音监测",
icon: "mdi-theme-light-dark", icon: "mdi-microphone",
value: "theme", value: "noise",
}, },
{ {
title: "通知铃声", title: "通知铃声",

View File

@ -100,6 +100,26 @@ const settingsDefinitions = {
icon: "mdi-card-outline", icon: "mdi-card-outline",
}, },
// 噪音监测设置
"noiseMonitor.enabled": {
type: "boolean",
default: true,
description: "启用环境噪音监测",
icon: "mdi-microphone",
},
"noiseMonitor.autoStart": {
type: "boolean",
default: true,
description: "打开页面时自动开始监测",
icon: "mdi-play-circle-outline",
},
"noiseMonitor.permissionDismissed": {
type: "boolean",
default: false,
description: "已跳过麦克风权限引导(不再弹出介绍弹框)",
icon: "mdi-microphone-off",
},
// 时间卡片设置 // 时间卡片设置
"timeCard.enabled": { "timeCard.enabled": {
type: "boolean", type: "boolean",