feat(meeting): 新增学生端和监控端会议页面
- 在路由中新增学生端和监控端页面路由 - 修改加入会议逻辑,根据角色跳转到不同页面 - 新增监控端页面,支持多学生画面宫格布局 - 新增学生端页面,支持主画面显示和本地视频小窗拖拽 - 扩展 useAgoraMeeting hook 支持禁用本地音视频轨道 - 更新悬浮列表窗口的会议和字幕创建函数参数 - 添加课程和评讲信息的存储常量
This commit is contained in:
@ -263,7 +263,7 @@
|
||||
async function openMeeting() {
|
||||
playPromptTone();
|
||||
|
||||
await createMeetingWindow(2, currTutorship.value || null);
|
||||
await createMeetingWindow(2, currTutorship.value?.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -271,7 +271,7 @@
|
||||
*/
|
||||
async function openSubtitle() {
|
||||
playPromptTone();
|
||||
await createSubtitleWindow(currTutorship.value || null);
|
||||
await createSubtitleWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,11 +1,32 @@
|
||||
import { isObject } from '@/utils/verify';
|
||||
import { STORAGE_COMMANDS, tauriSessionStorage } from '@/utils/tauri/tauri-session';
|
||||
import type { BriCourse, BriTutorship } from '@/api/select-classroom-type';
|
||||
import type { BriUserInfo } from '@/api/login';
|
||||
import { tauri_store_page_curr_course, tauri_store_page_curr_tutorship, tauri_store_page_user_info } from '@/utils/constant';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 课程信息(悬浮列表窗口只关心必要字段)
|
||||
*/
|
||||
export type BriCourse = {
|
||||
/** 课程 ID */
|
||||
id: number;
|
||||
/** 班级 ID */
|
||||
classId: number;
|
||||
/** 其他字段 */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* 评讲信息(悬浮列表窗口只关心必要字段)
|
||||
*/
|
||||
export type BriTutorship = {
|
||||
/** 评讲 ID */
|
||||
id: number;
|
||||
/** 其他字段 */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* useTauriSessionStorage
|
||||
*/
|
||||
@ -66,7 +87,7 @@ export function useTauriSessionStorage() {
|
||||
});
|
||||
tauriSessionStorage.get(tauri_store_page_curr_tutorship).then((value) => {
|
||||
const obj = isObject(value) ? value : null;
|
||||
if (obj && 'id') {
|
||||
if (obj && 'id' in obj) {
|
||||
currTutorship.value = obj as unknown as BriTutorship;
|
||||
}
|
||||
});
|
||||
|
||||
@ -54,8 +54,20 @@ export function getLayoutRoutesList(): RouteRecordRaw[] {
|
||||
{
|
||||
path: 'meeting-room',
|
||||
name: 'meeting-room',
|
||||
component: () => import('@/views/meeting/meeting-room/meeting-room.vue'),
|
||||
meta: { title: '会议 - 会议房间', affix: false, noCache: true },
|
||||
component: () => import('@/views/meeting/meeting-room/meeting-room-teacher.vue'),
|
||||
meta: { title: '会议 - 教室端', affix: false, noCache: true },
|
||||
},
|
||||
{
|
||||
path: 'meeting-room-student',
|
||||
name: 'meeting-room-student',
|
||||
component: () => import('@/views/meeting/meeting-room/meeting-room-student.vue'),
|
||||
meta: { title: '会议 - 学生端', affix: false, noCache: true },
|
||||
},
|
||||
{
|
||||
path: 'meeting-room-monitor',
|
||||
name: 'meeting-room-monitor',
|
||||
component: () => import('@/views/meeting/meeting-room/meeting-room-monitor.vue'),
|
||||
meta: { title: '会议 - 监控端', affix: false, noCache: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -11,6 +11,12 @@ export const MAIN_WINDOW_HEIGHT = 700;
|
||||
/** 存储用户信息 */
|
||||
export const tauri_store_page_user_info = 'tauri_store_page_user_info';
|
||||
|
||||
/** 存储当前课程信息 */
|
||||
export const tauri_store_page_curr_course = 'tauri_store_page_curr_course';
|
||||
|
||||
/** 存储当前评讲信息 */
|
||||
export const tauri_store_page_curr_tutorship = 'tauri_store_page_curr_tutorship';
|
||||
|
||||
/** 存储悬浮球页面动作 */
|
||||
export const tauri_floating_list_page_action = 'tauri_floating_list_page_action';
|
||||
|
||||
|
||||
@ -8,13 +8,55 @@ import { ElMessage as Toast } from 'element-plus';
|
||||
*/
|
||||
export async function createFloatListWindow(pageType: 'interaction' | 'tutorship') {
|
||||
try {
|
||||
await invoke('create_float_list_window', { pageType });
|
||||
await invoke('create_float_list_window', { page_type: pageType });
|
||||
} catch (error: any) {
|
||||
console.error('创建悬浮球窗口时出错:', error);
|
||||
Toast.error('悬浮球创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
*/
|
||||
export async function createMainWindow() {
|
||||
try {
|
||||
await invoke('create_main_window');
|
||||
} catch (error: any) {
|
||||
console.error('创建主窗口时出错:', error);
|
||||
Toast.error('创建主窗口失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会议窗口
|
||||
* @param windowType - 窗口类型
|
||||
* @param courseId - 课程ID
|
||||
*/
|
||||
export async function createMeetingWindow(windowType: number, courseId?: number) {
|
||||
try {
|
||||
await invoke('create_meeting_window', { window_type: windowType, course_id: typeof courseId === 'number' ? courseId : null });
|
||||
} catch (error: any) {
|
||||
console.error('创建会议窗口时出错:', error);
|
||||
Toast.error('创建会议窗口失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建画板窗口
|
||||
*/
|
||||
export function createDrawingBoardWindow(): Promise<boolean> {
|
||||
Toast.warning('当前版本暂不支持画板窗口');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建实时字幕窗口
|
||||
*/
|
||||
export function createSubtitleWindow(): Promise<boolean> {
|
||||
Toast.warning('当前版本暂不支持实时字幕窗口');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整窗口位置和大小
|
||||
*/
|
||||
|
||||
@ -428,8 +428,9 @@
|
||||
// 触发表单验证
|
||||
|
||||
console.log('即将跳转到 meeting-room 页面');
|
||||
const targetName = role.value === 'student' ? 'meeting-room-student' : 'meeting-room';
|
||||
router.push({
|
||||
name: 'meeting-room',
|
||||
name: targetName,
|
||||
query: {
|
||||
homeworkId: homeworkId.value,
|
||||
name: courseRoomName.value,
|
||||
|
||||
419
src/views/meeting/meeting-room/meeting-room-monitor.vue
Normal file
419
src/views/meeting/meeting-room/meeting-room-monitor.vue
Normal file
@ -0,0 +1,419 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-content">
|
||||
<div class="top-bar">
|
||||
<div class="room-info">
|
||||
<icon-mdi-video class="room-icon"></icon-mdi-video>
|
||||
<div class="room-details">
|
||||
<span class="room-name">{{ courseRoomName }}</span>
|
||||
<span class="room-id">监控端 · 房间 ID: {{ courseRoomId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-status" :class="networkQualityClass">
|
||||
<icon-mdi-wifi class="network-icon"></icon-mdi-wifi>
|
||||
<span class="network-text">{{ networkStatusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="monitor-container">
|
||||
<el-empty v-if="classStatus !== 'in_class'" :description="classStatus === 'not_started' ? '等待老师上课...' : '老师已下课,等待重新上课...'"></el-empty>
|
||||
<div v-else-if="studentUsers.length === 0" class="empty-students">
|
||||
<el-empty description="暂无学生画面"></el-empty>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid"
|
||||
:style="{
|
||||
'--grid-cols': String(gridCols),
|
||||
'--grid-rows': String(gridRows),
|
||||
}"
|
||||
>
|
||||
<div v-for="u in studentUsers" :key="String(u.uid)" class="grid-item">
|
||||
<div :id="`player-${u.uid}`" class="player-wrapper"></div>
|
||||
<div class="video-label">
|
||||
<span>{{ u.userName || `用户 ${u.uid}` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
|
||||
import IconMdiWifi from '~icons/mdi/wifi';
|
||||
import IconMdiVideo from '~icons/mdi/video';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage as Toast } from 'element-plus';
|
||||
import type { OnJoinRoomData, ServerToClientEvents } from './types';
|
||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
/** 课程房间名称 */
|
||||
const courseRoomName = computed(() => String(route.query.name || ''));
|
||||
/** 用户长 ID */
|
||||
const longUserId = computed(() => userStore.userInfo?.id || 0);
|
||||
/** 用户名称 */
|
||||
const userName = computed(() => userStore.userInfo?.name || '用户');
|
||||
/** 作业 ID */
|
||||
const homeworkId = computed(() => Number(route.query.homeworkId || 0));
|
||||
/** 课程房间ID */
|
||||
const courseRoomId = computed(() => `n_${homeworkId.value}`);
|
||||
|
||||
/** 课堂状态:not_started表示未开始 / in_class表示上课中 / finished表示已结束 */
|
||||
const classStatus = ref<'finished' | 'in_class' | 'not_started'>('not_started');
|
||||
/** 用户的短id */
|
||||
const shortUid = ref<number>(0);
|
||||
/** 房间号 */
|
||||
const roomId = ref<string>('');
|
||||
/** 老师短 UID */
|
||||
const teacherUid = ref<number>(0);
|
||||
|
||||
const meeting = useAgoraMeeting(
|
||||
courseRoomId,
|
||||
userName,
|
||||
shortUid,
|
||||
{
|
||||
onTokenWillExpire: () => {
|
||||
Toast.warning('Token 即将过期,正在自动刷新...');
|
||||
},
|
||||
onTokenExpired: () => {
|
||||
Toast.error('Token 已过期,请重新加入会议');
|
||||
},
|
||||
onConnectionStateChange: (state: string) => {
|
||||
if (state === 'DISCONNECTED') {
|
||||
Toast.warning('网络连接已断开');
|
||||
} else if (state === 'RECONNECTING') {
|
||||
Toast.info('正在重新连接...');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
Toast.error(error.message || '操作失败');
|
||||
},
|
||||
},
|
||||
{ disableLocalTracks: true }
|
||||
);
|
||||
|
||||
const socket = useWebSocket();
|
||||
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
|
||||
|
||||
const { networkQuality, selectedUserUid, leaveChannel, cleanup } = meeting;
|
||||
|
||||
/** 网络质量样式类名 */
|
||||
const networkQualityClass = computed(() => networkQuality.value);
|
||||
|
||||
/** 网络状态文本描述 */
|
||||
const networkStatusText = computed(() => {
|
||||
const qualityMap: Record<string, string> = {
|
||||
good: '网络良好',
|
||||
fair: '网络一般',
|
||||
poor: '网络较差',
|
||||
};
|
||||
return qualityMap[networkQuality.value] || '网络未知';
|
||||
});
|
||||
|
||||
/**
|
||||
* 监控端学生用户列表(只展示学生,不包含老师)
|
||||
*/
|
||||
const studentUsers = computed(() => {
|
||||
const users = meeting.remoteUsers.value as (IAgoraRTCRemoteUser & { userName?: string })[];
|
||||
return users.filter((u) => (teacherUid.value ? u.uid !== teacherUid.value : true));
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据学生人数计算宫格排数(最多 4 排)
|
||||
*/
|
||||
const gridRows = computed(() => {
|
||||
const count = studentUsers.value.length;
|
||||
if (count <= 1) {
|
||||
return 1;
|
||||
}
|
||||
if (count <= 4) {
|
||||
return 2;
|
||||
}
|
||||
if (count <= 10) {
|
||||
return 3;
|
||||
}
|
||||
return 4;
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据学生人数与排数计算列数
|
||||
*/
|
||||
const gridCols = computed(() => {
|
||||
const count = studentUsers.value.length;
|
||||
const rows = gridRows.value || 1;
|
||||
return Math.max(1, Math.ceil(count / rows));
|
||||
});
|
||||
|
||||
type MeetingWsPacket = Parameters<ServerToClientEvents['message']>[0];
|
||||
|
||||
/**
|
||||
* 离开监控端
|
||||
*/
|
||||
async function handleLeave(): Promise<void> {
|
||||
try {
|
||||
socket.off('message', onSocketMessage);
|
||||
try {
|
||||
meetingSocket.leaveRoom(socket);
|
||||
} catch {}
|
||||
destroyMeetingSocket();
|
||||
await cleanup();
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('离开监控端失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 消息处理
|
||||
* @param packet - 消息包
|
||||
*/
|
||||
function onSocketMessage(packet: MeetingWsPacket) {
|
||||
switch (packet.type) {
|
||||
case 'sev_join_room': {
|
||||
const data = packet.data;
|
||||
if (data?.roomId && !roomId.value) {
|
||||
roomId.value = data.roomId;
|
||||
}
|
||||
if (data?.classStatus) {
|
||||
classStatus.value = data.classStatus;
|
||||
}
|
||||
if (typeof data?.teacherUid === 'number') {
|
||||
teacherUid.value = data.teacherUid;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sev_set_main_video': {
|
||||
selectedUserUid.value = packet.data.targetUid;
|
||||
break;
|
||||
}
|
||||
case 'sev_class_started': {
|
||||
classStatus.value = 'in_class';
|
||||
meetingSocket
|
||||
.joinRoom(socket)
|
||||
.then(async (roomData: OnJoinRoomData) => {
|
||||
roomId.value = roomData.roomId;
|
||||
shortUid.value = roomData.shortUid;
|
||||
teacherUid.value = roomData.teacherUid;
|
||||
await meeting.initAndJoin(roomData);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (e instanceof Error && e.message) {
|
||||
Toast.error(e.message);
|
||||
return;
|
||||
}
|
||||
Toast.error('加入失败');
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'sev_class_ended': {
|
||||
classStatus.value = 'finished';
|
||||
leaveChannel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
case 'sev_kick_user':
|
||||
case 'sev_device_conflict': {
|
||||
Toast.warning('监控端已断开,请重新进入');
|
||||
handleLeave();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('message', onSocketMessage);
|
||||
|
||||
/**
|
||||
* 页面挂载后加入房间
|
||||
*/
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const roomData = await meetingSocket.joinRoom(socket);
|
||||
roomId.value = roomData.roomId;
|
||||
shortUid.value = roomData.shortUid;
|
||||
teacherUid.value = roomData.teacherUid;
|
||||
classStatus.value = roomData?.classStatus || 'not_started';
|
||||
if (classStatus.value === 'in_class') {
|
||||
await meeting.initAndJoin(roomData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加入监控端失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '加入监控端失败';
|
||||
Toast.error(errorMessage);
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 页面卸载清理
|
||||
*/
|
||||
onUnmounted(async () => {
|
||||
socket.off('message', onSocketMessage);
|
||||
await cleanup();
|
||||
try {
|
||||
meetingSocket.leaveRoom(socket);
|
||||
} catch {}
|
||||
destroyMeetingSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
|
||||
.page-content {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.room-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.room-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.room-id {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.network-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
|
||||
&.good {
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
&.fair {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
&.poor {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.network-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.network-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.monitor-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-students {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(var(--grid-rows), 1fr);
|
||||
grid-template-columns: repeat(var(--grid-cols), 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.player-wrapper :deep(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-label {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
772
src/views/meeting/meeting-room/meeting-room-student.vue
Normal file
772
src/views/meeting/meeting-room/meeting-room-student.vue
Normal file
@ -0,0 +1,772 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-content">
|
||||
<div class="top-bar">
|
||||
<div class="room-info">
|
||||
<icon-mdi-video class="room-icon"></icon-mdi-video>
|
||||
<div class="room-details">
|
||||
<span class="room-name">{{ courseRoomName }}</span>
|
||||
<span class="room-id">房间 ID: {{ courseRoomId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-status" :class="networkQualityClass">
|
||||
<icon-mdi-wifi class="network-icon"></icon-mdi-wifi>
|
||||
<span class="network-text">{{ networkStatusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="stageRef" class="stage" @pointermove="handleSelfPointerMove" @pointerup="handleSelfPointerUp" @pointercancel="handleSelfPointerUp">
|
||||
<el-empty v-if="classStatus !== 'in_class'" :description="classStatus === 'not_started' ? '等待老师上课...' : '老师已下课,等待重新上课...'"></el-empty>
|
||||
<template v-else>
|
||||
<div class="primary-video">
|
||||
<div v-if="primaryUid" :id="`player-${primaryUid}`" class="player-wrapper"></div>
|
||||
<div v-else class="primary-empty">暂无主讲画面</div>
|
||||
<div class="primary-label video-label">
|
||||
<span>{{ primaryName }}</span>
|
||||
<span v-if="isPrimaryScreenSharing" class="badge">投屏中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="selfWindowRef" class="self-window" :style="selfWindowStyle" @pointerdown.prevent="handleSelfPointerDown" @click.stop>
|
||||
<div id="player-local" class="player-wrapper"></div>
|
||||
<div class="self-label video-label">
|
||||
<icon-mdi-account class="local-icon"></icon-mdi-account>
|
||||
<span>{{ userName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="control-bar">
|
||||
<button class="control-btn" :class="{ active: !isMuted }" :disabled="muteLoading" @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>
|
||||
</button>
|
||||
<button class="control-btn" :class="{ active: !isVideoOff }" :disabled="videoLoading" @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>
|
||||
<icon-mdi-video-off v-else></icon-mdi-video-off>
|
||||
</template>
|
||||
<span class="btn-text">{{ isVideoOff ? '开启视频' : '关闭视频' }}</span>
|
||||
</button>
|
||||
<button class="control-btn danger" :disabled="leaveLoading" @click="handleLeave">
|
||||
<icon-mdi-loading v-if="leaveLoading" class="animate-spin"></icon-mdi-loading>
|
||||
<icon-mdi-exit-to-app v-else></icon-mdi-exit-to-app>
|
||||
<span class="btn-text">离开</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { playPromptTone } from '@/utils/prompt-tone';
|
||||
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
|
||||
import IconMdiAccount from '~icons/mdi/account';
|
||||
import IconMdiExitToApp from '~icons/mdi/exit-to-app';
|
||||
import IconMdiLoading from '~icons/mdi/loading';
|
||||
import IconMdiMicrophone from '~icons/mdi/microphone';
|
||||
import IconMdiMicrophoneOff from '~icons/mdi/microphone-off';
|
||||
import IconMdiVideo from '~icons/mdi/video';
|
||||
import IconMdiVideoOff from '~icons/mdi/video-off';
|
||||
import IconMdiWifi from '~icons/mdi/wifi';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage as Toast } from 'element-plus';
|
||||
import type { OnJoinRoomData, ServerToClientEvents } from './types';
|
||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
||||
import { useStreamType } from './useStreamType';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
/** 课程房间名称 */
|
||||
const courseRoomName = computed(() => String(route.query.name || ''));
|
||||
/** 用户长 ID */
|
||||
const longUserId = computed(() => userStore.userInfo?.id || 0);
|
||||
/** 用户名称 */
|
||||
const userName = computed(() => userStore.userInfo?.name || '用户');
|
||||
/** 预计时长 */
|
||||
/** 作业 ID */
|
||||
const homeworkId = computed(() => Number(route.query.homeworkId || 0));
|
||||
/** 课程房间ID */
|
||||
const courseRoomId = computed(() => `n_${homeworkId.value}`);
|
||||
|
||||
/** 课堂状态:not_started表示未开始 / in_class表示上课中 / finished表示已结束 */
|
||||
const classStatus = ref<'finished' | 'in_class' | 'not_started'>('not_started');
|
||||
/** 用户的短id */
|
||||
const shortUid = ref<number>(0);
|
||||
/** 房间号 */
|
||||
const roomId = ref<string>('');
|
||||
/** 老师短 UID */
|
||||
const teacherUid = ref<number>(0);
|
||||
/** 主讲短 UID */
|
||||
const speakerUid = ref<number | string | null>(null);
|
||||
/** 投屏用户 ID */
|
||||
const screenShareUid = ref<number | string | null>(null);
|
||||
|
||||
const meeting = useAgoraMeeting(courseRoomId, userName, shortUid, {
|
||||
onTokenWillExpire: () => {
|
||||
Toast.warning('Token 即将过期,正在自动刷新...');
|
||||
},
|
||||
onTokenExpired: () => {
|
||||
Toast.error('Token 已过期,请重新加入会议');
|
||||
},
|
||||
onConnectionStateChange: (state: string) => {
|
||||
if (state === 'DISCONNECTED') {
|
||||
Toast.warning('网络连接已断开');
|
||||
} else if (state === 'RECONNECTING') {
|
||||
Toast.info('正在重新连接...');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
Toast.error(error.message || '操作失败');
|
||||
},
|
||||
onLeft: () => {
|
||||
Toast.success('已离开会议');
|
||||
},
|
||||
});
|
||||
|
||||
const socket = useWebSocket();
|
||||
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
|
||||
const streamType = useStreamType(meeting.client);
|
||||
|
||||
const { isMuted, isVideoOff, muteLoading, videoLoading, leaveLoading, networkQuality, selectedUserUid, toggleMute, toggleVideo, leaveChannel, cleanup } =
|
||||
meeting;
|
||||
|
||||
/** 网络质量样式类名 */
|
||||
const networkQualityClass = computed(() => networkQuality.value);
|
||||
|
||||
/** 网络状态文本描述 */
|
||||
const networkStatusText = computed(() => {
|
||||
const qualityMap: Record<string, string> = {
|
||||
good: '网络良好',
|
||||
fair: '网络一般',
|
||||
poor: '网络较差',
|
||||
};
|
||||
return qualityMap[networkQuality.value] || '网络未知';
|
||||
});
|
||||
|
||||
/** 主讲 UID(投屏优先,其次主讲) */
|
||||
const primaryUid = computed<number | string | null>(() => {
|
||||
const candidate = screenShareUid.value ?? speakerUid.value ?? (teacherUid.value || null);
|
||||
const local = meeting.localUser.uid;
|
||||
if (candidate && local && candidate === local && teacherUid.value) {
|
||||
return teacherUid.value;
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
|
||||
/** 主讲名称 */
|
||||
const primaryName = computed(() => {
|
||||
const uid = primaryUid.value;
|
||||
if (!uid) {
|
||||
return '主讲';
|
||||
}
|
||||
const u = meeting.remoteUsers.value.find((it: IAgoraRTCRemoteUser) => it.uid === uid) as (IAgoraRTCRemoteUser & { userName?: string }) | undefined;
|
||||
return u?.userName || (uid === meeting.localUser.uid ? userName.value : `用户 ${uid}`);
|
||||
});
|
||||
|
||||
/** 是否为投屏主画面 */
|
||||
const isPrimaryScreenSharing = computed(() => Boolean(primaryUid.value && screenShareUid.value === primaryUid.value));
|
||||
|
||||
/** 舞台容器 DOM */
|
||||
const stageRef = ref<HTMLDivElement | null>(null);
|
||||
/** 小窗容器 DOM */
|
||||
const selfWindowRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
/** 小窗位置 X(单位:px) */
|
||||
const selfX = ref<number>(0);
|
||||
/** 小窗位置 Y(单位:px) */
|
||||
const selfY = ref<number>(0);
|
||||
/** 是否已经人为拖动过 */
|
||||
const hasMoved = ref<boolean>(false);
|
||||
/** 是否正在拖动 */
|
||||
const isDragging = ref<boolean>(false);
|
||||
/** 拖动偏移 X */
|
||||
const dragOffsetX = ref<number>(0);
|
||||
/** 拖动偏移 Y */
|
||||
const dragOffsetY = ref<number>(0);
|
||||
|
||||
/** 小窗样式 */
|
||||
const selfWindowStyle = computed(() => ({
|
||||
transform: `translate3d(${selfX.value}px, ${selfY.value}px, 0)`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 计算并设置小窗默认位置(右下角)
|
||||
*/
|
||||
function initSelfWindowPosition() {
|
||||
if (hasMoved.value) {
|
||||
return;
|
||||
}
|
||||
const stageEl = stageRef.value;
|
||||
const selfEl = selfWindowRef.value;
|
||||
if (!stageEl || !selfEl) {
|
||||
return;
|
||||
}
|
||||
const stageRect = stageEl.getBoundingClientRect();
|
||||
const selfRect = selfEl.getBoundingClientRect();
|
||||
const padding = 16;
|
||||
selfX.value = Math.max(0, Math.floor(stageRect.width - selfRect.width - padding));
|
||||
selfY.value = Math.max(0, Math.floor(stageRect.height - selfRect.height - padding));
|
||||
}
|
||||
|
||||
/**
|
||||
* 小窗按下开始拖动
|
||||
* @param e - 指针事件
|
||||
*/
|
||||
function handleSelfPointerDown(e: PointerEvent) {
|
||||
const stageEl = stageRef.value;
|
||||
const selfEl = selfWindowRef.value;
|
||||
if (!stageEl || !selfEl) {
|
||||
return;
|
||||
}
|
||||
hasMoved.value = true;
|
||||
isDragging.value = true;
|
||||
try {
|
||||
selfEl.setPointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
const rect = selfEl.getBoundingClientRect();
|
||||
dragOffsetX.value = e.clientX - rect.left;
|
||||
dragOffsetY.value = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小窗拖动移动
|
||||
* @param e - 指针事件
|
||||
*/
|
||||
function handleSelfPointerMove(e: PointerEvent) {
|
||||
if (!isDragging.value) {
|
||||
return;
|
||||
}
|
||||
const stageEl = stageRef.value;
|
||||
const selfEl = selfWindowRef.value;
|
||||
if (!stageEl || !selfEl) {
|
||||
return;
|
||||
}
|
||||
const stageRect = stageEl.getBoundingClientRect();
|
||||
const selfRect = selfEl.getBoundingClientRect();
|
||||
const nextX = e.clientX - stageRect.left - dragOffsetX.value;
|
||||
const nextY = e.clientY - stageRect.top - dragOffsetY.value;
|
||||
const maxX = Math.max(0, stageRect.width - selfRect.width);
|
||||
const maxY = Math.max(0, stageRect.height - selfRect.height);
|
||||
selfX.value = Math.max(0, Math.min(Math.floor(nextX), Math.floor(maxX)));
|
||||
selfY.value = Math.max(0, Math.min(Math.floor(nextY), Math.floor(maxY)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 小窗抬起结束拖动
|
||||
* @param e - 指针事件
|
||||
*/
|
||||
function handleSelfPointerUp(e: PointerEvent) {
|
||||
if (!isDragging.value) {
|
||||
return;
|
||||
}
|
||||
isDragging.value = false;
|
||||
const selfEl = selfWindowRef.value;
|
||||
if (!selfEl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
selfEl.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保主讲视频能播放到当前主讲容器
|
||||
* @param uid - 主讲 UID
|
||||
*/
|
||||
async function ensurePrimaryVideoPlaying(uid: number | string | null) {
|
||||
if (!uid) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const el = document.getElementById(`player-${uid}`);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const client = meeting.client.value;
|
||||
const user = client?.remoteUsers?.find((u: IAgoraRTCRemoteUser) => u.uid === uid);
|
||||
if (user?.videoTrack) {
|
||||
user.videoTrack.play(el);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => primaryUid.value,
|
||||
(uid) => {
|
||||
ensurePrimaryVideoPlaying(uid);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => meeting.remoteUsers.value.length,
|
||||
() => {
|
||||
ensurePrimaryVideoPlaying(primaryUid.value);
|
||||
}
|
||||
);
|
||||
|
||||
const isLeaving = ref<boolean>(false);
|
||||
type MeetingWsPacket = Parameters<ServerToClientEvents['message']>[0];
|
||||
|
||||
/**
|
||||
* 离开会议
|
||||
*/
|
||||
async function handleLeave(): Promise<void> {
|
||||
if (isLeaving.value) {
|
||||
return;
|
||||
}
|
||||
isLeaving.value = true;
|
||||
try {
|
||||
socket.off('message', onSocketMessage);
|
||||
try {
|
||||
meetingSocket.leaveRoom(socket);
|
||||
} catch {}
|
||||
destroyMeetingSocket();
|
||||
await cleanup();
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('离开会议失败:', error);
|
||||
} finally {
|
||||
isLeaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 消息处理
|
||||
* @param packet - 消息包
|
||||
*/
|
||||
function onSocketMessage(packet: MeetingWsPacket) {
|
||||
console.log('[会议室] 收到控制消息:', packet);
|
||||
switch (packet.type) {
|
||||
case 'sev_join_room': {
|
||||
const data = packet.data;
|
||||
if (data?.roomId && !roomId.value) {
|
||||
roomId.value = data.roomId;
|
||||
}
|
||||
if (data?.classStatus) {
|
||||
classStatus.value = data.classStatus;
|
||||
}
|
||||
if (typeof data?.teacherUid === 'number') {
|
||||
teacherUid.value = data.teacherUid;
|
||||
}
|
||||
if (typeof data?.speakerUid === 'number') {
|
||||
speakerUid.value = data.speakerUid;
|
||||
selectedUserUid.value = data.speakerUid;
|
||||
streamType.setMainVideo(data.speakerUid);
|
||||
}
|
||||
if (data?.screenShareUid) {
|
||||
screenShareUid.value = data.screenShareUid;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sev_mute_audio': {
|
||||
meeting.forceMuteAudio();
|
||||
Toast.warning('您已被创建者禁麦');
|
||||
break;
|
||||
}
|
||||
case 'sev_unmute_audio': {
|
||||
meeting.forceUnmuteAudio();
|
||||
Toast.success('您已被解除禁麦');
|
||||
break;
|
||||
}
|
||||
case 'sev_mute_video': {
|
||||
meeting.forceMuteVideo();
|
||||
Toast.warning('您已被创建者禁视频');
|
||||
break;
|
||||
}
|
||||
case 'sev_unmute_video': {
|
||||
meeting.forceUnmuteVideo();
|
||||
Toast.success('您已被解除禁视频');
|
||||
break;
|
||||
}
|
||||
case 'sev_set_main_video': {
|
||||
const targetUid = packet.data.targetUid;
|
||||
speakerUid.value = targetUid;
|
||||
selectedUserUid.value = targetUid;
|
||||
streamType.setMainVideo(targetUid);
|
||||
Toast.info?.('已切换主屏');
|
||||
break;
|
||||
}
|
||||
case 'sev_start_screen_share': {
|
||||
const targetUid = packet.data?.targetUid;
|
||||
if (typeof targetUid === 'number') {
|
||||
screenShareUid.value = targetUid;
|
||||
Toast.info?.(`用户 ${targetUid} 开始投屏`);
|
||||
setTimeout(() => {
|
||||
ensurePrimaryVideoPlaying(primaryUid.value);
|
||||
}, 300);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sev_stop_screen_share': {
|
||||
const targetUid = packet.data?.targetUid;
|
||||
if (typeof targetUid === 'number') {
|
||||
if (screenShareUid.value === targetUid) {
|
||||
screenShareUid.value = null;
|
||||
}
|
||||
Toast.info?.(`用户 ${targetUid} 停止投屏`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sev_class_started': {
|
||||
classStatus.value = 'in_class';
|
||||
Toast.success('老师已上课,准备加入音视频');
|
||||
meetingSocket
|
||||
.joinRoom(socket)
|
||||
.then(async (roomData: OnJoinRoomData) => {
|
||||
roomId.value = roomData.roomId;
|
||||
shortUid.value = roomData.shortUid;
|
||||
teacherUid.value = roomData.teacherUid;
|
||||
speakerUid.value = roomData.speakerUid;
|
||||
selectedUserUid.value = roomData.speakerUid;
|
||||
streamType.setMainVideo(roomData.speakerUid);
|
||||
if (roomData.screenShareUid) {
|
||||
screenShareUid.value = roomData.screenShareUid;
|
||||
}
|
||||
await meeting.initAndJoin(roomData);
|
||||
initSelfWindowPosition();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (e instanceof Error && e.message) {
|
||||
Toast.error(e.message);
|
||||
return;
|
||||
}
|
||||
Toast.error('加入失败');
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'sev_class_ended': {
|
||||
classStatus.value = 'finished';
|
||||
screenShareUid.value = null;
|
||||
leaveChannel().catch(() => {});
|
||||
Toast.info?.('老师已下课,等待重新上课');
|
||||
break;
|
||||
}
|
||||
case 'sev_kick_user': {
|
||||
Toast.error('您已被创建者移出会议');
|
||||
handleLeave();
|
||||
break;
|
||||
}
|
||||
case 'sev_device_conflict': {
|
||||
playPromptTone();
|
||||
Toast.warning('您已在其他设备进入课程');
|
||||
handleLeave();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('未处理的消息====', packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('message', onSocketMessage);
|
||||
|
||||
/**
|
||||
* 页面挂载后加入房间
|
||||
*/
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const roomData = await meetingSocket.joinRoom(socket);
|
||||
roomId.value = roomData.roomId;
|
||||
shortUid.value = roomData.shortUid;
|
||||
teacherUid.value = roomData.teacherUid;
|
||||
speakerUid.value = roomData.speakerUid;
|
||||
selectedUserUid.value = roomData.speakerUid;
|
||||
streamType.setMainVideo(roomData.speakerUid);
|
||||
classStatus.value = roomData?.classStatus || 'not_started';
|
||||
if (roomData.screenShareUid) {
|
||||
screenShareUid.value = roomData.screenShareUid;
|
||||
}
|
||||
if (classStatus.value === 'in_class') {
|
||||
await meeting.initAndJoin(roomData);
|
||||
initSelfWindowPosition();
|
||||
} else {
|
||||
Toast.info?.('等待老师上课...');
|
||||
}
|
||||
setTimeout(() => {
|
||||
initSelfWindowPosition();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('加入会议失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '加入会议失败';
|
||||
Toast.error(errorMessage);
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 页面卸载清理
|
||||
*/
|
||||
onUnmounted(async () => {
|
||||
if (isLeaving.value) {
|
||||
return;
|
||||
}
|
||||
socket.off('message', onSocketMessage);
|
||||
await cleanup();
|
||||
try {
|
||||
meetingSocket.leaveRoom(socket);
|
||||
} catch {}
|
||||
destroyMeetingSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
|
||||
.page-content {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.room-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.room-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.room-id {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.network-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
|
||||
&.good {
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
&.fair {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
&.poor {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.network-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.network-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.primary-video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.primary-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-label {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
background: rgba(16, 185, 129, 0.8);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.self-window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 240px;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.self-label {
|
||||
top: 8px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.local-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 14px 16px 18px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 14px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(102, 126, 234, 0.85);
|
||||
border-color: rgba(102, 126, 234, 0.55);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: rgba(239, 68, 68, 0.85);
|
||||
border-color: rgba(239, 68, 68, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.control-btn :deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.player-wrapper :deep(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@ -22,14 +22,29 @@ import { useRoute } from 'vue-router';
|
||||
import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types';
|
||||
import { TokenManager } from './useTokenManager';
|
||||
|
||||
/**
|
||||
* 声网会议 Hook 配置
|
||||
*/
|
||||
export interface AgoraMeetingOptions {
|
||||
/** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */
|
||||
disableLocalTracks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 声网会议核心 Hook
|
||||
* @param {Ref<string>} courseRoomId - 课程房间 ID
|
||||
* @param {Ref<string>} userName - 用户名称
|
||||
* @param {Ref<number>} shortUidRef - 短 UID 引用(由外部维护)
|
||||
* @param {MeetingEventCallbacks} callbacks - 事件回调函数
|
||||
* @param {AgoraMeetingOptions} options - 配置项
|
||||
*/
|
||||
export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>, shortUidRef: Ref<number>, callbacks?: MeetingEventCallbacks) {
|
||||
export function useAgoraMeeting(
|
||||
courseRoomId: Ref<string>,
|
||||
userName: Ref<string>,
|
||||
shortUidRef: Ref<number>,
|
||||
callbacks?: MeetingEventCallbacks,
|
||||
options?: AgoraMeetingOptions
|
||||
) {
|
||||
/** 设置声网日志等级 */
|
||||
AgoraRTC.setLogLevel(2);
|
||||
// 从路由获取设备 ID
|
||||
@ -81,6 +96,8 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
|
||||
|
||||
/** 是否正在共享屏幕 */
|
||||
const isScreenSharing = ref(false);
|
||||
/** 是否禁用本地音视频轨道 */
|
||||
const disableLocalTracks = Boolean(options?.disableLocalTracks);
|
||||
|
||||
// 同步本地用户名称
|
||||
watchEffect(() => {
|
||||
@ -355,40 +372,45 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
|
||||
try {
|
||||
FullLoading.show('正在加入会议...');
|
||||
|
||||
// 初始化本地麦克风
|
||||
try {
|
||||
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({
|
||||
microphoneId: microphoneDeviceId.value || undefined,
|
||||
encoderConfig: { sampleRate: 16000, bitrate: 16 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('麦克风初始化失败:', error);
|
||||
const err = error as { code?: string; name?: string; message?: string };
|
||||
if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) {
|
||||
throw new Error('麦克风被占用,请关闭其他使用麦克风的程序后重试', { cause: error });
|
||||
} else {
|
||||
throw new Error('麦克风初始化失败,请检查设备连接', { cause: error });
|
||||
if (!disableLocalTracks) {
|
||||
// 初始化本地麦克风
|
||||
try {
|
||||
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({
|
||||
microphoneId: microphoneDeviceId.value || undefined,
|
||||
encoderConfig: { sampleRate: 16000, bitrate: 16 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('麦克风初始化失败:', error);
|
||||
const err = error as { code?: string; name?: string; message?: string };
|
||||
if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) {
|
||||
throw new Error('麦克风被占用,请关闭其他使用麦克风的程序后重试', { cause: error });
|
||||
} else {
|
||||
throw new Error('麦克风初始化失败,请检查设备连接', { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化本地摄像头
|
||||
try {
|
||||
localVideoTrack = await AgoraRTC.createCameraVideoTrack({
|
||||
cameraId: cameraDeviceId.value || undefined,
|
||||
encoderConfig: { width: 640, height: 480, frameRate: 15, bitrateMin: 300, bitrateMax: 600 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('摄像头初始化失败:', error);
|
||||
const err = error as { code?: string; name?: string; message?: string };
|
||||
if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) {
|
||||
throw new Error('摄像头被占用,请关闭其他使用摄像头的程序后重试', { cause: error });
|
||||
} else {
|
||||
throw new Error('摄像头初始化失败,请检查设备连接', { cause: error });
|
||||
// 初始化本地摄像头
|
||||
try {
|
||||
localVideoTrack = await AgoraRTC.createCameraVideoTrack({
|
||||
cameraId: cameraDeviceId.value || undefined,
|
||||
encoderConfig: { width: 640, height: 480, frameRate: 15, bitrateMin: 300, bitrateMax: 600 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('摄像头初始化失败:', error);
|
||||
const err = error as { code?: string; name?: string; message?: string };
|
||||
if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) {
|
||||
throw new Error('摄像头被占用,请关闭其他使用摄像头的程序后重试', { cause: error });
|
||||
} else {
|
||||
throw new Error('摄像头初始化失败,请检查设备连接', { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localUser.videoTrack = localVideoTrack;
|
||||
localUser.audioTrack = localAudioTrack;
|
||||
localUser.videoTrack = localVideoTrack;
|
||||
localUser.audioTrack = localAudioTrack;
|
||||
} else {
|
||||
localUser.videoTrack = null;
|
||||
localUser.audioTrack = null;
|
||||
}
|
||||
|
||||
// join 返回声网实际分配的 uid(传 0 时声网会自动分配)
|
||||
// 更新本地用户 UID
|
||||
@ -402,43 +424,45 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
|
||||
tokenManagerRef.value.setCurrentToken(roomData.tokenInfo);
|
||||
}
|
||||
|
||||
// ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个)
|
||||
const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = [];
|
||||
if (!disableLocalTracks) {
|
||||
// ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个)
|
||||
const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = [];
|
||||
|
||||
// 如果没有被禁麦,发布音频
|
||||
if (!roomData?.isAudioMuted && localAudioTrack) {
|
||||
tracksToPublish.push(localAudioTrack);
|
||||
}
|
||||
// 如果没有被禁麦,发布音频
|
||||
if (!roomData?.isAudioMuted && localAudioTrack) {
|
||||
tracksToPublish.push(localAudioTrack);
|
||||
}
|
||||
|
||||
// 如果没有被禁视频,发布视频
|
||||
if (!roomData?.isVideoMuted && localVideoTrack) {
|
||||
tracksToPublish.push(localVideoTrack);
|
||||
}
|
||||
// 如果没有被禁视频,发布视频
|
||||
if (!roomData?.isVideoMuted && localVideoTrack) {
|
||||
tracksToPublish.push(localVideoTrack);
|
||||
}
|
||||
|
||||
console.log('[Agora] 准备发布的轨道:', {
|
||||
audio: !roomData?.isAudioMuted,
|
||||
video: !roomData?.isVideoMuted,
|
||||
tracksCount: tracksToPublish.length,
|
||||
});
|
||||
console.log('[Agora] 准备发布的轨道:', {
|
||||
audio: !roomData?.isAudioMuted,
|
||||
video: !roomData?.isVideoMuted,
|
||||
tracksCount: tracksToPublish.length,
|
||||
});
|
||||
|
||||
if (tracksToPublish.length > 0) {
|
||||
await client.value?.publish(tracksToPublish);
|
||||
console.log('[Agora] 轨道已发布,准备播放视频');
|
||||
} else {
|
||||
console.log('[Agora] 用户被禁麦/禁视频,暂不发布任何轨道');
|
||||
}
|
||||
|
||||
// 加入并发布成功后再播放本地视频(确保 player-local 元素已存在)
|
||||
if (localVideoTrack) {
|
||||
// 使用 nextTick 等待 Vue 响应式更新完成
|
||||
await nextTick();
|
||||
const el = document.getElementById('player-local');
|
||||
console.log('[Agora] 查找 player-local 元素:', el ? '找到' : '未找到');
|
||||
if (el && !el.querySelector('video')) {
|
||||
localVideoTrack?.play(el);
|
||||
console.log('[Agora] 本地视频已播放');
|
||||
if (tracksToPublish.length > 0) {
|
||||
await client.value?.publish(tracksToPublish);
|
||||
console.log('[Agora] 轨道已发布,准备播放视频');
|
||||
} else {
|
||||
console.warn('[Agora] 未找到 player-local 元素或已有视频', el ? '元素存在' : '元素不存在');
|
||||
console.log('[Agora] 用户被禁麦/禁视频,暂不发布任何轨道');
|
||||
}
|
||||
|
||||
// 加入并发布成功后再播放本地视频(确保 player-local 元素已存在)
|
||||
if (localVideoTrack) {
|
||||
// 使用 nextTick 等待 Vue 响应式更新完成
|
||||
await nextTick();
|
||||
const el = document.getElementById('player-local');
|
||||
console.log('[Agora] 查找 player-local 元素:', el ? '找到' : '未找到');
|
||||
if (el && !el.querySelector('video')) {
|
||||
localVideoTrack?.play(el);
|
||||
console.log('[Agora] 本地视频已播放');
|
||||
} else {
|
||||
console.warn('[Agora] 未找到 player-local 元素或已有视频', el ? '元素存在' : '元素不存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -606,9 +630,9 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
|
||||
|
||||
/**
|
||||
* 清理 Agora 客户端资源
|
||||
* @param options.toastSuccess - 是否显示成功提示
|
||||
* @param teardownOptions.toastSuccess - 是否显示成功提示
|
||||
*/
|
||||
async function teardownAgoraClient(options: { toastSuccess?: boolean }): Promise<void> {
|
||||
async function teardownAgoraClient(teardownOptions: { toastSuccess?: boolean }): Promise<void> {
|
||||
cleanupTokenManager();
|
||||
const currentClient = client.value;
|
||||
client.value = null;
|
||||
@ -647,7 +671,7 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
|
||||
localUser.videoTrack = null;
|
||||
localUser.audioTrack = null;
|
||||
|
||||
if (options.toastSuccess) {
|
||||
if (teardownOptions.toastSuccess) {
|
||||
callbacks?.onLeft?.();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user