1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-02-04 07:53:11 +00:00

feat: add notification sound settings component and sound management utilities

- Implemented NotificationSoundSettings.vue for managing notification sounds.
- Added functionality for selecting and previewing single and urgent notification sounds.
- Integrated autoplay warning for first-time audio playback.
- Created soundList.js to manage available sound files and playback functions.
- Included methods for resetting sounds to default settings.

Co-authored-by: tempChanghong <tuantule622@gmail.com>
This commit is contained in:
Sunwuyuan 2026-01-11 15:30:28 +08:00
parent d83dd3993d
commit 4d634095b9
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
45 changed files with 993 additions and 277 deletions

View File

@ -1,159 +0,0 @@
# 统一链接生成器功能测试
## 功能概述
新的统一链接生成器将预配置认证信息和设置分享功能合并到一个链接中,用户可以:
1. 输入命名空间和认证码(预配置认证)
2. 选择需要分享的设置项
3. 生成包含两种信息的统一链接
## 核心特性
### ✅ **统一链接生成**
- 同时包含预配置认证参数和设置配置
- 一个链接完成设备认证和设置应用
- 自动编码和参数组合
### ✅ **智能界面设计**
- 分层式界面:预配置认证 + 设置分享 + 链接生成
- 实时预览和状态显示
- 安全提醒和敏感信息保护
### ✅ **安全优化**
- 数据源设置和已变更设置默认排除 `server.kvToken`
- 敏感设置项标记和值遮盖
- 多重安全提醒
## 链接格式
### 基础预配置链接
```
https://domain.com/?namespace=classroom-001&authCode=pass123&autoExecute=true
```
### 包含设置的统一链接
```
https://domain.com/?namespace=classroom-001&authCode=pass123&autoExecute=true&config=eyJ...
```
其中:
- `namespace`: 设备命名空间
- `authCode`: 认证码(可选)
- `autoExecute`: 是否自动执行认证
- `config`: Base64编码的设置JSON对象
## 使用流程
1. **输入预配置信息**
- 命名空间(必填)
- 认证码(可选)
- 是否自动执行认证
2. **选择设置项**
- 快速选择:数据源设置、已变更设置、全选
- 手动选择:通过详细列表选择特定设置
- 安全保护:敏感设置默认不选中
3. **生成统一链接**
- 点击"生成统一链接"按钮
- 链接实时生成和预览
- 一键复制和测试
## 技术实现
### 参数组合逻辑
```javascript
// 1. 添加预配置参数
params.append("namespace", namespace);
params.append("authCode", authCode);
params.append("autoExecute", "true");
// 2. 添加设置配置
if (selectedSettings.length > 0) {
const configObj = {};
selectedSettings.forEach(key => {
configObj[key] = allSettings[key];
});
const base64Config = btoa(JSON.stringify(configObj));
params.append("config", base64Config);
}
```
### 自动更新机制
- 监听预配置表单变化
- 监听设置选择变化
- 实时生成统一链接
## 使用场景
### 1. **设备批量部署 + 环境配置**
```
https://classworks.example.com/?namespace=classroom-01&authCode=device01&autoExecute=true&config=eyJzZXJ2ZXIuZG9tYWluIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20ifQ==
```
- 自动认证为指定设备
- 自动配置服务器地址等设置
### 2. **演示环境快速部署**
```
https://classworks.example.com/?namespace=demo&autoExecute=true&config=eyJkaXNwbGF5LnRoZW1lIjoiZGFyayIsImVkaXQubW9kZSI6InJlYWRvbmx5In0=
```
- 自动认证为演示账号
- 自动应用演示环境设置
### 3. **培训环境标准化**
```
https://classworks.example.com/?namespace=training&authCode=train123&config=eyJkaXNwbGF5LnNob3dIZWxwIjp0cnVlLCJlZGl0LmVuYWJsZUd1aWRlIjp0cnVlfQ==
```
- 预配置培训账号
- 启用帮助和引导功能
## 安全考虑
### ✅ **默认安全**
- Token等敏感信息默认不包含
- 快速选择按钮智能排除敏感设置
- 明确的安全警告和提醒
### ✅ **用户控制**
- 用户仍可手动选择包含敏感设置
- 敏感设置有明确标记
- 提供充分的风险提示
### ✅ **传输安全**
- 建议HTTPS传输
- URL参数会被自动清理
- 设置信息经过Base64编码
## 兼容性
- ✅ 向后兼容现有的预配置功能
- ✅ 向后兼容现有的设置分享功能
- ✅ 新增统一链接格式
- ✅ 保持所有现有API接口
## 测试建议
1. **基础功能测试**
- 仅预配置信息的链接生成
- 预配置 + 设置的统一链接生成
- 链接复制和测试功能
2. **安全功能测试**
- 验证敏感设置默认不选中
- 验证敏感设置标记显示
- 验证安全提醒展示
3. **兼容性测试**
- 测试生成的链接是否正常工作
- 测试预配置认证是否正常
- 测试设置应用是否正常
## 优势总结
1. **用户体验**: 一个链接完成所有配置,无需多步操作
2. **部署效率**: 批量设备部署更加便捷
3. **管理简化**: 减少链接管理复杂度
4. **安全平衡**: 在便捷性和安全性之间找到平衡
5. **功能完整**: 涵盖认证和配置的完整解决方案

View File

@ -7,7 +7,8 @@
"dev": "vite --host", "dev": "vite --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix" "lint": "eslint . --fix",
"prebuild": "node scripts/generate-sound-list.js"
}, },
"dependencies": { "dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1", "@fingerprintjs/fingerprintjs": "^5.0.1",

View File

@ -0,0 +1,149 @@
/**
* 自动生成音频文件列表脚本
* 读取 src/assets/sounds 文件夹中的所有音频文件并生成列表
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 音频文件路径
const soundsDir = path.join(__dirname, '../src/assets/sounds');
const outputFile = path.join(__dirname, '../src/utils/soundList.js');
// 读取音频文件
function getSoundFiles() {
try {
const files = fs.readdirSync(soundsDir);
// 过滤出音频文件(.mp3, .wav, .ogg等
const audioFiles = files.filter(file => {
const ext = path.extname(file).toLowerCase();
return ['.mp3', '.wav', '.ogg', '.m4a', '.aac'].includes(ext);
});
return audioFiles.sort();
} catch (error) {
console.error('读取音频文件夹失败:', error);
return [];
}
}
// 生成文件内容
function generateSoundList() {
const soundFiles = getSoundFiles();
const fileContent = `/**
* 自动生成的音频文件列表
* scripts/generate-sound-list.js 生成
* 请勿手动修改此文件
*/
// 所有可用的音频文件列表
export const soundFiles = ${JSON.stringify(soundFiles, null, 2)};
// 默认的单次通知铃声
export const defaultSingleSound = 'Teams 默认.mp3';
// 默认的持续通知铃声
export const defaultUrgentSound = 'Teams 默认通话铃.mp3';
// 获取音频文件的完整路径
export function getSoundPath(filename) {
if (!filename) return null;
// 使用动态路径避免Vite在构建时加载所有音频文件
// 这样只有在真正需要播放时才会加载对应的音频文件
return \`/src/assets/sounds/\${filename}\`;
}
// 播放音频文件
export function playSound(filename, loop = false) {
const path = getSoundPath(filename);
if (!path) {
console.warn('音频文件不存在:', filename);
return null;
}
try {
// eslint-disable-next-line no-undef
const audio = new Audio(path);
audio.loop = loop;
// 尝试播放但不阻止返回audio对象
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.catch(err => {
// 静默处理错误,调用者应该自己处理
console.warn('播放音频失败:', err.name, err.message);
});
}
return audio;
} catch (error) {
console.error('创建音频对象失败:', error);
return null;
}
}
// 播放音频文件Promise版本用于更好的错误处理
export function playSoundAsync(filename, loop = false) {
return new Promise((resolve, reject) => {
const path = getSoundPath(filename);
if (!path) {
reject(new Error('音频文件不存在'));
return;
}
try {
// eslint-disable-next-line no-undef
const audio = new Audio(path);
audio.loop = loop;
// 预加载音频文件
audio.preload = 'auto';
audio.play()
.then(() => resolve(audio))
.catch(err => reject(err));
} catch (error) {
reject(error);
}
});
}
// 停止音频播放
export function stopSound(audio) {
if (audio) {
audio.pause();
audio.currentTime = 0;
}
}
`;
return fileContent;
}
// 写入文件
function writeSoundList() {
try {
const content = generateSoundList();
// 确保输出目录存在
const outputDir = path.dirname(outputFile);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputFile, content, 'utf-8');
console.log('✓ 音频文件列表生成成功:', outputFile);
console.log('✓ 共找到', getSoundFiles().length, '个音频文件');
} catch (error) {
console.error('✗ 生成音频列表失败:', error);
process.exit(1);
}
}
// 执行生成
writeSoundList();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,32 +1,92 @@
<template> <template>
<!-- 学生姓名选择对话框 --> <!-- 统一姓名设置对话框学生 / 教师 -->
<v-dialog <v-dialog
v-model="showDialog" v-model="showDialog"
max-width="500" max-width="720"
persistent persistent
> >
<v-card> <v-card>
<v-card-title>设置学生姓名</v-card-title> <v-card-title>{{ dialogTitle }}</v-card-title>
<v-card-text> <v-card-text>
<div class="mb-2"> <!-- 学生模式 -->
请从列表中选择您的姓名 <template v-if="isStudentToken">
</div> <div
<v-autocomplete class="mb-2"
v-model="selectedName" >
:items="studentList" 请从列表中选择您的姓名
clearable </div>
hide-details <v-autocomplete
item-title="name" v-model="selectedName"
item-value="name" :items="studentList"
label="学生姓名" clearable
placeholder="选择您的姓名" hide-details
/> item-title="name"
<div item-value="name"
v-if="studentList.length > 0" label="学生姓名"
class="mt-2 text-caption text-medium-emphasis" placeholder="选择您的姓名"
> />
{{ studentList.length }} 位学生 <div
</div> v-if="studentList.length > 0"
class="mt-2 text-caption text-medium-emphasis"
>
{{ studentList.length }} 位学生
</div>
</template>
<!-- 教师模式 -->
<template v-else-if="isTeacherToken">
<!-- 名称输入框 -->
<v-text-field
v-model="teacherForm.name"
label="教师姓名"
placeholder="输入姓名或从下方建议中选择"
clearable
/>
<!-- 建议列表 -->
<div
class="mt-2 mb-4"
>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="teacher in filteredTeacherSuggestions"
:key="teacher.name"
size="small"
@click="selectTeacherFromSuggestion(teacher)"
>
{{ teacher.name }}
<span
v-if="teacher.isHeadTeacher"
class="ms-1 text-error"
>
👨🏫
</span>
</v-chip>
</div>
</div>
<!-- 班主任开关 -->
<v-switch
v-model="teacherForm.isHeadTeacher"
inset
color="primary"
:label="teacherForm.isHeadTeacher ? '班主任' : '非班主任'"
/>
<!-- 任教科目 -->
<v-combobox
v-model="teacherForm.subjects"
multiple
chips
clearable
label="任教科目"
hint="可直接输入并回车添加多个科目"
persistent-hint
/>
</template>
<v-alert <v-alert
v-if="error" v-if="error"
class="mt-3" class="mt-3"
@ -43,8 +103,9 @@
> >
稍后设置 稍后设置
</v-btn> </v-btn>
<v-spacer/> <v-spacer />
<v-btn <v-btn
v-if="isStudentToken"
:disabled="!selectedName || saving" :disabled="!selectedName || saving"
:loading="saving" :loading="saving"
color="primary" color="primary"
@ -52,6 +113,15 @@
> >
确认 确认
</v-btn> </v-btn>
<v-btn
v-else-if="isTeacherToken"
:disabled="!teacherForm.name || saving"
:loading="saving"
color="primary"
@click="saveTeacherName"
>
确认
</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -61,6 +131,7 @@
:is-student="isStudentToken" :is-student="isStudentToken"
:open-dialog="openDialog" :open-dialog="openDialog"
:student-name="currentStudentName" :student-name="currentStudentName"
:teacher-name="currentTeacherName"
name="header-display" name="header-display"
/> />
</template> </template>
@ -81,15 +152,25 @@ const saving = ref(false)
const error = ref('') const error = ref('')
const tokenInfo = ref(null) const tokenInfo = ref(null)
//
const teacherList = ref([])
const selectedTeacherName = ref('')
const currentTeacherName = ref('')
const teacherForm = ref({ name: '', isHeadTeacher: false, subjects: [] })
const isStudentToken = computed(() => tokenInfo.value?.deviceType === 'student') const isStudentToken = computed(() => tokenInfo.value?.deviceType === 'student')
const isTeacherToken = computed(() => tokenInfo.value?.deviceType === 'teacher')
const isReadOnly = computed(() => tokenInfo.value?.isReadOnly === true) const isReadOnly = computed(() => tokenInfo.value?.isReadOnly === true)
const displayName = computed(() => tokenInfo.value?.note || '设置名称') const displayName = computed(() => tokenInfo.value?.note || '设置名称')
const hasToken = computed(() => !!kvToken.value) const hasToken = computed(() => !!kvToken.value)
const kvToken = computed(() => getSetting('server.kvToken')) const kvToken = computed(() => getSetting('server.kvToken'))
const provider = computed(() => getSetting('server.provider')) const provider = computed(() => getSetting('server.provider'))
const isKvProvider = computed(() => provider.value === 'kv-server' || provider.value === 'classworkscloud') const isKvProvider = computed(() => provider.value === 'kv-server' || provider.value === 'classworkscloud')
const dialogTitle = computed(() => (isStudentToken.value ? '设置学生姓名' : isTeacherToken.value ? '设置教师姓名' : '设置姓名'))
//
const filteredTeacherSuggestions = computed(() => teacherList.value)
// // /
const checkStudentNameStatus = async () => { const checkStudentNameStatus = async () => {
if (!isKvProvider.value || !kvToken.value) { if (!isKvProvider.value || !kvToken.value) {
return return
@ -108,37 +189,57 @@ const checkStudentNameStatus = async () => {
tokenInfo.value = tokenResponse.data tokenInfo.value = tokenResponse.data
// //
if (tokenInfo.value.deviceType !== 'student') { if (tokenInfo.value.deviceType === 'student') {
return currentStudentName.value = tokenInfo.value.note || ''
}
// const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-main`, {
currentStudentName.value = tokenInfo.value.note || '' headers: { Authorization: `Bearer ${kvToken.value}` }
})
const list = listResponse.data.value || []
studentList.value = Array.isArray(list) ? list : []
// if (studentList.value.length > 0) {
const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-main`, { const currentNote = tokenInfo.value.note || ''
headers: { const nameExists = studentList.value.some(s => s.name === currentNote)
Authorization: `Bearer ${kvToken.value}` if (!currentNote || !nameExists) {
showDialog.value = true
selectedName.value = ''
}
} }
})
const list = listResponse.data.value || []
studentList.value = Array.isArray(list) ? list : []
//
if (studentList.value.length === 0) {
return return
} }
// //
const currentNote = tokenInfo.value.note || '' if (tokenInfo.value.deviceType === 'teacher') {
const nameExists = studentList.value.some(student => student.name === currentNote) currentTeacherName.value = tokenInfo.value.note || ''
// try {
if (!currentNote || !nameExists) { const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-teacher`, {
showDialog.value = true headers: { Authorization: `Bearer ${kvToken.value}` }
selectedName.value = '' })
const list = listResponse.data.value || []
teacherList.value = Array.isArray(list) ? list : []
} catch (err) {
// 404
if (err?.response?.status === 404) {
console.log('教师列表不存在,初始化为空')
teacherList.value = []
} else {
console.error('加载教师列表失败:', err)
teacherList.value = []
}
}
if (teacherList.value.length > 0) {
const currentNote = tokenInfo.value.note || ''
const nameExists = teacherList.value.some(t => t.name === currentNote)
if (!currentNote || !nameExists) {
showDialog.value = true
selectedTeacherName.value = ''
}
}
return
} }
} catch (err) { } catch (err) {
@ -187,6 +288,74 @@ const saveStudentName = async () => {
} }
} }
//
const saveTeacherName = async () => {
if (!teacherForm.value.name || saving.value) return
error.value = ''
saving.value = true
try {
const serverUrl = getSetting('server.domain')
const token = kvToken.value
//
const entry = {
name: teacherForm.value.name.trim(),
isHeadTeacher: !!teacherForm.value.isHeadTeacher,
subjects: Array.isArray(teacherForm.value.subjects)
? teacherForm.value.subjects.filter(s => s && String(s).trim()).map(s => String(s).trim())
: []
}
//
const idx = teacherList.value.findIndex(t => t.name === entry.name)
if (idx >= 0) {
teacherList.value[idx] = entry
} else {
teacherList.value.push(entry)
}
// KV
const res = await dataProvider.saveData('classworks-list-teacher', teacherList.value)
if (res?.success === false) {
throw new Error(res?.error?.message || '保存列表失败')
}
//
const response = await axios.post(
`${serverUrl}/apps/tokens/${token}/set-teacher-name`,
{ name: entry.name }
)
if (response.data.success) {
currentTeacherName.value = entry.name
showDialog.value = false
await checkStudentNameStatus()
emit('token-info-updated')
}
} catch (err) {
const status = err?.response?.status
if (status === 400) {
error.value = '该名称不在教师列表中,请选择正确的姓名'
} else if (status === 403) {
error.value = '只有教师类型的 Token 可以设置姓名'
} else if (status === 404) {
error.value = '设备未设置教师列表或 Token 不存在'
} else {
error.value = err?.response?.data?.error?.message || err?.message || '设置失败,请稍后重试'
}
} finally {
saving.value = false
}
}
//
const selectTeacherFromSuggestion = (teacher) => {
teacherForm.value.name = teacher.name
teacherForm.value.isHeadTeacher = teacher.isHeadTeacher || false
teacherForm.value.subjects = Array.isArray(teacher.subjects) ? [...teacher.subjects] : []
}
// //
const skipSetting = () => { const skipSetting = () => {
showDialog.value = false showDialog.value = false
@ -199,29 +368,46 @@ const openDialog = async () => {
console.log('studentList.length:', studentList.value.length) console.log('studentList.length:', studentList.value.length)
console.log('currentStudentName:', currentStudentName.value) console.log('currentStudentName:', currentStudentName.value)
if (!isStudentToken.value) { if (isStudentToken.value) {
console.log('Not a student token, cannot open dialog') const resp = await dataProvider.loadData('classworks-list-main')
studentList.value = Array.isArray(resp?.value) ? resp.value : (Array.isArray(resp) ? resp : [])
if (studentList.value.length === 0) {
console.log('Student list is empty, trying to load...')
await checkStudentNameStatus()
selectedName.value = currentStudentName.value
showDialog.value = true
} else {
selectedName.value = currentStudentName.value
showDialog.value = true
}
return return
} }
studentList.value = await dataProvider.loadData('classworks-list-main')
// token使 if (isTeacherToken.value) {
// try {
if (studentList.value.length === 0) { const resp = await dataProvider.loadData('classworks-list-teacher')
console.log('Student list is empty, trying to load...') teacherList.value = Array.isArray(resp?.value) ? resp.value : (Array.isArray(resp) ? resp : [])
// } catch {
checkStudentNameStatus().then(() => { //
if (studentList.value.length > 0) { console.log('教师列表不存在或加载失败,允许手动创建')
selectedName.value = currentStudentName.value teacherList.value = []
showDialog.value = true }
} else { //
console.warn('Student list is still empty after reload') teacherForm.value = { name: currentTeacherName.value, isHeadTeacher: false, subjects: [] }
//
if (currentTeacherName.value) {
const found = teacherList.value.find(t => t.name === currentTeacherName.value)
if (found) {
teacherForm.value.isHeadTeacher = found.isHeadTeacher || false
teacherForm.value.subjects = Array.isArray(found.subjects) ? [...found.subjects] : []
} }
}) }
} else {
selectedName.value = currentStudentName.value
showDialog.value = true showDialog.value = true
console.log('Dialog opened, showDialog:', showDialog.value) console.log('Dialog opened (teacher), showDialog:', showDialog.value)
return
} }
console.log('Not a student/teacher token, cannot open dialog')
} }
// token // token
@ -249,7 +435,9 @@ defineExpose({
checkStudentNameStatus, checkStudentNameStatus,
openDialog, openDialog,
currentStudentName, currentStudentName,
currentTeacherName,
isStudentToken, isStudentToken,
isTeacherToken,
isReadOnly, isReadOnly,
displayName, displayName,
hasToken, hasToken,

View File

@ -15,40 +15,11 @@
<div class="urgent-title mb-6"> <div class="urgent-title mb-6">
{{ currentNotification?.content?.message || "无内容" }} {{ currentNotification?.content?.message || "无内容" }}
</div> </div>
<div class="urgent-subtitle mb-6">
{{ senderName }} {{ deviceType }} {{ formatTime(currentNotification?.timestamp) }}
</div>
<!-- 发送者信息使用 Vuetify Card -->
<v-card variant="flat" color="white">
<v-card-title>发送者信息</v-card-title>
<v-card-text>
<v-chip
class="mr-2 mb-2"
color="primary"
variant="outlined"
size="small"
>
<v-icon left size="16"> mdi-account </v-icon>
{{ senderName }}
</v-chip>
<v-chip
class="mr-2 mb-2"
color="info"
variant="outlined"
size="small"
>
<v-icon left size="16"> mdi-devices </v-icon>
{{ deviceType }}
</v-chip>
<v-chip
class="mb-2"
color="success"
variant="outlined"
size="small"
>
<v-icon left size="16"> mdi-clock </v-icon>
{{ formatTime(currentNotification?.timestamp) }}
</v-chip>
</v-card-text>
</v-card>
<!-- 多通知导航 --> <!-- 多通知导航 -->
<div v-if="hasMultipleNotifications" class="navigation-controls mt-6"> <div v-if="hasMultipleNotifications" class="navigation-controls mt-6">
@ -103,6 +74,8 @@
<script> <script>
import EventSender from "@/components/EventSender.vue"; import EventSender from "@/components/EventSender.vue";
import { getSetting } from "@/utils/settings.js";
import { playSound, stopSound } from "@/utils/soundList.js";
export default { export default {
name: "UrgentNotification", name: "UrgentNotification",
@ -116,6 +89,7 @@ export default {
currentIndex: 0, // currentIndex: 0, //
autoCloseTimer: null, autoCloseTimer: null,
urgentSoundTimer: null, urgentSoundTimer: null,
currentAudio: null, //
}; };
}, },
computed: { computed: {
@ -165,6 +139,9 @@ export default {
const senderInfo = const senderInfo =
this.currentNotification?.senderInfo || this.currentNotification?.senderInfo ||
this.currentNotification?.content?.senderInfo; this.currentNotification?.content?.senderInfo;
if(senderInfo?.deviceType=='teacher') return "教师";
if(senderInfo?.deviceType=='student') return "学生";
if(senderInfo?.deviceType=='classroom') return "教室";
return senderInfo?.deviceType || "未知类型"; return senderInfo?.deviceType || "未知类型";
}, },
targetDevices() { targetDevices() {
@ -178,6 +155,8 @@ export default {
if (this.urgentSoundTimer) { if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer); clearInterval(this.urgentSoundTimer);
} }
//
this.stopNotificationSound();
}, },
methods: { methods: {
show(notificationData) { show(notificationData) {
@ -202,6 +181,9 @@ export default {
this.sendDisplayedReceipt(); this.sendDisplayedReceipt();
this.playNotificationSound(); this.playNotificationSound();
//
this.sendBrowserNotification(notificationData);
// //
if (this.isUrgent) { if (this.isUrgent) {
this.startUrgentSound(); this.startUrgentSound();
@ -212,7 +194,11 @@ export default {
this.currentIndex = this.notificationQueue.length - 1; this.currentIndex = this.notificationQueue.length - 1;
this.sendDisplayedReceipt(); this.sendDisplayedReceipt();
this.playNotificationSound(); this.playNotificationSound();
this.sendBrowserNotification(notificationData);
this.startUrgentSound(); this.startUrgentSound();
} else {
// 使
this.sendBrowserNotification(notificationData);
} }
} }
}, },
@ -313,7 +299,29 @@ export default {
}, },
playNotificationSound() { playNotificationSound() {
try { try {
// //
this.stopNotificationSound();
//
const soundFile = this.isUrgent
? getSetting('notification.urgentSound')
: getSetting('notification.singleSound');
// 使
this.currentAudio = playSound(soundFile, false);
if (!this.currentAudio) {
// 使
this.playFallbackSound();
}
} catch (error) {
console.warn("无法播放通知音效:", error);
this.playFallbackSound();
}
},
//
playFallbackSound() {
try {
const audioContext = new (window.AudioContext || const audioContext = new (window.AudioContext ||
window.webkitAudioContext)(); window.webkitAudioContext)();
const oscillator = audioContext.createOscillator(); const oscillator = audioContext.createOscillator();
@ -330,7 +338,14 @@ export default {
oscillator.start(); oscillator.start();
oscillator.stop(audioContext.currentTime + 0.3); // 300ms oscillator.stop(audioContext.currentTime + 0.3); // 300ms
} catch (error) { } catch (error) {
console.warn("无法播放通知音效:", error); console.warn("无法播放备用音效:", error);
}
},
//
stopNotificationSound() {
if (this.currentAudio) {
stopSound(this.currentAudio);
this.currentAudio = null;
} }
}, },
// //
@ -393,14 +408,23 @@ export default {
startUrgentSound() { startUrgentSound() {
this.stopUrgentSound(); // this.stopUrgentSound(); //
// //
this.urgentSoundTimer = setInterval(() => { this.stopNotificationSound();
if (this.visible && this.isUrgent) {
this.playNotificationSound(); //
} else { const soundFile = getSetting('notification.urgentSound');
this.stopUrgentSound(); this.currentAudio = playSound(soundFile, true); //
}
}, 1000); if (!this.currentAudio) {
// 使
this.urgentSoundTimer = setInterval(() => {
if (this.visible && this.isUrgent) {
this.playFallbackSound();
} else {
this.stopUrgentSound();
}
}, 1000);
}
}, },
// //
stopUrgentSound() { stopUrgentSound() {
@ -408,6 +432,65 @@ export default {
clearInterval(this.urgentSoundTimer); clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null; this.urgentSoundTimer = null;
} }
this.stopNotificationSound();
},
//
async sendBrowserNotification(notificationData) {
// API
if (!('Notification' in window)) {
console.warn('浏览器不支持通知API');
return;
}
//
try {
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
console.warn('用户未授予通知权限');
return;
}
//
const message = notificationData.content?.message || '新通知';
const senderInfo = notificationData.senderInfo || notificationData.content?.senderInfo;
const senderName = senderInfo?.deviceName || senderInfo?.deviceType || '未知发送者';
const isUrgent = notificationData.content?.isUrgent || false;
//
const notification = new Notification(
isUrgent ? '🚨 紧急通知' : '📢 通知消息',
{
body: `${message}\n\n来自: ${senderName}`,
icon: '/pwa/image/icon-192.png', // 使
badge: '/pwa/image/icon-192.png',
tag: notificationData.content?.notificationId || `notification-${Date.now()}`,
requireInteraction: isUrgent, //
silent: false, // 使
vibrate: isUrgent ? [200, 100, 200, 100, 200] : [200], //
timestamp: notificationData.timestamp || Date.now(),
}
);
//
notification.onclick = () => {
window.focus();
notification.close();
};
//
if (!isUrgent) {
setTimeout(() => {
notification.close();
}, 10000); // 10
}
} catch (error) {
console.error('发送浏览器通知失败:', error);
}
}, },
}, },
}; };
@ -448,6 +531,12 @@ export default {
line-height: 1.2; line-height: 1.2;
} }
.urgent-subtitle {
font-size: 2rem;
font-weight: bold;
color: white;
line-height: 1.2;
}
.notification-content { .notification-content {
font-size: 1.4rem; font-size: 1.4rem;
color: rgba(255, 255, 255, 0.95); color: rgba(255, 255, 255, 0.95);

View File

@ -0,0 +1,276 @@
<template>
<v-card class="notification-sound-settings">
<v-card-title>
通知铃声设置
</v-card-title>
<v-card-text>
<!-- 自动播放提示 -->
<v-alert
v-if="showAutoplayWarning"
type="info"
variant="tonal"
closable
class="mb-4"
@click:close="showAutoplayWarning = false"
>
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-information</v-icon>
<span>首次使用请点击试听按钮测试音频播放是否正常</span>
</div>
</v-alert>
<v-row>
<!-- 单次通知铃声 -->
<v-col cols="12">
<v-card border>
<v-card-title class="text-subtitle-1">
<v-icon left>mdi-bell-ring</v-icon>
单次通知铃声
</v-card-title>
<v-card-text>
<v-select
v-model="singleSound"
:items="soundOptions"
label="选择铃声"
prepend-icon="mdi-music-note"
variant="outlined"
density="comfortable"
@update:model-value="onSingleSoundChange"
>
<template #item="{ props, item }">
<v-list-item
v-bind="props"
@click="previewSound(item.value)"
>
<template #prepend>
<v-icon>mdi-music-note</v-icon>
</template>
<template #append>
<v-btn
icon
size="small"
variant="text"
@click.stop="previewSound(item.value)"
>
<v-icon>mdi-play</v-icon>
</v-btn>
</template>
</v-list-item>
</template>
</v-select>
<div class="mt-3 d-flex gap-2">
<v-btn
color="primary"
variant="tonal"
@click="previewSound(singleSound)"
>
<v-icon left>mdi-play</v-icon>
试听
</v-btn>
<v-btn
@click="resetSingleSound"
>
<v-icon left>mdi-restore</v-icon>
恢复
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- 持续通知铃声紧急通知 -->
<v-col cols="12">
<v-card border>
<v-card-title class="text-subtitle-1">
<v-icon left color="error">mdi-bell-alert</v-icon>
紧急通知铃声
</v-card-title>
<v-card-text>
<v-select
v-model="urgentSound"
:items="soundOptions"
label="选择铃声"
prepend-icon="mdi-music-note"
variant="outlined"
density="comfortable"
@update:model-value="onUrgentSoundChange"
>
<template #item="{ props, item }">
<v-list-item
v-bind="props"
@click="previewSound(item.value)"
>
<template #prepend>
<v-icon>mdi-music-note</v-icon>
</template>
<template #append>
<v-btn
icon
size="small"
variant="text"
@click.stop="previewSound(item.value)"
>
<v-icon>mdi-play</v-icon>
</v-btn>
</template>
</v-list-item>
</template>
</v-select>
<div class="mt-3 d-flex gap-2">
<v-btn
color="error"
variant="tonal"
@click="previewSound(urgentSound)"
>
<v-icon left>mdi-play</v-icon>
试听
</v-btn>
<v-btn
@click="resetUrgentSound"
>
<v-icon left>mdi-restore</v-icon>
恢复
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import { getSetting, setSetting } from '@/utils/settings.js';
import { soundFiles, stopSound } from '@/utils/soundList.js';
export default {
name: 'NotificationSoundSettings',
data() {
return {
singleSound: '',
urgentSound: '',
currentAudio: null,
showAutoplayWarning: true,
};
},
computed: {
soundOptions() {
return soundFiles.map(file => ({
title: file.replace('.mp3', ''),
value: file,
}));
},
},
mounted() {
this.loadSettings();
},
beforeUnmount() {
this.stopPreview();
},
methods: {
loadSettings() {
this.singleSound = getSetting('notification.singleSound');
this.urgentSound = getSetting('notification.urgentSound');
},
onSingleSoundChange(value) {
setSetting('notification.singleSound', value);
this.$message?.success('设置已保存', `单次通知铃声: ${value}`);
},
onUrgentSoundChange(value) {
setSetting('notification.urgentSound', value);
this.$message?.success('设置已保存', `紧急通知铃声: ${value}`);
},
async previewSound(filename) {
//
this.showAutoplayWarning = false;
//
this.stopPreview();
try {
//
const audio = await this.playSoundWithPromise(filename, false);
this.currentAudio = audio;
//
if (this.currentAudio) {
this.currentAudio.addEventListener('ended', () => {
this.currentAudio = null;
}, { once: true });
}
} catch (error) {
console.error('播放音频失败:', error);
//
if (error.name === 'NotAllowedError') {
this.$message?.warning('无法播放音频', '浏览器阻止了自动播放,请再次点击试听按钮');
} else {
this.$message?.error('播放失败', '音频文件加载失败,请稍后重试');
}
}
},
// 使Promise便
playSoundWithPromise(filename, loop = false) {
return new Promise((resolve, reject) => {
const path = this.getSoundPath(filename);
if (!path) {
reject(new Error('音频文件不存在'));
return;
}
try {
// eslint-disable-next-line no-undef
const audio = new Audio(path);
audio.loop = loop;
//
audio.play()
.then(() => {
resolve(audio);
})
.catch(err => {
reject(err);
});
} catch (error) {
reject(error);
}
});
},
getSoundPath(filename) {
if (!filename) return null;
try {
// 使publicVite
//
return `/src/assets/sounds/${filename}`;
} catch {
return null;
}
},
stopPreview() {
if (this.currentAudio) {
stopSound(this.currentAudio);
this.currentAudio = null;
}
},
resetSingleSound() {
this.singleSound = 'Teams 默认.mp3';
setSetting('notification.singleSound', this.singleSound);
this.$message?.success('已恢复单次通知铃声默认设置');
},
resetUrgentSound() {
this.urgentSound = 'Teams 默认通话铃.mp3';
setSetting('notification.urgentSound', this.urgentSound);
this.$message?.success('已恢复紧急通知铃声默认设置');
},
},
};
</script>
<style scoped>
.notification-sound-settings {
margin: 16px 0;
}
</style>

View File

@ -944,12 +944,14 @@ export default {
this.updateSettings(); this.updateSettings();
}); });
// //
this.$nextTick(() => { this.$nextTick(() => {
const studentNameManager = this.$refs.studentNameManager; const studentNameManager = this.$refs.studentNameManager;
if (studentNameManager) { if (studentNameManager) {
this.studentNameInfo.name = studentNameManager.currentStudentName; // 使使
this.studentNameInfo.name = studentNameManager.currentStudentName || studentNameManager.currentTeacherName || '';
this.studentNameInfo.isStudent = studentNameManager.isStudentToken; this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
this.studentNameInfo.isTeacher = studentNameManager.isTeacherToken;
this.studentNameInfo.openDialog = () => this.studentNameInfo.openDialog = () =>
studentNameManager.openDialog(); studentNameManager.openDialog();
@ -958,12 +960,31 @@ export default {
() => studentNameManager.currentStudentName, () => studentNameManager.currentStudentName,
(newName) => { (newName) => {
this.studentNameInfo.name = newName; this.studentNameInfo.name = newName;
this.updateTokenDisplayInfo();
}
);
//
this.$watch(
() => studentNameManager.currentTeacherName,
(newName) => {
if (studentNameManager.isTeacherToken) {
this.studentNameInfo.name = newName;
this.updateTokenDisplayInfo();
}
} }
); );
this.$watch( this.$watch(
() => studentNameManager.isStudentToken, () => studentNameManager.isStudentToken,
(isStudent) => { (isStudent) => {
this.studentNameInfo.isStudent = isStudent; this.studentNameInfo.isStudent = isStudent;
this.updateTokenDisplayInfo();
}
);
this.$watch(
() => studentNameManager.isTeacherToken,
(isTeacher) => {
this.studentNameInfo.isTeacher = isTeacher;
this.updateTokenDisplayInfo();
} }
); );
} }
@ -1116,32 +1137,35 @@ export default {
const displayName = manager.displayName; const displayName = manager.displayName;
const isReadOnly = manager.isReadOnly; const isReadOnly = manager.isReadOnly;
const isStudent = manager.isStudentToken; const isStudent = manager.isStudentToken;
const isTeacher = manager.isTeacherToken;
// token // token
this.tokenDisplayInfo.readonly = isReadOnly; this.tokenDisplayInfo.readonly = isReadOnly;
// token chip // chip
if (!isStudent) { if (!isStudent && !isTeacher) {
this.tokenDisplayInfo.show = false; this.tokenDisplayInfo.show = false;
return; return;
} }
// //
this.tokenDisplayInfo.text = displayName; this.tokenDisplayInfo.text = displayName;
this.tokenDisplayInfo.color = "primary"; this.tokenDisplayInfo.color = "primary";
this.tokenDisplayInfo.icon = "mdi-account"; //
this.tokenDisplayInfo.icon = isTeacher ? "mdi-school" : "mdi-account";
this.tokenDisplayInfo.disabled = isReadOnly; // this.tokenDisplayInfo.disabled = isReadOnly; //
this.tokenDisplayInfo.show = true; this.tokenDisplayInfo.show = true;
}, },
// Token Chip // Token Chip
handleTokenChipClick() { handleTokenChipClick() {
console.log("Token chip clicked"); console.log("Token chip clicked");
const manager = this.$refs.studentNameManager; const manager = this.$refs.studentNameManager;
console.log("Manager:", manager); console.log("Manager:", manager);
console.log("Is student token:", manager?.isStudentToken); console.log("Is student token:", manager?.isStudentToken);
console.log("Is teacher token:", manager?.isTeacherToken);
if (manager && manager.isStudentToken) { if (manager && (manager.isStudentToken || manager.isTeacherToken)) {
console.log("Opening dialog..."); console.log("Opening dialog...");
manager.openDialog(); manager.openDialog();
} else { } else {

View File

@ -181,6 +181,10 @@
/> />
</v-tabs-window-item> </v-tabs-window-item>
<v-tabs-window-item value="notification">
<notification-sound-settings border />
</v-tabs-window-item>
<v-tabs-window-item value="hitokoto"> <v-tabs-window-item value="hitokoto">
<hitokoto-settings border /> <hitokoto-settings border />
</v-tabs-window-item> </v-tabs-window-item>
@ -277,6 +281,7 @@ import HomeworkTemplateCard from "@/components/settings/cards/HomeworkTemplateCa
import SubjectManagementCard from "@/components/settings/cards/SubjectManagementCard.vue"; import SubjectManagementCard from "@/components/settings/cards/SubjectManagementCard.vue";
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";
export default { export default {
name: "Settings", name: "Settings",
@ -299,6 +304,7 @@ export default {
SubjectManagementCard, SubjectManagementCard,
KvDatabaseCard, KvDatabaseCard,
HitokotoSettings, HitokotoSettings,
NotificationSoundSettings,
}, },
setup() { setup() {
const {mobile} = useDisplay(); const {mobile} = useDisplay();
@ -420,6 +426,11 @@ export default {
icon: "mdi-theme-light-dark", icon: "mdi-theme-light-dark",
value: "theme", value: "theme",
}, },
{
title: "通知铃声",
icon: "mdi-bell-ring",
value: "notification",
},
{ {
title: "一言", title: "一言",
icon: "mdi-comment-quote", icon: "mdi-comment-quote",

View File

@ -400,6 +400,22 @@ const settingsDefinitions = {
// 设置应用的主题模式,可选亮色或暗色主题 // 设置应用的主题模式,可选亮色或暗色主题
}, },
// 通知铃声设置
"notification.singleSound": {
type: "string",
default: "Teams 默认.mp3",
description: "单次通知铃声",
icon: "mdi-bell-ring",
// 设置单次通知时播放的音频文件
},
"notification.urgentSound": {
type: "string",
default: "Teams 默认通话铃.mp3",
description: "持续通知铃声",
icon: "mdi-bell-alert",
// 设置紧急通知时循环播放的音频文件
},
// 随机点名设置 // 随机点名设置
"randomPicker.enabled": { "randomPicker.enabled": {
type: "boolean", type: "boolean",

121
src/utils/soundList.js Normal file
View File

@ -0,0 +1,121 @@
/**
* 自动生成的音频文件列表
* scripts/generate-sound-list.js 生成
* 请勿手动修改此文件
*/
// 所有可用的音频文件列表
export const soundFiles = [
"Teams Ping.mp3",
"Teams Remix.mp3",
"Teams bounce.mp3",
"Teams incoming-ringtone-level30.mp3",
"Teams incoming-ringtone-level40.mp3",
"Teams meetup_ring.mp3",
"Teams screenshare_ring.mp3",
"Teams teams_meet_up_reminder.mp3",
"Teams teams_notification.mp3",
"Teams 优先处理.mp3",
"Teams 共鸣.mp3",
"Teams 召唤.mp3",
"Teams 叮铃.mp3",
"Teams 增强.mp3",
"Teams 尤里卡.mp3",
"Teams 弹拨.mp3",
"Teams 提醒.mp3",
"Teams 摇摆.mp3",
"Teams 时空.mp3",
"Teams 气泡(大声).mp3",
"Teams 气泡.mp3",
"Teams 波普.mp3",
"Teams 波纹.mp3",
"Teams 滴水.mp3",
"Teams 点击.mp3",
"Teams 蜂鸣声.mp3",
"Teams 警报.mp3",
"Teams 赋予希望.mp3",
"Teams 轻弹.mp3",
"Teams 进阶.mp3",
"Teams 重复振铃.mp3",
"Teams 颤振.mp3",
"Teams 高分.mp3",
"Teams 默认.mp3",
"Teams 默认通话铃.mp3"
];
// 默认的单次通知铃声
export const defaultSingleSound = 'Teams 默认.mp3';
// 默认的持续通知铃声
export const defaultUrgentSound = 'Teams 默认通话铃.mp3';
// 获取音频文件的完整路径
export function getSoundPath(filename) {
if (!filename) return null;
// 使用动态路径避免Vite在构建时加载所有音频文件
// 这样只有在真正需要播放时才会加载对应的音频文件
return `/src/assets/sounds/${filename}`;
}
// 播放音频文件
export function playSound(filename, loop = false) {
const path = getSoundPath(filename);
if (!path) {
console.warn('音频文件不存在:', filename);
return null;
}
try {
// eslint-disable-next-line no-undef
const audio = new Audio(path);
audio.loop = loop;
// 尝试播放但不阻止返回audio对象
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.catch(err => {
// 静默处理错误,调用者应该自己处理
console.warn('播放音频失败:', err.name, err.message);
});
}
return audio;
} catch (error) {
console.error('创建音频对象失败:', error);
return null;
}
}
// 播放音频文件Promise版本用于更好的错误处理
export function playSoundAsync(filename, loop = false) {
return new Promise((resolve, reject) => {
const path = getSoundPath(filename);
if (!path) {
reject(new Error('音频文件不存在'));
return;
}
try {
// eslint-disable-next-line no-undef
const audio = new Audio(path);
audio.loop = loop;
// 预加载音频文件
audio.preload = 'auto';
audio.play()
.then(() => resolve(audio))
.catch(err => reject(err));
} catch (error) {
reject(error);
}
});
}
// 停止音频播放
export function stopSound(audio) {
if (audio) {
audio.pause();
audio.currentTime = 0;
}
}