feat(meeting): 新增学生端和监控端会议页面

- 在路由中新增学生端和监控端页面路由
- 修改加入会议逻辑,根据角色跳转到不同页面
- 新增监控端页面,支持多学生画面宫格布局
- 新增学生端页面,支持主画面显示和本地视频小窗拖拽
- 扩展 useAgoraMeeting hook 支持禁用本地音视频轨道
- 更新悬浮列表窗口的会议和字幕创建函数参数
- 添加课程和评讲信息的存储常量
This commit is contained in:
2026-03-13 13:08:30 +08:00
parent 745dc0e8fa
commit bd31d23bda
10 changed files with 1371 additions and 74 deletions

View File

@ -263,7 +263,7 @@
async function openMeeting() { async function openMeeting() {
playPromptTone(); playPromptTone();
await createMeetingWindow(2, currTutorship.value || null); await createMeetingWindow(2, currTutorship.value?.id);
} }
/** /**
@ -271,7 +271,7 @@
*/ */
async function openSubtitle() { async function openSubtitle() {
playPromptTone(); playPromptTone();
await createSubtitleWindow(currTutorship.value || null); await createSubtitleWindow();
} }
/** /**

View File

@ -1,11 +1,32 @@
import { isObject } from '@/utils/verify'; import { isObject } from '@/utils/verify';
import { STORAGE_COMMANDS, tauriSessionStorage } from '@/utils/tauri/tauri-session'; 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 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 { 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 { getCurrentWindow } from '@tauri-apps/api/window';
import { ref } from 'vue'; 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 * useTauriSessionStorage
*/ */
@ -66,7 +87,7 @@ export function useTauriSessionStorage() {
}); });
tauriSessionStorage.get(tauri_store_page_curr_tutorship).then((value) => { tauriSessionStorage.get(tauri_store_page_curr_tutorship).then((value) => {
const obj = isObject(value) ? value : null; const obj = isObject(value) ? value : null;
if (obj && 'id') { if (obj && 'id' in obj) {
currTutorship.value = obj as unknown as BriTutorship; currTutorship.value = obj as unknown as BriTutorship;
} }
}); });

View File

@ -54,8 +54,20 @@ export function getLayoutRoutesList(): RouteRecordRaw[] {
{ {
path: 'meeting-room', path: 'meeting-room',
name: 'meeting-room', name: 'meeting-room',
component: () => import('@/views/meeting/meeting-room/meeting-room.vue'), component: () => import('@/views/meeting/meeting-room/meeting-room-teacher.vue'),
meta: { title: '会议 - 会议房间', affix: false, noCache: true }, 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 },
}, },
], ],
}, },

View File

@ -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_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'; export const tauri_floating_list_page_action = 'tauri_floating_list_page_action';

View File

@ -8,13 +8,55 @@ import { ElMessage as Toast } from 'element-plus';
*/ */
export async function createFloatListWindow(pageType: 'interaction' | 'tutorship') { export async function createFloatListWindow(pageType: 'interaction' | 'tutorship') {
try { try {
await invoke('create_float_list_window', { pageType }); await invoke('create_float_list_window', { page_type: pageType });
} catch (error: any) { } catch (error: any) {
console.error('创建悬浮球窗口时出错:', error); console.error('创建悬浮球窗口时出错:', error);
Toast.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);
}
/** /**
* 调整窗口位置和大小 * 调整窗口位置和大小
*/ */

View File

@ -428,8 +428,9 @@
// 触发表单验证 // 触发表单验证
console.log('即将跳转到 meeting-room 页面'); console.log('即将跳转到 meeting-room 页面');
const targetName = role.value === 'student' ? 'meeting-room-student' : 'meeting-room';
router.push({ router.push({
name: 'meeting-room', name: targetName,
query: { query: {
homeworkId: homeworkId.value, homeworkId: homeworkId.value,
name: courseRoomName.value, name: courseRoomName.value,

View 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>

View 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>

View File

@ -22,14 +22,29 @@ import { useRoute } from 'vue-router';
import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types'; import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types';
import { TokenManager } from './useTokenManager'; import { TokenManager } from './useTokenManager';
/**
* 声网会议 Hook 配置
*/
export interface AgoraMeetingOptions {
/** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */
disableLocalTracks?: boolean;
}
/** /**
* 声网会议核心 Hook * 声网会议核心 Hook
* @param {Ref<string>} courseRoomId - 课程房间 ID * @param {Ref<string>} courseRoomId - 课程房间 ID
* @param {Ref<string>} userName - 用户名称 * @param {Ref<string>} userName - 用户名称
* @param {Ref<number>} shortUidRef - 短 UID 引用(由外部维护) * @param {Ref<number>} shortUidRef - 短 UID 引用(由外部维护)
* @param {MeetingEventCallbacks} callbacks - 事件回调函数 * @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); AgoraRTC.setLogLevel(2);
// 从路由获取设备 ID // 从路由获取设备 ID
@ -81,6 +96,8 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
/** 是否正在共享屏幕 */ /** 是否正在共享屏幕 */
const isScreenSharing = ref(false); const isScreenSharing = ref(false);
/** 是否禁用本地音视频轨道 */
const disableLocalTracks = Boolean(options?.disableLocalTracks);
// 同步本地用户名称 // 同步本地用户名称
watchEffect(() => { watchEffect(() => {
@ -355,40 +372,45 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
try { try {
FullLoading.show('正在加入会议...'); FullLoading.show('正在加入会议...');
// 初始化本地麦克风 if (!disableLocalTracks) {
try { // 初始化本地麦克风
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({ try {
microphoneId: microphoneDeviceId.value || undefined, localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({
encoderConfig: { sampleRate: 16000, bitrate: 16 }, microphoneId: microphoneDeviceId.value || undefined,
}); encoderConfig: { sampleRate: 16000, bitrate: 16 },
} catch (error) { });
console.error('麦克风初始化失败:', error); } catch (error) {
const err = error as { code?: string; name?: string; message?: string }; console.error('麦克风初始化失败:', error);
if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) { const err = error as { code?: string; name?: string; message?: string };
throw new Error('麦克风被占用,请关闭其他使用麦克风的程序后重试', { cause: error }); if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) {
} else { throw new Error('麦克风被占用,请关闭其他使用麦克风的程序后重试', { cause: error });
throw new Error('麦克风初始化失败,请检查设备连接', { cause: error }); } else {
throw new Error('麦克风初始化失败,请检查设备连接', { cause: error });
}
} }
}
// 初始化本地摄像头 // 初始化本地摄像头
try { try {
localVideoTrack = await AgoraRTC.createCameraVideoTrack({ localVideoTrack = await AgoraRTC.createCameraVideoTrack({
cameraId: cameraDeviceId.value || undefined, cameraId: cameraDeviceId.value || undefined,
encoderConfig: { width: 640, height: 480, frameRate: 15, bitrateMin: 300, bitrateMax: 600 }, encoderConfig: { width: 640, height: 480, frameRate: 15, bitrateMin: 300, bitrateMax: 600 },
}); });
} catch (error) { } catch (error) {
console.error('摄像头初始化失败:', error); console.error('摄像头初始化失败:', error);
const err = error as { code?: string; name?: string; message?: string }; const err = error as { code?: string; name?: string; message?: string };
if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) { if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) {
throw new Error('摄像头被占用,请关闭其他使用摄像头的程序后重试', { cause: error }); throw new Error('摄像头被占用,请关闭其他使用摄像头的程序后重试', { cause: error });
} else { } else {
throw new Error('摄像头初始化失败,请检查设备连接', { cause: error }); throw new Error('摄像头初始化失败,请检查设备连接', { cause: error });
}
} }
}
localUser.videoTrack = localVideoTrack; localUser.videoTrack = localVideoTrack;
localUser.audioTrack = localAudioTrack; localUser.audioTrack = localAudioTrack;
} else {
localUser.videoTrack = null;
localUser.audioTrack = null;
}
// join 返回声网实际分配的 uid传 0 时声网会自动分配) // join 返回声网实际分配的 uid传 0 时声网会自动分配)
// 更新本地用户 UID // 更新本地用户 UID
@ -402,43 +424,45 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
tokenManagerRef.value.setCurrentToken(roomData.tokenInfo); tokenManagerRef.value.setCurrentToken(roomData.tokenInfo);
} }
// ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个) if (!disableLocalTracks) {
const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = []; // ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个)
const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = [];
// 如果没有被禁麦,发布音频 // 如果没有被禁麦,发布音频
if (!roomData?.isAudioMuted && localAudioTrack) { if (!roomData?.isAudioMuted && localAudioTrack) {
tracksToPublish.push(localAudioTrack); tracksToPublish.push(localAudioTrack);
} }
// 如果没有被禁视频,发布视频 // 如果没有被禁视频,发布视频
if (!roomData?.isVideoMuted && localVideoTrack) { if (!roomData?.isVideoMuted && localVideoTrack) {
tracksToPublish.push(localVideoTrack); tracksToPublish.push(localVideoTrack);
} }
console.log('[Agora] 准备发布的轨道:', { console.log('[Agora] 准备发布的轨道:', {
audio: !roomData?.isAudioMuted, audio: !roomData?.isAudioMuted,
video: !roomData?.isVideoMuted, video: !roomData?.isVideoMuted,
tracksCount: tracksToPublish.length, tracksCount: tracksToPublish.length,
}); });
if (tracksToPublish.length > 0) { if (tracksToPublish.length > 0) {
await client.value?.publish(tracksToPublish); await client.value?.publish(tracksToPublish);
console.log('[Agora] 轨道已发布,准备播放视频'); 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] 本地视频已播放');
} else { } 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 客户端资源 * 清理 Agora 客户端资源
* @param options.toastSuccess - 是否显示成功提示 * @param teardownOptions.toastSuccess - 是否显示成功提示
*/ */
async function teardownAgoraClient(options: { toastSuccess?: boolean }): Promise<void> { async function teardownAgoraClient(teardownOptions: { toastSuccess?: boolean }): Promise<void> {
cleanupTokenManager(); cleanupTokenManager();
const currentClient = client.value; const currentClient = client.value;
client.value = null; client.value = null;
@ -647,7 +671,7 @@ export function useAgoraMeeting(courseRoomId: Ref<string>, userName: Ref<string>
localUser.videoTrack = null; localUser.videoTrack = null;
localUser.audioTrack = null; localUser.audioTrack = null;
if (options.toastSuccess) { if (teardownOptions.toastSuccess) {
callbacks?.onLeft?.(); callbacks?.onLeft?.();
} }
} }