|
|
|
|
<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>
|
|
|
|
|
<!-- 主画面区域:2x2 四宫格 -->
|
|
|
|
|
<main class="main-content">
|
|
|
|
|
<div class="grid-container">
|
|
|
|
|
<div
|
|
|
|
|
v-for="(slot, index) in gridSlots"
|
|
|
|
|
:key="index"
|
|
|
|
|
class="grid-slot"
|
|
|
|
|
>
|
|
|
|
|
<!-- 有成员时的状态 -->
|
|
|
|
|
<template v-if="slot.user">
|
|
|
|
|
<!-- 左上角信号强度 -->
|
|
|
|
|
<div class="signal-icon">
|
|
|
|
|
<i
|
|
|
|
|
v-for="n in 5"
|
|
|
|
|
:key="n"
|
|
|
|
|
class="bar"
|
|
|
|
|
:class="{ active: n <= slot.signalLevel }"
|
|
|
|
|
></i>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- 顶部用户名称 -->
|
|
|
|
|
<div class="slot-name">{{ slot.user.name }}</div>
|
|
|
|
|
<!-- 取消质控按钮 -->
|
|
|
|
|
<el-button
|
|
|
|
|
class="cancel-btn"
|
|
|
|
|
type="info"
|
|
|
|
|
size="mini"
|
|
|
|
|
@click="cancelQualityControl(index)"
|
|
|
|
|
>
|
|
|
|
|
取消质控
|
|
|
|
|
</el-button>
|
|
|
|
|
<!-- 视频区域 -->
|
|
|
|
|
<video
|
|
|
|
|
v-if="slot.stream"
|
|
|
|
|
:ref="`video${index}`"
|
|
|
|
|
class="slot-video"
|
|
|
|
|
autoplay
|
|
|
|
|
playsinline
|
|
|
|
|
></video>
|
|
|
|
|
<!-- 无视频流时显示用户信息 -->
|
|
|
|
|
<div v-else class="user-btn" @click="showUserDetail(slot.user)">
|
|
|
|
|
<el-avatar :size="56" :src="slot.user.avatar" class="user-avatar">
|
|
|
|
|
<span class="avatar-text">{{ slot.user.name.charAt(0) }}</span>
|
|
|
|
|
</el-avatar>
|
|
|
|
|
<div class="user-info-text">
|
|
|
|
|
<span class="user-name">{{ slot.user.name }}</span>
|
|
|
|
|
<span :class="['status-dot', slot.user.status === '在线' ? 'online' : 'offline']"></span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- 无成员时的添加按钮 -->
|
|
|
|
|
<div v-else class="add-btn" @click="openAddUserDialog(index)">
|
|
|
|
|
<i class="el-icon-plus"></i>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<!-- 邀请弹窗 -->
|
|
|
|
|
<CreateGroupDialog
|
|
|
|
|
:visible.sync="inviteDialogVisible"
|
|
|
|
|
title="通讯录邀请"
|
|
|
|
|
@confirm="handleInviteConfirm"
|
|
|
|
|
@close="inviteDialogVisible = false"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- 添加质控成员弹窗 -->
|
|
|
|
|
<CreateGroupDialog
|
|
|
|
|
title="邀请参加人员"
|
|
|
|
|
:visible.sync="showAddDialog"
|
|
|
|
|
:recent-contacts="recentContacts"
|
|
|
|
|
:recent-groups="recentGroups"
|
|
|
|
|
:min-select-count="1"
|
|
|
|
|
confirm-text="确定"
|
|
|
|
|
@create-success="handleAddMembers"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- 设置弹窗 -->
|
|
|
|
|
<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.captureCardId" style="width: 100%">
|
|
|
|
|
<el-option
|
|
|
|
|
v-for="item in captureCardList"
|
|
|
|
|
:key="item.deviceId"
|
|
|
|
|
:label="item.label"
|
|
|
|
|
:value="item.deviceId"
|
|
|
|
|
></el-option>
|
|
|
|
|
</el-select>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="网络策略">
|
|
|
|
|
<el-radio-group v-model="tempSetting.networkStrategy">
|
|
|
|
|
<el-radio label="smooth">流畅优先</el-radio>
|
|
|
|
|
<el-radio label="quality">画质优先</el-radio>
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="分辨率">
|
|
|
|
|
<el-select v-model="tempSetting.resolution" style="width: 100%">
|
|
|
|
|
<el-option label="720P" value="720P"></el-option>
|
|
|
|
|
<el-option label="1080P" value="1080P"></el-option>
|
|
|
|
|
<el-option label="2K" value="2K"></el-option>
|
|
|
|
|
</el-select>
|
|
|
|
|
</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 audioDeviceList"
|
|
|
|
|
: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.speakerId" style="width: 100%">
|
|
|
|
|
<el-option
|
|
|
|
|
v-for="item in speakerDeviceList"
|
|
|
|
|
:key="item.deviceId"
|
|
|
|
|
:label="item.label"
|
|
|
|
|
:value="item.deviceId"
|
|
|
|
|
></el-option>
|
|
|
|
|
</el-select>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</el-form>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
<el-tab-pane label="画笔" name="brush">
|
|
|
|
|
<el-form :model="tempSetting" label-width="140px">
|
|
|
|
|
<el-form-item label="画笔颜色">
|
|
|
|
|
<el-color-picker v-model="tempSetting.brushColor"></el-color-picker>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="画笔粗细">
|
|
|
|
|
<el-slider
|
|
|
|
|
v-model="tempSetting.brushWidth"
|
|
|
|
|
:min="1"
|
|
|
|
|
:max="10"
|
|
|
|
|
></el-slider>
|
|
|
|
|
</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 CreateGroupDialog from "@/views/message/components/CreateGroupDialog";
|
|
|
|
|
import CaseFormDialog from "@/views/cases/components/CaseFormDialog";
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
name: "qualityControl",
|
|
|
|
|
components: {
|
|
|
|
|
CreateGroupDialog,
|
|
|
|
|
CaseFormDialog,
|
|
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
|
|
|
|
|
// ==================== 设置弹窗 ====================
|
|
|
|
|
settingDialogVisible: false,
|
|
|
|
|
settingTab: "general",
|
|
|
|
|
tempSetting: {
|
|
|
|
|
meetingTitle: "",
|
|
|
|
|
cameraId: "",
|
|
|
|
|
captureCardId: "",
|
|
|
|
|
networkStrategy: "smooth",
|
|
|
|
|
resolution: "1080P",
|
|
|
|
|
micId: "",
|
|
|
|
|
speakerId: "",
|
|
|
|
|
brushColor: "#FF69B4",
|
|
|
|
|
brushWidth: 3,
|
|
|
|
|
},
|
|
|
|
|
globalSetting: {
|
|
|
|
|
meetingTitle: "超声事业部的云质控",
|
|
|
|
|
cameraId: "",
|
|
|
|
|
captureCardId: "",
|
|
|
|
|
networkStrategy: "smooth",
|
|
|
|
|
resolution: "1080P",
|
|
|
|
|
micId: "",
|
|
|
|
|
speakerId: "",
|
|
|
|
|
brushColor: "#FF69B4",
|
|
|
|
|
brushWidth: 3,
|
|
|
|
|
},
|
|
|
|
|
videoDeviceList: [],
|
|
|
|
|
audioDeviceList: [],
|
|
|
|
|
speakerDeviceList: [],
|
|
|
|
|
captureCardList: [],
|
|
|
|
|
|
|
|
|
|
// ==================== 分屏数据(4个位置) ====================
|
|
|
|
|
gridSlots: [
|
|
|
|
|
{
|
|
|
|
|
index: 0,
|
|
|
|
|
signalLevel: 5,
|
|
|
|
|
user: {
|
|
|
|
|
name: "基层医生",
|
|
|
|
|
status: "在线",
|
|
|
|
|
avatar: "https://img.icons8.com/fluency/96/doctor-male.png",
|
|
|
|
|
},
|
|
|
|
|
stream: null,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
index: 1,
|
|
|
|
|
signalLevel: 0,
|
|
|
|
|
user: null,
|
|
|
|
|
stream: null,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
index: 2,
|
|
|
|
|
signalLevel: 5,
|
|
|
|
|
user: {
|
|
|
|
|
name: "于春晓",
|
|
|
|
|
status: "离线",
|
|
|
|
|
avatar: "https://img.icons8.com/fluency/96/doctor-male.png",
|
|
|
|
|
},
|
|
|
|
|
stream: null,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
index: 3,
|
|
|
|
|
signalLevel: 0,
|
|
|
|
|
user: null,
|
|
|
|
|
stream: null,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
// ==================== 添加弹窗状态 ====================
|
|
|
|
|
showAddDialog: false,
|
|
|
|
|
currentAddIndex: null,
|
|
|
|
|
|
|
|
|
|
// ==================== 联系人数据 ====================
|
|
|
|
|
recentContacts: [
|
|
|
|
|
{ id: "1", name: "基层医生(在线)", avatar: "" },
|
|
|
|
|
{ id: "2", name: "于春晓", avatar: "" },
|
|
|
|
|
{ id: "3", name: "郭君", avatar: "" },
|
|
|
|
|
{ id: "4", name: "张医生", avatar: "" },
|
|
|
|
|
{ id: "5", name: "李护士", avatar: "" },
|
|
|
|
|
{ id: "6", name: "王主任", avatar: "" },
|
|
|
|
|
],
|
|
|
|
|
recentGroups: [
|
|
|
|
|
{
|
|
|
|
|
id: "group1",
|
|
|
|
|
name: "医疗专家组",
|
|
|
|
|
avatar: "",
|
|
|
|
|
expanded: false,
|
|
|
|
|
members: [
|
|
|
|
|
{ id: "m1", name: "张医生", avatar: "", selected: false },
|
|
|
|
|
{ id: "m2", name: "李护士", avatar: "", selected: false },
|
|
|
|
|
{ id: "m3", name: "王主任", avatar: "", selected: false },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "group2",
|
|
|
|
|
name: "超声科团队",
|
|
|
|
|
avatar: "",
|
|
|
|
|
expanded: false,
|
|
|
|
|
members: [
|
|
|
|
|
{ id: "m4", name: "陈医生", avatar: "", selected: false },
|
|
|
|
|
{ id: "m5", name: "刘医生", avatar: "", selected: false },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async mounted() {
|
|
|
|
|
this.$store.dispatch("app/toggleSideBarHide", true);
|
|
|
|
|
this.$store.dispatch("settings/changeSetting", {
|
|
|
|
|
key: "fixedHeader",
|
|
|
|
|
value: false,
|
|
|
|
|
});
|
|
|
|
|
this.$store.dispatch("settings/changeSetting", {
|
|
|
|
|
key: "tagsView",
|
|
|
|
|
value: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.updateClock();
|
|
|
|
|
setInterval(this.updateClock, 1000);
|
|
|
|
|
this.startNetworkMonitoring();
|
|
|
|
|
this.initCanvas();
|
|
|
|
|
window.addEventListener("resize", this.initCanvas);
|
|
|
|
|
|
|
|
|
|
await this.openCameraAndMic();
|
|
|
|
|
await this.getDevices();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
beforeDestroy() {
|
|
|
|
|
clearInterval(this.timerInterval);
|
|
|
|
|
if (this.isRecording) this.stopRecording();
|
|
|
|
|
window.removeEventListener("resize", this.initCanvas);
|
|
|
|
|
if (this.localStream) this.localStream.getTracks().forEach((t) => t.stop());
|
|
|
|
|
if (this.shareStream) this.shareStream.getTracks().forEach((t) => t.stop());
|
|
|
|
|
if (this.mediaRecorder) this.mediaRecorder.stop();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
methods: {
|
|
|
|
|
// ==================== 【1】窗口控制 ====================
|
|
|
|
|
minimizeWindow() {
|
|
|
|
|
this.$router.push("/video/index");
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
closeWindow() {
|
|
|
|
|
this.$confirm("确定要退出本次质控吗?", "提示", {
|
|
|
|
|
type: "warning",
|
|
|
|
|
confirmButtonText: "确定",
|
|
|
|
|
cancelButtonText: "取消",
|
|
|
|
|
})
|
|
|
|
|
.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("/");
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【2】音视频控制 ====================
|
|
|
|
|
async openCameraAndMic() {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
video: true,
|
|
|
|
|
audio: true,
|
|
|
|
|
});
|
|
|
|
|
this.localStream = stream;
|
|
|
|
|
if (this.$refs.localVideo) {
|
|
|
|
|
this.$refs.localVideo.srcObject = stream;
|
|
|
|
|
}
|
|
|
|
|
this.startVoiceMonitor();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("获取摄像头/麦克风失败:", err);
|
|
|
|
|
this.$message.error("无法访问摄像头或麦克风");
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async getDevices() {
|
|
|
|
|
try {
|
|
|
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
|
|
|
this.videoDeviceList = devices.filter((d) => d.kind === "videoinput");
|
|
|
|
|
this.audioDeviceList = devices.filter((d) => d.kind === "audioinput");
|
|
|
|
|
this.speakerDeviceList = devices.filter((d) => d.kind === "audiooutput");
|
|
|
|
|
this.captureCardList = this.videoDeviceList.filter((d) =>
|
|
|
|
|
d.label.toLowerCase().includes("usb")
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("获取设备列表失败:", err);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleMic() {
|
|
|
|
|
if (!this.localStream) return;
|
|
|
|
|
this.micOn = !this.micOn;
|
|
|
|
|
this.localStream.getAudioTracks().forEach((t) => (t.enabled = this.micOn));
|
|
|
|
|
if (this.micOn) {
|
|
|
|
|
this.startVoiceMonitor();
|
|
|
|
|
} else {
|
|
|
|
|
this.stopVoiceMonitor();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleCam() {
|
|
|
|
|
if (!this.localStream) return;
|
|
|
|
|
this.camOn = !this.camOn;
|
|
|
|
|
this.localStream.getVideoTracks().forEach((t) => (t.enabled = this.camOn));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【3】音频检测(说话波形) ====================
|
|
|
|
|
startVoiceMonitor() {
|
|
|
|
|
if (!this.localStream || !this.micOn) return;
|
|
|
|
|
try {
|
|
|
|
|
this.audioContext = new (window.AudioContext ||
|
|
|
|
|
window.webkitAudioContext)();
|
|
|
|
|
this.analyser = this.audioContext.createAnalyser();
|
|
|
|
|
const source = this.audioContext.createMediaStreamSource(
|
|
|
|
|
this.localStream
|
|
|
|
|
);
|
|
|
|
|
source.connect(this.analyser);
|
|
|
|
|
this.analyser.fftSize = 64;
|
|
|
|
|
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
|
|
|
|
|
|
|
|
|
const updateVoice = () => {
|
|
|
|
|
if (!this.analyser) return;
|
|
|
|
|
this.analyser.getByteFrequencyData(dataArray);
|
|
|
|
|
const levels = [];
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
const value = dataArray[i] || 0;
|
|
|
|
|
levels.push(Math.max(2, Math.min(20, value / 10)));
|
|
|
|
|
}
|
|
|
|
|
this.voiceLevels = levels;
|
|
|
|
|
this.voiceMonitorTimer = requestAnimationFrame(updateVoice);
|
|
|
|
|
};
|
|
|
|
|
updateVoice();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("音频检测初始化失败:", e);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
stopVoiceMonitor() {
|
|
|
|
|
if (this.voiceMonitorTimer) {
|
|
|
|
|
cancelAnimationFrame(this.voiceMonitorTimer);
|
|
|
|
|
this.voiceMonitorTimer = null;
|
|
|
|
|
}
|
|
|
|
|
if (this.audioContext) {
|
|
|
|
|
this.audioContext.close();
|
|
|
|
|
this.audioContext = null;
|
|
|
|
|
}
|
|
|
|
|
this.voiceLevels = Array(12).fill(1);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【4】屏幕共享 ====================
|
|
|
|
|
async toggleShare() {
|
|
|
|
|
if (!this.isSharing) {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
|
|
|
video: true,
|
|
|
|
|
audio: true,
|
|
|
|
|
});
|
|
|
|
|
this.shareStream = stream;
|
|
|
|
|
if (this.$refs.shareVideo) {
|
|
|
|
|
this.$refs.shareVideo.srcObject = stream;
|
|
|
|
|
}
|
|
|
|
|
this.isSharing = true;
|
|
|
|
|
this.$message.info("开始共享");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("共享失败:", e);
|
|
|
|
|
this.$message.error("共享失败");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.stopShare();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
stopShare() {
|
|
|
|
|
if (this.shareStream) {
|
|
|
|
|
this.shareStream.getTracks().forEach((t) => t.stop());
|
|
|
|
|
this.shareStream = null;
|
|
|
|
|
}
|
|
|
|
|
this.isSharing = false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【5】录制 ====================
|
|
|
|
|
async toggleRecord() {
|
|
|
|
|
if (!this.isRecording) {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
|
|
|
video: true,
|
|
|
|
|
audio: true,
|
|
|
|
|
});
|
|
|
|
|
this.recordedChunks = [];
|
|
|
|
|
this.recordingTime = "00:00:00";
|
|
|
|
|
|
|
|
|
|
this.mediaRecorder = new MediaRecorder(stream, {
|
|
|
|
|
mimeType: "video/webm;codecs=vp9,opus",
|
|
|
|
|
});
|
|
|
|
|
this.mediaRecorder.ondataavailable = (e) => {
|
|
|
|
|
if (e.data.size > 0) 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 = `质控录制_${Date.now()}.webm`;
|
|
|
|
|
a.click();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
};
|
|
|
|
|
this.mediaRecorder.start();
|
|
|
|
|
this.isRecording = true;
|
|
|
|
|
this.startRecordingTimer();
|
|
|
|
|
this.$message.success("开始录制");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("录制失败:", e);
|
|
|
|
|
this.$message.error("录制失败");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.stopRecording();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
stopRecording() {
|
|
|
|
|
if (this.mediaRecorder && this.isRecording) {
|
|
|
|
|
this.mediaRecorder.stop();
|
|
|
|
|
this.isRecording = false;
|
|
|
|
|
this.mediaRecorder.stream.getTracks().forEach((t) => t.stop());
|
|
|
|
|
clearInterval(this.recordingTimer);
|
|
|
|
|
this.$message.success("录制已保存");
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
startRecordingTimer() {
|
|
|
|
|
this.recordingTimer = setInterval(() => {
|
|
|
|
|
let [h, m, s] = this.recordingTime.split(":").map(Number);
|
|
|
|
|
s++;
|
|
|
|
|
if (s >= 60) {
|
|
|
|
|
s = 0;
|
|
|
|
|
m++;
|
|
|
|
|
}
|
|
|
|
|
if (m >= 60) {
|
|
|
|
|
m = 0;
|
|
|
|
|
h++;
|
|
|
|
|
}
|
|
|
|
|
this.recordingTime = `${String(h).padStart(2, "0")}:${String(
|
|
|
|
|
m
|
|
|
|
|
).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
|
|
|
}, 1000);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【6】计时器 ====================
|
|
|
|
|
toggleTimer() {
|
|
|
|
|
if (this.timerRunning) {
|
|
|
|
|
clearInterval(this.timerInterval);
|
|
|
|
|
this.timerRunning = false;
|
|
|
|
|
} else {
|
|
|
|
|
this.timerRunning = true;
|
|
|
|
|
this.timerInterval = setInterval(() => {
|
|
|
|
|
this.timer++;
|
|
|
|
|
}, 1000);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【7】截图 ====================
|
|
|
|
|
takeScreenshot() {
|
|
|
|
|
this.$message.success("截图已保存");
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【8】画笔功能 ====================
|
|
|
|
|
initCanvas() {
|
|
|
|
|
const canvas = this.$refs.drawingCanvas;
|
|
|
|
|
const laserCanvas = this.$refs.laserCanvas;
|
|
|
|
|
const container = this.$refs.videoContainer;
|
|
|
|
|
if (canvas && container) {
|
|
|
|
|
canvas.width = container.clientWidth;
|
|
|
|
|
canvas.height = container.clientHeight;
|
|
|
|
|
this.ctx = canvas.getContext("2d");
|
|
|
|
|
this.ctx.lineWidth = this.brushLineWidth;
|
|
|
|
|
this.ctx.lineCap = "round";
|
|
|
|
|
this.ctx.strokeStyle = this.brushColor;
|
|
|
|
|
}
|
|
|
|
|
if (laserCanvas && container) {
|
|
|
|
|
laserCanvas.width = container.clientWidth;
|
|
|
|
|
laserCanvas.height = container.clientHeight;
|
|
|
|
|
this.laserCtx = laserCanvas.getContext("2d");
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleDraw() {
|
|
|
|
|
this.isDrawingMode = !this.isDrawingMode;
|
|
|
|
|
this.isLaserMode = false;
|
|
|
|
|
if (!this.isDrawingMode) this.clearCanvas();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clearCanvas() {
|
|
|
|
|
const canvas = this.$refs.drawingCanvas;
|
|
|
|
|
if (this.ctx && canvas)
|
|
|
|
|
this.ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getCoordinates(e) {
|
|
|
|
|
const canvas = this.$refs.drawingCanvas;
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
startDrawing(e) {
|
|
|
|
|
this.drawing = true;
|
|
|
|
|
const coords = this.getCoordinates(e);
|
|
|
|
|
this.lastX = coords.x;
|
|
|
|
|
this.lastY = coords.y;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
draw(e) {
|
|
|
|
|
if (!this.drawing) return;
|
|
|
|
|
const coords = this.getCoordinates(e);
|
|
|
|
|
this.ctx.beginPath();
|
|
|
|
|
this.ctx.moveTo(this.lastX, this.lastY);
|
|
|
|
|
this.ctx.lineTo(coords.x, coords.y);
|
|
|
|
|
this.ctx.stroke();
|
|
|
|
|
this.lastX = coords.x;
|
|
|
|
|
this.lastY = coords.y;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
stopDrawing() {
|
|
|
|
|
this.drawing = false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【9】激光笔功能 ====================
|
|
|
|
|
toggleLaser() {
|
|
|
|
|
this.isLaserMode = !this.isLaserMode;
|
|
|
|
|
this.isDrawingMode = false;
|
|
|
|
|
if (!this.isLaserMode) this.clearLaser();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clearLaser() {
|
|
|
|
|
const canvas = this.$refs.laserCanvas;
|
|
|
|
|
if (this.laserCtx && canvas)
|
|
|
|
|
this.laserCtx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
if (this.laserAnimationId) {
|
|
|
|
|
cancelAnimationFrame(this.laserAnimationId);
|
|
|
|
|
this.laserAnimationId = null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
handleMouseMove(e) {
|
|
|
|
|
if (!this.isLaserMode) return;
|
|
|
|
|
const canvas = this.$refs.laserCanvas;
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
this.laserX = e.clientX - rect.left;
|
|
|
|
|
this.laserY = e.clientY - rect.top;
|
|
|
|
|
this.drawLaser();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
drawLaser() {
|
|
|
|
|
if (!this.isLaserMode) return;
|
|
|
|
|
const ctx = this.laserCtx;
|
|
|
|
|
const canvas = this.$refs.laserCanvas;
|
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
|
|
|
|
this.laserAlpha += this.laserAlphaDir * 0.05;
|
|
|
|
|
if (this.laserAlpha >= 1 || this.laserAlpha <= 0.3) {
|
|
|
|
|
this.laserAlphaDir *= -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(this.laserX, this.laserY, 15, 0, Math.PI * 2);
|
|
|
|
|
ctx.fillStyle = `rgba(255, 0, 0, ${this.laserAlpha * 0.3})`;
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(this.laserX, this.laserY, 5, 0, Math.PI * 2);
|
|
|
|
|
ctx.fillStyle = `rgba(255, 0, 0, ${this.laserAlpha})`;
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
|
|
this.laserAnimationId = requestAnimationFrame(() => this.drawLaser());
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【10】设置弹窗 ====================
|
|
|
|
|
openSettingDialog() {
|
|
|
|
|
this.tempSetting = { ...this.globalSetting };
|
|
|
|
|
this.settingDialogVisible = true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onSettingDialogOpen() {
|
|
|
|
|
this.getDevices();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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.settingDialogVisible = false;
|
|
|
|
|
this.$message.success("设置已保存");
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【11】时间更新 ====================
|
|
|
|
|
updateClock() {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
this.currentTime = now.toLocaleTimeString("zh-CN", { hour12: false });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【12】网络监控 ====================
|
|
|
|
|
startNetworkMonitoring() {
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
this.networkDelay = Math.floor(Math.random() * 50);
|
|
|
|
|
if (this.networkDelay < 20) {
|
|
|
|
|
this.networkQuality = { text: "良好", color: "#00e676" };
|
|
|
|
|
} else if (this.networkDelay < 40) {
|
|
|
|
|
this.networkQuality = { text: "一般", color: "#ffab00" };
|
|
|
|
|
} else {
|
|
|
|
|
this.networkQuality = { text: "较差", color: "#ff5252" };
|
|
|
|
|
}
|
|
|
|
|
}, 2000);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【13】会议成员、邀请、病例 ====================
|
|
|
|
|
handleInvite() {
|
|
|
|
|
this.inviteDialogVisible = true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
handleInviteConfirm(selectedContacts) {
|
|
|
|
|
console.log("邀请的联系人:", selectedContacts);
|
|
|
|
|
this.$message.success(`已邀请 ${selectedContacts.length} 位联系人`);
|
|
|
|
|
this.inviteDialogVisible = false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 取消质控
|
|
|
|
|
cancelQualityControl(index) {
|
|
|
|
|
this.$confirm("确定要取消该成员的质控吗?", "提示", {
|
|
|
|
|
confirmButtonText: "确定",
|
|
|
|
|
cancelButtonText: "取消",
|
|
|
|
|
type: "warning",
|
|
|
|
|
})
|
|
|
|
|
.then(() => {
|
|
|
|
|
this.gridSlots[index].user = null;
|
|
|
|
|
this.gridSlots[index].stream = null;
|
|
|
|
|
this.gridSlots[index].signalLevel = 0;
|
|
|
|
|
this.$message({
|
|
|
|
|
type: "success",
|
|
|
|
|
message: "已取消质控",
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 打开添加用户弹窗
|
|
|
|
|
openAddUserDialog(index) {
|
|
|
|
|
this.currentAddIndex = index;
|
|
|
|
|
this.showAddDialog = true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 显示用户详情
|
|
|
|
|
showUserDetail(user) {
|
|
|
|
|
this.$message.info(`用户: ${user.name}`);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 添加成员成功回调
|
|
|
|
|
handleAddMembers(members) {
|
|
|
|
|
const emptySlots = this.gridSlots
|
|
|
|
|
.map((slot, index) => ({ slot, index }))
|
|
|
|
|
.filter((item) => !item.slot.user);
|
|
|
|
|
|
|
|
|
|
members.forEach((member, idx) => {
|
|
|
|
|
if (emptySlots[idx]) {
|
|
|
|
|
this.gridSlots[emptySlots[idx].index].user = {
|
|
|
|
|
name: member.name,
|
|
|
|
|
status: "在线",
|
|
|
|
|
avatar:
|
|
|
|
|
member.avatar ||
|
|
|
|
|
"https://img.icons8.com/fluency/96/doctor-male.png",
|
|
|
|
|
};
|
|
|
|
|
this.gridSlots[emptySlots[idx].index].signalLevel = 5;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.showAddDialog = false;
|
|
|
|
|
this.$message({
|
|
|
|
|
type: "success",
|
|
|
|
|
message: `已添加 ${members.length} 位质控成员`,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ==================== 【14】结束会议 ====================
|
|
|
|
|
endMeeting() {
|
|
|
|
|
this.$confirm("确定结束质控?", "提示", { type: "warning" }).then(() => {
|
|
|
|
|
this.stopShare();
|
|
|
|
|
if (this.localStream)
|
|
|
|
|
this.localStream.getTracks().forEach((t) => t.stop());
|
|
|
|
|
this.$message.success("质控已结束");
|
|
|
|
|
this.$store.dispatch("app/toggleSideBarHide", false);
|
|
|
|
|
this.$store.dispatch("settings/changeSetting", {
|
|
|
|
|
key: "fixedHeader",
|
|
|
|
|
value: true,
|
|
|
|
|
});
|
|
|
|
|
this.$store.dispatch("settings/changeSetting", {
|
|
|
|
|
key: "tagsView",
|
|
|
|
|
value: true,
|
|
|
|
|
});
|
|
|
|
|
if (window.opener && !window.opener.closed) {
|
|
|
|
|
window.opener.location.reload();
|
|
|
|
|
}
|
|
|
|
|
if (window.name === "qualityControlWindow") {
|
|
|
|
|
window.close();
|
|
|
|
|
} else {
|
|
|
|
|
this.$router.push("/");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.meeting-container {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
height: 100vh;
|
|
|
|
|
background-color: #00413d;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 顶部导航栏
|
|
|
|
|
.top-bar {
|
|
|
|
|
height: 36px;
|
|
|
|
|
background-color: #00584d;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 0 12px;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
border-bottom: 1px solid #00695c;
|
|
|
|
|
|
|
|
|
|
.logo {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
|
|
|
|
img {
|
|
|
|
|
width: 24px;
|
|
|
|
|
height: 24px;
|
|
|
|
|
margin-right: 6px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.title {
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.window-controls {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
|
|
i {
|
|
|
|
|
width: 30px;
|
|
|
|
|
height: 30px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
transition: background-color 0.2s;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 主内容区
|
|
|
|
|
.main-content {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
padding: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2x2 网格布局
|
|
|
|
|
.grid-container {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
grid-template-rows: 1fr 1fr;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 单个分屏
|
|
|
|
|
.grid-slot {
|
|
|
|
|
background-color: #00413d;
|
|
|
|
|
position: relative;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
border: 1px solid #00695c;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 信号强度图标
|
|
|
|
|
.signal-icon {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 8px;
|
|
|
|
|
left: 8px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
gap: 2px;
|
|
|
|
|
height: 14px;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
|
|
|
|
.bar {
|
|
|
|
|
width: 3px;
|
|
|
|
|
background: #666;
|
|
|
|
|
border-radius: 1px;
|
|
|
|
|
|
|
|
|
|
&:nth-child(1) { height: 4px; }
|
|
|
|
|
&:nth-child(2) { height: 7px; }
|
|
|
|
|
&:nth-child(3) { height: 10px; }
|
|
|
|
|
&:nth-child(4) { height: 13px; }
|
|
|
|
|
&:nth-child(5) { height: 16px; }
|
|
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
|
background: #4cd964;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 顶部用户名
|
|
|
|
|
.slot-name {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 8px;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #fff;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 取消质控按钮
|
|
|
|
|
.cancel-btn {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 8px;
|
|
|
|
|
right: 8px;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 视频区域
|
|
|
|
|
.slot-video {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加质控按钮
|
|
|
|
|
.add-btn {
|
|
|
|
|
width: 60px;
|
|
|
|
|
height: 60px;
|
|
|
|
|
background-color: #00897b;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: background-color 0.2s, transform 0.2s;
|
|
|
|
|
border: none;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background-color: #00a396;
|
|
|
|
|
transform: scale(1.05);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
transform: scale(0.95);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.el-icon-plus {
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 有成员时的用户按钮
|
|
|
|
|
.user-btn {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
.user-avatar {
|
|
|
|
|
width: 56px;
|
|
|
|
|
height: 56px;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
background-color: #00b3ad;
|
|
|
|
|
transition: background 0.2s;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background-color: #00d4cd;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.avatar-text {
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
color: #fff;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.user-info-text {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
|
|
|
|
.user-name {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-dot {
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
|
|
&.online {
|
|
|
|
|
background-color: #4cd964;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.offline {
|
|
|
|
|
background-color: #999;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
</style>
|