410 lines
10 KiB
Vue
410 lines
10 KiB
Vue
<template>
|
||
<div class="camera-container">
|
||
<!-- 左侧:摄像头列表 -->
|
||
<div class="camera-list">
|
||
<el-input
|
||
v-model="searchQuery"
|
||
placeholder="搜索摄像头名称"
|
||
prefix-icon="el-icon-search"
|
||
clearable
|
||
class="search-input"
|
||
/>
|
||
<el-card v-for="camera in filteredCameras" :key="camera.id" class="camera-item" @click="selectCamera(camera)">
|
||
<div class="camera-header">
|
||
<span>ID: {{ camera.id }}</span>
|
||
<span class="status" :class="{ 'online': camera.status === 'online', 'offline': camera.status !== 'online' }">
|
||
{{ camera.status === 'online' ? '在线' : '离线' }}
|
||
</span>
|
||
</div>
|
||
<div class="camera-content">
|
||
<img :src="camera.snapshot" alt="camera-preview" class="camera-thumbnail" />
|
||
<p class="camera-name">{{ camera.name }}</p>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 右侧:摄像头详情栅格 -->
|
||
<div class="camera-grid">
|
||
<div v-for="camera in selectedCameras" :key="camera.id" class="camera-details">
|
||
<el-card class="camera-card">
|
||
<div class="stream-control">
|
||
<p class="camera-name-title">{{ camera.name }}</p>
|
||
<el-button @click="closeStream(camera)" class="close-button" circle size="mini">X</el-button>
|
||
</div>
|
||
|
||
<!-- 视频播放和快照部分 -->
|
||
<div class="play-button-container" @mouseenter="showButton = true" @mouseleave="showButton = false">
|
||
<!-- 未播放时显示快照或占位符 -->
|
||
<div class="camera-placeholder" v-if="!camera.playing && !camera.snapshot">
|
||
<el-icon size="48">
|
||
<VideoCameraFilled />
|
||
</el-icon>
|
||
</div>
|
||
|
||
<img v-if="!camera.playing && camera.snapshot" :src="camera.snapshot" alt="camera snapshot"
|
||
class="camera-snapshot" />
|
||
|
||
<!-- 播放视频流的 canvas -->
|
||
<canvas v-show="camera.playing" :ref="el => setCanvasRef(camera.id, el)" class="camera-large"></canvas>
|
||
|
||
<!-- 播放和暂停按钮 -->
|
||
<el-button v-show="!camera.playing || showButton" class="play-button" type="primary" circle size="large"
|
||
@click="handlePlayPause(camera)">
|
||
<el-icon>
|
||
<VideoPlay v-if="!camera.playing" />
|
||
<VideoPause v-if="camera.playing" />
|
||
</el-icon>
|
||
</el-button>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onBeforeUnmount, nextTick,computed } from 'vue';
|
||
import { BoxApi } from '@/utils/boxApi.ts';
|
||
import { VideoPlay, VideoPause, VideoCameraFilled } from '@element-plus/icons-vue';
|
||
|
||
// 存储所有摄像头列表
|
||
const cameras = ref([]);
|
||
// 存储已选择并展示的摄像头
|
||
const selectedCameras = ref([]);
|
||
// 控制播放按钮的显示状态
|
||
const showButton = ref(false);
|
||
// API 实例
|
||
const apiInstance = new BoxApi();
|
||
// 存储 canvas 引用
|
||
const canvasRefs = ref({});
|
||
|
||
// 搜索输入的查询字符串
|
||
const searchQuery = ref('');
|
||
|
||
// 计算属性:根据搜索关键词过滤摄像头列表
|
||
const filteredCameras = computed(() =>
|
||
cameras.value.filter(camera =>
|
||
camera.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||
)
|
||
);
|
||
|
||
// 获取摄像头列表
|
||
const fetchCameras = async () => {
|
||
try {
|
||
const token = localStorage.getItem('alertToken');
|
||
const cameraData = await apiInstance.getMinCameras(token);
|
||
cameras.value = cameraData;
|
||
} catch (error) {
|
||
console.error('获取摄像头列表失败:', error);
|
||
}
|
||
};
|
||
|
||
// 选择摄像头并展示详情
|
||
const selectCamera = (camera) => {
|
||
if (!selectedCameras.value.some(c => c.id === camera.id)) {
|
||
selectedCameras.value.push({ ...camera, playing: false, streamPort: null });
|
||
}
|
||
};
|
||
|
||
// 设置 canvas 引用
|
||
const setCanvasRef = (cameraId, el) => {
|
||
if (el) {
|
||
canvasRefs.value[cameraId] = el;
|
||
}
|
||
};
|
||
|
||
// 播放/暂停摄像头视频流
|
||
const handlePlayPause = async (camera) => {
|
||
if (camera.playing) {
|
||
handleStopStream(camera);
|
||
} else {
|
||
handleStartStream(camera);
|
||
}
|
||
};
|
||
|
||
// 启动视频流
|
||
const handleStartStream = async (camera) => {
|
||
await nextTick(); // 确保 DOM 已渲染
|
||
const canvas = canvasRefs.value[camera.id];
|
||
|
||
if (!camera || !canvas) {
|
||
console.error('未找到对应的 canvas');
|
||
return;
|
||
}
|
||
|
||
const token = localStorage.getItem('alertToken');
|
||
try {
|
||
const response = await apiInstance.startCameraStream(token, camera.id);
|
||
camera.streamPort = response.port;
|
||
camera.playing = true;
|
||
|
||
const rememberedAddress = localStorage.getItem('rememberedAddress') || '127.0.0.1';
|
||
const url = `ws://${rememberedAddress}:${camera.streamPort}/`;
|
||
// const url = `ws://192.168.28.33:${camera.streamPort}/`;
|
||
console.log('播放路径:', url);
|
||
|
||
if (window.JSMpeg) {
|
||
const player = new window.JSMpeg.Player(url, {
|
||
canvas: canvas,
|
||
autoplay: true,
|
||
videoBufferSize: 15 * 1024 * 1024,
|
||
audioBufferSize: 5 * 1024 * 1024,
|
||
});
|
||
camera.player = player;
|
||
} else {
|
||
console.error('JSMpeg 未加载');
|
||
}
|
||
} catch (error) {
|
||
console.error('启动视频流失败:', error);
|
||
}
|
||
};
|
||
|
||
// 停止视频流
|
||
const handleStopStream = async (camera) => {
|
||
const token = localStorage.getItem('alertToken');
|
||
try {
|
||
await apiInstance.stopCameraStream(token, camera.id);
|
||
camera.playing = false;
|
||
|
||
if (camera.player) {
|
||
camera.player.destroy();
|
||
camera.player = null;
|
||
}
|
||
} catch (error) {
|
||
console.error('停止视频流失败:', error);
|
||
}
|
||
};
|
||
|
||
// 关闭摄像头流视图
|
||
const closeStream = (camera) => {
|
||
handleStopStream(camera);
|
||
selectedCameras.value = selectedCameras.value.filter(c => c.id !== camera.id);
|
||
};
|
||
|
||
// 组件挂载时获取摄像头列表
|
||
onMounted(() => {
|
||
fetchCameras();
|
||
});
|
||
|
||
// 组件卸载时清理资源
|
||
onBeforeUnmount(() => {
|
||
selectedCameras.value.forEach(camera => {
|
||
if (camera.player) {
|
||
camera.player.destroy();
|
||
}
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.camera-container {
|
||
display: flex;
|
||
height: 100vh;
|
||
background-color: #1E2E4A;
|
||
}
|
||
|
||
/* 左侧摄像头列表 */
|
||
.camera-list {
|
||
width: 20%;
|
||
min-width: 215px;
|
||
max-height: 100vh;
|
||
/* 限制高度为一屏 */
|
||
overflow-y: auto;
|
||
/* 超出时滚动 */
|
||
box-sizing: border-box;
|
||
border-right: 1px solid #1E2E4A;
|
||
padding-right: 10px;
|
||
}
|
||
|
||
.search-input {
|
||
margin-bottom: 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 每个摄像头项目的样式 */
|
||
.camera-item {
|
||
margin-bottom: 8px;
|
||
cursor: pointer;
|
||
padding: 12px;
|
||
border: 1px solid #458388;
|
||
border-radius: 4px;
|
||
transition: background-color 0.3s, box-shadow 0.3s;
|
||
}
|
||
|
||
.camera-item:hover {
|
||
background-color: #f5f7fa;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
/* 摄像头项目头部 */
|
||
.camera-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 14px;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
/* 摄像头状态标签 */
|
||
.status {
|
||
margin-left: 5px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.online {
|
||
color: green;
|
||
}
|
||
|
||
.offline {
|
||
color: red;
|
||
}
|
||
|
||
/* 摄像头内容:缩略图和名称 */
|
||
.camera-content {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.camera-thumbnail {
|
||
width: 70px;
|
||
height: 50px;
|
||
margin-right: 10px;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
border: 2px solid #12d1df;
|
||
}
|
||
|
||
.camera-name {
|
||
flex: 1;
|
||
word-break: break-word;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
/* 右侧摄像头详情展示 */
|
||
.camera-grid {
|
||
width: 80%;
|
||
height: 95vh; /* 占满页面右侧区域 */
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr); /* 两列 */
|
||
grid-template-rows: repeat(2, 1fr); /* 两行 */
|
||
gap: 5px; /* 栅格块之间的间距 */
|
||
padding: 10px;
|
||
/* background-color: #1E2E4A; */
|
||
background: linear-gradient(to top, rgba(0, 3, 3, 0.4), rgba(9, 21, 196, 0.3));
|
||
/* background: linear-gradient(to top, rgba(8, 53, 61, 0.4), rgba(9, 21, 196, 0.3)); */
|
||
/* border: 2px solid #ece9e9; */
|
||
box-sizing: border-box;
|
||
overflow-y: auto;
|
||
}
|
||
.camera-details {
|
||
height: 45vh;
|
||
/* background-color: #3b2c2c; */
|
||
/* background: linear-gradient(to top, rgba(8, 53, 61, 0.4), rgba(9, 21, 196, 0.3)); */
|
||
border-radius: 4px;
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
/* overflow-y: auto; */
|
||
}
|
||
.camera-card {
|
||
width: 100%;
|
||
height: 100%;
|
||
/* background-color: #1E2E4A; */
|
||
background: linear-gradient(to top, rgba(64, 226, 255, 0.7), rgba(211, 64, 248, 0.7));
|
||
/* background: linear-gradient(to top, rgba(0, 7, 8, 0.9), rgba(12, 2, 155, 0.7)); */
|
||
border: 2px solid #0b4c5f;
|
||
border-radius: 8px;
|
||
position: relative;
|
||
/* padding: 10px; */
|
||
box-sizing: border-box;
|
||
}
|
||
.stream-control {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
/* .stream-control p {
|
||
color: #f5f7fa;
|
||
font-size: 18px;
|
||
margin: 0;
|
||
line-height: 1;
|
||
font-weight: bold;
|
||
} */
|
||
|
||
.camera-name-title {
|
||
font-size: 18px;
|
||
color: white;
|
||
font-weight: bold;
|
||
margin: 0;
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
.close-button {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 20px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.camera-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
color: #6b6b6b;
|
||
border: 2px dashed rgba(109, 109, 109, 0.7);
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||
background-color: rgba(50, 50, 50, 0.5);
|
||
}
|
||
|
||
/* 播放中的 canvas 样式 */
|
||
.camera-large {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
border: 2px solid rgba(109, 109, 109, 0.3);
|
||
}
|
||
|
||
/* 摄像头大图和快照 */
|
||
.camera-snapshot {
|
||
width: 100%;
|
||
height: 40vh;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
border: 2px solid #dcdcdc;
|
||
}
|
||
|
||
|
||
/* 播放按钮容器 */
|
||
.play-button-container {
|
||
position: relative;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 播放按钮 */
|
||
.play-button {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 10;
|
||
opacity: 0.4;
|
||
transition: opacity 0.2s ease-in-out;
|
||
}
|
||
|
||
.play-button:hover {
|
||
opacity: 1;
|
||
}
|
||
</style> |