feat:大屏数据接口联调

master
zhoulexin 2 weeks ago
parent f23d9d98d3
commit fd149325e9

@ -0,0 +1,16 @@
import request from '@/utils/request'
/** 获取大屏核心统计数据 */
export const getBigScreenStats = () => {
return request.get('/bigscreen/stats')
}
/** 获取出勤趋势数据 */
export const getBigScreenTrend = () => {
return request.get('/bigscreen/trend')
}
/** 获取课堂行为分布数据 */
export const getBigScreenBehavior = () => {
return request.get('/bigscreen/behavior-distribution')
}

@ -27,6 +27,11 @@ export function deleteBuilding(ids) {
return request({ url: '/building', method: 'delete', data: ids })
}
// 获取教室列表(下拉用,返回全部)
export function getRoomsList() {
return request({ url: '/classroom/list', method: 'get' })
}
// 获取教室列表分页按教学楼id
export function getRooms(params) {
return request({ url: '/classroom/page', method: 'get', params })
@ -47,6 +52,11 @@ export function deleteRoom(ids) {
return request({ url: '/classroom', method: 'delete', data: ids })
}
// 获取摄像头列表(下拉用,返回全部)
export function getDeviceList() {
return request({ url: '/device/list', method: 'get' })
}
// 获取摄像头列表按教室id
export function getCameras(params) {
return request({ url: '/device/page', method: 'get', params })

@ -0,0 +1,280 @@
<template>
<div class="webrtc-wrapper">
<video
ref="videoRef"
autoplay
muted
playsinline
class="video-player"
></video>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<span class="spinner"></span>
<span>连接中...</span>
</div>
<!-- 录制状态指示器 -->
<div v-if="isRecording" class="recording-indicator">
<span class="dot"></span> 录制中 {{ recordTime }}s
</div>
<div v-if="error" class="error-overlay">{{ error }}</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
src: { type: String, required: true }
});
// src
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc && newSrc !== oldSrc) {
disconnect();
connect();
}
});
//
const emit = defineEmits(['record-status-change','record-complete']);
const videoRef = ref(null);
const error = ref('');
const loading = ref(false);
let pc = null;
// --- ---
const isRecording = ref(false);
const recordTime = ref(0);
let mediaRecorder = null;
let recordedChunks = [];
let timerInterval = null;
// 1. WebRTC
const disconnect = () => {
if (pc) {
pc.close();
pc = null;
}
if (videoRef.value) {
videoRef.value.srcObject = null;
}
error.value = '';
};
// 2. WebRTC
const connect = async () => {
loading.value = true;
error.value = '';
try {
pc = new RTCPeerConnection();
pc.ontrack = (event) => {
if (videoRef.value && event.streams[0]) {
videoRef.value.srcObject = event.streams[0];
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch(props.src, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
if (!res.ok) throw new Error(`Status: ${res.status}`);
const answer = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
} catch (e) {
error.value = '连接失败,请检查视频地址';
console.error(e);
} finally {
let timeout = setTimeout(() => {
loading.value = false;
clearTimeout(timeout);
}, 1500);
}
};
// 2.
const takeSnapshot = () => {
if (!videoRef.value) return null;
const video = videoRef.value;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Blob (: image/png, image/jpeg)
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/jpeg', 0.9);
});
};
// 3.
const startRecord = () => {
if (!videoRef.value || isRecording.value) return;
try {
// MediaStream
// captureStream API Chrome/Edge/Firefox
const stream = videoRef.value.captureStream ? videoRef.value.captureStream() : videoRef.value.mozCaptureStream();
if (!stream) {
error.value = '浏览器不支持捕获视频流';
return;
}
recordedChunks = [];
// MediaRecorder
// mimeType 'video/webm; codecs=vp9'
mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: 'video/webm' });
recordedChunks = []; //
// blob
emit('record-complete', blob);
console.log('录制完成Blob大小:', blob.size);
};
mediaRecorder.start();
isRecording.value = true;
recordTime.value = 0;
//
timerInterval = setInterval(() => {
recordTime.value++;
}, 1000);
emit('record-status-change', true);
} catch (err) {
console.error('录制启动失败:', err);
error.value = '录制启动失败';
}
};
// 4.
const stopRecord = () => {
if (mediaRecorder && isRecording.value) {
mediaRecorder.stop();
isRecording.value = false;
clearInterval(timerInterval);
emit('record-status-change', false);
}
};
// 5.
const handleRecordComplete = (blob) => {
console.log('录制完成,文件大小:', blob.size, 'bytes');
//
// uploadVideoToServer(blob);
// URL
// const url = URL.createObjectURL(blob);
};
//
const uploadVideoToServer = async (blob) => {
const formData = new FormData();
formData.append('file', blob, `record_${new Date().getTime()}.webm`);
try {
//
// await axios.post('/api/upload/video', formData);
console.log('视频上传成功');
} catch (e) {
console.error('上传失败', e);
}
};
onMounted(() => connect());
onUnmounted(() => {
if (pc) pc.close();
if (isRecording.value) stopRecord();
});
//
defineExpose({
takeSnapshot,
startRecord,
stopRecord
});
</script>
<style scoped>
.webrtc-wrapper { width: 100%; height: 100%; position: relative; background: #000; }
.video-player { width: 100%; height: 100%; object-fit: contain; }
.error-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; }
.loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #409eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.recording-indicator {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 1s infinite;
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
</style>

@ -61,7 +61,7 @@ const routes = [
{
path: 'info/student',
name: 'InfoStudent',
component: () => import('@/views/settings/personnel.vue'),
component: () => import('@/views/info/student.vue'),
meta: { title: '学生信息', icon: 'User' }
},
{

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

@ -509,7 +509,7 @@ const loadCameras = async () => {
}
}
const cameraForm = ref({ deviceNo: '', name: '', ip: '', streamType: 'webrtc', position: '', status: '在线' })
const cameraForm = ref({ deviceNo: '', name: '', streamUrl: '', streamType: 'webrtc', position: '', status: '在线' })
watch(cameraPage, () => loadCameras())
watch(cameraSearch, () => {
@ -528,7 +528,7 @@ const showCameraDialog = (row) => {
cameraForm.value = { ...row }
} else {
cameraEditing.value = false
cameraForm.value = { deviceNo: '', name: '', ip: '', streamType: 'webrtc', position: '', status: '在线' }
cameraForm.value = { deviceNo: '', name: '', streamUrl: '', streamType: 'webrtc', position: '', status: '在线' }
}
cameraDialogVisible.value = true
}
@ -539,7 +539,7 @@ const saveCamera = async () => {
deviceType: 'camera',
deviceName: cameraForm.value.name,
streamType: cameraForm.value.streamType,
streamUrl: cameraForm.value.ip,
streamUrl: cameraForm.value.streamUrl,
location: cameraForm.value.position,
onlineStatus: cameraForm.value.status === '在线' ? 1 : 0,
classroomId: currentRoom.value.id

Loading…
Cancel
Save