You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1028 lines
30 KiB
1028 lines
30 KiB
<template> |
|
<div class="meeting-container"> |
|
<!-- 顶部导航栏 --> |
|
<header class="top-bar"> |
|
<div class="logo"> |
|
<img src="@/assets/logo/logo.png" /> |
|
<span>信联</span> |
|
</div> |
|
<div class="title">{{ meetingTitle }}</div> |
|
<div class="window-controls"> |
|
<i class="el-icon-minus" @click="minimizeWindow"></i> |
|
<i class="el-icon-close" @click="closeWindow"></i> |
|
</div> |
|
</header> |
|
|
|
<!-- 顶部状态信息条 --> |
|
<div class="status-bar"> |
|
<span class="status-item">直播间口令:{{ meetingCode }}</span> |
|
<span class="divider">|</span> |
|
<span class="status-item">{{ participantCount }}人观看</span> |
|
<span class="divider">|</span> |
|
<span class="status-item">{{ timerDisplay }}</span> |
|
<span class="divider">|</span> |
|
<span class="status-item" :style="{ color: networkQuality.color }"> |
|
<i class="el-icon-caret-top" style="transform: rotate(-90deg); margin-right: 4px;"></i> |
|
</span> |
|
</div> |
|
|
|
<!-- 主画面区域:左右双分屏 --> |
|
<main class="main-content"> |
|
<!-- 左侧画面 --> |
|
<div class="video-stage left-stage" ref="videoContainerLeft"> |
|
<!-- 采集卡/主视频画面 --> |
|
<video |
|
ref="shareVideo" |
|
class="share-preview" |
|
autoplay |
|
playsinline |
|
muted |
|
v-if="isSharing" |
|
></video> |
|
</div> |
|
|
|
<!-- 右侧画面 --> |
|
<div class="video-stage right-stage" ref="videoContainerRight"> |
|
<!-- 本地摄像头画面 --> |
|
<video |
|
ref="localVideo" |
|
class="local-camera" |
|
autoplay |
|
muted |
|
playsinline |
|
v-if="camOn" |
|
/> |
|
</div> |
|
</main> |
|
|
|
<!-- 底部工具栏 --> |
|
<footer class="bottom-bar"> |
|
<div class="tools-section"> |
|
<div class="tool-item" @click="toggleMic"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-microphone"></i> |
|
<span v-if="!micOn" class="slash-line"></span> |
|
</div> |
|
<span>麦克风</span> |
|
</div> |
|
<div class="tool-item" @click="toggleSpeaker"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-phone-outline"></i> |
|
<span v-if="!speakerOn" class="slash-line"></span> |
|
</div> |
|
<span>扬声器</span> |
|
</div> |
|
<div class="tool-item" @click="toggleRecord"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-video-play" :class="{ 'is-recording': isRecording }"></i> |
|
</div> |
|
<span>{{ isRecording ? "录制中" : "录制" }}</span> |
|
</div> |
|
<div class="tool-item" @click="handleMessage"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-chat-line-round"></i> |
|
</div> |
|
<span>消息</span> |
|
</div> |
|
<div class="tool-item" @click="toggleShare"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-share"></i> |
|
</div> |
|
<span>共享</span> |
|
</div> |
|
<div class="tool-item" @click="handleInvite"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-user-solid"></i> |
|
</div> |
|
<span>邀请</span> |
|
</div> |
|
<div class="tool-item" @click="openSettingDialog"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-setting"></i> |
|
</div> |
|
<span>设置</span> |
|
</div> |
|
<div class="divider-line"></div> |
|
<div class="tool-item end-btn" @click="endMeeting"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-switch-button"></i> |
|
</div> |
|
<span>结束会议</span> |
|
</div> |
|
</div> |
|
</footer> |
|
|
|
<!-- 邀请弹窗 --> |
|
<CreateGroupDialog |
|
:visible.sync="inviteDialogVisible" |
|
title="通讯录邀请" |
|
@confirm="handleInviteConfirm" |
|
@close="inviteDialogVisible = false" |
|
/> |
|
|
|
<!-- 设置弹窗 --> |
|
<el-dialog |
|
title="设置" |
|
:visible.sync="settingDialogVisible" |
|
width="31%" |
|
@open="onSettingDialogOpen" |
|
:show-close="false" |
|
> |
|
<el-tabs v-model="settingTab" tab-position="left"> |
|
<el-tab-pane label="通用" name="general"> |
|
<el-form :model="tempSetting" label-width="140px"> |
|
<el-form-item label="会诊名称"> |
|
<el-input |
|
v-model="tempSetting.meetingTitle" |
|
style="width: 240px" |
|
></el-input> |
|
</el-form-item> |
|
</el-form> |
|
</el-tab-pane> |
|
<el-tab-pane label="视频" name="video"> |
|
<el-form :model="tempSetting" label-width="140px"> |
|
<el-form-item label="摄像头/工作站"> |
|
<el-select v-model="tempSetting.cameraId" style="width: 100%"> |
|
<el-option |
|
v-for="item in videoDeviceList" |
|
:key="item.deviceId" |
|
:label="item.label" |
|
:value="item.deviceId" |
|
></el-option> |
|
</el-select> |
|
</el-form-item> |
|
<el-form-item label="绿联采集卡"> |
|
<el-select |
|
v-model="tempSetting.captureCard" |
|
style="width: 100%" |
|
@change="switchCaptureCard" |
|
> |
|
<el-option label="不使用采集卡" value=""></el-option> |
|
<el-option |
|
v-for="item in videoDeviceList" |
|
:key="item.deviceId" |
|
:label="item.label" |
|
:value="item.deviceId" |
|
></el-option> |
|
</el-select> |
|
</el-form-item> |
|
<el-form-item label="网络调控策略"> |
|
<el-select v-model="tempSetting.netStrategy" style="width: 100%"> |
|
<el-option label="弱网流畅优先" value="smooth"></el-option> |
|
<el-option label="弱网清晰优先" value="quality"></el-option> |
|
</el-select> |
|
</el-form-item> |
|
<el-form-item label="摄像头分辨率"> |
|
<el-select v-model="tempSetting.resolution" style="width: 100%"> |
|
<el-option label="240P" value="240P"></el-option> |
|
<el-option label="480P" value="480P"></el-option> |
|
<el-option label="720P" value="720P"></el-option> |
|
<el-option label="1080P" value="1080P"></el-option> |
|
</el-select> |
|
</el-form-item> |
|
<el-form-item label="采集卡分辨率"> |
|
<el-select v-model="tempSetting.captureRes" style="width: 100%"> |
|
<el-option label="1080P" value="1080P"></el-option> |
|
<el-option label="720P" value="720P"></el-option> |
|
<el-option label="480P" value="480P"></el-option> |
|
</el-select> |
|
</el-form-item> |
|
<el-form-item label="采集卡帧率"> |
|
<el-select v-model="tempSetting.captureFps" style="width: 100%"> |
|
<el-option label="30" value="30"></el-option> |
|
<el-option label="25" value="25"></el-option> |
|
<el-option label="15" value="15"></el-option> |
|
</el-select> |
|
</el-form-item> |
|
<el-form-item label="采集卡码率"> |
|
<el-input v-model="tempSetting.captureBitrate"></el-input> |
|
</el-form-item> |
|
<el-form-item label="视频镜像"> |
|
<el-radio-group v-model="tempSetting.videoMirror"> |
|
<el-radio :label="true">开启</el-radio> |
|
<el-radio :label="false">关闭</el-radio> |
|
</el-radio-group> |
|
</el-form-item> |
|
</el-form> |
|
</el-tab-pane> |
|
<el-tab-pane label="音频" name="audio"> |
|
<el-form :model="tempSetting" label-width="140px"> |
|
<el-form-item label="麦克风"> |
|
<el-select v-model="tempSetting.micId" style="width: 100%"> |
|
<el-option |
|
v-for="item in audioInList" |
|
:key="item.deviceId" |
|
:label="item.label" |
|
:value="item.deviceId" |
|
/> |
|
</el-select> |
|
</el-form-item> |
|
<el-form-item label="扬声器"> |
|
<el-select v-model="tempSetting.speakerId" style="width: 100%"> |
|
<el-option |
|
v-for="item in audioOutList" |
|
:key="item.deviceId" |
|
:label="item.label" |
|
:value="item.deviceId" |
|
/> |
|
</el-select> |
|
</el-form-item> |
|
</el-form> |
|
</el-tab-pane> |
|
<el-tab-pane label="标注" name="draw"> |
|
<el-form :model="tempSetting" label-width="140px"> |
|
<el-form-item label="画笔颜色"> |
|
<el-color-picker v-model="tempSetting.brushColor" /> |
|
</el-form-item> |
|
<el-form-item label=""> |
|
<el-checkbox v-model="tempSetting.syncColor" |
|
>同步标记颜色</el-checkbox |
|
> |
|
</el-form-item> |
|
<el-form-item label="画笔类型"> |
|
<el-radio-group v-model="tempSetting.brushType"> |
|
<el-radio label="dashed">虚线</el-radio> |
|
<el-radio label="arrow">箭头</el-radio> |
|
</el-radio-group> |
|
</el-form-item> |
|
</el-form> |
|
</el-tab-pane> |
|
<el-tab-pane label="快捷键" name="shortcut"> |
|
<el-form :model="tempSetting" label-width="140px"> |
|
<el-form-item label="截图快捷键"> |
|
<el-input v-model="tempSetting.screenshotKey"></el-input> |
|
</el-form-item> |
|
</el-form> |
|
</el-tab-pane> |
|
</el-tabs> |
|
<template slot="footer"> |
|
<el-button @click="settingDialogVisible = false">取消</el-button> |
|
<el-button type="primary" @click="saveSetting">保存并应用</el-button> |
|
</template> |
|
</el-dialog> |
|
</div> |
|
</template> |
|
|
|
<script> |
|
import CreateGroupDialog from "@/views/message/components/CreateGroupDialog"; |
|
|
|
export default { |
|
name: "MeetingRoom", |
|
components: { |
|
CreateGroupDialog, |
|
}, |
|
data() { |
|
return { |
|
// ==================== 会议基础信息 ==================== |
|
meetingTitle: "超声事业部的云教学", |
|
meetingCode: "9019100", |
|
participantCount: 1, |
|
resolution: "1080P", |
|
frameRate: 25, |
|
networkDelay: 0, |
|
networkQuality: { text: "良好", color: "#00e676" }, |
|
|
|
// ==================== 计时器 ==================== |
|
timer: 0, |
|
timerInterval: null, |
|
timerRunning: false, |
|
|
|
// ==================== 录制相关 ==================== |
|
isRecording: false, |
|
recordedChunks: [], |
|
mediaRecorder: null, |
|
recordingStartTime: null, |
|
recordingTime: "00:00:00", |
|
recordingTimer: null, |
|
|
|
// ==================== 音视频设备 ==================== |
|
inviteDialogVisible: false, |
|
settingDialogVisible: false, |
|
settingTab: "general", |
|
micOn: true, // 麦克风(默认开启) |
|
speakerOn: true, // 扬声器(默认开启,非静音) |
|
camOn: true, // 摄像头(默认开启) |
|
localStream: null, |
|
|
|
// ==================== 屏幕共享 / 采集卡 ==================== |
|
isSharing: true, |
|
shareStream: null, |
|
captureCheckTimer: null, |
|
|
|
// ==================== 设置模块 ==================== |
|
videoDeviceList: [], |
|
audioInList: [], |
|
audioOutList: [], |
|
tempSetting: {}, |
|
globalSetting: { |
|
meetingTitle: "超声事业部的云教学", |
|
cameraId: "", |
|
micId: "", |
|
speakerId: "", |
|
resolution: "480P", |
|
netStrategy: "smooth", |
|
brushColor: "#FF69B4", |
|
brushWidth: 3, |
|
screenshotKey: "Ctrl+1", |
|
captureCard: "", |
|
captureRes: "1080P", |
|
captureFps: "30", |
|
captureBitrate: "", |
|
videoMirror: false, |
|
brushType: "dashed", |
|
syncColor: true, |
|
}, |
|
}; |
|
}, |
|
computed: { |
|
// 计时器格式化显示 |
|
timerDisplay() { |
|
const h = Math.floor(this.timer / 3600) |
|
.toString() |
|
.padStart(2, "0"); |
|
const m = Math.floor((this.timer % 3600) / 60) |
|
.toString() |
|
.padStart(2, "0"); |
|
const s = (this.timer % 60).toString().padStart(2, "0"); |
|
return `${h}:${m}:${s}`; |
|
}, |
|
}, |
|
mounted() { |
|
this.initPage(); |
|
this.initSDK(); |
|
}, |
|
methods: { |
|
// ==================== 【1】初始化 ==================== |
|
initPage() { |
|
this.loadLocalSetting(); |
|
this.startTimer(); |
|
this.openCameraAndMic(); // 进入页面自动打开摄像头和麦克风 |
|
this.fetchMeetingInfo(); |
|
this.startNetworkMonitoring(); |
|
// 隐藏侧边栏、调整布局 |
|
this.$store.dispatch("app/toggleSideBarHide", true); |
|
this.$store.dispatch("settings/changeSetting", { |
|
key: "fixedHeader", |
|
value: false, |
|
}); |
|
this.$store.dispatch("settings/changeSetting", { |
|
key: "tagsView", |
|
value: false, |
|
}); |
|
}, |
|
|
|
// SDK 初始化加入房间 |
|
initSDK() { |
|
window.hirtcwebsdk.init({ |
|
serviceID: "90f4d5a954ab449e9b6ac92", |
|
serviceKey: "2c17c6393771ee3048ae30d6b380c5ec", |
|
cameraLayers: [ |
|
{ |
|
width: 320, |
|
height: 180, |
|
frameRate: 20, |
|
targetBw: 160000, |
|
layerIndex: 0, |
|
}, |
|
{ |
|
width: 640, |
|
height: 360, |
|
frameRate: 20, |
|
targetBw: 500000, |
|
layerIndex: 1, |
|
}, |
|
{ |
|
width: 1280, |
|
height: 720, |
|
frameRate: 20, |
|
targetBw: 1100000, |
|
layerIndex: 2, |
|
}, |
|
], |
|
leaveBeforeUnload: false, |
|
debug: false, |
|
forceArea: true, |
|
screenEnableAudio: true, |
|
}); |
|
|
|
const roomId = "room_" + (this.meetingCode || "default"); |
|
const uid = String(this.$store.state.user.id || Date.now()); |
|
const uName = (this.$store.state.user.name || "用户").trim(); |
|
|
|
window.hirtcwebsdk.join(roomId, uid, uName); |
|
window.hirtcwebsdk.addListener("joined", () => { |
|
this.$message.success("加入会议成功"); |
|
this.updateParticipantCount(); |
|
}); |
|
window.hirtcwebsdk.addListener( |
|
"user-joined", |
|
this.updateParticipantCount |
|
); |
|
window.hirtcwebsdk.addListener("user-left", this.updateParticipantCount); |
|
}, |
|
|
|
// ==================== 【2】设备:摄像头 + 麦克风 ==================== |
|
// 打开摄像头+麦克风并渲染(进入页面自动执行) |
|
async openCameraAndMic() { |
|
try { |
|
const stream = await navigator.mediaDevices.getUserMedia({ |
|
video: true, |
|
audio: true, |
|
}); |
|
this.localStream = stream; |
|
this.$refs.localVideo.srcObject = stream; |
|
// 默认扬声器开启(不静音) |
|
this.$refs.localVideo.muted = !this.speakerOn; |
|
} catch (err) { |
|
console.error("打开设备失败", err); |
|
this.$message.error("请允许摄像头/麦克风权限"); |
|
} |
|
}, |
|
|
|
// 麦克风开关(控制上传的声音) |
|
toggleMic() { |
|
if (!this.localStream) return; |
|
this.micOn = !this.micOn; |
|
this.localStream |
|
.getAudioTracks() |
|
.forEach((t) => (t.enabled = this.micOn)); |
|
this.$message[this.micOn ? "success" : "warning"]( |
|
this.micOn ? "麦克风已打开" : "麦克风已关闭" |
|
); |
|
}, |
|
|
|
// 扬声器开关(控制本地播放的声音,静音/取消静音) |
|
toggleSpeaker() { |
|
this.speakerOn = !this.speakerOn; |
|
// 控制所有视频元素的静音状态 |
|
if (this.$refs.localVideo) { |
|
this.$refs.localVideo.muted = !this.speakerOn; |
|
} |
|
if (this.$refs.shareVideo) { |
|
this.$refs.shareVideo.muted = !this.speakerOn; |
|
} |
|
this.$message[this.speakerOn ? "success" : "warning"]( |
|
this.speakerOn ? "扬声器已打开" : "扬声器已静音" |
|
); |
|
}, |
|
|
|
// 摄像头开关(控制上传的视频画面) |
|
toggleCam() { |
|
if (!this.localStream) return; |
|
this.camOn = !this.camOn; |
|
this.localStream |
|
.getVideoTracks() |
|
.forEach((t) => (t.enabled = this.camOn)); |
|
this.$message[this.camOn ? "success" : "warning"]( |
|
this.camOn ? "摄像头已打开" : "摄像头已关闭" |
|
); |
|
}, |
|
|
|
// ==================== 【3】采集卡设备 ==================== |
|
async switchCaptureCard(deviceId) { |
|
// 清理旧采集卡流 |
|
if (this.captureCheckTimer) clearTimeout(this.captureCheckTimer); |
|
this.closeCaptureCard(); |
|
|
|
if (!deviceId) { |
|
this.isSharing = false; |
|
this.$message.success("已关闭采集卡"); |
|
return; |
|
} |
|
|
|
this.isSharing = true; |
|
await this.$nextTick(); |
|
|
|
const constraints = { |
|
video: { |
|
deviceId: { exact: deviceId }, |
|
width: { ideal: 1920 }, |
|
height: { ideal: 1080 }, |
|
frameRate: { ideal: 30 }, |
|
}, |
|
audio: false, |
|
}; |
|
|
|
let captureStream = null; |
|
try { |
|
captureStream = await navigator.mediaDevices.getUserMedia(constraints); |
|
} catch (err) { |
|
console.error("采集卡获取流失败:", err); |
|
const lowConstraints = { |
|
video: { deviceId: { exact: deviceId } }, |
|
audio: false, |
|
}; |
|
try { |
|
captureStream = await navigator.mediaDevices.getUserMedia( |
|
lowConstraints |
|
); |
|
} catch (e2) { |
|
this.$message.error( |
|
"无法打开采集卡,请检查USB口、权限、是否被其他软件占用" |
|
); |
|
this.isSharing = false; |
|
return; |
|
} |
|
} |
|
|
|
const videoEl = this.$refs.shareVideo; |
|
videoEl.srcObject = captureStream; |
|
videoEl.muted = true; |
|
videoEl.playsinline = true; |
|
videoEl.autoplay = true; |
|
|
|
let hasValidFrame = false; |
|
videoEl.onloadeddata = () => (hasValidFrame = true); |
|
|
|
// 3秒超时无信号判定失败 |
|
this.captureCheckTimer = setTimeout(() => { |
|
if (!hasValidFrame) { |
|
this.$message.error("采集卡无信号,请检查设备、HDMI线、USB3.0接口"); |
|
this.closeCaptureCard(); |
|
this.isSharing = false; |
|
} |
|
}, 3000); |
|
|
|
// 监听采集卡断开 |
|
const videoTrack = captureStream.getVideoTracks()[0]; |
|
if (videoTrack) |
|
videoTrack.onended = () => { |
|
this.$message.warning("采集卡信号已断开"); |
|
this.closeCaptureCard(); |
|
this.isSharing = false; |
|
}; |
|
}, |
|
|
|
// 关闭采集卡并释放资源 |
|
closeCaptureCard() { |
|
try { |
|
if (this.captureCheckTimer) clearTimeout(this.captureCheckTimer); |
|
const videoEl = this.$refs.shareVideo; |
|
if (!videoEl) return; |
|
const stream = videoEl.srcObject; |
|
if (stream) |
|
stream.getTracks().forEach((t) => { |
|
t.stop(); |
|
t.onended = null; |
|
}); |
|
videoEl.srcObject = null; |
|
videoEl.onloadeddata = null; |
|
} catch (e) { |
|
console.log("关闭采集卡异常:", e); |
|
} |
|
}, |
|
|
|
// ==================== 【4】屏幕共享 ==================== |
|
async toggleShare() { |
|
if (this.isSharing) { |
|
this.stopShare(); |
|
return; |
|
} |
|
try { |
|
const stream = await navigator.mediaDevices.getDisplayMedia({ |
|
video: { width: 1920, height: 1080, frameRate: 15 }, |
|
audio: false, |
|
}); |
|
const track = stream.getVideoTracks()[0]; |
|
track.onended = () => this.stopShare(); |
|
this.shareStream = stream; |
|
this.$refs.shareVideo.srcObject = stream; |
|
this.isSharing = true; |
|
this.$message.success("开始共享"); |
|
window.hirtcwebsdk?.publishScreen(stream); |
|
} catch (e) { |
|
this.$message.error("共享失败"); |
|
} |
|
}, |
|
|
|
// 停止共享 |
|
stopShare() { |
|
if (this.shareStream) |
|
this.shareStream.getTracks().forEach((t) => { |
|
t.stop(); |
|
t.onended = null; |
|
}); |
|
this.shareStream = null; |
|
this.isSharing = false; |
|
this.$refs.shareVideo.srcObject = null; |
|
this.$message.info("已停止共享"); |
|
window.hirtcwebsdk?.unPublishScreen(); |
|
}, |
|
|
|
// ==================== 【5】录制 ==================== |
|
async toggleRecord() { |
|
this.isRecording ? this.stopRecording() : await this.startRecording(); |
|
}, |
|
|
|
// 开始录制 |
|
async startRecording() { |
|
try { |
|
const stream = await navigator.mediaDevices.getDisplayMedia({ |
|
video: true, |
|
audio: true, |
|
}); |
|
this.mediaRecorder = new MediaRecorder(stream, { |
|
mimeType: "video/webm;codecs=vp8,opus", |
|
}); |
|
this.recordedChunks = []; |
|
this.mediaRecorder.ondataavailable = (e) => |
|
e.data.size && this.recordedChunks.push(e.data); |
|
this.mediaRecorder.onstop = () => { |
|
const blob = new Blob(this.recordedChunks, { type: "video/webm" }); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement("a"); |
|
a.href = url; |
|
a.download = `${ |
|
this.meetingTitle |
|
}录制_${new Date().toLocaleString()}.webm`; |
|
document.body.appendChild(a); |
|
a.click(); |
|
document.body.removeChild(a); |
|
URL.revokeObjectURL(url); |
|
this.$message.success("录制完成"); |
|
}; |
|
this.mediaRecorder.start(); |
|
this.isRecording = true; |
|
this.recordingStartTime = new Date(); |
|
this.startRecordingTimer(); |
|
this.$message.warning("录制中"); |
|
} catch (e) { |
|
this.$message.error("录制失败"); |
|
} |
|
}, |
|
|
|
// 停止录制 |
|
stopRecording() { |
|
if (this.mediaRecorder && this.isRecording) { |
|
this.mediaRecorder.stop(); |
|
this.mediaRecorder.stream.getTracks().forEach((t) => t.stop()); |
|
this.isRecording = false; |
|
this.stopRecordingTimer(); |
|
} |
|
}, |
|
|
|
// 录制计时 |
|
startRecordingTimer() { |
|
this.recordingTimer = setInterval(() => { |
|
const sec = Math.floor((new Date() - this.recordingStartTime) / 1000); |
|
const h = String(Math.floor(sec / 3600)).padStart(2, "0"); |
|
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, "0"); |
|
const s = String(sec % 60).padStart(2, "0"); |
|
this.recordingTime = `${h}:${m}:${s}`; |
|
}, 1000); |
|
}, |
|
|
|
// 停止录制计时 |
|
stopRecordingTimer() { |
|
clearInterval(this.recordingTimer); |
|
this.recordingTime = "00:00:00"; |
|
}, |
|
|
|
// ==================== 【6】计时器 ==================== |
|
toggleTimer() { |
|
this.timerRunning ? this.pauseTimer() : this.startTimer(); |
|
}, |
|
startTimer() { |
|
if (this.timerInterval) return; |
|
this.timerRunning = true; |
|
this.timerInterval = setInterval(() => this.timer++, 1000); |
|
}, |
|
pauseTimer() { |
|
clearInterval(this.timerInterval); |
|
this.timerInterval = null; |
|
this.timerRunning = false; |
|
}, |
|
|
|
// ==================== 【7】网络状态监控 ==================== |
|
startNetworkMonitoring() { |
|
setInterval(() => { |
|
if (!window.hirtcwebsdk) return; |
|
const s = window.hirtcwebsdk.getNetworkStats(); |
|
if (s?.rtt) { |
|
this.networkDelay = Math.round(s.rtt); |
|
if (this.networkDelay < 100) |
|
this.networkQuality = { text: "优秀", color: "#00e676" }; |
|
else if (this.networkDelay < 300) |
|
this.networkQuality = { text: "良好", color: "#00e676" }; |
|
else if (this.networkDelay < 600) |
|
this.networkQuality = { text: "一般", color: "#ffc107" }; |
|
else this.networkQuality = { text: "差", color: "#f56c6c" }; |
|
} |
|
}, 2000); |
|
}, |
|
|
|
// ==================== 【8】设置模块 ==================== |
|
openSettingDialog() { |
|
this.settingDialogVisible = true; |
|
}, |
|
async onSettingDialogOpen() { |
|
this.tempSetting = { ...this.globalSetting }; |
|
await this.enumerateDevices(); |
|
}, |
|
|
|
// 枚举音视频设备 |
|
async enumerateDevices() { |
|
try { |
|
await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); |
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
|
this.videoDeviceList = devices.filter((d) => d.kind === "videoinput"); |
|
this.audioInList = devices.filter((d) => d.kind === "audioinput"); |
|
this.audioOutList = devices.filter((d) => d.kind === "audiooutput"); |
|
} catch (e) { |
|
this.$message.warning("请允许音视频权限以查看设备"); |
|
} |
|
}, |
|
|
|
// 保存设置 |
|
saveSetting() { |
|
this.globalSetting = { ...this.tempSetting }; |
|
this.meetingTitle = this.globalSetting.meetingTitle; |
|
this.resolution = this.globalSetting.resolution; |
|
localStorage.setItem( |
|
"meetingGlobalSetting", |
|
JSON.stringify(this.globalSetting) |
|
); |
|
this.settingDialogVisible = false; |
|
this.$message.success("设置已保存"); |
|
}, |
|
|
|
// 加载本地缓存设置 |
|
loadLocalSetting() { |
|
const cache = localStorage.getItem("meetingGlobalSetting"); |
|
if (cache) { |
|
this.globalSetting = JSON.parse(cache); |
|
this.meetingTitle = this.globalSetting.meetingTitle; |
|
this.resolution = this.globalSetting.resolution; |
|
} |
|
}, |
|
|
|
// ==================== 【9】会议成员、邀请、消息 ==================== |
|
handleInvite() { |
|
this.inviteDialogVisible = true; |
|
}, |
|
handleInviteConfirm(selectedContacts) { |
|
console.log('邀请的联系人:', selectedContacts); |
|
this.$message.success(`已邀请 ${selectedContacts.length} 位联系人`); |
|
this.inviteDialogVisible = false; |
|
}, |
|
handleMessage() { |
|
this.$message.info("消息功能待开发"); |
|
}, |
|
fetchMeetingInfo() { |
|
const q = this.$route.query; |
|
if (q.title) this.meetingTitle = q.title; |
|
if (q.code) this.meetingCode = q.code; |
|
if (q.resolution) this.resolution = q.resolution; |
|
if (q.frameRate) this.frameRate = q.frameRate; |
|
}, |
|
updateParticipantCount() { |
|
if (window.hirtcwebsdk) { |
|
const us = window.hirtcwebsdk.getUsers(); |
|
this.participantCount = us ? us.length : 1; |
|
} |
|
}, |
|
|
|
// ==================== 【10】窗口与会议控制 ==================== |
|
minimizeWindow() { |
|
this.$router.push("/video/index"); |
|
}, |
|
closeWindow() { |
|
this.$confirm("确定退出会诊?", "提示", { type: "warning" }).then(() => { |
|
this.$store.dispatch("app/toggleSideBarHide", false); |
|
this.$store.dispatch("settings/changeSetting", { |
|
key: "fixedHeader", |
|
value: true, |
|
}); |
|
this.$store.dispatch("settings/changeSetting", { |
|
key: "tagsView", |
|
value: true, |
|
}); |
|
this.$router.push("/"); |
|
}); |
|
}, |
|
endMeeting() { |
|
this.$confirm("确定结束会议?", "提示", { type: "warning" }).then(() => { |
|
this.stopShare(); |
|
this.localStream?.getTracks().forEach((t) => t.stop()); |
|
this.$store.dispatch("app/toggleSideBarHide", false); |
|
this.$store.dispatch("settings/changeSetting", { |
|
key: "fixedHeader", |
|
value: true, |
|
}); |
|
this.$store.dispatch("settings/changeSetting", { |
|
key: "tagsView", |
|
value: true, |
|
}); |
|
window.name === "consultationWindow" |
|
? window.close() |
|
: this.$router.push("/"); |
|
}); |
|
}, |
|
}, |
|
beforeDestroy() { |
|
// 页面销毁清理所有资源 |
|
clearInterval(this.timerInterval); |
|
this.stopRecording(); |
|
this.stopShare(); |
|
this.closeCaptureCard(); |
|
this.localStream?.getTracks().forEach((t) => t.stop()); |
|
}, |
|
}; |
|
</script> |
|
|
|
<style lang="scss"> |
|
.meeting-container { |
|
display: flex; |
|
flex-direction: column; |
|
height: 100vh; |
|
background-color: #004d40; |
|
color: #ffffff; |
|
} |
|
|
|
.top-bar { |
|
height: 40px; |
|
background: linear-gradient(180deg, #00695c 0%, #004d40 100%); |
|
display: flex; |
|
align-items: center; |
|
padding: 0 10px; |
|
justify-content: space-between; |
|
border-bottom: 1px solid #00796b; |
|
|
|
.logo { |
|
display: flex; |
|
align-items: center; |
|
font-weight: 600; |
|
|
|
img { |
|
width: 30px; |
|
margin-right: 5px; |
|
} |
|
} |
|
|
|
.title { |
|
position: absolute; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
font-size: 13px; |
|
color: #b2dfdb; |
|
} |
|
|
|
.window-controls { |
|
display: flex; |
|
align-items: center; |
|
|
|
i { |
|
margin-left: 10px; |
|
cursor: pointer; |
|
font-size: 13px; |
|
color: #b2dfdb; |
|
} |
|
} |
|
} |
|
|
|
.status-bar { |
|
height: 40px; |
|
background-color: #004d40; |
|
display: flex; |
|
align-items: center; |
|
padding: 0 10px; |
|
color: #b2dfdb; |
|
|
|
.status-item { |
|
font-family: Consolas, monospace; |
|
} |
|
|
|
.divider { |
|
margin: 0 6px; |
|
color: #00796b; |
|
} |
|
} |
|
|
|
.main-content { |
|
flex: 1; |
|
display: flex; |
|
overflow: hidden; |
|
} |
|
|
|
.video-stage { |
|
flex: 1; |
|
background-color: #000; |
|
position: relative; |
|
|
|
&.left-stage { |
|
border-right: 2px solid #00796b; |
|
} |
|
|
|
.share-preview { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
object-fit: contain; |
|
z-index: 5; |
|
} |
|
|
|
.local-camera { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
object-fit: contain; |
|
z-index: 5; |
|
} |
|
} |
|
|
|
.bottom-bar { |
|
height: 90px; |
|
background: linear-gradient(180deg, #004d40 0%, #00695c 100%); |
|
border-top: 1px solid #00796b; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
padding: 0 12px; |
|
|
|
.tools-section { |
|
display: flex; |
|
justify-content: center; |
|
gap: 18px; |
|
align-items: center; |
|
|
|
.tool-item { |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
color: #fff; |
|
cursor: pointer; |
|
font-size: 11px; |
|
width: 60px; |
|
padding: 8px 4px; |
|
border-radius: 4px; |
|
position: relative; |
|
transition: background-color 0.2s ease; |
|
|
|
.icon-wrapper { |
|
position: relative; |
|
} |
|
|
|
i { |
|
font-size: 22px; |
|
margin-bottom: 3px; |
|
} |
|
|
|
&:hover { |
|
background-color: #00897b; |
|
} |
|
|
|
&.active { |
|
background-color: #00897b; |
|
} |
|
|
|
.slash-line { |
|
position: absolute; |
|
top: 2px; |
|
left: 2px; |
|
width: 22px; |
|
height: 22px; |
|
border-top: 2px solid #f44336; |
|
transform: rotate(45deg); |
|
transform-origin: left top; |
|
pointer-events: none; |
|
} |
|
} |
|
|
|
.divider-line { |
|
width: 1px; |
|
height: 30px; |
|
background-color: #00796b; |
|
margin: 0 4px; |
|
} |
|
|
|
.end-btn { |
|
color: #ff5252; |
|
|
|
&:hover { |
|
background-color: rgba(255, 82, 82, 0.2); |
|
} |
|
} |
|
} |
|
} |
|
|
|
.is-recording { |
|
color: #ef5350; |
|
animation: blink 1s infinite; |
|
} |
|
|
|
@keyframes blink { |
|
0% { |
|
opacity: 1; |
|
} |
|
50% { |
|
opacity: 0.5; |
|
} |
|
100% { |
|
opacity: 1; |
|
} |
|
} |
|
</style> |