fix:字段优化,页面调整

master
zhoulexin 1 week ago
parent 70ee94f39e
commit 5a5f5ff1d0

@ -1,10 +1,13 @@
<template>
<div class="data-card fade-in-up" @click="$emit('click')">
<div class="card-header">
<div class="card-icon" :style="{ background: iconBg }">
<el-icon :size="20" :color="iconColor"><component :is="icon" /></el-icon>
<div class="card-header-left">
<div class="card-icon" :style="{ background: iconBg }">
<el-icon :size="20" :color="iconColor"><component :is="icon" /></el-icon>
</div>
<span class="card-label">{{ label }}</span>
</div>
<span class="card-label">{{ label }}</span>
<span v-if="date" class="card-date">{{ date }}</span>
</div>
<div class="card-body">
<div class="card-value">
@ -31,7 +34,8 @@ defineProps({
icon: { type: [String, Object], required: true },
iconColor: { type: String, default: '#52c41a' },
iconBg: { type: String, default: '#e8f9e0' },
trend: { type: Number, default: undefined }
trend: { type: Number, default: undefined },
date: { type: String, default: '' }
})
defineEmits(['click'])
@ -56,10 +60,16 @@ defineEmits(['click'])
.card-header {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
margin-bottom: 16px;
}
.card-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.card-icon {
width: 40px;
height: 40px;
@ -74,6 +84,12 @@ defineEmits(['click'])
color: #525252;
}
.card-date {
font-size: 12px;
color: #bfbfbf;
white-space: nowrap;
}
.card-body {
display: flex;
align-items: flex-end;

@ -49,7 +49,7 @@
</template>
<el-menu-item index="/info/building">
<el-icon><OfficeBuilding /></el-icon>
<span>学楼信息</span>
<span>信息</span>
</el-menu-item>
<el-menu-item index="/info/class">
<el-icon><School /></el-icon>
@ -108,12 +108,7 @@ import { useAppStore } from '@/stores/app'
const route = useRoute()
const appStore = useAppStore()
const activeMenu = computed(() => {
const { path } = route
if (path.startsWith('/settings')) return '/settings'
if (path.startsWith('/info')) return '/info'
return path
})
const activeMenu = computed(() => route.path)
</script>
<style lang="scss" scoped>

@ -38,7 +38,7 @@ watch(() => props.src, (newSrc, oldSrc) => {
});
//
const emit = defineEmits(['record-status-change','record-complete']);
const emit = defineEmits(['record-status-change','record-complete','connection-status']);
const videoRef = ref(null);
const error = ref('');
@ -73,6 +73,7 @@ const connect = async () => {
pc.ontrack = (event) => {
if (videoRef.value && event.streams[0]) {
videoRef.value.srcObject = event.streams[0];
emit('connection-status', true);
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
@ -93,6 +94,7 @@ const connect = async () => {
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
} catch (e) {
error.value = '连接失败,请检查视频地址';
emit('connection-status', false);
console.error(e);
} finally {
let timeout = setTimeout(() => {

@ -68,7 +68,7 @@ const routes = [
path: 'info/building',
name: 'InfoBuilding',
component: () => import('@/views/info/building.vue'),
meta: { title: '教学楼信息', icon: 'OfficeBuilding' }
meta: { title: '教信息', icon: 'OfficeBuilding' }
},
{
path: 'info/class',
@ -87,6 +87,12 @@ const routes = [
name: 'InfoCourse',
component: () => import('@/views/info/course.vue'),
meta: { title: '课程信息', icon: 'Reading' }
},
{
path: 'bigscreen/room/:id',
name: 'BigScreenRoomDetail',
component: () => import('@/views/bigscreen/roomDetail.vue'),
meta: { title: '教室监控详情' }
}
]
}

@ -7,8 +7,12 @@
<!-- 课程选择区 -->
<div class="filter-bar">
<el-input v-model="filters.courseName" placeholder="搜索课程" clearable size="default" style="width: 200px" />
<el-input v-model="filters.teacherName" placeholder="搜索教师" clearable size="default" style="width: 180px" />
<el-select v-model="filters.courseId" placeholder="全部课程" clearable filterable size="default" style="width: 200px">
<el-option v-for="c in courseList" :key="c.id" :label="c.courseName" :value="c.id" />
</el-select>
<el-select v-model="filters.teacherId" placeholder="全部教师" clearable filterable size="default" style="width: 180px">
<el-option v-for="t in teacherList" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
<el-date-picker v-model="filters.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-button type="primary" :icon="Search" :loading="loading" @click="fetchData"></el-button>
<el-button :icon="RefreshRight" @click="handleReset"></el-button>
@ -67,10 +71,24 @@
</el-icon>
</div>
<div class="image-info">
<span class="image-time">{{ img.behaviorTime }}</span>
<el-tag :color="getTypeColor(img.behaviorTypeId)" size="small" effect="dark">
{{ img.behaviorTypeName }}
</el-tag>
<div class="info-row">
<span class="info-label">课程</span>
<span class="info-value">{{ img.courseName }}</span>
</div>
<div class="info-row">
<span class="info-label">教师</span>
<span class="info-value">{{ img.teacherName }}</span>
</div>
<div class="info-row">
<span class="info-label">学生</span>
<span class="info-value">{{ img.studentName }}</span>
</div>
<div class="info-bottom">
<span class="image-time">{{ img.behaviorTime }}</span>
<el-tag :color="getTypeColor(img.behaviorTypeId)" size="small" effect="dark">
{{ img.behaviorTypeName }}
</el-tag>
</div>
</div>
<div class="image-check" @click.stop>
<el-checkbox v-model="img.checked" />
@ -96,7 +114,7 @@
</div>
<!-- 图片预览 -->
<el-dialog v-model="previewVisible" title="图片预览" width="600px">
<el-dialog v-model="previewVisible" title="图片预览" width="600px" align-center>
<div class="preview-container">
<div class="preview-placeholder">
<img v-if="currentPreview?.snapshotUrl" :src="currentPreview.snapshotUrl" alt="" class="preview-img" />
@ -115,14 +133,19 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { Search, RefreshRight, PictureFilled, Download } from '@element-plus/icons-vue'
import { getBehaviorTypesWithStats, getBehaviorTimePeriod, getBehaviorRecords, getBehaviorTypes } from '@/api/behavior'
import { getCourseList, getTeacherList } from '@/api/info'
const filters = reactive({
courseName: '',
teacherName: '',
courseId: '',
teacherId: '',
date: null
})
const courseList = ref([])
const teacherList = ref([])
const imageFilter = ref('')
//
@ -184,14 +207,34 @@ const fetchBehaviorTypes = async () => {
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 获取全部课程列表(下拉用) */
const fetchAllCourses = async () => {
try {
const res = await getCourseList()
if (res && res.data) {
courseList.value = res.data
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 获取全部教师列表(下拉用) */
const fetchAllTeachers = async () => {
try {
const res = await getTeacherList()
if (res && res.data) {
teacherList.value = res.data
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 获取行为标记图片记录 */
const fetchRecords = async (page = 1) => {
try {
const params = {
current: page,
size: pagination.size,
courseName: filters.courseName || undefined,
teacherName: filters.teacherName || undefined,
courseId: filters.courseId || undefined,
teacherId: filters.teacherId || undefined,
attDate: filters.date || undefined,
behaviorTypeId: imageFilter.value || undefined
}
@ -210,8 +253,8 @@ const fetchRecords = async (page = 1) => {
/** 重置筛选条件 */
const handleReset = () => {
filters.courseName = ''
filters.teacherName = ''
filters.courseId = ''
filters.teacherId = ''
filters.date = null
imageFilter.value = ''
pagination.current = 1
@ -224,8 +267,8 @@ const fetchData = async () => {
pagination.current = 1
try {
const params = {
courseName: filters.courseName || undefined,
teacherName: filters.teacherName || undefined,
courseId: filters.courseId || undefined,
teacherId: filters.teacherId || undefined,
attDate: filters.date || undefined
}
const [res1, res2] = await Promise.all([
@ -380,6 +423,8 @@ const initCharts = () => {
onMounted(() => {
initCharts()
fetchBehaviorTypes()
fetchAllCourses()
fetchAllTeachers()
fetchData()
window.addEventListener('resize', () => {
pieChart?.resize()
@ -472,7 +517,7 @@ onUnmounted(() => {
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
@ -508,15 +553,42 @@ onUnmounted(() => {
}
.image-info {
padding: 8px 12px;
padding: 8px 10px;
}
.info-row {
display: flex;
align-items: center;
gap: 2px;
margin-bottom: 2px;
font-size: 12px;
line-height: 1.6;
}
.info-label {
color: #8c8c8c;
flex-shrink: 0;
}
.info-value {
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid #f0f0f0;
}
.image-time {
font-size: 12px;
color: #525252;
font-size: 11px;
color: #8c8c8c;
font-family: monospace;
}
@ -566,9 +638,31 @@ onUnmounted(() => {
color: #bfbfbf;
}
@media (max-width: 1280px) {
.image-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (max-width: 1024px) {
.charts-row-2 {
grid-template-columns: 1fr;
}
.image-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 768px) {
.image-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.image-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

@ -29,7 +29,7 @@
</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-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) }}% 较昨日
@ -71,87 +71,84 @@
</div>
</div>
<!-- 底部实时监控 -->
<!-- 底部教室概览 -->
<div class="bs-monitor">
<div class="monitor-header">
<h3>教室实时监控</h3>
<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 class="room-grid">
<div v-if="filteredRooms.length === 0" class="monitor-empty">{{ buildingList.length === 0 ? '' : '' }}</div>
<div
v-for="room in filteredRooms"
:key="room.id"
class="room-card"
@click="goToRoomDetail(room)"
>
<div class="room-card-header">
<el-icon :size="20" color="#52c41a"><School /></el-icon>
<span class="room-name">{{ room.roomName }}</span>
</div>
<div class="room-card-body">
<div class="room-info-item">
<span class="info-label">教室编号</span>
<span class="info-value">{{ room.roomNo }}</span>
</div>
<div class="room-info-item">
<span class="info-label">容纳人数</span>
<span class="info-value">{{ room.capacity }} </span>
</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 class="room-info-item">
<span class="info-label">摄像头</span>
<span class="info-value">{{ room.cameraCount ?? 0 }} </span>
</div>
</div>
<div v-if="filteredDevices.length === 0" class="monitor-empty"></div>
</template>
<div class="room-card-footer">
<el-tag :type="room.status === 1 ? 'success' : 'info'" size="small" effect="dark">
{{ room.status === 1 ? '正常' : '异常'}}
</el-tag>
<el-icon :size="14" color="rgba(255,255,255,0.3)"><ArrowRight /></el-icon>
</div>
</div>
</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 { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { Refresh, FullScreen } from '@element-plus/icons-vue'
import { Refresh, FullScreen, School, ArrowRight } 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'
import { getBuildingList, getRoomsList } from '@/api/info'
const currentTime = ref('')
const timer = ref(null)
const router = useRouter()
//
//
const buildingList = ref([])
const roomList = ref([])
const deviceList = ref([])
const selectedBuildingId = ref(null)
const selectedRoomId = ref(null)
const filteredRooms = computed(() => {
if (!selectedBuildingId.value) return []
console.log(roomList.value.filter(r => r.buildingId === selectedBuildingId.value),'++++++')
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 goToRoomDetail = (room) => {
router.push({ name: 'BigScreenRoomDetail', params: { id: room.id } })
}
const trendChartRef = ref(null)
@ -294,27 +291,20 @@ 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([
const [bRes, rRes] = await Promise.all([
getBuildingList(),
getRoomsList(),
getDeviceList()
getRoomsList()
])
if (bRes?.data) buildingList.value = bRes.data
if (rRes?.data) roomList.value = rRes.data
if (dRes?.data) deviceList.value = dRes.data
//
if (buildingList.value.length > 0 && !selectedBuildingId.value) {
selectedBuildingId.value = buildingList.value[0].id
}
} catch {
ElMessage.warning('获取监控数据失败')
ElMessage.warning('获取教室数据失败')
}
}
@ -611,139 +601,95 @@ onUnmounted(() => {
align-content: start;
}
.monitor-cell {
border-radius: 8px;
overflow: hidden;
.monitor-empty {
grid-column: 1 / -1;
text-align: center;
padding: 48px 0;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
/* 教室卡片区域 */
.room-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
padding: 16px;
overflow-y: auto;
align-content: start;
}
.room-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid transparent;
display: flex;
flex-direction: column;
gap: 12px;
&:hover {
border-color: rgba(82, 196, 26, 0.4);
transform: scale(1.02);
background: rgba(82, 196, 26, 0.06);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(82, 196, 26, 0.12);
}
}
.monitor-feed {
height: 180px;
background: #0a1020;
.room-card-header {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.room-name {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
}
.monitor-label {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
.room-card-body {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: 6px;
}
.label-left {
.room-info-item {
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;
justify-content: space-between;
font-size: 13px;
&:hover {
color: #52c41a;
transform: scale(1.2);
.info-label {
color: rgba(255, 255, 255, 0.4);
}
}
.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;
.info-value {
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
}
}
/* 视频放大弹窗 */
.dialog-video-wrapper {
width: 100%;
aspect-ratio: 16 / 9;
background: #0a1020;
border-radius: 6px;
overflow: hidden;
.room-card-footer {
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;
justify-content: space-between;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* 视频放大弹窗 */
@media (max-width: 1200px) {
.bs-charts { grid-template-columns: 1fr 1fr; }
.monitor-grid { grid-template-columns: repeat(2, 1fr); }
.room-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 {
/* 浮层容器:暗色背景 + 深色边框,与选中框视觉一致 */

@ -0,0 +1,45 @@
<template>
<div class="room-detail">
<div class="detail-header">
<el-button @click="$router.back()"></el-button>
<h2>{{ roomName }} 监控详情</h2>
</div>
<div class="detail-body">
<p class="placeholder">监控详情页面开发中...</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const roomName = ref(`教室 #${route.params.id}`)
</script>
<style lang="scss" scoped>
.room-detail {
padding: 24px;
}
.detail-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
h2 {
font-size: 18px;
font-weight: 600;
color: #262626;
}
}
.placeholder {
text-align: center;
padding: 80px 0;
color: #999;
font-size: 16px;
}
</style>

@ -26,7 +26,7 @@ const initTrendChart = (dates = [], values = []) => {
tooltip: { trigger: 'axis' },
grid: { top: 20, right: 20, bottom: 20, left: 40 },
xAxis: { type: 'category', data: dates, axisTick: { show: false } },
yAxis: { type: 'value', min: 80, max: 100, axisLabel: { formatter: '{value}%' } },
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [{
data: values,
type: 'line', smooth: true, symbol: 'circle', symbolSize: 6,
@ -44,21 +44,13 @@ const initTrendChart = (dates = [], values = []) => {
const fetchTrendData = async () => {
try {
const res = await getTrend()
const data = res.data
if (data && Array.isArray(data.dates) && Array.isArray(data.values)) {
initTrendChart(data.dates, data.values)
} else {
// 使
const defaultDates = ['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01']
const defaultValues = [93, 95, 94.5, 96, 95.8, 97, 96.8]
initTrendChart(defaultDates, defaultValues)
}
const list = res.data
if (Array.isArray(list) && list.length > 0) {
const dates = list.map(item => item.date)
const values = list.map(item => item.attendanceRate)
initTrendChart(dates, values)
}
} catch {
// 使
initTrendChart(
['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01'],
[93, 95, 94.5, 96, 95.8, 97, 96.8]
)
}
}

@ -3,13 +3,14 @@
<!-- 核心数据区 -->
<div class="stats-grid">
<DataCard
label="今日出勤率"
label="课程平均出勤率"
:value="stats.attendanceRate"
unit="%"
icon="UserFilled"
iconColor="#52c41a"
iconBg="#e8f9e0"
:trend="2.5"
:date="currentDate"
@click="activeTab = 'manage'"
/>
<DataCard
@ -87,7 +88,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getStats } from '@/api/dashboard'
import { getRecordPage } from '@/api/attendance'
@ -114,6 +115,11 @@ const mapRecord = (item) => ({
const { loadNameMaps, courseName, teacherName, className, roomName } = useNameMaps()
const currentDate = computed(() => {
const d = new Date()
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
})
// ===== =====
const stats = ref({ attendanceRate: 0, classroomUsage: 0, warningCount: 0 })

@ -1,154 +1,208 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">学楼信息</h2>
<h2 class="page-title">信息</h2>
<p class="page-subtitle">管理教学楼教室及摄像头设备信息</p>
</div>
<!-- 教学楼区域 -->
<div class="section-card">
<div class="section-title">
<el-icon :size="18"><OfficeBuilding /></el-icon>
<span>教学楼列表</span>
</div>
<div class="filter-bar">
<el-input v-model="buildingSearch" placeholder="搜索教学楼名称..." :prefix-icon="Search" clearable size="default" style="width: 240px" />
<el-button type="primary" :icon="Plus" @click="showBuildingDialog()"></el-button>
<el-button :icon="Delete" :disabled="selectedBuildings.length === 0" @click="batchDeleteBuilding"></el-button>
</div>
<div class="data-table-card">
<el-table
:data="buildings"
stripe
@selection-change="handleBuildingSelect"
row-key="id"
highlight-current-row
@current-change="handleBuildingRowClick"
>
<el-table-column type="selection" width="45" />
<el-table-column prop="name" label="教学楼名称" min-width="220" />
<el-table-column prop="floorCount" label="楼层数" width="100" align="center" />
<el-table-column prop="roomCount" label="教室数量" width="100" align="center" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '启用' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click.stop="showBuildingDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" :icon="Delete" @click.stop="deleteBuildingRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="buildingPage"
:total="buildingTotal"
:page-size="buildingPageSize"
size="small"
background
layout="total, prev, pager, next"
style="justify-content: flex-end; margin-top: 16px"
/>
</div>
</div>
<!-- 教室区域 -->
<div class="section-card" v-if="currentBuilding">
<div class="section-title">
<el-icon :size="18"><HomeFilled /></el-icon>
<span>{{ currentBuilding.name }} 教室列表</span>
</div>
<div class="filter-bar">
<el-input v-model="roomSearch" placeholder="搜索教室编号..." :prefix-icon="Search" clearable size="default" style="width: 240px" />
<el-button type="primary" :icon="Plus" @click="showRoomDialog()"></el-button>
<el-button :icon="Delete" :disabled="selectedRooms.length === 0" @click="batchDeleteRoom"></el-button>
</div>
<div class="data-table-card">
<el-table
:data="filteredRooms"
stripe
@selection-change="handleRoomSelect"
row-key="id"
highlight-current-row
@current-change="handleRoomRowClick"
>
<el-table-column type="selection" width="45" />
<el-table-column prop="roomNo" label="教室编号" width="140" />
<el-table-column prop="roomName" label="教室名称" min-width="220" />
<el-table-column prop="capacity" label="容纳人数" width="100" align="center" />
<el-table-column prop="cameraCount" label="摄像头数量" width="110" align="center" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '启用' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click.stop="showRoomDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" :icon="Delete" @click.stop="deleteRoomRow(row)">删除</el-button>
<div class="hierarchy-layout">
<!-- 左侧树形导航 -->
<div class="tree-panel">
<div class="tree-panel-header">
<div class="tree-panel-title">
<el-icon :size="16"><OfficeBuilding /></el-icon>
<span>教学楼 / 教室</span>
</div>
<el-button type="primary" :icon="Plus" size="small" @click="showBuildingDialog()"></el-button>
</div>
<div class="tree-search">
<el-input v-model="treeFilter" placeholder="搜索教学楼或教室..." :prefix-icon="Search" clearable size="small" />
</div>
<div class="tree-scroll">
<el-tree
ref="treeRef"
:data="treeData"
:props="{ children: 'children', label: 'label' }"
node-key="id"
highlight-current
default-expand-all
:filter-node-method="filterTreeNode"
@node-click="handleTreeNodeClick"
:expand-on-click-node="false"
>
<template #default="{ data }">
<div class="tree-node-content">
<el-icon :size="15">
<OfficeBuilding v-if="data.type === 'building'" />
<HomeFilled v-else />
</el-icon>
<span class="tree-node-label">{{ data.label }}</span>
<span v-if="data.type === 'building'" class="tree-node-badge">{{ data.roomCount }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="roomPage"
:total="roomTotal"
:page-size="roomPageSize"
size="small"
background
layout="total, prev, pager, next"
style="justify-content: flex-end; margin-top: 16px"
@current-change="loadRooms"
/>
</div>
</div>
<!-- 摄像头区域 -->
<div class="section-card" v-if="currentRoom">
<div class="section-title">
<el-icon :size="18"><Camera /></el-icon>
<span>{{ currentBuilding.name }} / {{ currentRoom.roomName }} 摄像头列表</span>
</el-tree>
<div v-if="treeData.length === 0" class="tree-empty"></div>
</div>
</div>
<div class="filter-bar">
<el-input v-model="cameraSearch" placeholder="搜索摄像头名称..." :prefix-icon="Search" clearable size="default" style="width: 240px" />
<el-button type="primary" :icon="Plus" @click="showCameraDialog()"></el-button>
<el-button :icon="Delete" :disabled="selectedCameras.length === 0" @click="batchDeleteCamera"></el-button>
</div>
<div class="data-table-card">
<el-table :data="cameras" stripe @selection-change="handleCameraSelect">
<el-table-column type="selection" width="45" />
<el-table-column prop="deviceNo" label="摄像头编号" min-width="180" />
<el-table-column prop="name" label="摄像头名称" min-width="180" />
<el-table-column prop="streamUrl" label="流地址" width="160" />
<el-table-column prop="streamType" label="流类型" width="100" align="center" />
<el-table-column prop="position" label="安装位置" min-width="200" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '在线' ? 'success' : 'danger'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click.stop="showCameraDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" :icon="Delete" @click.stop="deleteCameraRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="cameraPage"
:total="cameraTotal"
:page-size="cameraPageSize"
size="small"
background
layout="total, prev, pager, next"
style="justify-content: flex-end; margin-top: 16px"
/>
<!-- 右侧详情面板 -->
<div class="detail-panel">
<!-- 无选中 -->
<div v-if="!currentBuilding && !currentRoom" class="detail-empty">
<el-icon :size="48" color="#d9d9d9"><OfficeBuilding /></el-icon>
<p>请从左侧选择一个教学楼或教室</p>
</div>
<!-- 教学楼层级展示教室列表 -->
<template v-if="currentBuilding && !currentRoom">
<div class="detail-header">
<div class="breadcrumb-path">
<el-icon :size="16" color="#52c41a"><OfficeBuilding /></el-icon>
<span class="breadcrumb-current">{{ currentBuilding.name }}</span>
</div>
<div class="detail-actions">
<el-button size="small" :icon="Edit" @click="showBuildingDialog(currentBuilding)"></el-button>
<el-button size="small" type="danger" :icon="Delete" @click="deleteBuildingRow(currentBuilding)"></el-button>
</div>
</div>
<div class="detail-summary">
<div class="summary-item">
<span class="summary-label">楼层数</span>
<span class="summary-value">{{ currentBuilding.floorCount }}</span>
</div>
<div class="summary-item">
<span class="summary-label">教室总数</span>
<span class="summary-value">{{ currentBuilding.roomCount }}</span>
</div>
<div class="summary-item">
<span class="summary-label">状态</span>
<el-tag :type="currentBuilding.status === '启用' ? 'success' : 'info'" size="small">{{ currentBuilding.status }}</el-tag>
</div>
</div>
<div class="detail-section">
<div class="detail-section-header">
<h4>
<el-icon :size="15"><HomeFilled /></el-icon>
<span>教室列表</span>
</h4>
<div class="detail-section-actions">
<el-input v-model="roomSearch" placeholder="搜索教室编号..." :prefix-icon="Search" clearable size="small" style="width: 200px" />
<el-button size="small" type="primary" :icon="Plus" @click="showRoomDialog()"></el-button>
<el-button size="small" :icon="Delete" :disabled="selectedRooms.length === 0" @click="batchDeleteRoom"></el-button>
</div>
</div>
<el-table :data="filteredRooms" stripe @selection-change="handleRoomSelect" row-key="id" highlight-current-row @current-change="handleRoomRowClick">
<el-table-column type="selection" width="40" />
<el-table-column prop="roomNo" label="教室编号" width="120" />
<el-table-column prop="roomName" label="教室名称" min-width="160" />
<el-table-column prop="capacity" label="容纳人数" width="90" align="center" />
<el-table-column prop="cameraCount" label="摄像头数" width="90" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '启用' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click.stop="showRoomDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" :icon="Delete" @click.stop="deleteRoomRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="detail-pagination">
<el-pagination
v-model:current-page="roomPage"
:total="roomTotal"
:page-size="roomPageSize"
size="small"
background
layout="total, prev, pager, next"
@current-change="loadRooms"
/>
</div>
</template>
<!-- 教室层级展示摄像头列表 -->
<template v-if="currentRoom">
<div class="detail-header">
<div class="breadcrumb-path">
<el-link type="primary" :underline="false" @click="backToBuilding">
<el-icon :size="14"><OfficeBuilding /></el-icon>
{{ currentBuilding?.name }}
</el-link>
<el-icon :size="12" color="#999"><ArrowRight /></el-icon>
<!-- <el-icon :size="16" color="#409eff"><HomeFilled /></el-icon> -->
<span class="breadcrumb-current">{{ currentRoom.roomName }}</span>
</div>
<div class="detail-actions">
<el-button size="small" :icon="Edit" @click="showRoomDialog(currentRoom)"></el-button>
<el-button size="small" type="danger" :icon="Delete" @click="deleteRoomRow(currentRoom)"></el-button>
</div>
</div>
<div class="detail-summary">
<div class="summary-item">
<span class="summary-label">教室编号</span>
<span class="summary-value">{{ currentRoom.roomNo }}</span>
</div>
<div class="summary-item">
<span class="summary-label">容纳人数</span>
<span class="summary-value">{{ currentRoom.capacity }} </span>
</div>
<div class="summary-item">
<span class="summary-label">摄像头数量</span>
<span class="summary-value">{{ currentRoom.cameraCount }} </span>
</div>
<div class="summary-item">
<span class="summary-label">状态</span>
<el-tag :type="currentRoom.status === '启用' ? 'success' : 'info'" size="small">{{ currentRoom.status }}</el-tag>
</div>
</div>
<div class="detail-section">
<div class="detail-section-header">
<h4>
<el-icon :size="15"><Camera /></el-icon>
<span>摄像头列表</span>
</h4>
<div class="detail-section-actions">
<el-input v-model="cameraSearch" placeholder="搜索摄像头名称..." :prefix-icon="Search" clearable size="small" style="width: 200px" />
<el-button size="small" type="primary" :icon="Plus" @click="showCameraDialog()"></el-button>
<el-button size="small" :icon="Delete" :disabled="selectedCameras.length === 0" @click="batchDeleteCamera"></el-button>
</div>
</div>
<el-table :data="cameras" stripe @selection-change="handleCameraSelect">
<el-table-column type="selection" width="40" />
<el-table-column prop="deviceNo" label="摄像头编号" min-width="160" />
<el-table-column prop="name" label="摄像头名称" min-width="160" />
<el-table-column prop="streamUrl" label="流地址" width="150" />
<el-table-column prop="streamType" label="流类型" width="80" align="center" />
<el-table-column prop="position" label="安装位置" min-width="180" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '在线' ? 'success' : 'danger'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click.stop="showCameraDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" :icon="Delete" @click.stop="deleteCameraRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="detail-pagination">
<el-pagination
v-model:current-page="cameraPage"
:total="cameraTotal"
:page-size="cameraPageSize"
size="small"
background
layout="total, prev, pager, next"
/>
</div>
</template>
</div>
</div>
@ -238,18 +292,124 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete, Edit } from '@element-plus/icons-vue'
import { getBuildings, addBuilding, updateBuilding, deleteBuilding, getRooms, addRoom, updateRoom, deleteRoom, getCameras, addCamera, updateCamera, deleteCamera } from '@/api/info'
import { Search, Plus, Delete, Edit, ArrowRight } from '@element-plus/icons-vue'
import { getBuildingList, getBuildings, addBuilding, updateBuilding, deleteBuilding, getRoomsList, getRooms, addRoom, updateRoom, deleteRoom, getCameras, addCamera, updateCamera, deleteCamera } from '@/api/info'
// ========== ==========
const treeRef = ref(null)
const treeFilter = ref('')
const treeData = ref([])
const filterTreeNode = (value, data) => {
if (!value) return true
return data.label.toLowerCase().includes(value.toLowerCase())
}
watch(treeFilter, (val) => {
treeRef.value?.filter(val)
})
const buildTreeData = async () => {
try {
const [bRes, rRes] = await Promise.all([
getBuildingList(),
getRoomsList()
])
const buildings = bRes?.data || []
const rooms = rRes?.data || []
//
treeData.value = buildings.map(b => ({
id: `building-${b.id}`,
type: 'building',
label: b.buildingName,
roomCount: rooms.filter(r => r.buildingId === b.id).length,
floors: b.floors,
status: b.status === 1 ? '启用' : '停用',
buildingId: b.id,
children: rooms
.filter(r => r.buildingId === b.id)
.map(r => ({
id: `room-${r.id}`,
type: 'room',
label: r.roomName,
roomNo: r.roomNo,
capacity: r.capacity,
cameraCount: r.deviceCount || 0,
status: r.status === 1 ? '启用' : '停用',
buildingId: r.buildingId,
roomId: r.id
}))
}))
} catch {
//
}
}
const handleTreeNodeClick = (data) => {
if (data.type === 'building') {
// paginated
const matched = buildings.value.find(b => b.id === data.buildingId)
if (matched) {
currentBuilding.value = matched
} else {
//
currentBuilding.value = {
id: data.buildingId,
name: data.label,
floorCount: data.floors,
roomCount: data.roomCount,
status: data.status
}
}
currentRoom.value = null
roomPage.value = 1
selectedRooms.value = []
loadRooms()
} else if (data.type === 'room') {
//
const parentNode = treeData.value.find(b => b.children?.some(r => r.id === data.id))
if (parentNode) {
const matched = buildings.value.find(b => b.id === parentNode.buildingId)
currentBuilding.value = matched || {
id: parentNode.buildingId,
name: parentNode.label,
floorCount: parentNode.floors,
roomCount: parentNode.roomCount,
status: parentNode.status
}
}
currentRoom.value = {
id: data.roomId,
roomNo: data.roomNo,
roomName: data.label,
capacity: data.capacity,
cameraCount: data.cameraCount,
status: data.status,
buildingId: data.buildingId
}
//
if (currentBuilding.value) {
roomPage.value = 1
loadRooms()
}
cameraPage.value = 1
selectedCameras.value = []
loadCameras()
}
}
const backToBuilding = () => {
currentRoom.value = null
selectedCameras.value = []
}
// ========== ==========
const buildingPage = ref(1)
const buildingPageSize = ref(8)
const buildingSearch = ref('')
const buildingDialogVisible = ref(false)
const buildingEditing = ref(false)
const selectedBuildings = ref([])
const currentBuilding = ref(null)
const buildings = ref([])
@ -259,15 +419,14 @@ const loadBuildings = async () => {
try {
const res = await getBuildings({
current: buildingPage.value,
size: buildingPageSize.value,
keyword: buildingSearch.value || undefined
size: buildingPageSize.value
})
const pageData = res.data || {}
buildings.value = (pageData.records || []).map(b => ({
id: b.id,
name: b.buildingName,
floorCount: b.floors,
roomCount: b.classroomCount || 0,
roomCount: rooms.filter(r => r.buildingId === b.id).length,
status: b.status === 1 ? '启用' : '停用'
}))
buildingTotal.value = pageData.total || 0
@ -276,24 +435,19 @@ const loadBuildings = async () => {
}
}
watch(buildingPage, () => loadBuildings())
watch(buildingSearch, () => {
if (buildingPage.value !== 1) {
buildingPage.value = 1
} else {
loadBuildings()
onMounted(async () => {
await Promise.all([loadBuildings(), buildTreeData()])
//
if (treeData.value.length > 0) {
handleTreeNodeClick(treeData.value[0])
nextTick(() => {
treeRef.value?.setCurrentKey(treeData.value[0].id)
})
}
})
onMounted(() => {
loadBuildings()
})
const buildingForm = ref({ name: '', floorCount: 1, status: '启用' })
const handleBuildingSelect = (rows) => { selectedBuildings.value = rows }
const handleBuildingRowClick = (row) => { currentBuilding.value = row; roomPage.value = 1; currentRoom.value = null; loadRooms() }
const showBuildingDialog = (row) => {
if (row) {
buildingEditing.value = true
@ -321,7 +475,7 @@ const saveBuilding = async () => {
ElMessage.success('添加成功')
}
buildingDialogVisible.value = false
await loadBuildings()
await Promise.all([loadBuildings(), buildTreeData()])
} catch {
//
}
@ -334,23 +488,12 @@ const deleteBuildingRow = (row) => {
await deleteBuilding([row.id])
if (currentBuilding.value?.id === row.id) currentBuilding.value = null
ElMessage.success('删除成功')
await loadBuildings()
} catch {
//
}
})
.catch(() => {})
}
const batchDeleteBuilding = () => {
ElMessageBox.confirm(`确认删除选中的 ${selectedBuildings.value.length} 个教学楼?`, '批量删除', { type: 'warning' })
.then(async () => {
try {
const ids = selectedBuildings.value.map(b => b.id)
await deleteBuilding(ids)
currentBuilding.value = null
ElMessage.success('批量删除成功')
await loadBuildings()
await Promise.all([loadBuildings(), buildTreeData()])
//
if (treeData.value.length > 0) {
handleTreeNodeClick(treeData.value[0])
treeRef.value?.setCurrentKey(treeData.value[0].id)
}
} catch {
//
}
@ -402,7 +545,19 @@ const filteredRooms = computed(() => {
})
const handleRoomSelect = (rows) => { selectedRooms.value = rows }
const handleRoomRowClick = (row) => { currentRoom.value = row; cameraPage.value = 1; loadCameras() }
const handleRoomRowClick = (row) => {
currentRoom.value = row
cameraPage.value = 1
selectedCameras.value = []
loadCameras()
//
const treeNode = treeData.value
.flatMap(b => b.children || [])
.find(r => r.roomId === row.id)
if (treeNode) {
treeRef.value?.setCurrentKey(treeNode.id)
}
}
const showRoomDialog = (row) => {
if (row) {
@ -434,7 +589,7 @@ const saveRoom = async () => {
}
roomDialogVisible.value = false
await loadRooms()
await loadBuildings()
await Promise.all([loadBuildings(), buildTreeData()])
} catch {
//
}
@ -448,7 +603,7 @@ const deleteRoomRow = (row) => {
if (currentRoom.value?.id === row.id) currentRoom.value = null
ElMessage.success('删除成功')
await loadRooms()
await loadBuildings()
await Promise.all([loadBuildings(), buildTreeData()])
} catch {
//
}
@ -465,7 +620,7 @@ const batchDeleteRoom = () => {
currentRoom.value = null
ElMessage.success('批量删除成功')
await loadRooms()
await loadBuildings()
await Promise.all([loadBuildings(), buildTreeData()])
} catch {
//
}
@ -504,6 +659,10 @@ const loadCameras = async () => {
status: c.onlineStatus === 1 ? '在线' : '离线'
}))
cameraTotal.value = pageData.total || 0
//
if (currentRoom.value) {
currentRoom.value.cameraCount = cameraTotal.value
}
} catch {
//
}
@ -556,6 +715,7 @@ const saveCamera = async () => {
cameraDialogVisible.value = false
await loadCameras()
await loadRooms()
await buildTreeData()
} catch {
//
}
@ -569,6 +729,7 @@ const deleteCameraRow = (row) => {
ElMessage.success('删除成功')
await loadCameras()
await loadRooms()
await buildTreeData()
} catch {
//
}
@ -585,6 +746,7 @@ const batchDeleteCamera = () => {
ElMessage.success('批量删除成功')
await loadCameras()
await loadRooms()
await buildTreeData()
} catch {
//
}
@ -614,7 +776,247 @@ const batchDeleteCamera = () => {
border-bottom: 1px solid #f0f0f0;
}
.data-table-card {
margin-top: 0;
/* ===== 树形 + 详情分栏布局 ===== */
.hierarchy-layout {
display: flex;
gap: 20px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 左侧树形面板 */
.tree-panel {
width: 280px;
flex-shrink: 0;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.tree-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.tree-panel-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #262626;
}
.tree-search {
padding: 10px 16px;
flex-shrink: 0;
}
.tree-scroll {
flex: 1;
overflow-y: auto;
padding: 4px 0 16px;
}
.tree-empty {
text-align: center;
padding: 32px 16px;
color: #999;
font-size: 13px;
}
.tree-node-content {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
font-size: 13px;
}
.tree-node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-badge {
background: #a7e280;
color: #fff;
font-size: 11px;
padding: 0 6px;
border-radius: 8px;
line-height: 18px;
min-width: 20px;
text-align: center;
flex-shrink: 0;
}
/* 覆盖父容器,让分栏撑满可视区域 */
.page-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.page-header {
flex-shrink: 0;
}
/* 右侧详情面板 */
.detail-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-empty {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 12px;
flex: 1;
p {
color: #999;
font-size: 14px;
margin: 0;
}
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: #fff;
border-radius: 8px 8px 0 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
.breadcrumb-path {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
.breadcrumb-current {
font-weight: 600;
color: #262626;
}
}
.detail-actions {
display: flex;
gap: 8px;
}
.detail-summary {
display: flex;
gap: 0;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
padding: 12px 20px;
flex-shrink: 0;
}
.summary-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 24px;
border-right: 1px solid #eee;
&:first-child { padding-left: 0; }
&:last-child { border-right: none; }
}
.summary-label {
font-size: 13px;
color: #999;
}
.summary-value {
font-size: 15px;
font-weight: 600;
color: #262626;
}
.detail-section {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
padding: 20px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.detail-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px 20px;
background: #fff;
border-radius: 0 0 8px 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
.detail-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h4 {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #262626;
margin: 0;
}
}
.detail-section-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 树形选择高亮 */
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background: #e6f7ff;
}
/* 响应式 */
@media (max-width: 900px) {
.hierarchy-layout {
flex-direction: column;
}
.tree-panel {
width: 100%;
}
}
</style>

Loading…
Cancel
Save