feat(websocket): 优化投屏状态管理并新增全员禁麦功能
- 重构 Redis 投屏状态存储,统一使用 screenShareOwnerUid 字段 - 移除冗余的 screenShareUid 字段,简化房间状态数据结构 - 新增 client_mute_all_students 消息类型支持全员禁麦功能 - 实现创建者权限验证的全员禁麦逻辑,排除老师角色用户 - 优化 Tauri 桌面应用悬浮窗创建接口,支持状态参数传递 - 添加悬浮窗关闭功能及教师端静音状态同步机制 - 修复学生端界面中投屏状态变量命名不一致问题 - 统一按钮样式规范,提升界面组件一致性
This commit is contained in:
@ -247,19 +247,14 @@ export class MeetingRedisService {
|
||||
/**
|
||||
* 设置投屏状态
|
||||
* @param roomId - 房间 ID
|
||||
* @param screenShareUid - 投屏人短 UID,null 表示停止投屏
|
||||
* @param screenShareOwnerUid - 投屏人短 UID,null 表示停止投屏
|
||||
*/
|
||||
async setScreenSharing(roomId: string, screenShareUid: number | null, screenShareOwnerUid?: number | null): Promise<void> {
|
||||
async setScreenSharing(roomId: string, screenShareOwnerUid: number | null): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
if (screenShareUid === null) {
|
||||
await this.getClient().hdel(key, 'screenShareUid');
|
||||
if (screenShareOwnerUid === null) {
|
||||
await this.getClient().hdel(key, 'screenShareOwnerUid');
|
||||
} else {
|
||||
const hm: Record<string, string> = { screenShareUid: String(screenShareUid) };
|
||||
if (typeof screenShareOwnerUid === 'number' && Number.isFinite(screenShareOwnerUid) && screenShareOwnerUid > 0) {
|
||||
hm.screenShareOwnerUid = String(screenShareOwnerUid);
|
||||
}
|
||||
await this.getClient().hset(key, hm);
|
||||
await this.getClient().hset(key, { screenShareOwnerUid: String(screenShareOwnerUid) });
|
||||
await this.getClient().expire(key, 24 * 60 * 60);
|
||||
}
|
||||
}
|
||||
@ -297,7 +292,6 @@ export class MeetingRedisService {
|
||||
async getRoomState(roomId: string): Promise<{
|
||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||
speakerUid?: number;
|
||||
screenShareUid?: number;
|
||||
screenShareOwnerUid?: number;
|
||||
teacherUid?: number;
|
||||
endTimestamp?: number;
|
||||
@ -306,14 +300,12 @@ export class MeetingRedisService {
|
||||
const map = await this.getClient().hgetall(key);
|
||||
const status = (map?.classStatus as any) || 'not_started';
|
||||
const speakerUid = map?.speakerUid ? Number(map.speakerUid) : undefined;
|
||||
const screenShareUid = map?.screenShareUid ? Number(map.screenShareUid) : undefined;
|
||||
const screenShareOwnerUid = map?.screenShareOwnerUid ? Number(map.screenShareOwnerUid) : undefined;
|
||||
const teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined;
|
||||
const endTimestamp = map?.endTimestamp ? Number(map.endTimestamp) : undefined;
|
||||
return {
|
||||
classStatus: status,
|
||||
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
|
||||
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
|
||||
...(Number.isFinite(screenShareOwnerUid) ? { screenShareOwnerUid } : {}),
|
||||
...(Number.isFinite(teacherUid) ? { teacherUid } : {}),
|
||||
...(Number.isFinite(endTimestamp) ? { endTimestamp } : {}),
|
||||
|
||||
@ -363,7 +363,6 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
isAudioMuted: userState.isAudioMuted,
|
||||
isVideoMuted: userState.isVideoMuted,
|
||||
classStatus: roomState.classStatus,
|
||||
screenShareUid: roomState.screenShareUid,
|
||||
screenShareOwnerUid: roomState.screenShareOwnerUid,
|
||||
speakerUid,
|
||||
teacherUid,
|
||||
@ -501,6 +500,44 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
this.log(`用户 ${data.targetUid} 已被禁麦,房间 ${data.roomId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_mute_all_students')
|
||||
public async handleMuteAllStudents(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
if (!data?.roomId) {
|
||||
this.logger.warn({}, '[会议] mute_all_students 数据不完整', 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验:仅创建者/管理员可全员禁麦
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试全员禁麦:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取房间内所有用户的 socketIds
|
||||
const sockets = await this.server?.in(data.roomId).fetchSockets();
|
||||
if (!sockets || sockets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 遍历所有用户,排除老师(创建者),并行执行所有操作
|
||||
const promises = sockets.map(async (s) => {
|
||||
const targetUid = s.data.user?.shortUid;
|
||||
// 排除老师自己(role === 0 表示学生)
|
||||
if (s.data.user?.role === 0 && Number.isFinite(targetUid)) {
|
||||
// 下发禁麦通知
|
||||
this.server?.to(s.id).emit('message', { type: 'sev_mute_audio', data: { fromRoomId: data.roomId, targetUid: targetUid! } });
|
||||
// 写入 Redis:下次加入/重连时恢复禁麦状态
|
||||
return this.redisService.setUserState(data.roomId, targetUid!, { isAudioMuted: true }).catch(() => {});
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
this.log(`全员禁麦成功:roomId=${data.roomId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_unmute_audio')
|
||||
public async handleUnmuteAudio(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
@ -679,7 +716,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
return;
|
||||
}
|
||||
// 保存投屏状态到 Redis
|
||||
await this.redisService.setScreenSharing(data.roomId, screenUid, targetUid);
|
||||
await this.redisService.setScreenSharing(data.roomId, targetUid);
|
||||
// 广播给整个房间:有人开始投屏
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } });
|
||||
this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`);
|
||||
@ -733,7 +770,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
|
||||
if (monitorSocketIds.length > 0) {
|
||||
// 发送给所有监控端的 socket
|
||||
ns.to(monitorSocketIds).emit('message', { type: 'sev_student_order_changed', data: { fromRoomId: data.roomId, studentShortIds: data.studentShortIds } });
|
||||
ns.to(monitorSocketIds).emit('message', {
|
||||
type: 'sev_student_order_changed',
|
||||
data: { fromRoomId: data.roomId, studentShortIds: data.studentShortIds },
|
||||
});
|
||||
}
|
||||
}
|
||||
this.log(`创建者更新了学生排序:roomId=${data.roomId}, 学生数量=${data.studentShortIds.length}`);
|
||||
|
||||
@ -113,6 +113,8 @@ export interface MeetingClientToServerEvents {
|
||||
client_mute_audio: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者解除禁麦 */
|
||||
client_unmute_audio: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者全员禁麦(不包括老师) */
|
||||
client_mute_all_students: (data: { roomId: string }) => void;
|
||||
/** 创建者禁视频 */
|
||||
client_mute_video: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者解除禁视频 */
|
||||
@ -159,12 +161,11 @@ export interface MeetingJoinRoomSuccessData {
|
||||
isVideoMuted: boolean;
|
||||
/** 课堂状态:not_started(未开始)/ in_class(上课中)/ finished(已下课) */
|
||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
||||
screenShareUid?: number;
|
||||
/** 投屏状态:正在投屏的用户短 UID,undefined表示无人投屏 */
|
||||
screenShareOwnerUid?: number;
|
||||
/** 上台的短uid,默认为老师的uid */
|
||||
/** 上台的短 uid,默认为老师的 uid */
|
||||
speakerUid: number;
|
||||
/** 老师的短uid */
|
||||
/** 老师的短 uid */
|
||||
teacherUid: number;
|
||||
/** 房间结束时间(毫秒时间戳) */
|
||||
endTimestamp?: number;
|
||||
@ -268,6 +269,7 @@ export type ClientToServerMessageType =
|
||||
| 'client_join_room' // 加入会议房间
|
||||
| 'client_kick_user' // 踢出指定用户(仅创建者)
|
||||
| 'client_leave_room' // 离开会议房间
|
||||
| 'client_mute_all_students' // 全员禁麦(不包括老师,仅创建者)
|
||||
| 'client_mute_audio' // 禁麦指定用户(仅创建者)
|
||||
| 'client_mute_video' // 禁视频指定用户(仅创建者)
|
||||
| 'client_set_main_video' // 设置主屏用户
|
||||
|
||||
@ -37,6 +37,7 @@ pub fn register_commands(builder: Builder<tauri::Wry>) -> Builder<tauri::Wry> {
|
||||
stop_prompt_tone,
|
||||
// 窗口相关命令
|
||||
create_float_list_window,
|
||||
close_float_list_window,
|
||||
create_popup_window,
|
||||
create_main_window,
|
||||
// 系统托盘相关命令
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::window::create_float_list_window::create_float_list_window_impl;
|
||||
use crate::window::create_float_list_window::{close_float_list_window_impl, create_float_list_window_impl};
|
||||
use crate::window::create_main_window::create_main_window_impl;
|
||||
use crate::window::create_popup_window::create_popup_window_impl;
|
||||
use crate::window::derive::TargetLocation;
|
||||
@ -6,8 +6,17 @@ use tauri::{AppHandle, Window};
|
||||
|
||||
/// 创建悬浮列表窗口
|
||||
#[tauri::command]
|
||||
pub async fn create_float_list_window(app: AppHandle) -> Result<String, String> {
|
||||
create_float_list_window_impl(std::sync::Arc::new(app)).await.map(|_| "success".to_string())
|
||||
pub async fn create_float_list_window(app: AppHandle, is_teacher_muted: bool) -> Result<String, String> {
|
||||
create_float_list_window_impl(std::sync::Arc::new(app), is_teacher_muted)
|
||||
.await
|
||||
.map(|_| "success".to_string())
|
||||
}
|
||||
|
||||
/// 关闭悬浮列表窗口
|
||||
#[tauri::command]
|
||||
pub fn close_float_list_window(app: AppHandle) -> Result<String, String> {
|
||||
close_float_list_window_impl(std::sync::Arc::new(app))
|
||||
.map(|_| "success".to_string())
|
||||
}
|
||||
|
||||
/// 创建弹出框窗口
|
||||
|
||||
@ -11,20 +11,21 @@ use crate::{
|
||||
use tauri::{AppHandle, LogicalPosition, LogicalSize, Runtime, WebviewUrl, WebviewWindowBuilder, utils::config::BackgroundThrottlingPolicy};
|
||||
|
||||
/// 创建悬浮列表窗口(内部实现)
|
||||
pub async fn create_float_list_window_impl<R: Runtime>(app: Arc<AppHandle<R>>) -> Result<(), String> {
|
||||
pub async fn create_float_list_window_impl<R: Runtime>(app: Arc<AppHandle<R>>, is_teacher_muted: bool) -> Result<(), String> {
|
||||
if show_windows_by_label(app.as_ref(), WindowLabel::FLOAT_LIST) {
|
||||
return Ok(());
|
||||
}
|
||||
// 悬浮列表尺寸
|
||||
let float_info = calculate_float_info_sync();
|
||||
// 获取屏幕尺寸,优先为main窗口所在屏幕,主窗口没有打开时,才找主屏幕
|
||||
// 获取屏幕尺寸,优先为 main 窗口所在屏幕,主窗口没有打开时,才找主屏幕
|
||||
let monitor = get_monitor_for_window(app.as_ref())?;
|
||||
// 获取屏幕缩放因子
|
||||
let scale_factor = monitor.scale_factor();
|
||||
let page_size: LogicalSize<f64> = monitor.work_area().size.to_logical(scale_factor);
|
||||
let screen_width = page_size.width;
|
||||
let screen_height = page_size.height;
|
||||
let url = format!("floating-list-window.html");
|
||||
// 构建 URL 时附加状态参数
|
||||
let url = format!("floating-list-window.html?isTeacherMuted={}", is_teacher_muted);
|
||||
|
||||
let label = WindowLabel::FLOAT_LIST.as_ref();
|
||||
// 创建悬浮球窗口,直接在初始化时设置好位置和尺寸
|
||||
@ -64,7 +65,7 @@ pub async fn create_float_list_window_impl<R: Runtime>(app: Arc<AppHandle<R>>) -
|
||||
let _ = window.set_size(LogicalSize::new(float_info.width as f64, float_info.height as f64));
|
||||
}
|
||||
|
||||
// 窗口显示后等待200毫秒再执行动画
|
||||
// 窗口显示后等待 200 毫秒再执行动画
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
let _ = animate_to_bottom_right(window, screen_width as i32, screen_height as i32).await;
|
||||
@ -72,6 +73,22 @@ pub async fn create_float_list_window_impl<R: Runtime>(app: Arc<AppHandle<R>>) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 关闭悬浮列表窗口(内部实现)
|
||||
pub fn close_float_list_window_impl<R: Runtime>(app: Arc<AppHandle<R>>) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
|
||||
// 尝试获取悬浮列表窗口
|
||||
if let Some(window) = app.get_webview_window(WindowLabel::FLOAT_LIST.as_ref()) {
|
||||
// 关闭窗口
|
||||
window.close().map_err(|e| format!("关闭悬浮列表窗口失败:{}", e))?;
|
||||
log::info!("已成功关闭悬浮列表窗口");
|
||||
} else {
|
||||
log::warn!("悬浮列表窗口不存在,无需关闭");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据用户登录状态和课程选择情况动态计算弹出窗口高度
|
||||
pub fn calculate_float_info_sync() -> FloatListInfo {
|
||||
// 默认基础项目数
|
||||
|
||||
@ -164,40 +164,7 @@
|
||||
}
|
||||
|
||||
.operating-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
background-color: var(--button-bg);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.close-button {
|
||||
--button-bg: #f56057;
|
||||
}
|
||||
|
||||
&.minimum-button {
|
||||
--button-bg: #fec428;
|
||||
}
|
||||
|
||||
&.expand-button {
|
||||
--button-bg: #1fd42a;
|
||||
}
|
||||
|
||||
&.back-button {
|
||||
--button-bg: #7c7c7c;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
// 使用统一按钮样式,移除重复定义
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -37,23 +37,6 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operating-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
background-color: var(--button-bg);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
// 使用统一按钮样式,移除重复定义
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -11,6 +11,7 @@ import ErrorHandler from '@/utils/error-handler';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import '@/styles/main/root.scss';
|
||||
import '@/styles/main/dom.scss';
|
||||
import '@/styles/main/button.scss'; // 统一按钮样式规范
|
||||
import '@/styles/main/element-plus.scss';
|
||||
import '@/styles/main/global.scss';
|
||||
import '@/styles/main/font.css';
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<icon-mdi-volume-high v-if="!isMuted" class="menu-icon"></icon-mdi-volume-high>
|
||||
<icon-mdi-volume-off v-else class="menu-icon muted"></icon-mdi-volume-off>
|
||||
</div>
|
||||
<span class="menu-text" :class="{ 'text-muted': isMuted }">全员静音</span>
|
||||
<span class="menu-text" :class="{ 'text-muted': isAllMuted }">全员静音</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="muteTeacher()">
|
||||
<div class="icon-wrapper">
|
||||
@ -36,8 +36,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { emitTo } from '@tauri-apps/api/event';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { emitTo, listen } from '@tauri-apps/api/event';
|
||||
import { windowLabel } from '@/utils/tauri/windows-util';
|
||||
import { handleMouseDown, handleTouchStart } from '../move-window';
|
||||
|
||||
@ -56,8 +56,21 @@
|
||||
|
||||
/** 全员静音状态 */
|
||||
const isMuted = ref(false);
|
||||
/** 老师静音状态 */
|
||||
/** 老师静音状态 (从主窗口同步) */
|
||||
const isTeacherMuted = ref(false);
|
||||
/** 全员静音状态 (与主窗口同步) */
|
||||
const isAllMuted = ref(false);
|
||||
|
||||
/**
|
||||
* 从 URL 参数中获取初始状态
|
||||
*/
|
||||
function getInitialStateFromURL(): boolean {
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('isTeacherMuted') === 'true';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换折叠状态
|
||||
@ -65,16 +78,42 @@
|
||||
function toggleFold() {
|
||||
isFold.value = !isFold.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化状态
|
||||
*/
|
||||
onMounted(() => {
|
||||
// 从 URL 获取初始状态
|
||||
isTeacherMuted.value = getInitialStateFromURL();
|
||||
console.log('[悬浮窗] 初始状态:isTeacherMuted =', isTeacherMuted.value);
|
||||
|
||||
// 监听来自主窗口的状态更新消息
|
||||
listen<{ isTeacherMuted: boolean }>('teacher-mute-status-changed', (event) => {
|
||||
console.log('[悬浮窗] 收到状态更新:', event.payload);
|
||||
isTeacherMuted.value = event.payload.isTeacherMuted;
|
||||
});
|
||||
|
||||
// 监听来自主窗口的全员静音状态更新(双向同步)
|
||||
listen<{ isAllMuted: boolean }>('all-mute-status-changed', (event) => {
|
||||
console.log('[悬浮窗] 收到全员静音状态变更:', event.payload);
|
||||
isAllMuted.value = event.payload.isAllMuted;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 全员静音 (不包括老师)
|
||||
*/
|
||||
function muteAllStudents() {
|
||||
playPromptTone();
|
||||
isMuted.value = !isMuted.value;
|
||||
// 同步本地全员静音状态
|
||||
isAllMuted.value = isMuted.value;
|
||||
// 发送事件给主窗口,让主窗口的 socket 处理
|
||||
emitTo(windowLabel.main, 'meeting-control', {
|
||||
action: isMuted.value ? 'mute_all_students' : 'unmute_all_students',
|
||||
});
|
||||
// 通知主窗口更新状态(双向同步)
|
||||
emitTo(windowLabel.main, 'all-mute-status-changed', { isAllMuted: isAllMuted.value });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -82,10 +121,10 @@
|
||||
*/
|
||||
function muteTeacher() {
|
||||
playPromptTone();
|
||||
isTeacherMuted.value = !isTeacherMuted.value;
|
||||
// 发送事件给主窗口,让主窗口的 socket 处理
|
||||
// 不再维护本地状态,由教师端 toggleMute 方法根据实际状态切换
|
||||
emitTo(windowLabel.main, 'meeting-control', {
|
||||
action: isTeacherMuted.value ? 'mute_teacher' : 'unmute_teacher',
|
||||
action: 'mute_teacher',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -112,8 +112,7 @@
|
||||
// 从折叠状态展开时,使用专门的方法处理位置调整
|
||||
await expandWindowSizeFromFolded();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
337
src/styles/main/button.scss
Normal file
337
src/styles/main/button.scss
Normal file
@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 统一按钮样式规范
|
||||
* 适用于项目中所有的按钮组件
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
会议室控制栏按钮 (.control-btn)
|
||||
用于底部控制栏的功能按钮(上课/下课、静音、视频等)
|
||||
======================================== */
|
||||
.control-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 64px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
border: 1px solid var(--el-color-danger-light-7);
|
||||
|
||||
.btn-text {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--el-color-danger-light-8);
|
||||
border-color: var(--el-color-danger-light-6);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: var(--el-color-danger-light-7);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
|
||||
.btn-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #63408a 100%);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
视频用户控制按钮 (.control-icon-btn)
|
||||
用于视频卡片右上角的图标按钮(设为主讲、禁麦、禁视频、踢人等)
|
||||
======================================== */
|
||||
.control-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.muted {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.kick {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #667eea;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background: rgba(102, 126, 234, 0.6);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
窗口控制按钮 (.operating-button)
|
||||
用于窗口标题栏的控制按钮(最小化、最大化、关闭、返回等)
|
||||
======================================== */
|
||||
.operating-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
background-color: var(--button-bg);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.close-button {
|
||||
--button-bg: #f56057;
|
||||
|
||||
&:hover {
|
||||
background-color: #ff6b5b;
|
||||
}
|
||||
}
|
||||
|
||||
&.minimum-button {
|
||||
--button-bg: #fec428;
|
||||
|
||||
&:hover {
|
||||
background-color: #ffd347;
|
||||
}
|
||||
}
|
||||
|
||||
&.expand-button {
|
||||
--button-bg: #1fd42a;
|
||||
|
||||
&:hover {
|
||||
background-color: #3ce346;
|
||||
}
|
||||
}
|
||||
|
||||
&.back-button {
|
||||
--button-bg: #7c7c7c;
|
||||
|
||||
&:hover {
|
||||
background-color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Element Plus 按钮覆盖样式
|
||||
用于登录页面等使用 el-button 的场景
|
||||
======================================== */
|
||||
.el-button {
|
||||
&.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(93deg, #74e8ff 0%, #306fff 100%);
|
||||
border: transparent;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(93deg, #5dd6ff 0%, #2560e8 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px 0 rgba(48, 111, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
黑名单解除按钮 (.remove-btn)
|
||||
用于黑名单列表中的解除按钮
|
||||
======================================== */
|
||||
.remove-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #63408a 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
响应式适配
|
||||
======================================== */
|
||||
@media (max-width: 768px) {
|
||||
.control-btn {
|
||||
min-width: 56px;
|
||||
padding: 8px 12px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.control-icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,9 @@ img {
|
||||
-ms-user-drag: none;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
按钮基础样式重置
|
||||
======================================== */
|
||||
button {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@ -37,22 +40,13 @@ button {
|
||||
border: none; /* 去除边框 */
|
||||
outline: none; /* 去除点击时的轮廓 */
|
||||
box-shadow: none; /* 去除阴影 */
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
text-transform: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner,
|
||||
@ -70,6 +64,13 @@ button:-moz-focusring,
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
@ -6,15 +6,26 @@ import { ElMessage as Toast } from 'element-plus';
|
||||
/**
|
||||
* 创建悬浮球窗口
|
||||
*/
|
||||
export async function createFloatListWindow(pageType: 'interaction' | 'tutorship') {
|
||||
export async function createFloatListWindow(pageType: 'interaction' | 'tutorship', isTeacherMuted = false) {
|
||||
try {
|
||||
await invoke('create_float_list_window', { page_type: pageType });
|
||||
await invoke('create_float_list_window', { page_type: pageType, isTeacherMuted });
|
||||
} catch (error: any) {
|
||||
console.error('创建悬浮球窗口时出错:', error);
|
||||
Toast.error('悬浮球创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭悬浮球窗口
|
||||
*/
|
||||
export async function closeFloatListWindow() {
|
||||
try {
|
||||
await invoke('close_float_list_window');
|
||||
} catch (error: any) {
|
||||
console.error('关闭悬浮球窗口时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
*/
|
||||
|
||||
@ -115,7 +115,6 @@
|
||||
/** 主讲短 UID */
|
||||
const speakerUid = ref<number | string | null>(null);
|
||||
/** 投屏用户 ID */
|
||||
const screenShareStreamUid = ref<number | string | null>(null);
|
||||
const screenShareOwnerUid = ref<number | string | null>(null);
|
||||
const studentPrimaryOverrideUid = ref<number | string | null>(null);
|
||||
|
||||
@ -175,16 +174,16 @@
|
||||
return qualityMap[networkQuality.value] || '网络未知';
|
||||
});
|
||||
|
||||
const isTeacherDualStream = computed(() => Boolean(teacherUid.value && screenShareStreamUid.value));
|
||||
const isTeacherDualStream = computed(() => Boolean(teacherUid.value && screenShareOwnerUid.value));
|
||||
|
||||
const teacherPreferredPrimaryUid = computed<number | string | null>(() => {
|
||||
return screenShareStreamUid.value ?? speakerUid.value ?? (teacherUid.value || null);
|
||||
return screenShareOwnerUid.value ?? speakerUid.value ?? (teacherUid.value || null);
|
||||
});
|
||||
|
||||
const primaryUid = computed<number | string | null>(() => {
|
||||
if (isTeacherDualStream.value && studentPrimaryOverrideUid.value) {
|
||||
const candidate = studentPrimaryOverrideUid.value;
|
||||
if (candidate === teacherUid.value || candidate === screenShareStreamUid.value) {
|
||||
if (candidate === teacherUid.value || candidate === screenShareOwnerUid.value) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
@ -195,10 +194,10 @@
|
||||
if (!isTeacherDualStream.value) {
|
||||
return null;
|
||||
}
|
||||
if (!teacherUid.value || !screenShareStreamUid.value) {
|
||||
if (!teacherUid.value || !screenShareOwnerUid.value) {
|
||||
return null;
|
||||
}
|
||||
return primaryUid.value === screenShareStreamUid.value ? teacherUid.value : screenShareStreamUid.value;
|
||||
return primaryUid.value === screenShareOwnerUid.value ? teacherUid.value : screenShareOwnerUid.value;
|
||||
});
|
||||
|
||||
const selfWindowUid = computed<number | string | null>(() => secondaryUid.value);
|
||||
@ -216,7 +215,7 @@
|
||||
if (!selfWindowUid.value) {
|
||||
return userName.value;
|
||||
}
|
||||
if (selfWindowUid.value === screenShareStreamUid.value) {
|
||||
if (selfWindowUid.value === screenShareOwnerUid.value) {
|
||||
return `${screenOwnerName.value || '主讲'}(投屏)`;
|
||||
}
|
||||
return '主讲(摄像头)';
|
||||
@ -228,7 +227,7 @@
|
||||
if (!uid) {
|
||||
return '主讲';
|
||||
}
|
||||
if (screenShareStreamUid.value && uid === screenShareStreamUid.value) {
|
||||
if (screenShareOwnerUid.value && uid === screenShareOwnerUid.value) {
|
||||
return `${screenOwnerName.value || '主讲'}(投屏)`;
|
||||
}
|
||||
const u = meeting.remoteUsers.value.find((it: IAgoraRTCRemoteUser) => it.uid === uid) as (IAgoraRTCRemoteUser & { userName?: string }) | undefined;
|
||||
@ -236,7 +235,7 @@
|
||||
});
|
||||
|
||||
/** 是否为投屏主画面 */
|
||||
const isPrimaryScreenSharing = computed(() => Boolean(primaryUid.value && screenShareStreamUid.value === primaryUid.value));
|
||||
const isPrimaryScreenSharing = computed(() => Boolean(primaryUid.value && screenShareOwnerUid.value === primaryUid.value));
|
||||
|
||||
/** 舞台容器 DOM */
|
||||
const stageRef = ref<HTMLDivElement | null>(null);
|
||||
@ -501,9 +500,6 @@
|
||||
selectedUserUid.value = data.speakerUid;
|
||||
streamType.setMainVideo(data.speakerUid);
|
||||
}
|
||||
if (typeof data?.screenShareUid === 'number') {
|
||||
screenShareStreamUid.value = data.screenShareUid;
|
||||
}
|
||||
if (typeof data?.screenShareOwnerUid === 'number') {
|
||||
screenShareOwnerUid.value = data.screenShareOwnerUid;
|
||||
}
|
||||
@ -539,12 +535,8 @@
|
||||
}
|
||||
case 'sev_start_screen_share': {
|
||||
const targetUid = packet.data?.targetUid;
|
||||
const screenUid = packet.data?.screenUid;
|
||||
if (typeof targetUid === 'number') {
|
||||
screenShareOwnerUid.value = targetUid;
|
||||
}
|
||||
if (typeof screenUid === 'number') {
|
||||
screenShareStreamUid.value = screenUid;
|
||||
Toast.info?.(`用户 ${targetUid} 开始投屏`);
|
||||
}
|
||||
break;
|
||||
@ -553,7 +545,6 @@
|
||||
const targetUid = packet.data?.targetUid;
|
||||
if (typeof targetUid === 'number') {
|
||||
screenShareOwnerUid.value = null;
|
||||
screenShareStreamUid.value = null;
|
||||
Toast.info?.(`用户 ${targetUid} 停止投屏`);
|
||||
}
|
||||
break;
|
||||
@ -570,9 +561,6 @@
|
||||
speakerUid.value = roomData.speakerUid;
|
||||
selectedUserUid.value = roomData.speakerUid;
|
||||
streamType.setMainVideo(roomData.speakerUid);
|
||||
if (typeof roomData.screenShareUid === 'number') {
|
||||
screenShareStreamUid.value = roomData.screenShareUid;
|
||||
}
|
||||
if (typeof roomData.screenShareOwnerUid === 'number') {
|
||||
screenShareOwnerUid.value = roomData.screenShareOwnerUid;
|
||||
}
|
||||
@ -608,7 +596,6 @@
|
||||
case 'sev_class_ended': {
|
||||
classStatus.value = 'finished';
|
||||
screenShareOwnerUid.value = null;
|
||||
screenShareStreamUid.value = null;
|
||||
leaveChannel().catch(() => {});
|
||||
Toast.info?.('老师已下课,等待重新上课');
|
||||
break;
|
||||
@ -645,9 +632,6 @@
|
||||
selectedUserUid.value = roomData.speakerUid;
|
||||
streamType.setMainVideo(roomData.speakerUid);
|
||||
classStatus.value = roomData?.classStatus || 'not_started';
|
||||
if (roomData.screenShareUid) {
|
||||
screenShareStreamUid.value = roomData.screenShareUid;
|
||||
}
|
||||
if (typeof roomData.screenShareOwnerUid === 'number') {
|
||||
screenShareOwnerUid.value = roomData.screenShareOwnerUid;
|
||||
}
|
||||
@ -919,60 +903,8 @@
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 12px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
border: 1px solid var(--el-color-danger-light-7);
|
||||
|
||||
.btn-text {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-danger-light-7);
|
||||
}
|
||||
}
|
||||
// 使用统一按钮样式,移除重复的 .control-btn 定义
|
||||
}
|
||||
|
||||
.player-wrapper :deep(video) {
|
||||
|
||||
@ -100,23 +100,29 @@
|
||||
<icon-mdi-exit-to-app v-else></icon-mdi-exit-to-app>
|
||||
<span class="btn-text">{{ classStatus === 'in_class' ? '下课' : '上课' }}</span>
|
||||
</button>
|
||||
<button v-if="isHost" class="control-btn" @click="openBlacklist">
|
||||
<!-- 全员静音按钮 (仅教师端显示) -->
|
||||
<button v-if="isHost" class="control-btn" :class="{ active: isAllMuted }" :disabled="classStatus !== 'in_class'" @click="muteAllStudents">
|
||||
<icon-mdi-volume-high v-if="!isAllMuted"></icon-mdi-volume-high>
|
||||
<icon-mdi-volume-off v-else></icon-mdi-volume-off>
|
||||
<span class="btn-text">{{ isAllMuted ? '解除静音' : '全员静音' }}</span>
|
||||
</button>
|
||||
<button v-if="isHost" class="control-btn" :disabled="classStatus !== 'in_class'" @click="openBlacklist">
|
||||
<icon-mdi-monitor></icon-mdi-monitor>
|
||||
<span class="btn-text">黑名单</span>
|
||||
</button>
|
||||
<button v-if="isHost" class="control-btn" @click="openStudentSort">
|
||||
<button v-if="isHost" class="control-btn" :disabled="classStatus !== 'in_class'" @click="openStudentSort">
|
||||
<icon-mdi-format-list-bulleted></icon-mdi-format-list-bulleted>
|
||||
<span class="btn-text">学生列表</span>
|
||||
</button>
|
||||
<button class="control-btn" :class="{ active: !isMuted }" :disabled="muteLoading" @click="toggleMute">
|
||||
<button v-if="isHost" class="control-btn" :disabled="muteLoading || classStatus !== 'in_class'" @click="toggleMute">
|
||||
<icon-mdi-loading v-if="muteLoading" class="animate-spin"></icon-mdi-loading>
|
||||
<template v-else>
|
||||
<icon-mdi-microphone v-if="!isMuted"></icon-mdi-microphone>
|
||||
<icon-mdi-microphone-off v-else></icon-mdi-microphone-off>
|
||||
</template>
|
||||
<span class="btn-text">{{ isMuted ? '取消静音' : '静音' }}</span>
|
||||
<span class="btn-text">{{ isMuted ? '取消静音' : '教师静音' }}</span>
|
||||
</button>
|
||||
<button class="control-btn" :class="{ active: !isVideoOff }" :disabled="videoLoading" @click="toggleVideo">
|
||||
<button v-if="isHost" class="control-btn" :disabled="videoLoading || classStatus !== 'in_class'" @click="toggleVideo">
|
||||
<icon-mdi-loading v-if="videoLoading" class="animate-spin"></icon-mdi-loading>
|
||||
<template v-else>
|
||||
<icon-mdi-video v-if="!isVideoOff"></icon-mdi-video>
|
||||
@ -172,18 +178,21 @@
|
||||
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
|
||||
import IconMdiWifi from '~icons/mdi/wifi';
|
||||
import IconMdiFormatListBulleted from '~icons/mdi/format-list-bulleted';
|
||||
import IconMdiVolumeHigh from '~icons/mdi/volume-high';
|
||||
import IconMdiVolumeOff from '~icons/mdi/volume-off';
|
||||
import { ElIcon, ElMessageBox, ElMessage as Toast } from 'element-plus';
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStreamType } from './useStreamType';
|
||||
import { usePermissions } from './usePermissions';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { emitTo, listen } from '@tauri-apps/api/event';
|
||||
import type { DisplayUser, OnJoinRoomData, ServerToClientEvents } from './types';
|
||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
||||
import { getBlacklistAxios, getStudentDetailsAxios, removeFromBlacklistAxios } from '@/api/meeting';
|
||||
import StudentSortDialog from './student-sort-dialog.vue';
|
||||
import { createFloatListWindow } from '@/utils/tauri/windows-action';
|
||||
import { closeFloatListWindow, createFloatListWindow } from '@/utils/tauri/windows-action';
|
||||
import { windowLabel } from '@/utils/tauri/windows-util';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -229,7 +238,6 @@
|
||||
const roomId = ref<string>(''); // 房间 ID 会在加入后设置
|
||||
const socket = useWebSocket();
|
||||
const screenShareOwnerUid = ref<number | string | null>(null);
|
||||
const screenShareStreamUid = ref<number | null>(null);
|
||||
const meeting = useAgoraMeeting(courseRoomId, userName, shortUid, {
|
||||
onTokenWillExpire: () => {
|
||||
Toast.warning('Token 即将过期,正在自动刷新...');
|
||||
@ -252,10 +260,9 @@
|
||||
},
|
||||
onLocalScreenShareEnded: () => {
|
||||
if (roomId.value) {
|
||||
socket.emit('client_stop_screen_share', { roomId: roomId.value, screenUid: screenShareStreamUid.value || undefined });
|
||||
socket.emit('client_stop_screen_share', { roomId: roomId.value });
|
||||
}
|
||||
screenShareOwnerUid.value = null;
|
||||
screenShareStreamUid.value = null;
|
||||
Toast.info?.('已停止投屏');
|
||||
},
|
||||
});
|
||||
@ -281,13 +288,14 @@
|
||||
leaveLoading,
|
||||
networkQuality,
|
||||
selectedUserUid,
|
||||
toggleMute,
|
||||
toggleVideo,
|
||||
leaveChannel,
|
||||
cleanup,
|
||||
startScreenShare,
|
||||
stopScreenTrack,
|
||||
isScreenSharing,
|
||||
forceMuteAudio,
|
||||
forceUnmuteAudio,
|
||||
} = meeting;
|
||||
|
||||
// 解构双流模式
|
||||
@ -302,6 +310,8 @@
|
||||
const roomEndTimestamp = ref<number | null>(null);
|
||||
/** 是否已加入房间 */
|
||||
const isRoomJoined = ref(false);
|
||||
/** 全员静音状态(与悬浮窗同步) */
|
||||
const isAllMuted = ref(false);
|
||||
|
||||
/** 用于排序的学生列表 */
|
||||
const studentListForSort = ref<Array<{ longId: number; studentName: string; shortId: number }>>([]);
|
||||
@ -362,9 +372,6 @@
|
||||
if (typeof data?.endTimestamp === 'number') {
|
||||
roomEndTimestamp.value = data.endTimestamp;
|
||||
}
|
||||
if (typeof data?.screenShareUid === 'number') {
|
||||
screenShareStreamUid.value = data.screenShareUid;
|
||||
}
|
||||
if (typeof data?.screenShareOwnerUid === 'number') {
|
||||
screenShareOwnerUid.value = data.screenShareOwnerUid;
|
||||
}
|
||||
@ -375,7 +382,6 @@
|
||||
try {
|
||||
const screenUid = await startScreenShare();
|
||||
socket.emit('client_start_screen_share', { roomId: roomId.value, screenUid });
|
||||
screenShareStreamUid.value = screenUid;
|
||||
screenShareOwnerUid.value = shortUid.value;
|
||||
Toast.info?.('已恢复投屏状态');
|
||||
} catch {}
|
||||
@ -429,6 +435,10 @@
|
||||
isVideoMuted: permissions.userPermissions.get(targetUid)?.isVideoMuted ?? false,
|
||||
isKicked: permissions.userPermissions.get(targetUid)?.isKicked ?? false,
|
||||
});
|
||||
// 如果是老师自己被禁麦,同步状态到悬浮窗
|
||||
if (targetUid === shortUid.value) {
|
||||
emitTo(windowLabel.floatList, 'teacher-mute-status-changed', { isTeacherMuted: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
meeting.forceMuteAudio();
|
||||
@ -447,6 +457,10 @@
|
||||
isVideoMuted: permissions.userPermissions.get(targetUid)?.isVideoMuted ?? false,
|
||||
isKicked: permissions.userPermissions.get(targetUid)?.isKicked ?? false,
|
||||
});
|
||||
// 如果是老师自己被解除禁麦,同步状态到悬浮窗
|
||||
if (targetUid === shortUid.value) {
|
||||
emitTo(windowLabel.floatList, 'teacher-mute-status-changed', { isTeacherMuted: false });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
meeting.forceUnmuteAudio();
|
||||
@ -499,13 +513,9 @@
|
||||
}
|
||||
case 'sev_start_screen_share': {
|
||||
const targetUid = packet.data?.targetUid;
|
||||
const screenUid = packet.data?.screenUid;
|
||||
console.log('[屏幕共享] 收到 sev_start_screen_share 消息:', targetUid);
|
||||
if (typeof targetUid === 'number') {
|
||||
screenShareOwnerUid.value = targetUid;
|
||||
if (typeof screenUid === 'number') {
|
||||
screenShareStreamUid.value = screenUid;
|
||||
}
|
||||
console.log('[屏幕共享] screenShareOwnerUid 已设置为:', targetUid);
|
||||
Toast.info?.(`用户 ${targetUid} 开始投屏`);
|
||||
}
|
||||
@ -515,7 +525,6 @@
|
||||
const targetUid = packet.data?.targetUid;
|
||||
if (typeof targetUid === 'number') {
|
||||
screenShareOwnerUid.value = null;
|
||||
screenShareStreamUid.value = null;
|
||||
Toast.info?.(`用户 ${targetUid} 停止投屏`);
|
||||
}
|
||||
break;
|
||||
@ -559,7 +568,6 @@
|
||||
if (isScreenSharing.value) {
|
||||
stopScreenTrack().catch(() => {});
|
||||
screenShareOwnerUid.value = null;
|
||||
screenShareStreamUid.value = null;
|
||||
}
|
||||
leaveChannel().catch(() => {});
|
||||
Toast.info?.('老师已下课,等待重新上课');
|
||||
@ -727,14 +735,14 @@
|
||||
// 下课:发送下课消息并离开
|
||||
endClassAndLeave();
|
||||
} else {
|
||||
createFloatListWindow('interaction');
|
||||
// 上课:发送上课消息
|
||||
// 上课:发送上课消息,并打开悬浮窗(带上老师静音状态)
|
||||
createFloatListWindow('interaction', isMuted.value);
|
||||
socket.emit('client_start_class', { roomId: roomId.value, meetingDuration: meetingDuration.value });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下课并离开(教师专用)
|
||||
* 下课并离开(教师专用)
|
||||
* 先发送下课消息,然后离开会议
|
||||
*/
|
||||
async function endClassAndLeave(): Promise<void> {
|
||||
@ -751,6 +759,8 @@
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
// 关闭悬浮窗
|
||||
await closeFloatListWindow();
|
||||
// 移除 WebSocket 消息监听
|
||||
socket.off('message', onSocketMessage);
|
||||
// 离开 WebSocket 房间
|
||||
@ -796,24 +806,21 @@
|
||||
try {
|
||||
console.log('[投屏] toggleScreenShare 被调用,isScreenSharing.value:', isScreenSharing.value);
|
||||
if (isScreenSharing.value) {
|
||||
const currentScreenUid = screenShareStreamUid.value || meeting.localScreenUid.value || undefined;
|
||||
// 停止投屏
|
||||
await stopScreenTrack();
|
||||
// 通知后端停止投屏
|
||||
socket.emit('client_stop_screen_share', { roomId: roomId.value, screenUid: currentScreenUid });
|
||||
socket.emit('client_stop_screen_share', { roomId: roomId.value });
|
||||
// 清除投屏状态
|
||||
screenShareOwnerUid.value = null;
|
||||
screenShareStreamUid.value = null;
|
||||
Toast.success('已停止投屏');
|
||||
} else {
|
||||
// 开始投屏
|
||||
console.log('[投屏] 准备开始投屏');
|
||||
const screenUid = await startScreenShare();
|
||||
// 通知后端开始投屏(带上自己的短 UID)
|
||||
socket.emit('client_start_screen_share', { roomId: roomId.value, shortUid: shortUid.value, screenUid });
|
||||
socket.emit('client_start_screen_share', { roomId: roomId.value, screenUid });
|
||||
// 设置投屏状态
|
||||
screenShareOwnerUid.value = shortUid.value;
|
||||
screenShareStreamUid.value = screenUid;
|
||||
Toast.success('已开始投屏');
|
||||
}
|
||||
} catch (error) {
|
||||
@ -896,6 +903,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换静音状态(禁麦/解除禁麦)
|
||||
* 老师自己静音时:禁止发布声音,并发送 socket 消息到服务器记录 Redis 状态
|
||||
*/
|
||||
async function toggleMute(): Promise<void> {
|
||||
if (!roomId.value || !shortUid.value) {
|
||||
Toast.warning('房间未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果当前是静音状态,则解除静音
|
||||
if (isMuted.value) {
|
||||
// 解除本地静音
|
||||
await forceUnmuteAudio();
|
||||
// 发送解除禁麦消息给服务器
|
||||
socket.emit('client_unmute_audio', {
|
||||
roomId: roomId.value,
|
||||
targetUid: shortUid.value,
|
||||
});
|
||||
Toast.success('取消静音');
|
||||
// 通知悬浮窗更新状态
|
||||
emitTo(windowLabel.floatList, 'teacher-mute-status-changed', { isTeacherMuted: false });
|
||||
} else {
|
||||
// 如果当前是开启状态,则静音
|
||||
// 禁止发布声音
|
||||
await forceMuteAudio();
|
||||
// 发送禁麦消息给服务器
|
||||
socket.emit('client_mute_audio', {
|
||||
roomId: roomId.value,
|
||||
targetUid: shortUid.value,
|
||||
});
|
||||
Toast.success('已静音');
|
||||
// 通知悬浮窗更新状态
|
||||
emitTo(windowLabel.floatList, 'teacher-mute-status-changed', { isTeacherMuted: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全员静音 (不包括老师)
|
||||
*/
|
||||
@ -906,43 +950,22 @@
|
||||
}
|
||||
|
||||
try {
|
||||
// 遍历所有学生用户,发送禁麦消息
|
||||
const studentUids = displayUsers.value.map((u: DisplayUser) => u.uid);
|
||||
for (const uid of studentUids) {
|
||||
socket.emit('client_mute_audio', {
|
||||
roomId: roomId.value,
|
||||
targetUid: uid,
|
||||
});
|
||||
}
|
||||
Toast.success('已全员静音');
|
||||
// 切换全员静音状态
|
||||
isAllMuted.value = !isAllMuted.value;
|
||||
|
||||
// 发送全员静音消息给服务器,由服务器处理
|
||||
socket.emit('client_mute_all_students', {
|
||||
roomId: roomId.value,
|
||||
});
|
||||
Toast.success(isAllMuted.value ? '已全员静音' : '已解除全员静音');
|
||||
// 通知悬浮窗更新状态
|
||||
emitTo(windowLabel.floatList, 'all-mute-status-changed', { isAllMuted: isAllMuted.value });
|
||||
} catch (error) {
|
||||
console.error('全员静音失败:', error);
|
||||
Toast.error('全员静音失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师静音
|
||||
*/
|
||||
function muteTeacher(): void {
|
||||
if (!roomId.value || !shortUid.value) {
|
||||
Toast.warning('房间未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 给自己发送禁麦消息
|
||||
socket.emit('client_mute_audio', {
|
||||
roomId: roomId.value,
|
||||
targetUid: shortUid.value,
|
||||
});
|
||||
Toast.success('老师已静音');
|
||||
} catch (error) {
|
||||
console.error('老师静音失败:', error);
|
||||
Toast.error('老师静音失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理来自悬浮窗的会议控制事件
|
||||
* @param action - 控制动作
|
||||
@ -957,8 +980,8 @@
|
||||
break;
|
||||
}
|
||||
case 'mute_teacher': {
|
||||
// 老师静音
|
||||
muteTeacher();
|
||||
// 老师静音:调用 toggleMute 方法
|
||||
await toggleMute();
|
||||
break;
|
||||
}
|
||||
case 'end_class': {
|
||||
@ -972,7 +995,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件挂载生命周期钩子
|
||||
* 监听悬浮窗的全员静音状态变更
|
||||
*/
|
||||
onMounted(async () => {
|
||||
role.value = String(route.query.role) === 'student' ? 'student' : 'teacher';
|
||||
@ -996,11 +1019,17 @@
|
||||
Toast.info?.('等待老师上课...');
|
||||
}
|
||||
|
||||
// 监听来自悬浮窗的控制事件
|
||||
// 监听来自悬浮窗的控制事件(只注册一次,防止重复)
|
||||
listen<{ action: string }>('meeting-control', (event) => {
|
||||
console.log('[教师端] 收到会议控制事件:', event.payload);
|
||||
handleMeetingControl(event.payload.action);
|
||||
});
|
||||
|
||||
// 监听悬浮窗的全员静音状态变更(双向同步)
|
||||
listen<{ isAllMuted: boolean }>('all-mute-status-changed', (event) => {
|
||||
console.log('[教师端] 收到全员静音状态变更:', event.payload);
|
||||
isAllMuted.value = event.payload.isAllMuted;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加入会议失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '加入会议失败';
|
||||
@ -1017,6 +1046,8 @@
|
||||
return;
|
||||
}
|
||||
socket.off('message', onSocketMessage);
|
||||
// 关闭悬浮窗
|
||||
await closeFloatListWindow();
|
||||
await cleanup();
|
||||
meetingSocket.leaveRoom(socket);
|
||||
destroyMeetingSocket();
|
||||
@ -1366,58 +1397,6 @@
|
||||
border-radius: 12px;
|
||||
transform: translateX(-50%);
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
border: 1px solid var(--el-color-danger-light-7);
|
||||
|
||||
.btn-text {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-danger-light-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 使用统一按钮样式,移除重复的 .control-btn 定义
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -260,8 +260,7 @@ export interface OnJoinRoomData {
|
||||
tokenInfo: MeetingToken;
|
||||
/** 课堂状态 */
|
||||
classStatus?: ClassStatus;
|
||||
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
||||
screenShareUid?: number;
|
||||
/** 投屏状态:正在投屏的用户短 UID,undefined表示无人投屏 */
|
||||
screenShareOwnerUid?: number;
|
||||
/** 主讲人短 UID(默认等于 teacherUid) */
|
||||
speakerUid: number;
|
||||
@ -435,6 +434,7 @@ export type UplinkControlMessagePacket =
|
||||
*/
|
||||
export type ControlCommandType =
|
||||
| 'client_kick_user' // 踢出用户
|
||||
| 'client_mute_all_students' // 全员禁麦(不包括老师)
|
||||
| 'client_mute_audio' // 禁麦(关闭用户麦克风)
|
||||
| 'client_mute_video' // 禁视频(关闭用户摄像头)
|
||||
| 'client_renew_room_end_timestamp' // 续期房间结束时间
|
||||
@ -551,6 +551,7 @@ export interface ClientToServerEvents {
|
||||
client_leave_room: (data: SendLeaveRoomData) => void;
|
||||
client_kick_user: (data: SendKickUserData) => void;
|
||||
client_mute_audio: (data: SendMuteUserData) => void;
|
||||
client_mute_all_students: (data: { roomId: string }) => void;
|
||||
client_unmute_audio: (data: SendMuteUserData) => void;
|
||||
client_mute_video: (data: SendMuteUserData) => void;
|
||||
client_unmute_video: (data: SendMuteUserData) => void;
|
||||
|
||||
Reference in New Issue
Block a user