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.
1452 lines
42 KiB
1452 lines
42 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">{{ resolution }}</span> |
|
<span class="divider">|</span> |
|
<span class="status-item">{{ frameRate }}fps</span> |
|
<span class="divider">|</span> |
|
<span class="status-item">网络延迟 {{ networkDelay }}ms</span> |
|
<span class="divider">|</span> |
|
<span class="status-item" :style="{ color: networkQuality.color }"> |
|
网络:{{ networkQuality.text }} |
|
</span> |
|
<span class="divider" v-if="isRecording">|</span> |
|
<span class="status-item" v-if="isRecording"> |
|
录制中 {{ recordingTime }} |
|
</span> |
|
<span class="divider" v-if="timerRunning">|</span> |
|
<span class="status-item" v-if="timerRunning"> |
|
计时中{{ timerDisplay }} |
|
</span> |
|
</div> |
|
|
|
<!-- 主画面区域:左侧共享画面 + 右侧摄像头 --> |
|
<main class="main-content"> |
|
<!-- 左侧主画面容器 --> |
|
<div |
|
class="video-stage" |
|
ref="videoContainer" |
|
@mousemove="handleMouseMove" |
|
> |
|
<!-- 激光笔画布 --> |
|
<canvas |
|
v-show="isLaserMode" |
|
ref="laserCanvas" |
|
class="laser-canvas" |
|
></canvas> |
|
<!-- 画笔标注画布 --> |
|
<canvas |
|
v-show="isDrawingMode" |
|
ref="drawingCanvas" |
|
class="drawing-canvas" |
|
@mousedown="startDrawing" |
|
@mousemove="draw" |
|
@mouseup="stopDrawing" |
|
@mouseleave="stopDrawing" |
|
></canvas> |
|
<!-- 共享/采集卡画面 --> |
|
<video |
|
v-if="isSharing" |
|
ref="shareVideo" |
|
class="share-preview" |
|
autoplay |
|
playsinline |
|
:muted="false" |
|
></video> |
|
</div> |
|
|
|
<!-- 右侧本地摄像头 + 说话音量条 --> |
|
<aside class="sidebar"> |
|
<div class="remote-video-box"> |
|
<div class="video-area"> |
|
<video |
|
ref="localVideo" |
|
class="local-camera" |
|
autoplay |
|
muted |
|
playsinline |
|
/> |
|
<!-- 说话音量波形 --> |
|
<div class="voice-bar-container" v-if="micOn"> |
|
<div |
|
class="voice-bar" |
|
v-for="(i, index) in 12" |
|
:key="index" |
|
:style="{ height: `${voiceLevels[index] || 1}px` }" |
|
/> |
|
</div> |
|
<div class="video-controls"> |
|
<div class="signal-bars"> |
|
<span></span><span></span><span></span><span></span |
|
><span></span> |
|
</div> |
|
<div class="volume-icon"> |
|
<i class="el-icon-phone-outline"></i> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</aside> |
|
</main> |
|
|
|
<!-- 底部工具栏 --> |
|
<footer class="bottom-bar"> |
|
<div class="left-section"> |
|
<el-button type="primary" @click="showCaseDialog = true" |
|
>创建病例</el-button |
|
> |
|
</div> |
|
<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="toggleCam"> |
|
<div class="icon-wrapper"> |
|
<i class="el-icon-video-camera"></i> |
|
<span v-if="!camOn" class="slash-line"></span> |
|
</div> |
|
<span>摄像头</span> |
|
</div> |
|
<div |
|
class="tool-item" |
|
@click="toggleShare" |
|
:class="{ active: isSharing }" |
|
> |
|
<i class="el-icon-share"></i> |
|
<span>{{ isSharing ? "停止共享" : "共享" }}</span> |
|
</div> |
|
<div |
|
class="tool-item" |
|
:class="{ active: isDrawingMode }" |
|
@click="toggleDraw" |
|
> |
|
<i class="el-icon-edit"></i> |
|
<span>画笔</span> |
|
</div> |
|
<div |
|
class="tool-item" |
|
:class="{ active: isLaserMode }" |
|
@click="toggleLaser" |
|
> |
|
<i class="el-icon-aim"></i> |
|
<span>激光笔</span> |
|
</div> |
|
<div class="tool-item" @click="handleInvite"> |
|
<i class="el-icon-user-solid"></i> |
|
<span>邀请</span> |
|
</div> |
|
<div class="tool-item" @click="toggleRecord"> |
|
<i |
|
class="el-icon-video-play" |
|
:class="{ 'is-recording': isRecording }" |
|
></i> |
|
<span>{{ isRecording ? "停止" : "录制" }}</span> |
|
</div> |
|
<div class="tool-item" @click="takeScreenshot"> |
|
<i class="el-icon-camera"></i> |
|
<span>截图</span> |
|
</div> |
|
<div class="tool-item" @click="toggleTimer"> |
|
<i class="el-icon-timer"></i> |
|
<span>{{ timerRunning ? "暂停" : "计时器" }}</span> |
|
</div> |
|
<div class="tool-item" @click="openSettingDialog"> |
|
<i class="el-icon-setting"></i> |
|
<span>设置</span> |
|
</div> |
|
<div class="divider-line"></div> |
|
<div class="tool-item end-btn" @click="endMeeting"> |
|
<i class="el-icon-switch-button"></i> |
|
<span>结束会议</span> |
|
</div> |
|
</div> |
|
<div class="right-section"> |
|
<span class="clock">{{ currentTime }}</span> |
|
</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> |
|
|
|
<!-- 创建病例弹窗 --> |
|
<CaseFormDialog :visible.sync="showCaseDialog" :is-edit="false" /> |
|
</div> |
|
</template> |
|
|
|
<script> |
|
import CaseFormDialog from "@/views/cases/components/CaseFormDialog"; |
|
import CreateGroupDialog from "@/views/message/components/CreateGroupDialog"; |
|
|
|
export default { |
|
name: "MeetingRoom", |
|
components: { |
|
CaseFormDialog, |
|
CreateGroupDialog, |
|
}, |
|
data() { |
|
return { |
|
// ==================== 会议基础信息 ==================== |
|
meetingTitle: "测试部门的云诊室", |
|
meetingCode: "1687110", |
|
participantCount: 1, |
|
resolution: "1080P", |
|
frameRate: 25, |
|
networkDelay: 0, |
|
currentTime: "", |
|
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, |
|
showCaseDialog: false, |
|
micOn: true, |
|
camOn: true, |
|
localStream: null, |
|
|
|
// 音频说话波形 |
|
audioContext: null, |
|
analyser: null, |
|
voiceLevels: Array(12).fill(1), |
|
voiceMonitorTimer: null, |
|
|
|
// ==================== 画笔工具 ==================== |
|
isDrawingMode: false, |
|
drawing: false, |
|
lastX: 0, |
|
lastY: 0, |
|
brushColor: "#FF69B4", |
|
brushLineWidth: 3, |
|
brushLineDash: [5, 5], |
|
ctx: null, |
|
|
|
// ==================== 激光笔工具 ==================== |
|
isLaserMode: false, |
|
laserX: 0, |
|
laserY: 0, |
|
laserAnimationId: null, |
|
laserAlpha: 1, |
|
laserAlphaDir: 1, |
|
laserCtx: null, |
|
|
|
// ==================== 屏幕共享 / 采集卡 ==================== |
|
isSharing: false, |
|
shareStream: null, |
|
captureCheckTimer: null, |
|
|
|
// ==================== 设置模块 ==================== |
|
settingDialogVisible: false, |
|
settingTab: "general", |
|
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, |
|
}, |
|
|
|
// 无用冗余变量(保留兼容) |
|
isSelectingCapture: false, |
|
markCanvas: null, |
|
markCtx: null, |
|
markImg: null, |
|
markDrawing: false, |
|
markToolType: "pen", |
|
markColor: "#ff0000", |
|
markLineWidth: 3, |
|
textInput: null, |
|
fullPageCaptureOverlay: null, |
|
fullPageCaptureSelectBox: null, |
|
fullPageCaptureIsSelecting: false, |
|
fullPageCaptureStartX: 0, |
|
fullPageCaptureStartY: 0, |
|
}; |
|
}, |
|
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.updateClock(); |
|
setInterval(this.updateClock, 1000); |
|
this.initCanvas(); |
|
this.initLaserCanvas(); |
|
window.addEventListener("resize", this.initCanvas); |
|
window.addEventListener("resize", this.initLaserCanvas); |
|
this.openCameraAndMic(); |
|
this.fetchMeetingInfo(); |
|
this.startNetworkMonitoring(); |
|
window.addEventListener("keydown", this.handleKeydown); |
|
// 隐藏侧边栏、调整布局 |
|
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.startVoiceMonitor(); |
|
} 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 ? "麦克风已打开" : "已关闭" |
|
); |
|
}, |
|
|
|
// 摄像头开关 |
|
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】音频说话音量波形检测 ==================== |
|
startVoiceMonitor() { |
|
try { |
|
this.audioContext = new (window.AudioContext || |
|
window.webkitAudioContext)(); |
|
this.analyser = this.audioContext.createAnalyser(); |
|
this.analyser.fftSize = 256; |
|
const source = this.audioContext.createMediaStreamSource( |
|
this.localStream |
|
); |
|
source.connect(this.analyser); |
|
|
|
this.voiceMonitorTimer = setInterval(() => { |
|
if (!this.micOn) { |
|
this.voiceLevels = this.voiceLevels.map(() => 1); |
|
return; |
|
} |
|
const dataArray = new Uint8Array(this.analyser.frequencyBinCount); |
|
this.analyser.getByteFrequencyData(dataArray); |
|
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length; |
|
const level = Math.min(25, Math.max(1, avg / 4)); |
|
this.voiceLevels = this.voiceLevels.map( |
|
() => Math.random() * level + 1 |
|
); |
|
}, 80); |
|
} catch (e) { |
|
console.log("语音检测不支持"); |
|
} |
|
}, |
|
|
|
// ==================== 【4】采集卡设备 ==================== |
|
async switchCaptureCard(deviceId) { |
|
// 1. 彻底清理旧资源(防止残留) |
|
if (this.captureCheckTimer) clearTimeout(this.captureCheckTimer); |
|
this.closeCaptureCard(); |
|
|
|
if (!deviceId) { |
|
this.isSharing = false; |
|
this.$message.success("已关闭采集卡"); |
|
return; |
|
} |
|
|
|
this.isSharing = true; |
|
await this.$nextTick(); |
|
const videoEl = this.$refs.shareVideo; |
|
|
|
// 2. 强制重置 Video 标签状态(防止 CSS 缓存黑屏) |
|
// 强制重置 Video 标签状态 |
|
if (videoEl) { |
|
videoEl.srcObject = null; |
|
videoEl.load(); |
|
videoEl.style.width = "100%"; |
|
videoEl.style.height = "100%"; |
|
videoEl.style.objectFit = "contain"; |
|
videoEl.style.background = "#000"; |
|
videoEl.muted = false; // ✅ 允许播放声音 |
|
} |
|
|
|
try { |
|
console.log("正在执行最终修复方案:强制30帧 + 无分辨率限制..."); |
|
|
|
// 3. 发起请求(核心修改点) |
|
const stream = await navigator.mediaDevices.getUserMedia({ |
|
video: { |
|
deviceId: { exact: deviceId }, |
|
// 核心修复 A:强制帧率 30,这是绿联 USB 2.0 采集卡的“生命线” |
|
frameRate: { exact: 30 }, |
|
// 核心修复 B:强制 MJPEG,绕过 USB 2.0 带宽瓶颈 |
|
advanced: [{ chromegfx: "mjpeg" }], |
|
// 注意:这里绝对不要写 width/height,让它默认输出 640x480 |
|
}, |
|
audio: true, |
|
}); |
|
|
|
console.log( |
|
"采集成功!实际设置:", |
|
stream.getVideoTracks()[0].getSettings() |
|
); |
|
|
|
// 4. 绑定流 |
|
if (videoEl) { |
|
videoEl.srcObject = stream; |
|
|
|
// 核心修复 C:确保播放 |
|
const playPromise = videoEl.play(); |
|
if (playPromise !== undefined) { |
|
playPromise |
|
.then(() => { |
|
console.log("视频播放已成功启动"); |
|
this.$message.success("采集卡已连接 (强制30帧/MJPEG)"); |
|
}) |
|
.catch((error) => { |
|
console.error("自动播放被拦截:", error); |
|
this.$message.error("播放被拦截,请尝试点击页面任意位置"); |
|
}); |
|
} |
|
} |
|
} catch (err) { |
|
console.error("最终方案失败:", err); |
|
this.$message.error("连接失败: " + err.message); |
|
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((track) => { |
|
track.stop(); |
|
track.onended = null; |
|
}); |
|
} |
|
videoEl.srcObject = null; |
|
} catch (e) { |
|
console.log("关闭采集卡异常:", e); |
|
} |
|
}, |
|
|
|
// ==================== 【5】屏幕共享 ==================== |
|
async toggleShare() { |
|
if (this.isSharing) { |
|
this.stopShare(); |
|
return; |
|
} |
|
try { |
|
const stream = await navigator.mediaDevices.getDisplayMedia({ |
|
video: { width: 1920, height: 1080, frameRate: 15 }, |
|
audio: true, |
|
}); |
|
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(); |
|
}, |
|
|
|
// ==================== 【6】画笔标注 ==================== |
|
initCanvas() { |
|
const canvas = this.$refs.drawingCanvas; |
|
const cont = this.$refs.videoContainer; |
|
if (!canvas || !cont) return; |
|
canvas.width = cont.clientWidth; |
|
canvas.height = cont.clientHeight; |
|
this.ctx = canvas.getContext("2d"); |
|
this.ctx.lineWidth = this.brushLineWidth; |
|
this.ctx.lineCap = "round"; |
|
this.ctx.strokeStyle = this.brushColor; |
|
this.ctx.setLineDash(this.brushLineDash); |
|
}, |
|
|
|
// 画笔开关 |
|
toggleDraw() { |
|
this.isDrawingMode = !this.isDrawingMode; |
|
this.$message.info(this.isDrawingMode ? "画笔开启" : "画笔关闭"); |
|
}, |
|
|
|
// 开始绘制 |
|
startDrawing(e) { |
|
if (!this.isDrawingMode) return; |
|
this.drawing = true; |
|
const c = this.getCoord(e); |
|
this.lastX = c.x; |
|
this.lastY = c.y; |
|
}, |
|
|
|
// 绘制中 |
|
draw(e) { |
|
if (!this.drawing || !this.isDrawingMode) return; |
|
const c = this.getCoord(e); |
|
this.ctx.beginPath(); |
|
this.ctx.moveTo(this.lastX, this.lastY); |
|
this.ctx.lineTo(c.x, c.y); |
|
this.ctx.stroke(); |
|
this.lastX = c.x; |
|
this.lastY = c.y; |
|
}, |
|
|
|
// 结束绘制 |
|
stopDrawing() { |
|
this.drawing = false; |
|
}, |
|
|
|
// 获取鼠标在画布上的坐标 |
|
getCoord(e) { |
|
const r = this.$refs.drawingCanvas.getBoundingClientRect(); |
|
return { x: e.clientX - r.left, y: e.clientY - r.top }; |
|
}, |
|
|
|
// ==================== 【7】激光笔 ==================== |
|
initLaserCanvas() { |
|
const c = this.$refs.laserCanvas; |
|
const r = this.$refs.videoContainer; |
|
if (!c || !r) return; |
|
c.width = r.clientWidth; |
|
c.height = r.clientHeight; |
|
this.laserCtx = c.getContext("2d"); |
|
}, |
|
|
|
// 激光笔开关 |
|
toggleLaser() { |
|
this.isLaserMode = !this.isLaserMode; |
|
this.isLaserMode ? this.startLaser() : this.stopLaser(); |
|
this.$message.info(this.isLaserMode ? "激光笔开启" : "激光笔关闭"); |
|
}, |
|
|
|
// 激光笔动画 |
|
startLaser() { |
|
const draw = () => { |
|
if (!this.isLaserMode) return; |
|
this.laserCtx.clearRect( |
|
0, |
|
0, |
|
this.$refs.laserCanvas.width, |
|
this.$refs.laserCanvas.height |
|
); |
|
const g = this.laserCtx.createRadialGradient( |
|
this.laserX, |
|
this.laserY, |
|
0, |
|
this.laserX, |
|
this.laserY, |
|
20 |
|
); |
|
g.addColorStop(0, `rgba(0,255,0,${this.laserAlpha})`); |
|
g.addColorStop(0.5, `rgba(0,255,0,${this.laserAlpha * 0.5})`); |
|
g.addColorStop(1, "rgba(0,255,0,0)"); |
|
this.laserCtx.beginPath(); |
|
this.laserCtx.arc(this.laserX, this.laserY, 20, 0, Math.PI * 2); |
|
this.laserCtx.fillStyle = g; |
|
this.laserCtx.fill(); |
|
this.laserAlpha += 0.02 * this.laserAlphaDir; |
|
if (this.laserAlpha > 1) this.laserAlphaDir = -1; |
|
if (this.laserAlpha < 0.7) this.laserAlphaDir = 1; |
|
this.laserAnimationId = requestAnimationFrame(draw); |
|
}; |
|
draw(); |
|
}, |
|
|
|
// 停止激光笔 |
|
stopLaser() { |
|
cancelAnimationFrame(this.laserAnimationId); |
|
this.laserAnimationId = null; |
|
this.laserCtx?.clearRect( |
|
0, |
|
0, |
|
this.$refs.laserCanvas.width, |
|
this.$refs.laserCanvas.height |
|
); |
|
}, |
|
|
|
// 鼠标移动更新激光笔位置 |
|
handleMouseMove(e) { |
|
if (this.isLaserMode) { |
|
const r = this.$refs.videoContainer.getBoundingClientRect(); |
|
this.laserX = e.clientX - r.left; |
|
this.laserY = e.clientY - r.top; |
|
} |
|
}, |
|
|
|
// ==================== 【8】录制 ==================== |
|
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"; |
|
}, |
|
|
|
// ==================== 【9】截图 ==================== |
|
takeScreenshot() { |
|
this.saveMeetingSnapshot(); |
|
}, |
|
|
|
// 保存会议界面截图 |
|
async saveMeetingSnapshot() { |
|
try { |
|
const html2canvas = (await import("html2canvas")).default; |
|
const container = document.querySelector(".meeting-container"); |
|
if (!container) { |
|
this.$message.error("截图失败:未找到会议界面"); |
|
return; |
|
} |
|
const canvas = await html2canvas(container, { |
|
useCORS: true, |
|
scale: 1.5, |
|
logging: false, |
|
allowTaint: false, |
|
}); |
|
const link = document.createElement("a"); |
|
link.download = `会诊截图_${new Date().toLocaleString()}.png`; |
|
link.href = canvas.toDataURL("image/png"); |
|
link.click(); |
|
this.$message.success("截图已保存"); |
|
} catch (err) { |
|
console.error("截图失败", err); |
|
this.$message.error("截图失败"); |
|
} |
|
}, |
|
|
|
// ==================== 【10】计时器 ==================== |
|
toggleTimer() { |
|
this.timerRunning ? this.pauseTimer() : this.startTimer(); |
|
}, |
|
startTimer() { |
|
if (this.timerInterval) return; |
|
this.timerRunning = true; |
|
this.timerInterval = setInterval(() => this.timer++, 1000); |
|
this.$message.info("计时开始"); |
|
}, |
|
pauseTimer() { |
|
clearInterval(this.timerInterval); |
|
this.timerInterval = null; |
|
this.timerRunning = false; |
|
this.$message.info("计时暂停"); |
|
}, |
|
|
|
// ==================== 【11】网络状态监控 ==================== |
|
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); |
|
}, |
|
|
|
// ==================== 【12】设置模块 ==================== |
|
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; |
|
this.brushColor = this.globalSetting.brushColor; |
|
this.brushLineWidth = this.globalSetting.brushWidth; |
|
this.initCanvas(); |
|
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; |
|
this.brushColor = this.globalSetting.brushColor; |
|
this.brushLineWidth = this.globalSetting.brushWidth; |
|
} |
|
}, |
|
|
|
// ==================== 【13】会议成员、邀请、病例 ==================== |
|
handleInvite() { |
|
this.inviteDialogVisible = true; |
|
}, |
|
handleInviteConfirm(selectedContacts) { |
|
console.log("邀请的联系人:", selectedContacts); |
|
this.$message.success(`已邀请 ${selectedContacts.length} 位联系人`); |
|
this.inviteDialogVisible = false; |
|
}, |
|
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; |
|
} |
|
}, |
|
|
|
// ==================== 【14】窗口与会议控制 ==================== |
|
updateClock() { |
|
this.currentTime = new Date().toLocaleTimeString("zh-CN", { |
|
hour12: false, |
|
}); |
|
}, |
|
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("/"); |
|
}); |
|
}, |
|
|
|
// 快捷键监听 |
|
handleKeydown(e) { |
|
if (e.key === "Escape") { |
|
const layer = document.getElementById("markEditor"); |
|
if (layer) layer.remove(); |
|
this.isSelectingCapture = false; |
|
} |
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { |
|
e.preventDefault(); |
|
this.takeScreenshot(); |
|
} |
|
}, |
|
}, |
|
beforeDestroy() { |
|
// 页面销毁清理所有资源 |
|
clearInterval(this.timerInterval); |
|
clearInterval(this.voiceMonitorTimer); |
|
this.stopRecording(); |
|
this.stopLaser(); |
|
this.stopShare(); |
|
this.closeCaptureCard(); |
|
window.removeEventListener("resize", this.initCanvas); |
|
window.removeEventListener("keydown", this.handleKeydown); |
|
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; |
|
|
|
.laser-canvas { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
z-index: 15; |
|
pointer-events: none; |
|
} |
|
|
|
.drawing-canvas { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
cursor: crosshair; |
|
z-index: 10; |
|
} |
|
|
|
.share-preview { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
object-fit: contain; |
|
z-index: 5; |
|
} |
|
} |
|
|
|
.sidebar { |
|
width: 380px; |
|
background-color: #004d40; |
|
border-left: 1px solid #00796b; |
|
display: flex; |
|
flex-direction: column; |
|
|
|
.remote-video-box { |
|
padding: 6px; |
|
|
|
.video-area { |
|
width: 100%; |
|
height: 210px; |
|
background-color: #00584d; |
|
border: 1px solid #00796b; |
|
position: relative; |
|
|
|
.local-camera { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
} |
|
|
|
.voice-bar-container { |
|
position: absolute; |
|
left: 10px; |
|
bottom: 30px; |
|
display: flex; |
|
align-items: flex-end; |
|
height: 25px; |
|
gap: 2px; |
|
z-index: 10; |
|
|
|
.voice-bar { |
|
width: 3px; |
|
background-color: #00e676; |
|
border-radius: 1px; |
|
transition: height 0.1s ease; |
|
} |
|
} |
|
|
|
.video-controls { |
|
position: absolute; |
|
bottom: 0; |
|
left: 0; |
|
right: 0; |
|
height: 24px; |
|
background-color: rgba(0, 77, 64, 0.7); |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
padding: 0 8px; |
|
|
|
.signal-bars { |
|
display: flex; |
|
align-items: flex-end; |
|
gap: 2px; |
|
height: 14px; |
|
|
|
span { |
|
width: 3px; |
|
background-color: #00e676; |
|
border-radius: 1px; |
|
|
|
@for $i from 1 through 5 { |
|
&:nth-child(#{$i}) { |
|
height: #{$i * 3}px; |
|
} |
|
} |
|
} |
|
} |
|
|
|
.volume-icon { |
|
font-size: 16px; |
|
color: #00e676; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
.bottom-bar { |
|
height: 90px; |
|
background: linear-gradient(180deg, #004d40 0%, #00695c 100%); |
|
border-top: 1px solid #00796b; |
|
display: flex; |
|
align-items: center; |
|
padding: 0 12px; |
|
|
|
.left-section { |
|
margin-right: 20px; |
|
} |
|
|
|
.tools-section { |
|
flex: 1; |
|
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); |
|
} |
|
} |
|
} |
|
|
|
.right-section { |
|
margin-left: 20px; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: flex-end; |
|
|
|
.clock { |
|
margin-top: 3px; |
|
color: #b2dfdb; |
|
font-family: Consolas, monospace; |
|
} |
|
} |
|
} |
|
|
|
.is-recording { |
|
color: #ef5350; |
|
animation: blink 1s infinite; |
|
} |
|
|
|
@keyframes blink { |
|
0% { |
|
opacity: 1; |
|
} |
|
50% { |
|
opacity: 0.5; |
|
} |
|
100% { |
|
opacity: 1; |
|
} |
|
} |
|
</style> |