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