视频管理

This commit is contained in:
cwchen 2025-12-23 15:30:42 +08:00
parent c77a06511d
commit 9fb36f623d
2 changed files with 867 additions and 0 deletions

20
src/api/device/video.js Normal file
View File

@ -0,0 +1,20 @@
import request from '@/utils/request'
// 设备管理->视频监控->保存画线数据
export function saveVideoLineAPI(data) {
return request({
url: '/smartCar/device/video/saveVideoLine',
method: 'POST',
data
})
}
// 设备管理->视频监控->获取视频流地址
export function getVideoStreamAPI(params) {
return request({
url: '/smartCar/device/video/getVideoStream',
method: 'GET',
params
})
}

View File

@ -0,0 +1,847 @@
<template>
<div class="video-monitor-container">
<!-- 顶部状态栏 -->
<el-card class="top-bar-card" shadow="never">
<div class="top-bar">
<div class="status-bar">
<span class="status-text">博诺思车辆道路计数仪</span>
<span class="status-indicator" :class="deviceStatus">
{{ statusText }}
</span>
</div>
<div class="top-right">
<div class="timestamp">
<i class="el-icon-time"></i>
{{ currentTime }}
</div>
<!-- 画线按钮组 -->
<div class="draw-line-buttons">
<el-button
v-if="!isDrawingMode"
type="primary"
class="draw-line-btn"
@click="handleDrawLine"
icon="el-icon-edit"
>
画线
</el-button>
<template v-else>
<el-button
type="success"
class="save-line-btn"
@click="handleSaveLines"
icon="el-icon-check"
:loading="saving"
>
保存
</el-button>
<el-button
type="info"
class="cancel-line-btn"
@click="handleCancelLines"
icon="el-icon-close"
>
取消
</el-button>
</template>
</div>
</div>
</div>
</el-card>
<!-- 视频播放区域 -->
<el-card class="video-card" shadow="never" :body-style="{ padding: '0', height: '100%' }">
<div class="video-wrapper">
<div class="video-container" ref="videoContainer">
<video
ref="videoPlayer"
class="video-player"
:src="videoUrl"
autoplay
muted
controls
@loadedmetadata="handleVideoLoaded"
@error="handleVideoError"
>
您的浏览器不支持视频播放
</video>
<!-- 画线画布覆盖层 -->
<canvas
ref="drawCanvas"
class="draw-canvas"
:class="{ active: isDrawingMode }"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
></canvas>
<!-- 已保存的画线显示层只读 -->
<canvas
ref="savedLineCanvas"
class="saved-line-canvas"
></canvas>
<!-- 车辆检测框覆盖层 -->
<div class="detection-overlay">
<div
v-for="(vehicle, index) in detectedVehicles"
:key="index"
class="vehicle-box"
:style="{
left: vehicle.x + '%',
top: vehicle.y + '%',
width: vehicle.width + '%',
height: vehicle.height + '%'
}"
>
<div class="box-corner corner-tl"></div>
<div class="box-corner corner-tr"></div>
<div class="box-corner corner-bl"></div>
<div class="box-corner corner-br"></div>
</div>
</div>
<!-- 视频加载提示 -->
<div v-if="!videoUrl" class="video-placeholder">
<i class="el-icon-video-camera"></i>
<p>暂无视频流</p>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script>
import { saveVideoLineAPI } from '@/api/device/video'
export default {
name: 'VideoMonitor',
data() {
return {
deviceStatus: 'online', // online, offline, standby, upgrading
currentTime: '',
videoUrl: '', //
isDrawingMode: false, // 线
isDrawing: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
line: null, // 线线
savedLine: null, // 线
initialLine: null, // 线线
currentLine: null, // 线
detectedVehicles: [], //
timer: null,
saving: false, //
}
},
computed: {
statusText() {
const statusMap = {
online: '在线',
offline: '离线',
standby: '待机',
upgrading: '升级中'
}
return statusMap[this.deviceStatus] || '未知'
}
},
mounted() {
this.updateTime()
this.timer = setInterval(() => {
this.updateTime()
}, 1000)
//
this.$nextTick(() => {
this.initCanvas()
// 线使
this.initInitialLine()
})
//
// this.loadVideoStream()
},
beforeDestroy() {
if (this.timer) {
clearInterval(this.timer)
}
},
methods: {
/** 更新时间 */
updateTime() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
this.currentTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
},
/** 初始化画布 */
initCanvas() {
const canvas = this.$refs.drawCanvas
const savedCanvas = this.$refs.savedLineCanvas
const container = this.$refs.videoContainer
if (container) {
if (canvas) {
canvas.width = container.offsetWidth
canvas.height = container.offsetHeight
}
if (savedCanvas) {
savedCanvas.width = container.offsetWidth
savedCanvas.height = container.offsetHeight
// 线线
if (this.savedLine || this.line || this.initialLine) {
this.redrawCanvas()
}
}
}
},
/** 初始化默认线 */
initInitialLine() {
// 线
// 使线
const container = this.$refs.videoContainer
if (container) {
const containerWidth = container.offsetWidth || 1920
const containerHeight = container.offsetHeight || 1080
// 20%80%
this.initialLine = {
startX: containerWidth * 0.2,
startY: containerHeight * 0.2,
endX: containerWidth * 0.8,
endY: containerHeight * 0.8
}
// 线线
if (this.initialLine) {
this.savedLine = { ...this.initialLine }
this.redrawCanvas()
}
}
// 线
// this.loadInitialLine()
},
/** 处理画线按钮点击 */
handleDrawLine() {
this.isDrawingMode = true
// 线线
this.line = null // 线
this.clearCanvas() // 线
// 线
if (this.savedLine) {
this.redrawCanvas()
}
this.$message.success('画线模式已开启,在视频上按住鼠标左键拖动即可画线(只能画一条线)')
},
/** 保存画线 */
async handleSaveLines() {
// 线
// 线线
const lineToSave = this.line || this.savedLine
if (!lineToSave) {
this.$message.warning('请先画线后再保存')
return
}
this.saving = true
try {
//
const container = this.$refs.videoContainer
const containerWidth = container ? container.offsetWidth : 1920
const containerHeight = container ? container.offsetHeight : 1080
//
const lineData = {
startX: (lineToSave.startX / containerWidth) * 100,
startY: (lineToSave.startY / containerHeight) * 100,
endX: (lineToSave.endX / containerWidth) * 100,
endY: (lineToSave.endY / containerHeight) * 100,
}
const params = {
line: lineData,
videoUrl: this.videoUrl,
deviceId: this.$route.query.deviceId || '', // ID
}
// 500msAPI
await new Promise(resolve => setTimeout(resolve, 500))
// API
const res = { code: 200, msg: '保存成功' }
// API使
// const res = await saveVideoLineAPI(params)
if (res.code === 200) {
this.$message.success('画线数据保存成功')
// 线线线
if (this.line) {
const newLine = { ...this.line }
this.savedLine = newLine
// 线线
this.initialLine = { ...newLine }
this.line = null
}
// 线线
this.isDrawingMode = false
//
this.clearCanvas()
// 线线
this.redrawCanvas()
} else {
this.$message.error(res.msg || '保存失败')
}
} catch (error) {
console.error('保存画线数据失败:', error)
this.$message.error('保存失败,请稍后重试')
} finally {
this.saving = false
}
},
/** 取消画线 */
handleCancelLines() {
// 线使线
if (this.initialLine) {
this.isDrawingMode = false
this.line = null // 线
this.clearCanvas() //
// 使线
this.savedLine = { ...this.initialLine }
this.redrawCanvas()
this.$message.info('已取消画线模式,恢复初始线')
} else if (this.savedLine) {
// 线线线线
this.isDrawingMode = false
this.line = null // 线
this.clearCanvas() //
// 线
this.redrawCanvas()
this.$message.info('已取消画线模式')
} else {
// 线线
this.$confirm('确定要取消画线吗?画线将被清除', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.isDrawingMode = false
this.line = null
this.clearCanvas()
// 线
const savedCanvas = this.$refs.savedLineCanvas
if (savedCanvas) {
const ctx = savedCanvas.getContext('2d')
ctx.clearRect(0, 0, savedCanvas.width, savedCanvas.height)
}
this.$message.info('已取消画线')
}).catch(() => {
//
})
}
},
/** 清除画布 */
clearCanvas() {
const canvas = this.$refs.drawCanvas
if (canvas) {
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
},
/** 开始画线 */
startDrawing(e) {
if (!this.isDrawingMode) return
// 线使线
if (this.savedLine) {
this.savedLine = null
// 线
const savedCanvas = this.$refs.savedLineCanvas
if (savedCanvas) {
const ctx = savedCanvas.getContext('2d')
ctx.clearRect(0, 0, savedCanvas.width, savedCanvas.height)
}
}
// 线线线
if (this.line) {
this.line = null
}
this.isDrawing = true
const canvas = this.$refs.drawCanvas
const rect = canvas.getBoundingClientRect()
this.startX = e.clientX - rect.left
this.startY = e.clientY - rect.top
this.currentLine = {
startX: this.startX,
startY: this.startY,
endX: this.startX,
endY: this.startY
}
},
/** 画线 */
draw(e) {
if (!this.isDrawing) return
const canvas = this.$refs.drawCanvas
const ctx = canvas.getContext('2d')
const rect = canvas.getBoundingClientRect()
const currentX = e.clientX - rect.left
const currentY = e.clientY - rect.top
// 线
if (this.currentLine) {
this.currentLine.endX = currentX
this.currentLine.endY = currentY
}
// 线线
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.strokeStyle = '#00ff00'
ctx.lineWidth = 2
// 线
if (this.currentLine) {
ctx.beginPath()
ctx.moveTo(this.currentLine.startX, this.currentLine.startY)
ctx.lineTo(this.currentLine.endX, this.currentLine.endY)
ctx.stroke()
}
},
/** 停止画线 */
stopDrawing() {
if (this.isDrawing && this.currentLine) {
//
if (this.currentLine.startX !== this.currentLine.endX ||
this.currentLine.startY !== this.currentLine.endY) {
// 线使线
if (this.savedLine) {
this.savedLine = null
// 线
const savedCanvas = this.$refs.savedLineCanvas
if (savedCanvas) {
const ctx = savedCanvas.getContext('2d')
ctx.clearRect(0, 0, savedCanvas.width, savedCanvas.height)
}
}
// 线线
this.line = {
startX: this.currentLine.startX,
startY: this.currentLine.startY,
endX: this.currentLine.endX,
endY: this.currentLine.endY
}
// 线
this.redrawCanvas()
}
this.currentLine = null
}
this.isDrawing = false
},
/** 重绘画布 */
redrawCanvas() {
// 线
const savedCanvas = this.$refs.savedLineCanvas
if (savedCanvas) {
const container = this.$refs.videoContainer
if (container) {
savedCanvas.width = container.offsetWidth
savedCanvas.height = container.offsetHeight
}
const ctx = savedCanvas.getContext('2d')
ctx.clearRect(0, 0, savedCanvas.width, savedCanvas.height)
// 线线线线
const lineToDraw = this.savedLine || this.line || this.initialLine
if (lineToDraw) {
ctx.strokeStyle = '#00ff00'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(lineToDraw.startX, lineToDraw.startY)
ctx.lineTo(lineToDraw.endX, lineToDraw.endY)
ctx.stroke()
}
}
},
/** 视频加载完成 */
handleVideoLoaded() {
console.log('视频加载完成')
//
this.$nextTick(() => {
this.initCanvas()
// 线线
if (this.savedLine || this.line || this.initialLine) {
this.redrawCanvas()
}
})
},
/** 视频加载错误 */
handleVideoError(e) {
console.error('视频加载失败:', e)
this.$message.error('视频加载失败,请检查视频源地址')
},
/** 加载视频流 */
async loadVideoStream() {
// API
// const res = await getVideoStreamAPI()
// if (res.code === 200) {
// this.videoUrl = res.data.streamUrl
// }
// 使URL
// this.videoUrl = 'https://example.com/video-stream.mp4'
}
}
}
</script>
<style scoped lang="scss">
.video-monitor-container {
width: 100%;
height: calc(100vh - 84px);
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #f1f6ff 20%, #e5efff 100%);
padding: 20px;
gap: 20px;
overflow: hidden;
}
.top-bar-card {
margin-bottom: 0;
flex-shrink: 0;
::v-deep .el-card__body {
padding: 20px 24px;
}
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.status-bar {
display: flex;
align-items: center;
gap: 16px;
.status-text {
font-size: 18px;
font-weight: 600;
color: #333;
letter-spacing: 0.5px;
}
.status-indicator {
padding: 6px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
&.online {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #fff;
}
&.offline {
background: linear-gradient(135deg, #909399 0%, #a6a9ad 100%);
color: #fff;
}
&.standby {
background: linear-gradient(135deg, #e6a23c 0%, #ebb563 100%);
color: #fff;
}
&.upgrading {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #fff;
}
}
}
.top-right {
display: flex;
align-items: center;
gap: 20px;
.timestamp {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
font-family: 'Courier New', monospace;
padding: 6px 12px;
background: #f5f7fa;
border-radius: 4px;
i {
font-size: 16px;
color: #409eff;
}
}
.draw-line-buttons {
display: flex;
align-items: center;
gap: 12px;
}
.draw-line-btn {
background: #1f72ea;
border: none;
box-shadow: 0px 4px 8px 0px rgba(51, 135, 255, 0.5);
border-radius: 4px;
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
&:hover {
background: #4a8bff;
box-shadow: 0px 6px 12px 0px rgba(51, 135, 255, 0.6);
transform: translateY(-1px);
}
}
.save-line-btn {
background: #67c23a;
border: none;
box-shadow: 0px 4px 8px 0px rgba(103, 194, 58, 0.5);
border-radius: 4px;
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
&:hover {
background: #85ce61;
box-shadow: 0px 6px 12px 0px rgba(103, 194, 58, 0.6);
transform: translateY(-1px);
}
}
.cancel-line-btn {
background: #909399;
border: none;
box-shadow: 0px 4px 8px 0px rgba(144, 147, 153, 0.5);
border-radius: 4px;
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
&:hover {
background: #a6a9ad;
box-shadow: 0px 6px 12px 0px rgba(144, 147, 153, 0.6);
transform: translateY(-1px);
}
}
}
}
.video-card {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
::v-deep .el-card__body {
height: 100%;
padding: 0;
display: flex;
flex-direction: column;
}
}
.video-wrapper {
flex: 1;
position: relative;
overflow: hidden;
background: #000;
border-radius: 4px;
min-height: 0;
}
.video-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.video-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: #909399;
z-index: 0;
i {
font-size: 64px;
opacity: 0.5;
}
p {
font-size: 16px;
margin: 0;
}
}
.draw-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
}
.draw-canvas.active {
pointer-events: auto;
cursor: crosshair;
}
.saved-line-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1.5;
}
.detection-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.vehicle-box {
position: absolute;
border: 2px solid #00ff00;
box-sizing: border-box;
pointer-events: none;
animation: pulse 2s infinite;
.box-corner {
position: absolute;
width: 12px;
height: 12px;
background: #00ff00;
border: 2px solid #000;
box-sizing: border-box;
&.corner-tl {
top: -6px;
left: -6px;
border-right: none;
border-bottom: none;
}
&.corner-tr {
top: -6px;
right: -6px;
border-left: none;
border-bottom: none;
}
&.corner-bl {
bottom: -6px;
left: -6px;
border-right: none;
border-top: none;
}
&.corner-br {
bottom: -6px;
right: -6px;
border-left: none;
border-top: none;
}
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
//
@media (max-width: 768px) {
.video-monitor-container {
padding: 8px;
gap: 8px;
}
.top-bar {
flex-direction: column;
gap: 12px;
align-items: flex-start;
.top-right {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
}
.top-bar-card {
::v-deep .el-card__body {
padding: 12px;
}
}
}
</style>