海信医疗-远程超声管理平台-信创国产化
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.
 
 
 
 

1493 lines
46 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
:title="isLocalCameraShared ? '双击取消画面' : '双击将画面切换到采集卡窗口'" @dblclick="switchLocalCameraToShare" />
<!-- 说话音量波形 -->
<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="handleAddCase">创建病例</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 ref="CreateGroupDialogRef" :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 ref="caseFormDialogRef" @handleView="handleView" />
</div>
</template>
<script>
import CaseFormDialog from '@/views/cases/components/CaseFormDialog';
import CreateGroupDialog from '@/views/message/components/CreateGroupDialog';
import ConsultationCaseStatsDialog from './components/ConsultationCaseStatsDialog';
import {
postConsultationCreate,
postConsultationInfo,
postConsultationStop
} from "@/api/videoCommunication";
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' },
consultation_id: null,
// ==================== 计时器 ====================
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,
drawHistory: [], // 画笔历史记录(用于撤销)
// ==================== 激光笔工具 ====================
isLaserMode: false,
laserX: 0,
laserY: 0,
laserAnimationId: null,
laserAlpha: 1,
laserAlphaDir: 1,
laserCtx: null,
// ==================== 屏幕共享 / 采集卡 ====================
isSharing: false,
shareStream: null,
captureCheckTimer: null,
isLocalCameraShared: false, // 双击本地摄像头切换到采集卡窗口的状态
// ==================== 设置模块 ====================
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() {
if (this.$route.query.roomId_id) this.creatRoom()
this.initPage();
this.initSDK();
},
methods: {
async creatRoom() {
try {
const res = await postConsultationCreate({
avatar: '',
init_users: [],
invite_code: '1234',
name: '',
room_id: this.$route.query.roomId_id
})
console.log(1111111111,res)
this.consultation_id = res.data.consultation_id
const ret = await postConsultationInfo({
consultation_id: res.data.consultation_id,
from_history: 0,
room_id: ''
})
this.meetingTitle = ret.data.name
} catch (error) {
}
},
// 新增病例
handleAddCase() {
this.$refs.caseFormDialogRef.handleAdd();
},
// 查看按钮操作
handleView(row) {
this.$tab.openPage(row.patient_name + '-' + row.id, '/cases/detail/' + row.id);
},
// ==================== 【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: '56da5fd8921f4f7093a42e2a',
serviceKey: '2c17c6393771ee3048ae34d6b965sdew',
Services: { BasicRoomServiceToken: "https://192.168.69.174:3001/v1/auth/token" },
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();
console.log(roomId, uid, uName)
window.hirtcwebsdk.addListener('joined', () => {
this.$message.success('加入会议成功');
this.updateParticipantCount();
});
window.hirtcwebsdk.join(this.$route.query.roomId_id, uid, uName);
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('语音检测不支持');
}
},
// ==================== 【3.5】双击本地摄像头切换到采集卡画面(toggle) ====================
switchLocalCameraToShare() {
if (!this.localStream) {
this.$message.warning('摄像头未就绪');
return;
}
// 如果当前已显示本地摄像头画面 → 取消显示
if (this.isLocalCameraShared) {
this.isLocalCameraShared = false;
this.isSharing = false;
this.$nextTick(() => {
const videoEl = this.$refs.shareVideo;
if (videoEl) {
videoEl.srcObject = null;
}
});
this.$message.success('已取消采集卡窗口画面');
return;
}
// 否则 → 切换到采集卡窗口显示
// 先关闭已有的共享/采集卡
this.closeCaptureCard();
this.stopShare();
this.isLocalCameraShared = true;
this.isSharing = true;
this.$nextTick(() => {
const videoEl = this.$refs.shareVideo;
if (!videoEl) return;
videoEl.srcObject = this.localStream;
this.$message.success('本地摄像头画面已切换到采集卡窗口');
});
},
// ==================== 【4】采集卡设备 ====================
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 videoEl = this.$refs.shareVideo;
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;
videoEl.volume = 1; // ✅ 强制音量
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: deviceId },
frameRate: { exact: 30 },
advanced: [{ chromegfx: 'mjpeg' }],
},
audio: true,
});
// ✅ 强制开启音频
stream.getAudioTracks().forEach(track => (track.enabled = true));
if (videoEl) {
videoEl.srcObject = stream;
const playPromise = videoEl.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
console.log('采集卡音视频播放成功');
this.$message.success('采集卡已连接(带声音)');
})
.catch(err => { });
}
}
} catch (err) {
console.error('采集卡失败', err);
this.$message.error('采集卡失败:' + err.message);
this.isSharing = false;
}
},
// 关闭采集卡并释放资源
closeCaptureCard() {
try {
this.isLocalCameraShared = false;
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 {
this.isLocalCameraShared = false; // 开启屏幕共享时重置本地摄像头共享状态
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
const track = stream.getVideoTracks()[0];
track.addEventListener('ended', () => {
this.stopShare();
});
this.shareStream = stream;
// 不绑定到本地 <video>,避免"无限镜子"嵌套:捕获的画面包含自身时产生递归
// 流仅通过 SDK 发布给远端参会者
this.isSharing = true;
this.$message.success('开始共享');
window.hirtcwebsdk?.publish(stream);
} catch (e) {
this.isSharing = false;
if (e.name !== 'AbortError') {
this.$message.error('共享失败');
}
}
},
// 停止共享
stopShare() {
this.isLocalCameraShared = false;
if (this.shareStream) {
this.shareStream.getTracks().forEach(t => {
t.stop();
});
}
this.shareStream = null;
this.isSharing = false;
this.$message.info('已停止共享');
window.hirtcwebsdk?.unpublish();
},
// ==================== 【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;
if (this.isDrawingMode) {
this.clearCanvas(); // 打开画笔 → 清空
this.$message.info('画笔开启(画布已清空)');
} else {
this.$message.info('画笔关闭');
}
},
// 清空画布
clearCanvas() {
const canvas = this.$refs.drawingCanvas;
if (!canvas || !this.ctx) return;
this.ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawHistory = [];
},
// 撤销上一笔(右键)
undoLastDraw() {
if (this.drawHistory.length === 0) return;
this.drawHistory.pop();
this.ctx.clearRect(0, 0, this.$refs.drawingCanvas.width, this.$refs.drawingCanvas.height);
this.redrawAll();
this.$message.info('已撤销上一笔');
},
// 重新绘制所有历史
redrawAll() {
this.drawHistory.forEach(item => {
this.ctx.beginPath();
this.ctx.moveTo(item.fromX, item.fromY);
this.ctx.lineTo(item.toX, item.toY);
this.ctx.strokeStyle = item.color;
this.ctx.lineWidth = item.width;
this.ctx.stroke();
});
},
// 开始绘制
startDrawing(e) {
if (!this.isDrawingMode) return;
// 右键 → 撤销
if (e.button === 2) {
e.preventDefault();
this.undoLastDraw();
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.drawHistory.push({
fromX: this.lastX,
fromY: this.lastY,
toX: c.x,
toY: c.y,
color: this.brushColor,
width: this.brushLineWidth,
});
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.$refs['CreateGroupDialogRef'].show()
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');
},
// 是否跳过会诊评价/统计弹窗
shouldSkipEvaluation() {
try {
const settings = JSON.parse(localStorage.getItem('systemSettings') || '{}');
return !!settings.otherForm?.skipEvaluation;
} catch (e) {
return false;
}
},
// closeWindow 确认后的实际清理与跳转
executeCloseWindow() {
this.$store.dispatch('app/toggleSideBarHide', false);
this.$store.dispatch('settings/changeSetting', {
key: 'fixedHeader',
value: true,
});
this.$store.dispatch('settings/changeSetting', {
key: 'tagsView',
value: true,
});
postConsultationStop({ consultation_id: this.consultation_id });
this.$router.push('/');
},
// endMeeting 确认后的实际清理与跳转/关闭
executeEndMeeting() {
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,
});
postConsultationStop({ consultation_id: this.consultation_id });
window.name === 'consultationWindow' ? window.close() : this.$router.push('/');
},
closeWindow() {
this.$confirm('确定退出会诊?', '提示', { type: 'warning' })
.then(() => {
// 先执行退出清理和路由跳转
this.executeCloseWindow();
// 路由跳转后再弹出统计弹窗
if (!this.shouldSkipEvaluation()) {
ConsultationCaseStatsDialog.open(this.consultation_id);
}
})
.catch(() => {});
},
endMeeting() {
this.$confirm('确定结束会议?', '提示', { type: 'warning' })
.then(() => {
// 先执行退出清理和路由跳转
this.executeEndMeeting();
// 路由跳转后再弹出统计弹窗
if (!this.shouldSkipEvaluation()) {
ConsultationCaseStatsDialog.open(this.consultation_id);
}
})
.catch(() => {});
},
// 快捷键监听
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;
cursor: pointer;
}
.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>