|
|
|
|
@ -1,5 +1,5 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="bigscreen" @dblclick="toggleFullscreen">
|
|
|
|
|
<div ref="bigscreenRef" class="bigscreen" @dblclick="toggleFullscreen">
|
|
|
|
|
<!-- 顶部标题区 -->
|
|
|
|
|
<header class="bs-header">
|
|
|
|
|
<div class="bs-header-left">
|
|
|
|
|
@ -14,7 +14,10 @@
|
|
|
|
|
<el-icon><Clock /></el-icon>
|
|
|
|
|
<span>{{ currentTime }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<el-button :icon="Refresh" circle size="small" @click="refreshData" />
|
|
|
|
|
<el-button :icon="Refresh" circle size="small" class="refresh-btn" @click="refreshData" />
|
|
|
|
|
<el-tooltip :content="isFullscreen ? '退出全屏' : '全屏展示'" placement="bottom">
|
|
|
|
|
<el-button :icon="FullScreen" circle size="small" class="fullscreen-btn" @click="toggleFullscreen" />
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
@ -25,11 +28,11 @@
|
|
|
|
|
<el-icon :size="28"><UserFilled /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-body">
|
|
|
|
|
<div class="stat-value">96.8<span class="stat-unit">%</span></div>
|
|
|
|
|
<div class="stat-value">{{ stats.attendanceRate }}<span class="stat-unit">%</span></div>
|
|
|
|
|
<div class="stat-label">全校今日出勤率</div>
|
|
|
|
|
<div class="stat-trend up">
|
|
|
|
|
<el-icon><CaretTop /></el-icon>
|
|
|
|
|
2.5% 较昨日
|
|
|
|
|
<div class="stat-trend" :class="stats.trend >= 0 ? 'up' : 'down'">
|
|
|
|
|
<el-icon><CaretTop v-if="stats.trend >= 0" /><CaretBottom v-else /></el-icon>
|
|
|
|
|
{{ Math.abs(stats.trend) }}% 较昨日
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@ -39,11 +42,11 @@
|
|
|
|
|
<el-icon :size="28"><TrendCharts /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-body">
|
|
|
|
|
<div class="stat-value">85.2<span class="stat-unit">%</span></div>
|
|
|
|
|
<div class="stat-value">{{ stats.focusRate }}<span class="stat-unit">%</span></div>
|
|
|
|
|
<div class="stat-label">课堂专注度占比</div>
|
|
|
|
|
<div class="stat-trend up">
|
|
|
|
|
<el-icon><CaretTop /></el-icon>
|
|
|
|
|
0.8% 较昨日
|
|
|
|
|
<div class="stat-trend" :class="stats.focusTrend >= 0 ? 'up' : 'down'">
|
|
|
|
|
<el-icon><CaretTop v-if="stats.focusTrend >= 0" /><CaretBottom v-else /></el-icon>
|
|
|
|
|
{{ Math.abs(stats.focusTrend) }}% 较昨日
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@ -72,80 +75,148 @@
|
|
|
|
|
<div class="bs-monitor">
|
|
|
|
|
<div class="monitor-header">
|
|
|
|
|
<h3>教室实时监控</h3>
|
|
|
|
|
<div class="monitor-tabs">
|
|
|
|
|
<span v-for="(cam, i) in cameras" :key="i" :class="{ active: activeCamera === i }" @click="activeCamera = i">
|
|
|
|
|
{{ cam }}
|
|
|
|
|
</span>
|
|
|
|
|
<div class="monitor-selects">
|
|
|
|
|
<el-select v-model="selectedBuildingId" placeholder="选择教学楼" size="small" class="monitor-select" popper-class="monitor-select-popper" :teleported="false" @change="onBuildingChange">
|
|
|
|
|
<el-option v-for="b in buildingList" :key="b.id" :label="b.buildingName" :value="b.id" />
|
|
|
|
|
</el-select>
|
|
|
|
|
<el-select v-model="selectedRoomId" placeholder="选择教室" size="small" class="monitor-select" popper-class="monitor-select-popper" :teleported="false" :disabled="!selectedBuildingId" @change="onRoomChange">
|
|
|
|
|
<el-option v-for="r in filteredRooms" :key="r.id" :label="r.roomName" :value="r.id" />
|
|
|
|
|
</el-select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="monitor-grid">
|
|
|
|
|
<div v-for="i in 4" :key="i" class="monitor-cell" @click="enlargeMonitor(i)">
|
|
|
|
|
<div class="monitor-feed">
|
|
|
|
|
<el-icon :size="36" color="#4a4a6a"><VideoCamera /></el-icon>
|
|
|
|
|
<div v-if="!selectedBuildingId" class="monitor-empty">请选择教学楼和教室查看监控</div>
|
|
|
|
|
<template v-else-if="!selectedRoomId">
|
|
|
|
|
<div class="monitor-empty">请选择教室查看监控</div>
|
|
|
|
|
</template>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div v-for="device in filteredDevices" :key="device.id" class="monitor-cell">
|
|
|
|
|
<div class="monitor-feed">
|
|
|
|
|
<WebRtcPlayer v-if="device.streamUrl" :src="`${device.streamUrl}/whep`"/>
|
|
|
|
|
<el-icon v-else :size="36" color="#4a4a6a"><VideoCamera /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="monitor-label">
|
|
|
|
|
<span class="label-left">
|
|
|
|
|
<span class="dot" :class="{ pulse: device.onlineStatus === 1, offline: device.onlineStatus !== 1 }"></span>
|
|
|
|
|
{{ device.deviceName || device.deviceNo }} · {{ device.onlineStatus === 1 ? '直播中' : '离线' }}
|
|
|
|
|
</span>
|
|
|
|
|
<el-icon :size="15" class="zoom-icon" @click.stop="enlargeMonitor(device)"><FullScreen /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="monitor-label">
|
|
|
|
|
<span class="dot pulse"></span>
|
|
|
|
|
教室 {{ 300 + i }} · 直播中
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="filteredDevices.length === 0" class="monitor-empty">暂无数据</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- 视频放大弹窗 -->
|
|
|
|
|
<el-dialog v-model="videoDialogVisible" :title="videoDialogDevice?.deviceName || videoDialogDevice?.deviceNo || '监控画面'" width="80%" top="5vh" class="video-dialog" destroy-on-close>
|
|
|
|
|
<div class="dialog-video-wrapper">
|
|
|
|
|
<WebRtcPlayer v-if="videoDialogDevice?.streamUrl" :src="`${videoDialogDevice.streamUrl}/whep`" />
|
|
|
|
|
<div v-else class="dialog-no-stream">该设备暂无视频流</div>
|
|
|
|
|
</div>
|
|
|
|
|
</el-dialog>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
|
|
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import * as echarts from 'echarts'
|
|
|
|
|
import { Refresh, FullScreen } from '@element-plus/icons-vue'
|
|
|
|
|
import { getBigScreenStats, getBigScreenTrend, getBigScreenBehavior } from '@/api/bigscreen'
|
|
|
|
|
import { getBuildingList, getRoomsList, getDeviceList } from '@/api/info'
|
|
|
|
|
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
|
|
|
|
|
|
|
|
|
|
const currentTime = ref('')
|
|
|
|
|
const timer = ref(null)
|
|
|
|
|
|
|
|
|
|
const activeCamera = ref(0)
|
|
|
|
|
const cameras = ref(['第一教学楼', '第二教学楼', '实验楼'])
|
|
|
|
|
// 监控区域
|
|
|
|
|
const buildingList = ref([])
|
|
|
|
|
const roomList = ref([])
|
|
|
|
|
const deviceList = ref([])
|
|
|
|
|
const selectedBuildingId = ref(null)
|
|
|
|
|
const selectedRoomId = ref(null)
|
|
|
|
|
|
|
|
|
|
const filteredRooms = computed(() => {
|
|
|
|
|
if (!selectedBuildingId.value) return []
|
|
|
|
|
return roomList.value.filter(r => r.buildingId === selectedBuildingId.value)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const filteredDevices = computed(() => {
|
|
|
|
|
if (!selectedRoomId.value) return []
|
|
|
|
|
return deviceList.value.filter(d => d.classroomId === selectedRoomId.value)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const onBuildingChange = () => {
|
|
|
|
|
selectedRoomId.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onRoomChange = () => {
|
|
|
|
|
// 选择教室后设备自动过滤
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trendChartRef = ref(null)
|
|
|
|
|
const behaviorChartRef = ref(null)
|
|
|
|
|
const trendChart = ref(null)
|
|
|
|
|
|
|
|
|
|
const refreshData = () => {
|
|
|
|
|
ElMessage.success('数据已刷新')
|
|
|
|
|
}
|
|
|
|
|
const stats = reactive({
|
|
|
|
|
attendanceRate: 0,
|
|
|
|
|
trend: 0,
|
|
|
|
|
focusRate: 0,
|
|
|
|
|
focusTrend: 0
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const toggleFullscreen = () => {
|
|
|
|
|
if (document.fullscreenElement) {
|
|
|
|
|
document.exitFullscreen()
|
|
|
|
|
} else {
|
|
|
|
|
document.documentElement.requestFullscreen()
|
|
|
|
|
const fetchStats = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await getBigScreenStats()
|
|
|
|
|
if (res?.data) {
|
|
|
|
|
stats.attendanceRate = res.data.attendanceRate ?? stats.attendanceRate
|
|
|
|
|
stats.trend = res.data.trend ?? stats.trend
|
|
|
|
|
stats.focusRate = res.data.focusRate ?? stats.focusRate
|
|
|
|
|
stats.focusTrend = res.data.focusTrend ?? stats.focusTrend
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
ElMessage.warning('获取大屏数据失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const enlargeMonitor = (i) => {
|
|
|
|
|
ElMessage.info(`放大查看教室${300 + i}监控画面`)
|
|
|
|
|
}
|
|
|
|
|
const fetchTrend = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await getBigScreenTrend()
|
|
|
|
|
if (!res?.data || !trendChartRef.value) return
|
|
|
|
|
const { dates, rates } = res.data
|
|
|
|
|
const yMin = Math.max(0, Math.floor(Math.min(...rates) - 2))
|
|
|
|
|
const yMax = Math.min(100, Math.ceil(Math.max(...rates) + 2))
|
|
|
|
|
|
|
|
|
|
const updateTime = () => {
|
|
|
|
|
const now = new Date()
|
|
|
|
|
currentTime.value = now.toLocaleString('zh-CN', {
|
|
|
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
|
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
// 释放旧实例,重新初始化确保 tooltip 等配置完整生效
|
|
|
|
|
const old = echarts.getInstanceByDom(trendChartRef.value)
|
|
|
|
|
old?.dispose()
|
|
|
|
|
|
|
|
|
|
const initCharts = () => {
|
|
|
|
|
// 出勤趋势
|
|
|
|
|
if (trendChartRef.value) {
|
|
|
|
|
const chart = echarts.init(trendChartRef.value)
|
|
|
|
|
trendChart.value = chart
|
|
|
|
|
chart.setOption({
|
|
|
|
|
tooltip: { trigger: 'axis' },
|
|
|
|
|
tooltip: {
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
backgroundColor: 'rgba(6, 12, 28, 0.92)',
|
|
|
|
|
borderColor: 'rgba(82, 196, 26, 0.35)',
|
|
|
|
|
textStyle: { color: '#fff', fontSize: 13 },
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
const p = Array.isArray(params) ? params[0] : params
|
|
|
|
|
return `<div style="line-height:1.8">
|
|
|
|
|
<span style="color:rgba(255,255,255,0.5)">日期:</span><span style="color:#fff">${p.axisValue}</span><br/>
|
|
|
|
|
<span style="color:rgba(255,255,255,0.5)">出勤率:</span><span style="color:#52c41a;font-weight:700;font-size:16px">${p.value}%</span>
|
|
|
|
|
</div>`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
grid: { top: 20, right: 20, bottom: 20, left: 40 },
|
|
|
|
|
xAxis: { type: 'category', data: ['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01'], axisLabel: { fontSize: 11 } },
|
|
|
|
|
yAxis: { type: 'value', min: 85, max: 100, axisLabel: { formatter: '{value}%' } },
|
|
|
|
|
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11 } },
|
|
|
|
|
yAxis: { type: 'value', min: yMin, max: yMax, axisLabel: { formatter: '{value}%' } },
|
|
|
|
|
series: [{
|
|
|
|
|
data: [93, 95, 94.5, 96, 95.8, 97, 96.8],
|
|
|
|
|
name: '出勤率',
|
|
|
|
|
data: rates,
|
|
|
|
|
type: 'line',
|
|
|
|
|
smooth: true,
|
|
|
|
|
symbol: 'circle',
|
|
|
|
|
symbolSize: 6,
|
|
|
|
|
symbolSize: 8,
|
|
|
|
|
lineStyle: { color: '#52c41a', width: 2.5 },
|
|
|
|
|
itemStyle: { color: '#52c41a' },
|
|
|
|
|
areaStyle: {
|
|
|
|
|
@ -156,34 +227,115 @@ const initCharts = () => {
|
|
|
|
|
}
|
|
|
|
|
}]
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
ElMessage.warning('获取出勤趋势失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchBehavior = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await getBigScreenBehavior()
|
|
|
|
|
if (!res?.data || !behaviorChartRef.value) return
|
|
|
|
|
|
|
|
|
|
const old = echarts.getInstanceByDom(behaviorChartRef.value)
|
|
|
|
|
old?.dispose()
|
|
|
|
|
|
|
|
|
|
// 行为分布
|
|
|
|
|
if (behaviorChartRef.value) {
|
|
|
|
|
const chart = echarts.init(behaviorChartRef.value)
|
|
|
|
|
chart.setOption({
|
|
|
|
|
tooltip: { trigger: 'item', formatter: '{b}: {c}%' },
|
|
|
|
|
tooltip: {
|
|
|
|
|
trigger: 'item',
|
|
|
|
|
backgroundColor: 'rgba(6, 12, 28, 0.92)',
|
|
|
|
|
borderColor: 'rgba(114, 46, 209, 0.35)',
|
|
|
|
|
textStyle: { color: '#fff', fontSize: 13 },
|
|
|
|
|
formatter: '{b}: {c}%'
|
|
|
|
|
},
|
|
|
|
|
series: [{
|
|
|
|
|
type: 'pie',
|
|
|
|
|
radius: ['50%', '75%'],
|
|
|
|
|
center: ['50%', '55%'],
|
|
|
|
|
label: { fontSize: 11 },
|
|
|
|
|
data: [
|
|
|
|
|
{ value: 65, name: '专注听讲', itemStyle: { color: '#52c41a' } },
|
|
|
|
|
{ value: 15, name: '举手互动', itemStyle: { color: '#1890ff' } },
|
|
|
|
|
{ value: 12, name: '低头书写', itemStyle: { color: '#722ed1' } },
|
|
|
|
|
{ value: 5, name: '交谈讨论', itemStyle: { color: '#faad14' } },
|
|
|
|
|
{ value: 3, name: '其他', itemStyle: { color: '#bfbfbf' } }
|
|
|
|
|
]
|
|
|
|
|
label: { fontSize: 11, color: 'rgba(255,255,255,0.6)' },
|
|
|
|
|
data: res.data.map(item => ({
|
|
|
|
|
value: item.value,
|
|
|
|
|
name: item.name,
|
|
|
|
|
itemStyle: { color: item.color }
|
|
|
|
|
}))
|
|
|
|
|
}]
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
ElMessage.warning('获取行为分布失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const refreshData = () => {
|
|
|
|
|
Promise.all([fetchStats(), fetchTrend(), fetchBehavior()])
|
|
|
|
|
ElMessage.success('数据已刷新')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bigscreenRef = ref(null)
|
|
|
|
|
const isFullscreen = ref(false)
|
|
|
|
|
|
|
|
|
|
const syncFullscreenState = () => {
|
|
|
|
|
isFullscreen.value = document.fullscreenElement === bigscreenRef.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toggleFullscreen = () => {
|
|
|
|
|
if (document.fullscreenElement) {
|
|
|
|
|
document.exitFullscreen()
|
|
|
|
|
} else {
|
|
|
|
|
bigscreenRef.value?.requestFullscreen()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
document.addEventListener('fullscreenchange', syncFullscreenState)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
document.removeEventListener('fullscreenchange', syncFullscreenState)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const enlargeMonitor = (device) => {
|
|
|
|
|
videoDialogDevice.value = device
|
|
|
|
|
videoDialogVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 视频放大弹窗
|
|
|
|
|
const videoDialogVisible = ref(false)
|
|
|
|
|
const videoDialogDevice = ref(null)
|
|
|
|
|
|
|
|
|
|
const fetchMonitorData = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const [bRes, rRes, dRes] = await Promise.all([
|
|
|
|
|
getBuildingList(),
|
|
|
|
|
getRoomsList(),
|
|
|
|
|
getDeviceList()
|
|
|
|
|
])
|
|
|
|
|
if (bRes?.data) buildingList.value = bRes.data
|
|
|
|
|
if (rRes?.data) roomList.value = rRes.data
|
|
|
|
|
if (dRes?.data) deviceList.value = dRes.data
|
|
|
|
|
} catch {
|
|
|
|
|
ElMessage.warning('获取监控数据失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updateTime = () => {
|
|
|
|
|
const now = new Date()
|
|
|
|
|
currentTime.value = now.toLocaleString('zh-CN', {
|
|
|
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
|
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initCharts = () => {
|
|
|
|
|
// 出勤趋势图表由 fetchTrend 完整初始化(含 API 数据)
|
|
|
|
|
// 课堂行为分布图表由 fetchBehavior 完整初始化(含 API 数据)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
updateTime()
|
|
|
|
|
timer.value = setInterval(updateTime, 1000)
|
|
|
|
|
initCharts()
|
|
|
|
|
Promise.all([fetchStats(), fetchTrend(), fetchBehavior(), fetchMonitorData()])
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
[trendChartRef.value, behaviorChartRef.value].forEach(el => {
|
|
|
|
|
const instance = echarts.getInstanceByDom(el)
|
|
|
|
|
@ -200,14 +352,25 @@ onUnmounted(() => {
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.bigscreen {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: calc(100vh - 96px);
|
|
|
|
|
height: calc(100vh - 96px);
|
|
|
|
|
background: linear-gradient(180deg, #0a1628 0%, #132042 50%, #1a2d4a 100%);
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
padding: 0 24px 20px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 全屏时占满整个屏幕,隐藏左侧菜单和顶部导航 */
|
|
|
|
|
.bigscreen:fullscreen {
|
|
|
|
|
height: 100vh;
|
|
|
|
|
width: 100vw;
|
|
|
|
|
padding: 16px 32px 24px;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bs-header {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
@ -252,7 +415,24 @@ onUnmounted(() => {
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.refresh-btn,
|
|
|
|
|
.fullscreen-btn {
|
|
|
|
|
--el-button-bg-color: rgba(82, 196, 26, 0.12) !important;
|
|
|
|
|
--el-button-border-color: rgba(82, 196, 26, 0.25) !important;
|
|
|
|
|
--el-button-hover-bg-color: rgba(82, 196, 26, 0.22) !important;
|
|
|
|
|
--el-button-hover-border-color: rgba(82, 196, 26, 0.45) !important;
|
|
|
|
|
--el-button-text-color: #52c41a !important;
|
|
|
|
|
box-shadow: 0 0 12px rgba(82, 196, 26, 0.15);
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
box-shadow: 0 0 20px rgba(82, 196, 26, 0.3);
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bs-stats {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
|
gap: 16px;
|
|
|
|
|
@ -322,6 +502,7 @@ onUnmounted(() => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bs-charts {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
@ -354,10 +535,14 @@ onUnmounted(() => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.bs-monitor {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.monitor-header {
|
|
|
|
|
@ -374,31 +559,56 @@ onUnmounted(() => {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.monitor-tabs {
|
|
|
|
|
.monitor-selects {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: rgba(255, 255, 255, 0.4);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
.monitor-select {
|
|
|
|
|
width: 160px;
|
|
|
|
|
|
|
|
|
|
&:hover { color: rgba(255, 255, 255, 0.7); }
|
|
|
|
|
&.active {
|
|
|
|
|
background: rgba(82, 196, 26, 0.2);
|
|
|
|
|
color: #52c41a;
|
|
|
|
|
}
|
|
|
|
|
:deep(.el-select__wrapper) {
|
|
|
|
|
background: rgba(255, 255, 255, 0.06);
|
|
|
|
|
border-color: rgba(255, 255, 255, 0.12);
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-select__wrapper:hover) {
|
|
|
|
|
border-color: rgba(82, 196, 26, 0.35);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-select__wrapper.is-focus) {
|
|
|
|
|
border-color: rgba(82, 196, 26, 0.5);
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(82, 196, 26, 0.2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-select__placeholder) {
|
|
|
|
|
color: rgba(255, 255, 255, 0.3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-select__placeholder.is-transparent) {
|
|
|
|
|
color: rgba(255, 255, 255, 0.3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-select__selected-item) {
|
|
|
|
|
color: rgba(255, 255, 255, 0.85);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-select__caret) {
|
|
|
|
|
color: rgba(255, 255, 255, 0.35);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.monitor-grid {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
grid-auto-rows: min-content;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
align-content: start;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.monitor-cell {
|
|
|
|
|
@ -415,7 +625,7 @@ onUnmounted(() => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.monitor-feed {
|
|
|
|
|
height: 160px;
|
|
|
|
|
height: 180px;
|
|
|
|
|
background: #0a1020;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
@ -429,15 +639,71 @@ onUnmounted(() => {
|
|
|
|
|
color: rgba(255, 255, 255, 0.6);
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.label-left {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.zoom-icon {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
color: rgba(255, 255, 255, 0.3);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
color: #52c41a;
|
|
|
|
|
transform: scale(1.2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dot {
|
|
|
|
|
width: 6px;
|
|
|
|
|
height: 6px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: #52c41a;
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
|
|
|
|
&.offline { background: #666; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.monitor-empty {
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 48px 0;
|
|
|
|
|
color: rgba(255, 255, 255, 0.3);
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 视频放大弹窗 */
|
|
|
|
|
.dialog-video-wrapper {
|
|
|
|
|
width: 100%;
|
|
|
|
|
aspect-ratio: 16 / 9;
|
|
|
|
|
background: #0a1020;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
|
|
:deep(.webrtc-wrapper),
|
|
|
|
|
:deep(.video-player) {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dialog-no-stream {
|
|
|
|
|
color: rgba(255, 255, 255, 0.3);
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1200px) {
|
|
|
|
|
@ -445,3 +711,74 @@ onUnmounted(() => {
|
|
|
|
|
.monitor-grid { grid-template-columns: repeat(2, 1fr); }
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
<style lang="scss">
|
|
|
|
|
/* 视频弹窗全局暗色覆盖(dialog 渲染到 body,需非 scoped) */
|
|
|
|
|
.video-dialog {
|
|
|
|
|
--el-dialog-bg-color: #0a1628;
|
|
|
|
|
--el-dialog-border-radius: 10px;
|
|
|
|
|
--el-border-color: rgba(82, 196, 26, 0.15);
|
|
|
|
|
|
|
|
|
|
.el-dialog__header {
|
|
|
|
|
padding: 16px 20px;
|
|
|
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.el-dialog__title {
|
|
|
|
|
color: rgba(255, 255, 255, 0.85);
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.el-dialog__headerbtn .el-dialog__close {
|
|
|
|
|
color: rgba(255, 255, 255, 0.4);
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
color: #52c41a;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.el-dialog__body {
|
|
|
|
|
padding: 16px 20px 20px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 大屏监控下拉框浮层 — popper-class 挂在 .el-popper 根元素上 */
|
|
|
|
|
.el-popper.monitor-select-popper {
|
|
|
|
|
/* 浮层容器:暗色背景 + 深色边框,与选中框视觉一致 */
|
|
|
|
|
&.is-light,
|
|
|
|
|
&.is-dark {
|
|
|
|
|
background-color: rgba(10, 22, 40, 0.96) !important;
|
|
|
|
|
border-color: rgba(60, 80, 110, 0.4) !important;
|
|
|
|
|
|
|
|
|
|
/* 小三角:背景和边框与浮层完全一致 */
|
|
|
|
|
.el-popper__arrow::before {
|
|
|
|
|
background-color: rgba(10, 22, 40, 0.96);
|
|
|
|
|
border-color: rgba(60, 80, 110, 0.4);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 下拉列表区域 */
|
|
|
|
|
.el-select-dropdown {
|
|
|
|
|
background: transparent !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 列表项文字 + hover / 选中态 */
|
|
|
|
|
.el-select-dropdown__item {
|
|
|
|
|
color: rgba(255, 255, 255, 0.75) !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.el-select-dropdown__item:hover,
|
|
|
|
|
.el-select-dropdown__item.is-hovering {
|
|
|
|
|
color: #fff !important;
|
|
|
|
|
background: rgba(82, 196, 26, 0.15) !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.el-select-dropdown__item.is-selected {
|
|
|
|
|
color: #52c41a !important;
|
|
|
|
|
background: rgba(82, 196, 26, 0.12) !important;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|