492 lines
11 KiB
Vue
492 lines
11 KiB
Vue
<template>
|
||
<div class="monitor-page">
|
||
<div class="layout-container">
|
||
<!-- 左侧设备树面板 -->
|
||
<div class="device-tree-panel">
|
||
<div class="tree-header">
|
||
<div class="search-add">
|
||
<ElInput
|
||
v-model="searchDevice"
|
||
placeholder="请输入设备"
|
||
:prefix-icon="Search"
|
||
size="small"
|
||
></ElInput>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自定义设备列表 -->
|
||
<div class="custom-tree">
|
||
<div
|
||
v-for="group in filteredTreeData"
|
||
:key="group.label"
|
||
class="tree-group"
|
||
>
|
||
<div class="group-label">{{ group.label }}</div>
|
||
<div
|
||
v-for="item in group.children"
|
||
:key="item.label"
|
||
class="tree-item"
|
||
:class="{
|
||
'selected': isItemSelected(item.label),
|
||
'disabled': isItemDisabled && !isItemSelected(item.label)
|
||
}"
|
||
@click="handleItemClick(item)"
|
||
>
|
||
<div class="item-content">
|
||
<Camera class="item-icon"></Camera>
|
||
<span class="item-label">{{ item.label }}</span>
|
||
</div>
|
||
<div v-if="isItemSelected(item.label)" class="selected-indicator">
|
||
<el-icon><Check /></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧视频墙面板 -->
|
||
<div class="video-wall-panel">
|
||
<div class="video-header">
|
||
<Camera class="icon"></Camera>
|
||
<span>监控视频</span>
|
||
</div>
|
||
|
||
<div
|
||
class="video-container"
|
||
:style="{
|
||
gridTemplateColumns: gridColumns,
|
||
gridTemplateRows: gridRows
|
||
}"
|
||
>
|
||
<div
|
||
v-for="(video, index) in videoList"
|
||
:key="`video-${index}`"
|
||
class="video-item"
|
||
:style="{
|
||
backgroundColor: video.bgColor,
|
||
border: video.isSelected ? '2px solid #409eff' : 'none'
|
||
}"
|
||
@click="handleVideoClick(index)"
|
||
>
|
||
<div class="video-placeholder">
|
||
<Camera class="icon"></Camera>
|
||
<span>{{ video.name }}</span>
|
||
<Close
|
||
v-if="video.name !== '待选择区域'"
|
||
class="close-icon"
|
||
@click.stop="removeVideo(index)"
|
||
></Close>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 布局切换按钮 -->
|
||
<div class="layout-switch">
|
||
<ElButton
|
||
v-for="item in buttonType"
|
||
:key="`layout-${item.type}`"
|
||
:type="currentLayout === item.type ? 'primary' : ''"
|
||
size="small"
|
||
@click="changeLayout(item.type)"
|
||
:disabled="currentLayout === item.type"
|
||
>
|
||
{{ item.label }}
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, onMounted } from 'vue';
|
||
import { ElInput, ElButton, ElMessage } from 'element-plus';
|
||
import { Search, Camera, Close, Check } from '@element-plus/icons-vue';
|
||
|
||
const searchDevice = ref('');
|
||
const deviceTreeData = ref([
|
||
{
|
||
label: "公共区域",
|
||
children: [
|
||
{ label: "电梯前厅" },
|
||
{ label: "大会议室前走道" },
|
||
{ label: "东侧走道北向" },
|
||
{ label: "西侧走道南向" },
|
||
{ label: "西侧走道北向" },
|
||
{ label: "东侧走道南向" },
|
||
{ label: "小会议室前走道" }
|
||
]
|
||
}
|
||
]);
|
||
|
||
const currentLayout = ref(1);
|
||
// 重构:使用对象来记录每个位置的设备,key为位置索引,value为设备信息
|
||
const videoPositions = ref({});
|
||
const selectedNodes = ref([]);
|
||
|
||
const buttonType = [
|
||
{ label: "1x1", type: 1 },
|
||
{ label: "2x2", type: 2 },
|
||
{ label: "3x3", type: 3 }
|
||
];
|
||
|
||
// 计算属性
|
||
const gridColumns = computed(() => {
|
||
switch (currentLayout.value) {
|
||
case 1: return "1fr";
|
||
case 2: return "repeat(2, 1fr)";
|
||
case 3: return "repeat(3, 1fr)";
|
||
default: return "1fr";
|
||
}
|
||
});
|
||
|
||
const gridRows = computed(() => {
|
||
switch (currentLayout.value) {
|
||
case 1: return "1fr";
|
||
case 2: return "repeat(2, 1fr)";
|
||
case 3: return "repeat(3, 1fr)";
|
||
default: return "1fr";
|
||
}
|
||
});
|
||
|
||
const maxCapacity = computed(() => {
|
||
return currentLayout.value === 1 ? 1 : (currentLayout.value === 2 ? 4 : 9);
|
||
});
|
||
|
||
const isItemDisabled = computed(() => {
|
||
return selectedNodes.value.length >= maxCapacity.value;
|
||
});
|
||
|
||
// 视频列表,根据位置信息生成
|
||
const videoList = computed(() => {
|
||
const capacity = maxCapacity.value;
|
||
const list = [];
|
||
|
||
for (let i = 0; i < capacity; i++) {
|
||
if (videoPositions.value[i]) {
|
||
// 该位置有设备
|
||
const device = videoPositions.value[i];
|
||
list.push({
|
||
name: device.label,
|
||
bgColor: `rgba(64, 158, 255, ${0.1 + (i % 5) * 0.15})`,
|
||
isSelected: false
|
||
});
|
||
} else {
|
||
// 该位置为空
|
||
list.push({
|
||
name: "待选择区域",
|
||
bgColor: "#e6f7ff",
|
||
isSelected: false
|
||
});
|
||
}
|
||
}
|
||
|
||
return list;
|
||
});
|
||
|
||
// 过滤树数据
|
||
const filteredTreeData = computed(() => {
|
||
if (!searchDevice.value) return deviceTreeData.value;
|
||
|
||
return deviceTreeData.value.map(group => ({
|
||
...group,
|
||
children: group.children.filter(item =>
|
||
item.label.toLowerCase().includes(searchDevice.value.toLowerCase())
|
||
)
|
||
})).filter(group => group.children.length > 0);
|
||
});
|
||
|
||
// 判断项目是否被选中
|
||
const isItemSelected = (label) => {
|
||
return selectedNodes.value.includes(label);
|
||
};
|
||
|
||
// 查找第一个可用的位置
|
||
const findAvailablePosition = () => {
|
||
const capacity = maxCapacity.value;
|
||
for (let i = 0; i < capacity; i++) {
|
||
if (!videoPositions.value[i]) {
|
||
return i;
|
||
}
|
||
}
|
||
return -1;
|
||
};
|
||
|
||
// 查找设备所在的位置
|
||
const findDevicePosition = (label) => {
|
||
for (const [position, device] of Object.entries(videoPositions.value)) {
|
||
if (device.label === label) {
|
||
return parseInt(position);
|
||
}
|
||
}
|
||
return -1;
|
||
};
|
||
|
||
// 处理项目点击
|
||
const handleItemClick = (item) => {
|
||
const itemLabel = item.label;
|
||
const position = findDevicePosition(itemLabel);
|
||
|
||
if (position !== -1) {
|
||
// 设备已存在,取消选择
|
||
delete videoPositions.value[position];
|
||
selectedNodes.value = selectedNodes.value.filter(label => label !== itemLabel);
|
||
ElMessage.info(`已移除${itemLabel}`);
|
||
} else {
|
||
// 设备不存在,尝试添加
|
||
if (selectedNodes.value.length >= maxCapacity.value) {
|
||
ElMessage.warning(`当前${buttonType.find(b => b.type === currentLayout.value)?.label}模式最多选择${maxCapacity.value}个视频`);
|
||
return;
|
||
}
|
||
|
||
const availablePosition = findAvailablePosition();
|
||
if (availablePosition !== -1) {
|
||
videoPositions.value[availablePosition] = item;
|
||
selectedNodes.value.push(itemLabel);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 切换布局
|
||
const changeLayout = (layoutType) => {
|
||
currentLayout.value = layoutType;
|
||
videoPositions.value = {};
|
||
selectedNodes.value = [];
|
||
};
|
||
|
||
// 处理视频点击
|
||
const handleVideoClick = (index) => {
|
||
// 重置所有视频的选中状态
|
||
const updatedList = [...videoList.value];
|
||
updatedList.forEach((video, i) => {
|
||
video.isSelected = i === index && video.name !== "待选择区域";
|
||
});
|
||
|
||
// 注意:由于videoList是计算属性,这里直接修改不会持久化
|
||
// 我们可以通过其他方式处理选中状态,或者如果需要持久化选中状态,可以添加一个响应式变量
|
||
};
|
||
|
||
// 移除视频
|
||
const removeVideo = (index) => {
|
||
if (videoPositions.value[index]) {
|
||
const removedDevice = videoPositions.value[index];
|
||
delete videoPositions.value[index];
|
||
selectedNodes.value = selectedNodes.value.filter(label => label !== removedDevice.label);
|
||
ElMessage.info(`已移除${removedDevice.label}`);
|
||
}
|
||
};
|
||
|
||
// 监听搜索
|
||
watch(searchDevice, () => {
|
||
// 搜索功能已在 computed 中处理
|
||
});
|
||
|
||
onMounted(() => {
|
||
// 初始化样式
|
||
const style = document.createElement("style");
|
||
style.textContent = `
|
||
.video-item .close-icon {
|
||
position: absolute;
|
||
top: 5px;
|
||
right: 5px;
|
||
font-size: 16px;
|
||
color: #606266;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
width: 30px;
|
||
height: 30px;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
.video-item:hover .close-icon {
|
||
opacity: 1;
|
||
}
|
||
.video-header .icon,
|
||
.video-placeholder .icon {
|
||
color: #409eff;
|
||
}
|
||
.video-header .icon {
|
||
margin-right: 8px;
|
||
}
|
||
.video-placeholder .icon {
|
||
font-size: 36px;
|
||
margin-bottom: 8px;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.monitor-page {
|
||
height: calc(100vh - 84px);
|
||
background-color: #f5f7fa;
|
||
padding: 20px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.layout-container {
|
||
display: flex;
|
||
height: 100%;
|
||
gap: 20px;
|
||
}
|
||
|
||
.device-tree-panel {
|
||
flex: 0 0 280px;
|
||
background-color: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tree-header {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.search-add {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.custom-tree {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.tree-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.group-label {
|
||
font-weight: 600;
|
||
color: #303133;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.tree-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
margin: 4px 0;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.tree-item:hover {
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.tree-item.selected {
|
||
background-color: #e6f7ff;
|
||
color: #409eff;
|
||
}
|
||
|
||
.tree-item.disabled:not(.selected) {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.item-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.item-icon {
|
||
color: #909399;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.tree-item.selected .item-icon {
|
||
color: #409eff;
|
||
}
|
||
|
||
.item-label {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.selected-indicator {
|
||
color: #409eff;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.video-wall-panel {
|
||
flex: 1;
|
||
background-color: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.video-header {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.video-header span {
|
||
font-weight: 500;
|
||
color: #303133;
|
||
}
|
||
|
||
.video-container {
|
||
flex: 1;
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 16px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.video-item {
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
min-height: 120px;
|
||
}
|
||
.item-icon {
|
||
width: 18px;
|
||
}
|
||
.icon {
|
||
width: 30px;
|
||
}
|
||
.video-item:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.video-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.video-placeholder span {
|
||
color: #606266;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.layout-switch {
|
||
padding: 12px 16px;
|
||
border-top: 1px solid #e4e7ed;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
</style> |