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:
co-authored by
tempChanghong
parent
d83dd3993d
commit
4d634095b9
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.
@@ -1,32 +1,92 @@
|
||||
<template>
|
||||
<!-- 学生姓名选择对话框 -->
|
||||
<!-- 统一姓名设置对话框(学生 / 教师) -->
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
max-width="500"
|
||||
max-width="720"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>设置学生姓名</v-card-title>
|
||||
<v-card-title>{{ dialogTitle }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-2">
|
||||
请从列表中选择您的姓名:
|
||||
</div>
|
||||
<v-autocomplete
|
||||
v-model="selectedName"
|
||||
:items="studentList"
|
||||
clearable
|
||||
hide-details
|
||||
item-title="name"
|
||||
item-value="name"
|
||||
label="学生姓名"
|
||||
placeholder="选择您的姓名"
|
||||
/>
|
||||
<div
|
||||
v-if="studentList.length > 0"
|
||||
class="mt-2 text-caption text-medium-emphasis"
|
||||
>
|
||||
共 {{ studentList.length }} 位学生
|
||||
</div>
|
||||
<!-- 学生模式 -->
|
||||
<template v-if="isStudentToken">
|
||||
<div
|
||||
class="mb-2"
|
||||
>
|
||||
请从列表中选择您的姓名:
|
||||
</div>
|
||||
<v-autocomplete
|
||||
v-model="selectedName"
|
||||
:items="studentList"
|
||||
clearable
|
||||
hide-details
|
||||
item-title="name"
|
||||
item-value="name"
|
||||
label="学生姓名"
|
||||
placeholder="选择您的姓名"
|
||||
/>
|
||||
<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-if="error"
|
||||
class="mt-3"
|
||||
@@ -43,8 +103,9 @@
|
||||
>
|
||||
稍后设置
|
||||
</v-btn>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="isStudentToken"
|
||||
:disabled="!selectedName || saving"
|
||||
:loading="saving"
|
||||
color="primary"
|
||||
@@ -52,6 +113,15 @@
|
||||
>
|
||||
确认
|
||||
</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>
|
||||
</v-dialog>
|
||||
@@ -61,6 +131,7 @@
|
||||
:is-student="isStudentToken"
|
||||
:open-dialog="openDialog"
|
||||
:student-name="currentStudentName"
|
||||
:teacher-name="currentTeacherName"
|
||||
name="header-display"
|
||||
/>
|
||||
</template>
|
||||
@@ -81,15 +152,25 @@ const saving = ref(false)
|
||||
const error = ref('')
|
||||
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 isTeacherToken = computed(() => tokenInfo.value?.deviceType === 'teacher')
|
||||
const isReadOnly = computed(() => tokenInfo.value?.isReadOnly === true)
|
||||
const displayName = computed(() => tokenInfo.value?.note || '设置名称')
|
||||
const hasToken = computed(() => !!kvToken.value)
|
||||
const kvToken = computed(() => getSetting('server.kvToken'))
|
||||
const provider = computed(() => getSetting('server.provider'))
|
||||
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 () => {
|
||||
if (!isKvProvider.value || !kvToken.value) {
|
||||
return
|
||||
@@ -108,37 +189,57 @@ const checkStudentNameStatus = async () => {
|
||||
|
||||
tokenInfo.value = tokenResponse.data
|
||||
|
||||
// 如果不是学生类型,不需要处理
|
||||
if (tokenInfo.value.deviceType !== 'student') {
|
||||
return
|
||||
}
|
||||
// 学生设备处理
|
||||
if (tokenInfo.value.deviceType === 'student') {
|
||||
currentStudentName.value = tokenInfo.value.note || ''
|
||||
|
||||
// 保存当前学生姓名
|
||||
currentStudentName.value = tokenInfo.value.note || ''
|
||||
const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-main`, {
|
||||
headers: { Authorization: `Bearer ${kvToken.value}` }
|
||||
})
|
||||
const list = listResponse.data.value || []
|
||||
studentList.value = Array.isArray(list) ? list : []
|
||||
|
||||
// 获取学生列表
|
||||
const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-main`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${kvToken.value}`
|
||||
if (studentList.value.length > 0) {
|
||||
const currentNote = tokenInfo.value.note || ''
|
||||
const nameExists = studentList.value.some(s => s.name === currentNote)
|
||||
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
|
||||
}
|
||||
|
||||
// 检查当前姓名是否在学生列表中
|
||||
const currentNote = tokenInfo.value.note || ''
|
||||
const nameExists = studentList.value.some(student => student.name === currentNote)
|
||||
// 教师设备处理
|
||||
if (tokenInfo.value.deviceType === 'teacher') {
|
||||
currentTeacherName.value = tokenInfo.value.note || ''
|
||||
|
||||
// 如果姓名为空或不在列表中,显示对话框
|
||||
if (!currentNote || !nameExists) {
|
||||
showDialog.value = true
|
||||
selectedName.value = ''
|
||||
try {
|
||||
const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-teacher`, {
|
||||
headers: { Authorization: `Bearer ${kvToken.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) {
|
||||
@@ -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 = () => {
|
||||
showDialog.value = false
|
||||
@@ -199,29 +368,46 @@ const openDialog = async () => {
|
||||
console.log('studentList.length:', studentList.value.length)
|
||||
console.log('currentStudentName:', currentStudentName.value)
|
||||
|
||||
if (!isStudentToken.value) {
|
||||
console.log('Not a student token, cannot open dialog')
|
||||
if (isStudentToken.value) {
|
||||
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
|
||||
}
|
||||
studentList.value = await dataProvider.loadData('classworks-list-main')
|
||||
// 如果是学生 token,即使列表为空也应该打开对话框
|
||||
// 可能是列表还在加载中
|
||||
if (studentList.value.length === 0) {
|
||||
console.log('Student list is empty, trying to load...')
|
||||
// 重新加载学生列表
|
||||
checkStudentNameStatus().then(() => {
|
||||
if (studentList.value.length > 0) {
|
||||
selectedName.value = currentStudentName.value
|
||||
showDialog.value = true
|
||||
} else {
|
||||
console.warn('Student list is still empty after reload')
|
||||
|
||||
if (isTeacherToken.value) {
|
||||
try {
|
||||
const resp = await dataProvider.loadData('classworks-list-teacher')
|
||||
teacherList.value = Array.isArray(resp?.value) ? resp.value : (Array.isArray(resp) ? resp : [])
|
||||
} catch {
|
||||
// 如果列表不存在,初始化为空
|
||||
console.log('教师列表不存在或加载失败,允许手动创建')
|
||||
teacherList.value = []
|
||||
}
|
||||
// 重置教师表单
|
||||
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
|
||||
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 变化
|
||||
@@ -249,7 +435,9 @@ defineExpose({
|
||||
checkStudentNameStatus,
|
||||
openDialog,
|
||||
currentStudentName,
|
||||
currentTeacherName,
|
||||
isStudentToken,
|
||||
isTeacherToken,
|
||||
isReadOnly,
|
||||
displayName,
|
||||
hasToken,
|
||||
|
||||
@@ -15,40 +15,11 @@
|
||||
<div class="urgent-title mb-6">
|
||||
{{ currentNotification?.content?.message || "无内容" }}
|
||||
</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">
|
||||
@@ -103,6 +74,8 @@
|
||||
|
||||
<script>
|
||||
import EventSender from "@/components/EventSender.vue";
|
||||
import { getSetting } from "@/utils/settings.js";
|
||||
import { playSound, stopSound } from "@/utils/soundList.js";
|
||||
|
||||
export default {
|
||||
name: "UrgentNotification",
|
||||
@@ -116,6 +89,7 @@ export default {
|
||||
currentIndex: 0, // 当前显示的通知索引
|
||||
autoCloseTimer: null,
|
||||
urgentSoundTimer: null,
|
||||
currentAudio: null, // 当前播放的音频对象
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -165,6 +139,9 @@ export default {
|
||||
const senderInfo =
|
||||
this.currentNotification?.senderInfo ||
|
||||
this.currentNotification?.content?.senderInfo;
|
||||
if(senderInfo?.deviceType=='teacher') return "教师";
|
||||
if(senderInfo?.deviceType=='student') return "学生";
|
||||
if(senderInfo?.deviceType=='classroom') return "教室";
|
||||
return senderInfo?.deviceType || "未知类型";
|
||||
},
|
||||
targetDevices() {
|
||||
@@ -178,6 +155,8 @@ export default {
|
||||
if (this.urgentSoundTimer) {
|
||||
clearInterval(this.urgentSoundTimer);
|
||||
}
|
||||
// 停止音频播放
|
||||
this.stopNotificationSound();
|
||||
},
|
||||
methods: {
|
||||
show(notificationData) {
|
||||
@@ -202,6 +181,9 @@ export default {
|
||||
this.sendDisplayedReceipt();
|
||||
this.playNotificationSound();
|
||||
|
||||
// 发送浏览器通知
|
||||
this.sendBrowserNotification(notificationData);
|
||||
|
||||
// 如果是加急通知,启动定时音效
|
||||
if (this.isUrgent) {
|
||||
this.startUrgentSound();
|
||||
@@ -212,7 +194,11 @@ export default {
|
||||
this.currentIndex = this.notificationQueue.length - 1;
|
||||
this.sendDisplayedReceipt();
|
||||
this.playNotificationSound();
|
||||
this.sendBrowserNotification(notificationData);
|
||||
this.startUrgentSound();
|
||||
} else {
|
||||
// 即使不立即显示,也发送浏览器通知
|
||||
this.sendBrowserNotification(notificationData);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -313,7 +299,29 @@ export default {
|
||||
},
|
||||
playNotificationSound() {
|
||||
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 ||
|
||||
window.webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
@@ -330,7 +338,14 @@ export default {
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.3); // 300ms
|
||||
} 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() {
|
||||
this.stopUrgentSound(); // 先清除之前的定时器
|
||||
|
||||
// 每秒播放一次提示音
|
||||
this.urgentSoundTimer = setInterval(() => {
|
||||
if (this.visible && this.isUrgent) {
|
||||
this.playNotificationSound();
|
||||
} else {
|
||||
this.stopUrgentSound();
|
||||
}
|
||||
}, 1000);
|
||||
// 停止之前的音频
|
||||
this.stopNotificationSound();
|
||||
|
||||
// 播放循环铃声
|
||||
const soundFile = getSetting('notification.urgentSound');
|
||||
this.currentAudio = playSound(soundFile, true); // 循环播放
|
||||
|
||||
if (!this.currentAudio) {
|
||||
// 如果播放失败,使用备用方案:每秒播放一次提示音
|
||||
this.urgentSoundTimer = setInterval(() => {
|
||||
if (this.visible && this.isUrgent) {
|
||||
this.playFallbackSound();
|
||||
} else {
|
||||
this.stopUrgentSound();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
// 停止加急音效
|
||||
stopUrgentSound() {
|
||||
@@ -408,6 +432,65 @@ export default {
|
||||
clearInterval(this.urgentSoundTimer);
|
||||
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;
|
||||
}
|
||||
|
||||
.urgent-subtitle {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.notification-content {
|
||||
font-size: 1.4rem;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
|
||||
@@ -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 {
|
||||
// 使用相对于public目录的路径,这样Vite不会在构建时打包所有音频
|
||||
// 而是按需加载
|
||||
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>
|
||||
+32
-8
@@ -944,12 +944,14 @@ export default {
|
||||
this.updateSettings();
|
||||
});
|
||||
|
||||
// 连接学生姓名管理组件
|
||||
// 连接学生姓名管理组件(支持学生和教师)
|
||||
this.$nextTick(() => {
|
||||
const studentNameManager = this.$refs.studentNameManager;
|
||||
if (studentNameManager) {
|
||||
this.studentNameInfo.name = studentNameManager.currentStudentName;
|
||||
// 优先使用学生名称,如果不是学生则使用教师名称
|
||||
this.studentNameInfo.name = studentNameManager.currentStudentName || studentNameManager.currentTeacherName || '';
|
||||
this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
|
||||
this.studentNameInfo.isTeacher = studentNameManager.isTeacherToken;
|
||||
this.studentNameInfo.openDialog = () =>
|
||||
studentNameManager.openDialog();
|
||||
|
||||
@@ -958,12 +960,31 @@ export default {
|
||||
() => studentNameManager.currentStudentName,
|
||||
(newName) => {
|
||||
this.studentNameInfo.name = newName;
|
||||
this.updateTokenDisplayInfo();
|
||||
}
|
||||
);
|
||||
// 监听教师姓名变化
|
||||
this.$watch(
|
||||
() => studentNameManager.currentTeacherName,
|
||||
(newName) => {
|
||||
if (studentNameManager.isTeacherToken) {
|
||||
this.studentNameInfo.name = newName;
|
||||
this.updateTokenDisplayInfo();
|
||||
}
|
||||
}
|
||||
);
|
||||
this.$watch(
|
||||
() => studentNameManager.isStudentToken,
|
||||
(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 isReadOnly = manager.isReadOnly;
|
||||
const isStudent = manager.isStudentToken;
|
||||
const isTeacher = manager.isTeacherToken;
|
||||
|
||||
// 设置只读状态(对所有类型的 token 都显示)
|
||||
this.tokenDisplayInfo.readonly = isReadOnly;
|
||||
|
||||
// 只有学生类型的 token 才显示名称 chip
|
||||
if (!isStudent) {
|
||||
// 学生和教师都显示名称 chip
|
||||
if (!isStudent && !isTeacher) {
|
||||
this.tokenDisplayInfo.show = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置学生名称显示(始终蓝色)
|
||||
// 设置名称显示(始终蓝色)
|
||||
this.tokenDisplayInfo.text = displayName;
|
||||
this.tokenDisplayInfo.color = "primary";
|
||||
this.tokenDisplayInfo.icon = "mdi-account";
|
||||
// 学生用人头图标,教师用学校图标
|
||||
this.tokenDisplayInfo.icon = isTeacher ? "mdi-school" : "mdi-account";
|
||||
this.tokenDisplayInfo.disabled = isReadOnly; // 只读时不可点击
|
||||
this.tokenDisplayInfo.show = true;
|
||||
},
|
||||
|
||||
// 处理 Token Chip 点击
|
||||
// 处理 Token Chip 点击(学生和教师都支持)
|
||||
handleTokenChipClick() {
|
||||
console.log("Token chip clicked");
|
||||
const manager = this.$refs.studentNameManager;
|
||||
console.log("Manager:", manager);
|
||||
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...");
|
||||
manager.openDialog();
|
||||
} else {
|
||||
|
||||
@@ -181,6 +181,10 @@
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="notification">
|
||||
<notification-sound-settings border />
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="hitokoto">
|
||||
<hitokoto-settings border />
|
||||
</v-tabs-window-item>
|
||||
@@ -277,6 +281,7 @@ import HomeworkTemplateCard from "@/components/settings/cards/HomeworkTemplateCa
|
||||
import SubjectManagementCard from "@/components/settings/cards/SubjectManagementCard.vue";
|
||||
import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue";
|
||||
import HitokotoSettings from "@/components/HitokotoSettings.vue";
|
||||
import NotificationSoundSettings from "@/components/settings/NotificationSoundSettings.vue";
|
||||
|
||||
export default {
|
||||
name: "Settings",
|
||||
@@ -299,6 +304,7 @@ export default {
|
||||
SubjectManagementCard,
|
||||
KvDatabaseCard,
|
||||
HitokotoSettings,
|
||||
NotificationSoundSettings,
|
||||
},
|
||||
setup() {
|
||||
const {mobile} = useDisplay();
|
||||
@@ -420,6 +426,11 @@ export default {
|
||||
icon: "mdi-theme-light-dark",
|
||||
value: "theme",
|
||||
},
|
||||
{
|
||||
title: "通知铃声",
|
||||
icon: "mdi-bell-ring",
|
||||
value: "notification",
|
||||
},
|
||||
{
|
||||
title: "一言",
|
||||
icon: "mdi-comment-quote",
|
||||
|
||||
@@ -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": {
|
||||
type: "boolean",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user