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
@@ -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>
|
||||
Reference in New Issue
Block a user