You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

785 lines
20 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div ref="bigscreenRef" class="bigscreen" @dblclick="toggleFullscreen">
<!-- -->
<header class="bs-header">
<div class="bs-header-left">
<div class="header-decoration"></div>
</div>
<div class="bs-header-center">
<h1 class="bs-title">教室智能人脸考勤数据大屏</h1>
<p class="bs-subtitle">Classroom Smart Attendance Data Dashboard</p>
</div>
<div class="bs-header-right">
<div class="header-time">
<el-icon><Clock /></el-icon>
<span>{{ currentTime }}</span>
</div>
<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>
<!-- 核心数据卡片 -->
<div class="bs-stats">
<div class="bs-stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #52c41a, #73d13d)">
<el-icon :size="28"><UserFilled /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.attendanceRate }}<span class="stat-unit">%</span></div>
<div class="stat-label">全校今日出勤率</div>
<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>
<div class="bs-stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #722ed1, #9254de)">
<el-icon :size="28"><TrendCharts /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.focusRate }}<span class="stat-unit">%</span></div>
<div class="stat-label">课堂专注度占比</div>
<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>
</div>
<!-- 多维度展示区 -->
<div class="bs-charts">
<!-- 左侧:出勤趋势 -->
<div class="bs-panel">
<div class="panel-header">
<h3>出勤趋势</h3>
</div>
<div ref="trendChartRef" class="panel-body"></div>
</div>
<!-- 右侧:行为分布饼图 -->
<div class="bs-panel">
<div class="panel-header">
<h3>课堂行为分布</h3>
</div>
<div ref="behaviorChartRef" class="panel-body"></div>
</div>
</div>
<!-- 底部实时监控 -->
<div class="bs-monitor">
<div class="monitor-header">
<h3>教室实时监控</h3>
<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-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 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, 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 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 stats = reactive({
attendanceRate: 0,
trend: 0,
focusRate: 0,
focusTrend: 0
})
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 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))
// 释放旧实例,重新初始化确保 tooltip 等配置完整生效
const old = echarts.getInstanceByDom(trendChartRef.value)
old?.dispose()
const chart = echarts.init(trendChartRef.value)
trendChart.value = chart
chart.setOption({
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: dates, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', min: yMin, max: yMax, axisLabel: { formatter: '{value}%' } },
series: [{
name: '出勤率',
data: rates,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: { color: '#52c41a', width: 2.5 },
itemStyle: { color: '#52c41a' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82,196,26,0.2)' },
{ offset: 1, color: 'rgba(82,196,26,0.02)' }
])
}
}]
})
} 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()
const chart = echarts.init(behaviorChartRef.value)
chart.setOption({
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, 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)
instance?.resize()
})
})
})
onUnmounted(() => {
clearInterval(timer.value)
})
</script>
<style lang="scss" scoped>
.bigscreen {
width: 100%;
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;
height: 64px;
padding: 0 8px;
}
.bs-header-left,
.bs-header-right {
width: 200px;
display: flex;
align-items: center;
gap: 12px;
}
.bs-header-center {
text-align: center;
}
.bs-title {
font-size: 24px;
font-weight: 700;
background: linear-gradient(90deg, #52c41a, #73d13d);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 2px;
}
.bs-subtitle {
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 2px;
}
.header-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
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;
margin-bottom: 16px;
}
.bs-stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 20px;
display: flex;
gap: 16px;
align-items: center;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
&:hover {
border-color: rgba(82, 196, 26, 0.3);
background: rgba(82, 196, 26, 0.04);
}
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.stat-body {
flex: 1;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.stat-unit {
font-size: 18px;
font-weight: 400;
opacity: 0.5;
}
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
margin-top: 4px;
margin-bottom: 6px;
}
.stat-trend {
font-size: 12px;
display: flex;
align-items: center;
gap: 2px;
&.up { color: #52c41a; }
&.down { color: #f5222d; }
}
.bs-charts {
flex-shrink: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.bs-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
}
}
.panel-body {
height: 260px;
}
.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 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
}
}
.monitor-selects {
display: flex;
gap: 8px;
}
.monitor-select {
width: 160px;
: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 {
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid transparent;
&:hover {
border-color: rgba(82, 196, 26, 0.4);
transform: scale(1.02);
}
}
.monitor-feed {
height: 180px;
background: #0a1020;
display: flex;
align-items: center;
justify-content: center;
}
.monitor-label {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
font-size: 12px;
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) {
.bs-charts { grid-template-columns: 1fr 1fr; }
.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>