mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-02-04 07:53:11 +00:00
Compare commits
4 Commits
42ecc77ee3
...
f3534f9411
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3534f9411 | ||
|
|
1325038fa0 | ||
|
|
472519ef5e | ||
|
|
4d634095b9 |
@ -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. **功能完整**: 涵盖认证和配置的完整解决方案
|
||||
@ -7,7 +7,8 @@
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix"
|
||||
"lint": "eslint . --fix",
|
||||
"prebuild": "node scripts/generate-sound-list.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
|
||||
149
scripts/generate-sound-list.js
Normal file
149
scripts/generate-sound-list.js
Normal 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();
|
||||
BIN
src/assets/sounds/Teams Ping.mp3
Normal file
BIN
src/assets/sounds/Teams Ping.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams Remix.mp3
Normal file
BIN
src/assets/sounds/Teams Remix.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams bounce.mp3
Normal file
BIN
src/assets/sounds/Teams bounce.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams incoming-ringtone-level30.mp3
Normal file
BIN
src/assets/sounds/Teams incoming-ringtone-level30.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams incoming-ringtone-level40.mp3
Normal file
BIN
src/assets/sounds/Teams incoming-ringtone-level40.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams meetup_ring.mp3
Normal file
BIN
src/assets/sounds/Teams meetup_ring.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams screenshare_ring.mp3
Normal file
BIN
src/assets/sounds/Teams screenshare_ring.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams teams_meet_up_reminder.mp3
Normal file
BIN
src/assets/sounds/Teams teams_meet_up_reminder.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams teams_notification.mp3
Normal file
BIN
src/assets/sounds/Teams teams_notification.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 优先处理.mp3
Normal file
BIN
src/assets/sounds/Teams 优先处理.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 共鸣.mp3
Normal file
BIN
src/assets/sounds/Teams 共鸣.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 召唤.mp3
Normal file
BIN
src/assets/sounds/Teams 召唤.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 叮铃.mp3
Normal file
BIN
src/assets/sounds/Teams 叮铃.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 增强.mp3
Normal file
BIN
src/assets/sounds/Teams 增强.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 尤里卡.mp3
Normal file
BIN
src/assets/sounds/Teams 尤里卡.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 弹拨.mp3
Normal file
BIN
src/assets/sounds/Teams 弹拨.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 提醒.mp3
Normal file
BIN
src/assets/sounds/Teams 提醒.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 摇摆.mp3
Normal file
BIN
src/assets/sounds/Teams 摇摆.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 时空.mp3
Normal file
BIN
src/assets/sounds/Teams 时空.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 气泡(大声).mp3
Normal file
BIN
src/assets/sounds/Teams 气泡(大声).mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 气泡.mp3
Normal file
BIN
src/assets/sounds/Teams 气泡.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 波普.mp3
Normal file
BIN
src/assets/sounds/Teams 波普.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 波纹.mp3
Normal file
BIN
src/assets/sounds/Teams 波纹.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 滴水.mp3
Normal file
BIN
src/assets/sounds/Teams 滴水.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 点击.mp3
Normal file
BIN
src/assets/sounds/Teams 点击.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 蜂鸣声.mp3
Normal file
BIN
src/assets/sounds/Teams 蜂鸣声.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 警报.mp3
Normal file
BIN
src/assets/sounds/Teams 警报.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 赋予希望.mp3
Normal file
BIN
src/assets/sounds/Teams 赋予希望.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 轻弹.mp3
Normal file
BIN
src/assets/sounds/Teams 轻弹.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 进阶.mp3
Normal file
BIN
src/assets/sounds/Teams 进阶.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 重复振铃.mp3
Normal file
BIN
src/assets/sounds/Teams 重复振铃.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 颤振.mp3
Normal file
BIN
src/assets/sounds/Teams 颤振.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 高分.mp3
Normal file
BIN
src/assets/sounds/Teams 高分.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 默认.mp3
Normal file
BIN
src/assets/sounds/Teams 默认.mp3
Normal file
Binary file not shown.
BIN
src/assets/sounds/Teams 默认通话铃.mp3
Normal file
BIN
src/assets/sounds/Teams 默认通话铃.mp3
Normal file
Binary file not shown.
@ -277,7 +277,7 @@ export default {
|
||||
emits: ["update:modelValue", "save"],
|
||||
setup() {
|
||||
const { mobile } = useDisplay();
|
||||
return { isMobile: mobile };
|
||||
return { mobile };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -290,6 +290,14 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isMobile() {
|
||||
// 如果启用了强制一体机UI模式,返回false(使用桌面UI)
|
||||
const forceDesktopMode = getSetting('display.forceDesktopMode');
|
||||
if (forceDesktopMode) {
|
||||
return false;
|
||||
}
|
||||
return this.mobile;
|
||||
},
|
||||
dialogVisible: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
<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">
|
||||
<!-- 学生模式 -->
|
||||
<template v-if="isStudentToken">
|
||||
<div
|
||||
class="mb-2"
|
||||
>
|
||||
请从列表中选择您的姓名:
|
||||
</div>
|
||||
<v-autocomplete
|
||||
@ -27,6 +31,62 @@
|
||||
>
|
||||
共 {{ 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,38 +189,58 @@ const checkStudentNameStatus = async () => {
|
||||
|
||||
tokenInfo.value = tokenResponse.data
|
||||
|
||||
// 如果不是学生类型,不需要处理
|
||||
if (tokenInfo.value.deviceType !== 'student') {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存当前学生姓名
|
||||
// 学生设备处理
|
||||
if (tokenInfo.value.deviceType === 'student') {
|
||||
currentStudentName.value = tokenInfo.value.note || ''
|
||||
|
||||
// 获取学生列表
|
||||
const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-main`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${kvToken.value}`
|
||||
}
|
||||
headers: { Authorization: `Bearer ${kvToken.value}` }
|
||||
})
|
||||
|
||||
const list = listResponse.data.value || []
|
||||
studentList.value = Array.isArray(list) ? list : []
|
||||
|
||||
// 如果学生列表为空,不需要提示
|
||||
if (studentList.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查当前姓名是否在学生列表中
|
||||
if (studentList.value.length > 0) {
|
||||
const currentNote = tokenInfo.value.note || ''
|
||||
const nameExists = studentList.value.some(student => student.name === currentNote)
|
||||
|
||||
// 如果姓名为空或不在列表中,显示对话框
|
||||
const nameExists = studentList.value.some(s => s.name === currentNote)
|
||||
if (!currentNote || !nameExists) {
|
||||
showDialog.value = true
|
||||
selectedName.value = ''
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 教师设备处理
|
||||
if (tokenInfo.value.deviceType === 'teacher') {
|
||||
currentTeacherName.value = tokenInfo.value.note || ''
|
||||
|
||||
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) {
|
||||
console.error('检查学生姓名状态失败:', 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')
|
||||
return
|
||||
}
|
||||
studentList.value = await dataProvider.loadData('classworks-list-main')
|
||||
// 如果是学生 token,即使列表为空也应该打开对话框
|
||||
// 可能是列表还在加载中
|
||||
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...')
|
||||
// 重新加载学生列表
|
||||
checkStudentNameStatus().then(() => {
|
||||
if (studentList.value.length > 0) {
|
||||
await checkStudentNameStatus()
|
||||
selectedName.value = currentStudentName.value
|
||||
showDialog.value = true
|
||||
} else {
|
||||
console.warn('Student list is still empty after reload')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
selectedName.value = currentStudentName.value
|
||||
showDialog.value = true
|
||||
console.log('Dialog opened, showDialog:', showDialog.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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] : []
|
||||
}
|
||||
}
|
||||
showDialog.value = true
|
||||
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.stopNotificationSound();
|
||||
|
||||
// 播放循环铃声
|
||||
const soundFile = getSetting('notification.urgentSound');
|
||||
this.currentAudio = playSound(soundFile, true); // 循环播放
|
||||
|
||||
if (!this.currentAudio) {
|
||||
// 如果播放失败,使用备用方案:每秒播放一次提示音
|
||||
this.urgentSoundTimer = setInterval(() => {
|
||||
if (this.visible && this.isUrgent) {
|
||||
this.playNotificationSound();
|
||||
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);
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" icon="mdi-account-group" />
|
||||
出勤状态管理
|
||||
考勤
|
||||
<v-spacer />
|
||||
<v-chip v-if="!isMobile" class="ml-2" color="primary" size="small">
|
||||
{{ dateString }}
|
||||
@ -244,6 +244,7 @@
|
||||
<script>
|
||||
import { pinyin } from "pinyin-pro";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { getSetting } from "@/utils/settings";
|
||||
|
||||
export default {
|
||||
name: "AttendanceManagementDialog",
|
||||
@ -268,7 +269,7 @@ export default {
|
||||
emits: ["update:modelValue", "save", "change"],
|
||||
setup() {
|
||||
const { mobile } = useDisplay();
|
||||
return { isMobile: mobile };
|
||||
return { mobile };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -277,6 +278,14 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isMobile() {
|
||||
// 如果启用了强制一体机UI模式,返回false(使用桌面UI)
|
||||
const forceDesktopMode = getSetting('display.forceDesktopMode');
|
||||
if (forceDesktopMode) {
|
||||
return false;
|
||||
}
|
||||
return this.mobile;
|
||||
},
|
||||
filteredStudents() {
|
||||
let students = [...this.studentList];
|
||||
|
||||
|
||||
276
src/components/settings/NotificationSoundSettings.vue
Normal file
276
src/components/settings/NotificationSoundSettings.vue
Normal 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 {
|
||||
// 使用相对于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>
|
||||
543
src/components/settings/TeacherListCard.vue
Normal file
543
src/components/settings/TeacherListCard.vue
Normal file
@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<v-card
|
||||
:class="{ 'unsaved-changes': unsavedChanges }"
|
||||
:color="unsavedChanges ? 'warning-subtle' : undefined"
|
||||
border
|
||||
>
|
||||
<v-card-item>
|
||||
<template #prepend>
|
||||
<v-icon class="mr-2" icon="mdi-account-tie" size="large"/>
|
||||
</template>
|
||||
<v-card-title class="text-h6">教师列表</v-card-title>
|
||||
<template #append>
|
||||
<unsaved-warning :show="unsavedChanges" message="有未保存的更改"/>
|
||||
<v-btn
|
||||
:color="modelValue.advanced ? 'primary' : undefined"
|
||||
prepend-icon="mdi-code-braces"
|
||||
variant="text"
|
||||
@click="toggleAdvanced"
|
||||
>
|
||||
{{ modelValue.advanced ? "返回基础编辑" : "高级编辑" }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
<v-progress-linear
|
||||
v-if="loading"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
indeterminate
|
||||
/>
|
||||
|
||||
<v-alert v-if="error" class="mb-4" closable type="error" variant="tonal">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
|
||||
<v-expand-transition>
|
||||
<!-- 普通编辑模式 -->
|
||||
<div v-if="!modelValue.advanced">
|
||||
<!-- 添加教师表单 -->
|
||||
<v-card class="mb-6" variant="outlined">
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model="newTeacher.name"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
label="教师姓名"
|
||||
placeholder="输入教师姓名"
|
||||
prepend-inner-icon="mdi-account"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="5">
|
||||
<v-combobox
|
||||
v-model="newTeacher.subjects"
|
||||
:items="commonSubjects"
|
||||
chips
|
||||
clearable
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
hide-details
|
||||
label="任教科目"
|
||||
multiple
|
||||
placeholder="选择或输入科目"
|
||||
prepend-inner-icon="mdi-book-open-variant"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3" class="d-flex align-center gap-2">
|
||||
<v-checkbox
|
||||
v-model="newTeacher.isHeadTeacher"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
label="班主任"
|
||||
/>
|
||||
<v-btn
|
||||
:disabled="!newTeacher.name.trim() || newTeacher.subjects.length === 0"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="addTeacher"
|
||||
>
|
||||
添加教师
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 教师列表 -->
|
||||
<v-row v-if="modelValue.list.length === 0">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
暂无教师信息,请添加教师
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col
|
||||
v-for="(teacher, index) in modelValue.list"
|
||||
:key="index"
|
||||
cols="12"
|
||||
lg="6"
|
||||
xl="4"
|
||||
>
|
||||
<v-hover v-slot="{ isHovering, props }">
|
||||
<v-card
|
||||
:elevation="isMobile ? 1 : isHovering ? 4 : 1"
|
||||
border
|
||||
class="teacher-card"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-start mb-3">
|
||||
<v-avatar
|
||||
:color="teacher.isHeadTeacher ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
size="48"
|
||||
>
|
||||
<v-icon
|
||||
:icon="teacher.isHeadTeacher ? 'mdi-star' : 'mdi-account'"
|
||||
size="28"
|
||||
/>
|
||||
</v-avatar>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<v-text-field
|
||||
v-if="editState.index === index"
|
||||
v-model="editState.teacher.name"
|
||||
autofocus
|
||||
class="flex-grow-1"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="underlined"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-h6 font-weight-medium"
|
||||
@click="handleClick(index, teacher)"
|
||||
>
|
||||
{{ teacher.name }}
|
||||
</span>
|
||||
<v-chip
|
||||
v-if="teacher.isHeadTeacher"
|
||||
class="ml-2"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
班主任
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div v-if="editState.index === index" class="mt-2">
|
||||
<v-combobox
|
||||
v-model="editState.teacher.subjects"
|
||||
:items="commonSubjects"
|
||||
chips
|
||||
closable-chips
|
||||
density="compact"
|
||||
hide-details
|
||||
label="任教科目"
|
||||
multiple
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="editState.teacher.isHeadTeacher"
|
||||
class="mt-2"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="班主任"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mt-1">
|
||||
<v-chip
|
||||
v-for="(subject, sIndex) in teacher.subjects"
|
||||
:key="sIndex"
|
||||
class="mr-1 mb-1"
|
||||
density="comfortable"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ subject }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="{ 'opacity-100': isHovering || isMobile || editState.index === index }"
|
||||
class="d-flex gap-1 action-buttons ml-2"
|
||||
>
|
||||
<v-btn
|
||||
v-if="editState.index === index"
|
||||
color="success"
|
||||
icon="mdi-check"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="editState.index === index"
|
||||
color="grey"
|
||||
icon="mdi-close"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="cancelEdit"
|
||||
/>
|
||||
<v-btn
|
||||
v-else
|
||||
color="primary"
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="startEdit(index, teacher)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="editState.index !== index"
|
||||
color="error"
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="removeTeacher(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 高级编辑模式 -->
|
||||
<div v-else class="pt-2">
|
||||
<v-textarea
|
||||
v-model="modelValue.text"
|
||||
hint="JSON 格式编辑教师列表。每个教师需包含 name、subjects(数组)、isHeadTeacher(布尔值)"
|
||||
label="批量编辑教师列表 (JSON)"
|
||||
persistent-hint
|
||||
placeholder='[{"name":"教师姓名","subjects":["语文","数学"],"isHeadTeacher":true}]'
|
||||
rows="15"
|
||||
variant="outlined"
|
||||
@update:model-value="handleTextInput"
|
||||
/>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-row class="mt-6">
|
||||
<v-col class="d-flex gap-2" cols="12">
|
||||
<v-btn
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save"
|
||||
size="large"
|
||||
@click="saveTeachers"
|
||||
>
|
||||
保存教师列表
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
color="error"
|
||||
prepend-icon="mdi-refresh"
|
||||
size="large"
|
||||
variant="outlined"
|
||||
@click="loadTeachers"
|
||||
>
|
||||
重载教师列表
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import UnsavedWarning from "../common/UnsavedWarning.vue";
|
||||
import "@/styles/warnings.scss";
|
||||
import dataProvider from "@/utils/dataProvider";
|
||||
import {getSetting} from "@/utils/settings";
|
||||
|
||||
export default {
|
||||
name: "TeacherListCard",
|
||||
components: {
|
||||
UnsavedWarning,
|
||||
},
|
||||
props: {
|
||||
isMobile: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
newTeacher: {
|
||||
name: "",
|
||||
subjects: [],
|
||||
isHeadTeacher: false,
|
||||
},
|
||||
editState: {
|
||||
index: -1,
|
||||
teacher: null,
|
||||
},
|
||||
modelValue: {
|
||||
list: [],
|
||||
text: "",
|
||||
advanced: false,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
lastSavedData: null,
|
||||
unsavedChanges: false,
|
||||
commonSubjects: [
|
||||
"语文",
|
||||
"数学",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
"政治",
|
||||
"历史",
|
||||
"地理",
|
||||
"信息技术",
|
||||
"音乐",
|
||||
"美术",
|
||||
"体育",
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
modelValue: {
|
||||
handler(newData) {
|
||||
if (this.lastSavedData) {
|
||||
this.unsavedChanges = JSON.stringify(newData.list) !== JSON.stringify(this.lastSavedData);
|
||||
}
|
||||
if (!this.modelValue.advanced) {
|
||||
this.modelValue.text = JSON.stringify(newData.list, null, 2);
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadTeachers();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadTeachers() {
|
||||
this.error = null;
|
||||
try {
|
||||
this.loading = true;
|
||||
const classNum = getSetting("server.classNumber");
|
||||
|
||||
if (!classNum) {
|
||||
throw new Error("请先设置班号");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await dataProvider.loadData("classworks-list-teacher");
|
||||
|
||||
if (response.success !== false && Array.isArray(response)) {
|
||||
this.modelValue.list = response.map((item) => ({
|
||||
name: item.name || "",
|
||||
subjects: Array.isArray(item.subjects) ? item.subjects : [],
|
||||
isHeadTeacher: Boolean(item.isHeadTeacher),
|
||||
}));
|
||||
|
||||
this.modelValue.text = JSON.stringify(this.modelValue.list, null, 2);
|
||||
this.lastSavedData = JSON.parse(JSON.stringify(this.modelValue.list));
|
||||
this.unsavedChanges = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load teacher list, initializing empty list", error);
|
||||
this.modelValue.list = [];
|
||||
this.modelValue.text = "[]";
|
||||
this.lastSavedData = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载教师列表失败:", error);
|
||||
this.error = error.message || "加载失败,请检查设置";
|
||||
this.$message?.error("加载失败", this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveTeachers() {
|
||||
try {
|
||||
const classNum = getSetting("server.classNumber");
|
||||
|
||||
if (!classNum) {
|
||||
throw new Error("请先设置班号");
|
||||
}
|
||||
|
||||
const formattedList = this.modelValue.list.map((teacher) => ({
|
||||
name: teacher.name,
|
||||
subjects: Array.isArray(teacher.subjects) ? teacher.subjects : [],
|
||||
isHeadTeacher: Boolean(teacher.isHeadTeacher),
|
||||
}));
|
||||
|
||||
const response = await dataProvider.saveData(
|
||||
"classworks-list-teacher",
|
||||
formattedList
|
||||
);
|
||||
|
||||
if (response.success === false) {
|
||||
throw new Error(response.error?.message || "保存失败");
|
||||
}
|
||||
|
||||
this.modelValue.list = formattedList;
|
||||
this.lastSavedData = JSON.parse(JSON.stringify(formattedList));
|
||||
this.unsavedChanges = false;
|
||||
this.$message?.success("保存成功", "教师列表已更新");
|
||||
} catch (error) {
|
||||
console.error("保存教师列表失败:", error);
|
||||
this.$message?.error("保存失败", error.message || "请重试");
|
||||
}
|
||||
},
|
||||
|
||||
toggleAdvanced() {
|
||||
this.modelValue.advanced = !this.modelValue.advanced;
|
||||
if (this.modelValue.advanced) {
|
||||
this.modelValue.text = JSON.stringify(this.modelValue.list, null, 2);
|
||||
}
|
||||
},
|
||||
|
||||
handleTextInput(text) {
|
||||
if (!this.modelValue.advanced) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.modelValue.list = parsed.map((item) => ({
|
||||
name: item.name || "",
|
||||
subjects: Array.isArray(item.subjects) ? item.subjects : [],
|
||||
isHeadTeacher: Boolean(item.isHeadTeacher),
|
||||
}));
|
||||
this.error = null;
|
||||
} else {
|
||||
this.error = "JSON 必须是一个数组";
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 解析错误,不更新列表,用户可能还在输入
|
||||
this.error = "JSON 格式错误: " + e.message;
|
||||
}
|
||||
},
|
||||
|
||||
addTeacher() {
|
||||
const name = this.newTeacher.name.trim();
|
||||
if (!name) {
|
||||
this.$message?.warning("提示", "请输入教师姓名");
|
||||
return;
|
||||
}
|
||||
if (this.newTeacher.subjects.length === 0) {
|
||||
this.$message?.warning("提示", "请选择至少一个任教科目");
|
||||
return;
|
||||
}
|
||||
|
||||
this.modelValue.list.push({
|
||||
name,
|
||||
subjects: [...this.newTeacher.subjects],
|
||||
isHeadTeacher: this.newTeacher.isHeadTeacher,
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
this.newTeacher = {
|
||||
name: "",
|
||||
subjects: [],
|
||||
isHeadTeacher: false,
|
||||
};
|
||||
},
|
||||
|
||||
startEdit(index, teacher) {
|
||||
this.editState.index = index;
|
||||
this.editState.teacher = {
|
||||
name: teacher.name,
|
||||
subjects: [...teacher.subjects],
|
||||
isHeadTeacher: teacher.isHeadTeacher,
|
||||
};
|
||||
},
|
||||
|
||||
saveEdit() {
|
||||
if (this.editState.index !== -1) {
|
||||
const newName = this.editState.teacher.name.trim();
|
||||
if (!newName) {
|
||||
this.$message?.warning("提示", "教师姓名不能为空");
|
||||
return;
|
||||
}
|
||||
if (this.editState.teacher.subjects.length === 0) {
|
||||
this.$message?.warning("提示", "请选择至少一个任教科目");
|
||||
return;
|
||||
}
|
||||
|
||||
this.modelValue.list[this.editState.index] = {
|
||||
name: newName,
|
||||
subjects: [...this.editState.teacher.subjects],
|
||||
isHeadTeacher: this.editState.teacher.isHeadTeacher,
|
||||
};
|
||||
|
||||
this.editState.index = -1;
|
||||
this.editState.teacher = null;
|
||||
}
|
||||
},
|
||||
|
||||
cancelEdit() {
|
||||
this.editState.index = -1;
|
||||
this.editState.teacher = null;
|
||||
},
|
||||
|
||||
removeTeacher(index) {
|
||||
if (index !== undefined) {
|
||||
this.modelValue.list.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
handleClick(index, teacher) {
|
||||
if (this.isMobile) {
|
||||
this.startEdit(index, teacher);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.teacher-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.unsaved-changes {
|
||||
border-color: rgb(var(--v-theme-warning)) !important;
|
||||
}
|
||||
</style>
|
||||
@ -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 {
|
||||
|
||||
@ -144,6 +144,7 @@
|
||||
|
||||
<v-tabs-window-item value="student">
|
||||
<student-list-card :is-mobile="isMobile" border/>
|
||||
<teacher-list-card :is-mobile="isMobile" border class="mt-4"/>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="share">
|
||||
<settings-link-generator border class="mt-4"/>
|
||||
@ -181,6 +182,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>
|
||||
@ -268,6 +273,7 @@ import {
|
||||
import MessageLog from "@/components/MessageLog.vue";
|
||||
import SettingsCard from "@/components/SettingsCard.vue";
|
||||
import StudentListCard from "@/components/settings/StudentListCard.vue";
|
||||
import TeacherListCard from "@/components/settings/TeacherListCard.vue";
|
||||
import AboutCard from "@/components/settings/AboutCard.vue";
|
||||
import "../styles/settings.scss";
|
||||
import SettingsExplorer from "@/components/settings/SettingsExplorer.vue";
|
||||
@ -277,6 +283,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",
|
||||
@ -288,6 +295,7 @@ export default {
|
||||
MessageLog,
|
||||
SettingsCard,
|
||||
StudentListCard,
|
||||
TeacherListCard,
|
||||
AboutCard,
|
||||
DataProviderSettingsCard,
|
||||
ThemeSettingsCard,
|
||||
@ -299,6 +307,7 @@ export default {
|
||||
SubjectManagementCard,
|
||||
KvDatabaseCard,
|
||||
HitokotoSettings,
|
||||
NotificationSoundSettings,
|
||||
},
|
||||
setup() {
|
||||
const {mobile} = useDisplay();
|
||||
@ -391,7 +400,7 @@ export default {
|
||||
value: "subject",
|
||||
},
|
||||
{
|
||||
title: "学生列表",
|
||||
title: "花名册",
|
||||
icon: "mdi-account-group",
|
||||
value: "student",
|
||||
},
|
||||
@ -420,6 +429,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",
|
||||
|
||||
121
src/utils/soundList.js
Normal file
121
src/utils/soundList.js
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user