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

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