init: 初始化前端项目

This commit is contained in:
liuzhiyuan 2025-07-30 18:07:47 +08:00
parent 12a8f7426c
commit 5fd4b38b6a
37 changed files with 5901 additions and 0 deletions

1
web/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

5
web/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

25
web/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "analytics-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@kjgl77/datav-vue3": "^1.7.4",
"echarts": "^5.6.0",
"echarts-liquidfill": "^3.1.0",
"element-plus": "^2.10.4",
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"sass": "^1.89.2",
"sass-embedded": "^1.89.2",
"vite": "^7.0.4"
}
}

1597
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
web/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

362
web/src/App.vue Normal file
View File

@ -0,0 +1,362 @@
<template>
<div class="dashboard-container" ref="dashboardContainer">
<!-- DataV顶部标题栏 -->
<DashboardHeader :active-tab="activeTab" @tab-change="handleTabChange" />
<!-- 全屏按钮 -->
<div class="fullscreen-btn" @click="toggleFullscreen">
<svg v-if="!isFullscreen" viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg>
<svg v-else viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>
</svg>
</div>
<!-- 根据当前标签显示不同内容 -->
<div v-if="activeTab === 'overview'" class="main-content">
<!-- 原有数据总览内容 -->
<div class="left-section">
<dv-border-box-1>
<MainKPI />
</dv-border-box-1>
</div>
<div class="center-section">
<AlarmTrend />
<CameraStats />
</div>
<div class="right-section">
<dv-border-box-1>
<EventHotSpots />
<AlgorithmStats />
</dv-border-box-1>
</div>
</div>
<!-- 监控视角内容 -->
<div v-if="activeTab === 'monitor'" class="monitor-content">
<!-- 监控列表 -->
<MonitorDetail :visible="showMonitorDetail" :monitor="currentMonitor" @close="closeMonitorDetail" />
<div class="monitor-list">
<div class="monitor-grid">
<div v-for="monitor in monitors" :key="monitor.id">
<MonitorThumbnail :monitor="monitor" @click="selectMonitor(monitor)" />
</div>
</div>
</div>
</div>
<!-- 算法中心内容 -->
<div v-if="activeTab === 'algorithms'" class="algorithm-content">
<AlgorithmCenter />
</div>
<!-- 告警管理 -->
<div v-if="activeTab === 'alarms'" class="alarm-content">
<AlarmManagement />
</div>
<!-- 事件中心 -->
<div v-if="activeTab === 'events'" class="event-content">
<EventCenter />
</div>
<!-- 设备管理 -->
<div v-if="activeTab === 'devices'" class="device-content">
<DeviceManagement />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import MainKPI from './components/MainKPI.vue'
import AlarmTrend from './components/AlarmTrend.vue'
import EventHotSpots from './components/EventHotSpots.vue'
import AlgorithmStats from './components/AlgorithmStats.vue'
import CameraStats from './components/CameraStats.vue'
import DashboardHeader from './components/DashboardHeader.vue'
import MonitorThumbnail from './components/monitor/MonitorThumbnail.vue'
import MonitorDetail from './components/monitor/MonitorDetail.vue'
import AlgorithmCenter from './components/algorithm/AlgorithmCenter.vue'
import AlarmManagement from './components/alarm/AlarmManagement.vue'
import EventCenter from './components/event/EventCenter.vue'
import DeviceManagement from './components/device/DeviceManagement.vue'
const activeTab = ref('overview')
const dashboardContainer = ref(null)
const isFullscreen = ref(false)
const showMonitorDetail = ref(false)
const currentMonitor = ref({
id: 1,
name: '港口区主监控',
location: '港口区', //
status: 'online',
videoSrc: '/videos/port-main.mp4'
})
const monitors = ref([
{
id: 1,
name: '港口区监控1',
location: '港口A区',
status: 'online',
videoSrc: '/videos/port-1.mp4',
detections: [
{ type: 'person', x: 25, y: 35, width: 40, height: 80 }
]
},
{
id: 2,
name: '港口区监控2',
location: '港口B区',
status: 'online',
videoSrc: '/videos/port-2.mp4',
detections: [
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
]
},
{
id: 3,
name: '港口区监控2',
location: '港口B区',
status: 'online',
videoSrc: '/videos/port-2.mp4',
detections: [
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
]
},
{
id: 4,
name: '港口区监控2',
location: '港口B区',
status: 'online',
videoSrc: '/videos/port-2.mp4',
detections: [
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
]
},
{
id: 5,
name: '港口区监控1',
location: '港口A区',
status: 'online',
videoSrc: '/videos/port-1.mp4',
detections: [
{ type: 'person', x: 25, y: 35, width: 40, height: 80 }
]
},
{
id: 6,
name: '港口区监控2',
location: '港口B区',
status: 'online',
videoSrc: '/videos/port-2.mp4',
detections: [
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
]
},
{
id: 7,
name: '港口区监控2',
location: '港口B区',
status: 'online',
videoSrc: '/videos/port-2.mp4',
detections: [
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
]
},
{
id: 8,
name: '港口区监控2',
location: '港口B区',
status: 'online',
videoSrc: '/videos/port-2.mp4',
detections: [
{ type: 'vehicle', x: 40, y: 50, width: 80, height: 60 }
]
},
])
//
const handleTabChange = (tab) => {
console.log(tab)
activeTab.value = tab
}
//
const selectMonitor = (monitor) => {
currentMonitor.value = monitor
showMonitorDetail.value = true
}
//
const closeMonitorDetail = () => {
showMonitorDetail.value = false
}
//
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
dashboardContainer.value.requestFullscreen().catch(err => {
console.error(`全屏错误: ${err.message}`)
})
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
}
//
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
//
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
})
//
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
})
</script>
<style lang="scss">
.dashboard-container {
width: 100%;
height: 100vh;
background-color: #0a1d3c;
color: #fff;
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
.fullscreen-btn {
position: fixed;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(16, 42, 87, 0.6);
border: 1px solid #7cd0ff;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1000;
color: #7cd0ff;
transition: all 0.3s;
&:hover {
background: rgba(124, 208, 255, 0.2);
color: white;
}
}
.main-content {
display: flex;
flex: 1;
gap: 10px;
margin-top: 10px;
.left-section {
width: 25%;
display: flex;
flex-direction: column;
gap: 20px;
height: 84vh;
}
.center-section {
height: 84vh;
display: flex;
flex-direction: column;
flex: 1;
}
.right-section {
height: 84vh;
width: 25%;
display: flex;
flex-direction: column;
gap: 20px;
}
}
/* 监控视角样式 */
.monitor-content {
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.monitor-list {
width: 100%;
padding: 20px;
.monitor-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
>div {
aspect-ratio: 16/9;
border-radius: 4px;
overflow: hidden;
}
}
}
}
/* 设备管理样式 */
.device-content {
overflow: hidden;
background-color: rgba(16, 42, 87, 0.3);
}
.algorithm-content {
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.alarm-content {
height: calc(100vh - 60px);
overflow: hidden;
}
.event-content {
height: calc(100vh - 60px);
overflow: hidden;
}
}
/* 全屏样式 */
:fullscreen .dashboard-container {
background-color: #0a1d3c;
padding: 20px;
}
:fullscreen .main-content {
height: calc(100vh - 80px);
}
:fullscreen .monitor-content,
:fullscreen .algorithm-content,
:fullscreen .alarm-content,
:fullscreen .event-content {
height: calc(100vh - 80px);
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

View File

@ -0,0 +1,46 @@
// 全局变量
$primary-color: #409eff;
$background-color: #0a1d3c;
$text-color: #fff;
$text-secondary: rgba(255, 255, 255, 0.8);
// 重置样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: 'Arial', sans-serif;
background-color: $background-color;
color: $text-color;
overflow: hidden;
}
// 卡片通用样式
.card {
border-radius: 8px;
padding: 20px;
height: 100%;
}
// 图表容器
.chart-container {
@extend .card;
display: flex;
flex-direction: column;
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
}
.chart {
flex: 1;
width: 100%;
}
}

1
web/src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,172 @@
<template>
<div class="alert-trend-container">
<dv-border-box-10 class="border-box">
<div class="chart-title">告警趋势</div>
<div ref="chart" class="chart"></div>
</dv-border-box-10>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
const chart = ref(null)
onMounted(() => {
const myChart = echarts.init(chart.value)
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0,0,0,0.7)',
borderColor: 'rgba(64, 158, 255, 0.8)',
borderWidth: 1,
textStyle: { color: '#fff' }
},
legend: {
data: ['P0告警', 'P1告警', 'P2告警', 'P3告警'],
textStyle: { color: '#fff', fontSize: 12 },
itemWidth: 12,
itemHeight: 8,
itemGap: 15,
right: 20,
top: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '20%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['1月', '2月', '3月', '4月', '5月'],
axisLine: { lineStyle: { color: 'rgba(124, 208, 255, 0.5)' } },
axisLabel: {
color: 'rgba(124, 208, 255, 0.8)',
fontSize: 12
},
axisTick: { show: false }
},
yAxis: {
type: 'value',
min: 0,
max: 550,
interval: 50,
axisLine: {
lineStyle: { color: 'rgba(124, 208, 255, 0.5)' }
},
splitLine: {
lineStyle: {
color: 'rgba(124, 208, 255, 0.1)',
type: 'dashed'
}
},
axisLabel: {
color: 'rgba(124, 208, 255, 0.8)',
fontSize: 12
}
},
series: [
{
name: 'P0告警',
type: 'line',
smooth: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#FF4D4F' },
itemStyle: {
color: '#FF4D4F',
borderWidth: 1,
borderColor: '#fff'
},
data: [300, 100, 400, 250, 200]
},
{
name: 'P1告警',
type: 'line',
smooth: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#FFA500' },
itemStyle: {
color: '#FFA500',
borderWidth: 1,
borderColor: '#fff'
},
data: [300, 100, 400, 250, 200]
},
{
name: 'P2告警',
type: 'line',
smooth: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#A020F0' },
itemStyle: {
color: '#A020F0',
borderWidth: 1,
borderColor: '#fff'
},
data: [350, 150, 550, 50, 110]
},
{
name: 'P3告警',
type: 'line',
smooth: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#00FF00' },
itemStyle: {
color: '#00FF00',
borderWidth: 1,
borderColor: '#fff'
},
data: [200, 300, 400, 100, 50]
}
]
}
myChart.setOption(option)
window.addEventListener('resize', function() {
myChart.resize()
})
})
</script>
<style scoped>
.alert-trend-container {
width: 100%;
height: 50vh; /* 增加高度 */
padding: 15px 10px;
box-sizing: border-box;
margin-top: 50px; /* 增加上方间距 */
}
.border-box {
width: 100%;
height: 100%;
border-radius: 4px;
/* padding: 20px; */
box-sizing: border-box;
}
.chart-title {
font-size: 16px;
color: #7cd0ff;
text-align: center;
padding-top: 15px;
font-weight: 500;
letter-spacing: 1px;
}
.chart {
width: 100%;
height: calc(100% - 30px);
}
</style>

View File

@ -0,0 +1,152 @@
<template>
<div class="radar-chart-container">
<dv-border-box-10 class="border-box">
<div class="chart-title">算法命中率统计</div>
<div ref="radarChart" class="chart"></div>
</dv-border-box-10>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
const radarChart = ref(null)
onMounted(() => {
const radarOption = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(0,0,0,0.8)',
borderColor: 'rgba(124, 208, 255, 0.8)',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12
}
},
legend: {
data: ['目标识别算法', '行为识别算法'],
bottom: 10,
textStyle: {
color: '#fff',
fontSize: 12
},
itemWidth: 12,
itemHeight: 8,
itemGap: 20
},
radar: {
indicator: [
{ name: '命中率', max: 100 },
{ name: '误报率', max: 100 },
{ name: '触发器', max: 100 },
{ name: '处理时延', max: 100 },
{ name: '稳定性', max: 100 }
],
radius: '65%',
axisName: {
color: 'rgba(124, 208, 255, 0.8)',
fontSize: 12,
padding: [3, 5]
},
splitLine: {
lineStyle: {
color: 'rgba(124, 208, 255, 0.2)',
width: 1
}
},
axisLine: {
lineStyle: {
color: 'rgba(124, 208, 255, 0.5)',
width: 1
}
},
splitArea: {
show: false
}
},
series: [{
type: 'radar',
data: [
{
value: [90, 85, 95, 80, 88],
name: '目标识别算法',
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#A020F0' //
},
itemStyle: {
color: '#A020F0',
borderWidth: 1,
borderColor: '#fff'
},
areaStyle: {
color: 'rgba(160, 32, 240, 0.2)'
}
},
{
value: [80, 75, 85, 70, 82],
name: '行为识别算法',
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#1E90FF' //
},
itemStyle: {
color: '#1E90FF',
borderWidth: 1,
borderColor: '#fff'
},
areaStyle: {
color: 'rgba(30, 144, 255, 0.2)'
}
}
]
}]
}
const chartInstance = echarts.init(radarChart.value)
chartInstance.setOption(radarOption)
window.addEventListener('resize', () => {
chartInstance.resize()
})
})
</script>
<style scoped>
.radar-chart-container {
width: 100%;
height: 40vh; /* 高度占一半 */
padding: 15px 30px;
box-sizing: border-box;
}
.border-box {
width: 100%;
height: 100%;
border-radius: 4px;
/* padding: 20px; */
box-sizing: border-box;
}
.chart-title {
font-size: 16px;
color: #7cd0ff;
text-align: center;
padding-top: 15px;
font-weight: 500;
letter-spacing: 1px;
}
.chart {
width: 100%;
height: calc(100% - 30px);
padding: 20px;
}
</style>

View File

@ -0,0 +1,126 @@
<template>
<div class="stats-container">
<dv-border-box-10>
<div class="stat-title">区域/设备类指标</div>
<div class="gauge-container">
<div class="gauge">
<div ref="onlineRateChart" class="gauge-chart"></div>
<div class="gauge-value">58%</div>
<div class="gauge-label">摄像头在线率</div>
</div>
<div class="gauge">
<div ref="coverageChart" class="gauge-chart"></div>
<div class="gauge-value">40%</div>
<div class="gauge-label">算法部署覆盖率</div>
</div>
</div>
</dv-border-box-10>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import 'echarts-liquidfill' //
const onlineRateChart = ref(null)
const coverageChart = ref(null)
onMounted(() => {
//
const createLiquidOption = (color) => ({
series: [{
type: 'liquidFill',
radius: '70%',
center: ['50%', '50%'],
data: [0.338, 0.48], //
color: [color],
backgroundStyle: {
color: 'rgba(16, 42, 87, 0.5)',
borderWidth: 1,
borderColor: 'rgba(124, 208, 255, 0.3)'
},
outline: {
show: false
},
label: {
show: false
},
waveAnimation: true,
animationDuration: 2000,
animationEasing: 'linear',
amplitude: 6,
waveLength: '80%'
}]
})
//
const onlineRate = echarts.init(onlineRateChart.value)
onlineRate.setOption(createLiquidOption('#3A8BFF'))
const coverage = echarts.init(coverageChart.value)
coverage.setOption(createLiquidOption('#FFA726'))
window.addEventListener('resize', () => {
onlineRate.resize()
coverage.resize()
})
})
</script>
<style scoped>
/* 样式保持不变 */
.stats-container {
height: 30vh;
box-sizing: border-box;
padding: 15px 10px;
}
.border-box {
height: 100%;
border-radius: 4px;
padding: 0px;
box-sizing: border-box;
}
.stat-title {
font-size: 16px;
color: #7cd0ff;
text-align: center;
padding-top: 15px;
font-weight: 500;
letter-spacing: 1px;
}
.gauge-container {
display: flex;
justify-content: space-around;
align-items: center;
height: calc(100% - 40px);
}
.gauge {
display: flex;
flex-direction: column;
align-items: center;
width: 45%;
}
.gauge-chart {
width: 120px;
height: 120px;
}
.gauge-value {
font-size: 24px;
font-weight: bold;
color: #fff;
margin: 10px 0 5px;
}
.gauge-label {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
}
</style>

View File

@ -0,0 +1,281 @@
<template>
<div class="dashboard-header">
<div class="header-top">
<div class="header-left">
<img src="/src/assets/images/header-left@1x.png"></img>
<div class="left-tabs">
<template v-if="isOverviewOrMonitor">
<div
class="tab-item overview-tab"
:class="{active: activeTab === 'overview'}"
@click="handleTabClick('overview')"
>
数据总览
</div>
<div
class="tab-item monitor-tab"
:class="{active: activeTab === 'monitor'}"
@click="handleTabClick('monitor')"
>
监控视角
</div>
</template>
<img
v-else
src="/src/assets/images/st-home@1x.png"
@click="handleTabClick('overview')"
class="home-icon"
></img>
</div>
</div>
<div class="header-center">
<div class="center-top">
<img class="out-bg" src="/src/assets/images/header-center-out.png"></img>
<img class="in-bg"src="/src/assets/images/header-center-in@1x.png"></img>
<div class="l-lx">
<img src="/src/assets/images/l-lx@1x.png" ></img>
<img src="/src/assets/images/l-lx@1x.png" ></img>
<img src="/src/assets/images/l-lx@1x.png" ></img>
</div>
<div class="r-lx">
<img src="/src/assets/images/r-lx@1x.png" ></img>
<img src="/src/assets/images/r-lx@1x.png" ></img>
<img src="/src/assets/images/r-lx@1x.png" ></img>
</div>
<div class="title">视频智能分析系统</div>
</div>
</div>
<div class="header-right">
<img src="/src/assets/images/header-right@1x.png"></img>
</div>
</div>
<div class="center-tabs">
<img src="/src/assets/images/header-tab-bg@1x.png"></img>
<div class="item">
<a @click="handleTabClick('devices')">设备管理</a>
<a @click="handleTabClick('algorithms')">算法中心</a>
<a class="has-alarm" @click="handleTabClick('alarms')">
告警管理<span class="alarm-badge">99</span>
</a>
<a @click="handleTabClick('events')">事件中心</a>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed } from 'vue'
const props = defineProps({
activeTab: {
type: String,
default: 'overview'
}
})
const emit = defineEmits(['tab-change'])
const isOverviewOrMonitor = computed(() => {
return props.activeTab === 'overview' || props.activeTab === 'monitor'
})
const handleTabClick = (tab) => {
emit('tab-change', tab)
}
</script>
<style lang="scss" scoped>
.dashboard-header {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.header-top{
width: 95%;
margin: 0 auto;
position: relative;
display: flex;
flex-direction: row;
background: #0a1d3c;
.header-left{
width: 28%;
img{
width: 100%;
height: auto;
}
.left-tabs {
position: absolute;
left: 10px;
bottom: 0;
display: flex;
gap: 10px;
align-items: center;
.tab-item {
padding: 0 30px;
height: 30px;
line-height: 30px;
color: rgba(16,16,16,1);
font-size: 14px;
text-align: center;
color: rgba(255,255,255,1);
cursor: pointer;
border: 1px solid white;
border-radius: 2px;
&.overview-tab{
background-color: #3a3a42;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
&.overview-tab.active {
background-color: rgba(38,140,140,1);
}
&.monitor-tab {
background-color: #3a3a42;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
&.monitor-tab.active {
background-color: rgba(38,140,140,1);
}
}
.home-icon {
cursor: pointer;
transition: all 0.3s;
height: 24px;
width: auto;
&:hover {
opacity: 0.8;
transform: scale(1.05);
}
}
}
}
.header-center{
width:48%;
position: relative;
display: flex;
flex-direction: column;
.center-top{
.out-bg{
position: absolute;
width: 104%;
height: auto;
left: -2%;
}
.in-bg{
position: absolute;
width: 102%;
height: auto;
top:8%;
left: -1%;
}
.l-lx{
position: absolute;
bottom: 25%;
display: flex;
justify-content:flex-end;
align-items: right;
img{
width: 25%;
margin-right: 0;
}
}
.r-lx{
position: absolute;
bottom: 25%;
display: flex;
right: -0.5%;
justify-content:flex-start;
img{
width: 25%;
margin-left: 0;
}
}
.title{
position: absolute;
left: 50%;
top: 15px;
transform: translateX(-50%);
width: 400px;
text-align: center;
width: 100%;
height: 50px;
font-size: 24px;
font-weight: 500;
color: #7cd0ff;
text-shadow: 0 0 10px rgba(124, 208, 255, 0.7);
letter-spacing: 0.5em;
}
}
}
.header-right{
width: 28%;
img{
width: 100%;
height: auto;
}
}
}
.center-tabs{
width: 32%;
position: relative;
margin-top: -4px;
img{
width: 100%;
}
.item {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
width: 80%;
a {
position: relative;
color: white;
font-size: 14px;
padding: 8px 10px;
margin: 0 5px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #7cd0ff;
text-shadow: 0 0 8px rgba(124, 208, 255, 0.7);
}
&.has-alarm {
.alarm-badge {
position: absolute;
top: -8px;
right: 2px;
background-color: #ff4d4f;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5);
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,167 @@
<template>
<div class="half-height-chart-container">
<dv-border-box-10 class="border-box">
<div class="chart-title">事件高发场景/区域分布</div>
<div ref="barChart" class="chart"></div>
</dv-border-box-10>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
const barChart = ref(null)
onMounted(() => {
const barOption = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(0,0,0,0.8)',
borderColor: 'rgba(64, 158, 255, 0.8)',
borderWidth: 1,
textStyle: { color: '#fff' }
},
legend: {
data: ['检测数据1', '检测数据2'],
bottom: 10, // 10px
left: 'center', //
textStyle: {
color: '#fff',
fontSize: 12
},
itemWidth: 12,
itemHeight: 8,
itemGap: 30
},
grid: {
left: '2%',
right: '4%',
bottom: '15%', //
top: '5%',
containLabel: true
},
xAxis: {
type: 'value',
min: 0,
max: 250,
interval: 50,
axisLine: {
lineStyle: {
color: 'rgba(124, 208, 255, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(124, 208, 255, 0.1)',
type: 'dashed'
}
},
axisLabel: {
color: 'rgba(124, 208, 255, 0.8)',
fontSize: 12
}
},
yAxis: {
type: 'category',
data: [
'办公走廊/楼道区',
'会议室区域',
'值班岗亭区',
'边检通道区',
'港口泊位区'
],
axisLine: {
lineStyle: {
color: 'rgba(124, 208, 255, 0.5)'
}
},
axisLabel: {
color: 'rgba(124, 208, 255, 0.8)',
fontSize: 12
},
axisTick: {
show: false
}
},
series: [
{
name: '检测数据1',
type: 'bar',
barWidth: 15,
label: {
show: true,
position: 'right',
color: '#FFA500',
fontSize: 12,
formatter: '{c}'
},
itemStyle: {
color: '#FFA500',
borderRadius: [0, 4, 4, 0]
},
data: [120, 150, 200, 100, 150]
},
{
name: '检测数据2',
type: 'bar',
barWidth: 15,
label: {
show: true,
position: 'right',
color: '#00FFC0',
fontSize: 12,
formatter: '{c}'
},
itemStyle: {
color: '#00FFC0',
borderRadius: [0, 4, 4, 0]
},
data: [100, 120, 100, 80, 100]
}
]
}
const chartInstance = echarts.init(barChart.value)
chartInstance.setOption(barOption)
window.addEventListener('resize', () => {
chartInstance.resize()
})
})
</script>
<style scoped>
.half-height-chart-container {
width: 100%;
height: 40vh; /* 关键修改高度设为视口高度的50% */
padding: 15px 30px;
box-sizing: border-box;
margin-top: 20px;
}
.border-box {
width: 100%;
height: 100%;
border-radius: 4px;
/* padding: 20px; */
box-sizing: border-box;
}
.chart-title {
font-size: 16px;
color: #7cd0ff;
text-align: center;
padding-top: 15px;
font-weight: 500;
letter-spacing: 1px;
}
.chart {
width: 100%;
height: calc(100% - 30px);
padding: 20px;
}
</style>

View File

@ -0,0 +1,223 @@
<template>
<div class="kpi-container">
<dv-border-box-10 class="kpi-card">
<div class="chart-title">今日告警</div>
<div class="kpi-value">6549.69<span class="monitor-unit"></span></div>
</dv-border-box-10>
<dv-border-box-10 class="kpi-card online-monitor">
<div class="chart-title">在线监控数</div>
<div class="kpi-value">
<dv-border-box-12 class="monitor-box">
<div class="monitor-title">当前在线监控</div>
<div class="monitor-value">13,365<span class="monitor-unit"></span></div>
</dv-border-box-12>
</div>
</dv-border-box-10>
<dv-border-box-10 class="kpi-card algorithm-card">
<div class="chart-title">算法执行</div>
<div class="algorithm-content">
<div class="algorithm-subtitle">累计执行次数</div>
<div class="flip-counter">
<div class="flip-digit" v-for="(digit, index) in digits" :key="index">
<div class="digit-scroll" :style="{ transform: `translateY(${-digit.current * 10}%)` }">
<span v-for="n in 10" :key="n">{{ n }}</span>
</div>
</div>
</div>
</div>
</dv-border-box-10>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 22415
const digits = ref([
{ current: 2, target: 2 },
{ current: 2, target: 2 },
{ current: 4, target: 4 },
{ current: 1, target: 6 },
{ current: 5, target: 4 },
{ current: 5, target: 4 }
])
const animateDigits = () => {
// 322464
setTimeout(() => {
digits.value.forEach((digit, index) => {
setTimeout(() => {
let steps = Math.abs(digit.target - digit.current)
if (steps > 5) steps = 10 - steps
const duration = 1000
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
digit.current = Math.floor(digit.current + progress * (digit.target - digit.current))
if (digit.current > 9) digit.current = 0
if (digit.current < 0) digit.current = 9
if (progress < 1) {
requestAnimationFrame(animate)
} else {
digit.current = digit.target
}
}
animate()
}, index * 300)
})
}, 1000)
}
onMounted(() => {
animateDigits()
})
</script>
<style scoped>
/* 基础样式 */
.kpi-container {
display: flex;
flex-direction: column;
align-items: center;
min-height: 80vh;
padding: 20px 0;
box-sizing: border-box;
}
.kpi-card {
width: 91%;
height: 25vh;
margin: 10px 0;
/* display: flex; */
/* flex-direction: column;
justify-content: center;
align-items: center; */
}
.chart-title {
font-size: 16px;
color: #7cd0ff;
text-align: center;
padding-top: 15px;
font-weight: 500;
letter-spacing: 1px;
}
.kpi-value {
font-size: 28px;
font-weight: bold;
color: #fff;
text-align: center;
margin-top: 10%;
}
/* 在线监控数卡片样式 */
.online-monitor {
border-color: rgba(124, 208, 255, 0.7);
}
.online-monitor ::v-deep .border-box-title {
color: #7cd0ff !important;
font-size: 16px;
font-weight: normal;
}
.monitor-box {
width: 50%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 15px 0;
margin: 0 auto;
background: #094290;
}
.monitor-title {
color: #7cd0ff;
font-size: 16px;
font-weight: 400;
margin-bottom: 10px;
letter-spacing: 1px;
}
.monitor-value {
color: #ffffff;
font-size: 32px;
font-weight: bold;
letter-spacing: 1px;
line-height: 1.2;
}
.monitor-unit {
font-size: 16px;
font-weight: normal;
margin-left: 6px;
color: rgba(255, 255, 255, 0.8);
position: relative;
top: -2px;
}
/* 算法执行卡片样式 */
.algorithm-card ::v-deep .border-box-title {
display: none !important;
}
.algorithm-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 0;
margin-top: 4%;
}
.algorithm-subtitle {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
margin-bottom: 15px;
}
/* 数字翻牌器样式 */
.flip-counter {
display: flex;
justify-content: center;
gap: 10px;
height: 60px;
}
.flip-digit {
width: 40px;
height: 50px;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #1a5fb4, #3584e4);
border-radius: 4px;
box-shadow: 0 0 10px rgba(53, 132, 228, 0.6);
}
.digit-scroll {
position: absolute;
top: 0;
left: 0;
width: 100%;
transition: transform 0.8s cubic-bezier(0.25, 1, 0.5, 1);
}
.digit-scroll span {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: bold;
color: white;
}
</style>

View File

@ -0,0 +1,74 @@
<!-- src/components/alarm/AlarmItem.vue -->
<template>
<div
class="alarm-item"
:class="{active: isActive, 'has-new': alarm.hasNew}"
@click="$emit('click')"
>
<div class="a-content">
{{ alarm.name }}
<span v-if="alarm.hasNew" class="new-dot"></span>
</div>
</div>
</template>
<script setup>
defineProps({
alarm: {
type: Object,
required: true
},
isActive: {
type: Boolean,
default: false
}
})
defineEmits(['click'])
</script>
<style scoped>
.alarm-item {
width: 90%;
padding: 15px;
margin-bottom: 10px;
background: rgba(16, 42, 87, 0.6);
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid #09D2FF;
&:hover {
background: rgba(58, 139, 255, 0.3);
}
&.active {
border-left: 3px solid #ff4d4f;
background: rgba(58, 139, 255, 0.2);
}
}
.alarm-item .a-content{
color: white;
font-size: 14px;
position: relative;
padding-right: 15px;
}
.alarm-time {
color: rgba(124, 208, 255, 0.7);
font-size: 12px;
margin-top: 5px;
}
.new-dot {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
background: #ff4d4f;
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,534 @@
<!-- src/components/alarm/AlarmManagement.vue -->
<template>
<div class="alarm-management">
<!-- 左侧P0紧急告警 -->
<div class="alarm-column p0-alarms">
<dv-border-box-12>
<div class="column-header">
<span class="level-tag p0">P0</span>
<span class="column-title">紧急告警</span>
</div>
<div class="alarm-list">
<AlarmItem v-for="alarm in p0Alarms" :key="alarm.id" :alarm="alarm" @click="selectAlarm(alarm)" />
</div>
</dv-border-box-12>
</div>
<!-- 中间告警详情 -->
<div class="alarm-detail">
<div class="center-top">
<dv-border-box-12>
<div class="detail-container">
<div class="video-container">
<video autoplay loop muted>
<source :src="selectedAlarm.videoSrc" type="video/mp4">
</video>
</div>
<div class="alarm-info">
<div class="info-item">
<span class="info-label">事件名称</span>
<span class="info-value">{{ selectedAlarm.name }}</span>
<span class="info-label">时间</span>
<span class="info-value">{{ selectedAlarm.time }}</span>
</div>
<div class="info-item">
<span class="info-label">告警等级</span>
<span class="level-tag p0">P0</span>
</div>
<div class="info-item">
<span class="info-label">详细信息</span>
<span class="info-label">1231312312312321312312</span>
</div>
<button class="confirm-btn" @click="openConfirmDialog">人员二次确认</button>
</div>
</div>
</dv-border-box-12>
</div>
<div class="center-bottom">
<div class="bottom-column">
<dv-border-box-12>
<div class="column-header">
<span class="level-tag p1">P1</span>
<span class="column-title">高级告警</span>
</div>
<div class="alarm-list">
<AlarmItem v-for="alarm in p1Alarms" :key="alarm.id" :alarm="alarm"
@click="selectAlarm(alarm)" />
</div>
</dv-border-box-12>
</div>
<div class="bottom-column">
<dv-border-box-12>
<div class="column-header">
<span class="level-tag p2">P2</span>
<span class="column-title">一般告警</span>
</div>
<div class="alarm-list">
<AlarmItem v-for="alarm in p2Alarms" :key="alarm.id" :alarm="alarm"
@click="selectAlarm(alarm)" />
</div>
</dv-border-box-12>
</div>
</div>
</div>
<!-- 右侧P3提示信息 -->
<div class="alarm-column p3-notices">
<dv-border-box-12>
<div class="column-header">
<span class="level-tag p3">P3</span>
<span class="column-title">提示信息</span>
</div>
<div class="notice-list">
<NoticeItem v-for="notice in p3Notices" :key="notice.id" :notice="notice" />
</div>
</dv-border-box-12>
</div>
</div>
<div v-if="showConfirmDialog" class="confirm-dialog-overlay">
<div class="confirm-dialog">
<div class="dialog-header">告警二次确认</div>
<div class="dialog-content">
<div class="input-group">
<span class="input-label">唯一ID</span>
<input type="text" v-model="uniqueId" placeholder="请输入" class="id-input" />
</div>
</div>
<div class="dialog-footer">
<button class="cancel-btn" @click="closeDialog">取消</button>
<button class="confirm-btn" @click="handleConfirm">保存应用设置</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AlarmItem from './AlarmItem.vue'
import NoticeItem from './NoticeItem.vue'
const showConfirmDialog = ref(false)
const uniqueId = ref('')
const selectedAlarm = ref({
name: '',
time: '',
confirmed: true,
videoSrc: ''
})
const p0Alarms = ref([
{
id: 1,
name: '非法登轮事件识别',
time: '2025/10/15 15:48',
hasNew: true
},
{
id: 2,
name: '暴力行为识别',
time: '2025/10/15 16:12',
hasNew: true
},
{
id: 3,
name: '执法岗长时间无人值守',
time: '2025/10/15 16:30',
hasNew: false
},
{
id: 4,
name: '黑名单人员出现在控制区域',
time: '2025/10/15 17:05',
hasNew: true
},
{
id: 5,
name: '越界穿越警戒线',
time: '2025/10/15 17:22',
hasNew: false
},
{
id: 6,
name: '重点物品丢失检测',
time: '2025/10/15 17:45',
hasNew: true
},
{
id: 7,
name: '电脑屏幕长时间未锁定+无人状态',
time: '2025/10/15 18:03',
hasNew: false
},
{
id: 8,
name: '可疑行为',
time: '2025/10/15 18:20',
hasNew: true
}
])
const p1Alarms = ref([
{
id: 1,
name: '员工办公室内长时间离岗',
time: '2025/10/15 09:30',
hasNew: true
},
{
id: 2,
name: '电脑屏幕未锁定+短时无人',
time: '2025/10/15 10:15',
hasNew: false
},
// P1...
])
const p2Alarms = ref([
{
id: 1,
name: '会议期间频繁查看手机',
time: '2025/10/15 11:20',
hasNew: true
},
{
id: 2,
name: '门口区域逗留/张望行为',
time: '2025/10/15 12:45',
hasNew: false
},
// P2...
])
const p3Notices = ref([
{
id: 1,
content: '摄像头恢复在线',
},
{
id: 2,
content: '摄像头恢复在线',
},
{
id: 3,
content: '摄像头恢复在线',
},
{
id: 4,
content: '摄像头恢复在线',
},
// P3...
])
const selectAlarm = (alarm) => {
selectedAlarm.value = alarm
//
alarm.hasNew = false
}
const openConfirmDialog = () => {
showConfirmDialog.value = true
}
const closeDialog = () => {
showConfirmDialog.value = false
uniqueId.value = ''
}
const handleConfirm = () => {
//
console.log('确认ID:', uniqueId.value)
closeDialog()
}
// confirm-btn
const confirmBtn = document.querySelector('.confirm-btn')
if (confirmBtn) {
confirmBtn.addEventListener('click', openConfirmDialog)
}
</script>
<style lang="scss">
.alarm-management {
display: flex;
height: 84vh;
background: #0a1d3c;
}
.alarm-column {
width: 25%;
padding: 15px;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.p0-alarms {
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.p0-alarms .alarm-list {
display: flex;
flex-direction: column;
align-items: center;
}
.alarm-detail {
flex: 1;
display: flex;
flex-direction: column;
.center-top {
padding: 20px;
height: 35vh;
.detail-container {
display: flex;
align-items: center;
height: 100%;
padding: 20px;
.video-container {
width: 40%;
height: 100%;
background: #000;
border-radius: 4px;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.alarm-info {
position: relative; //
width: 60%;
padding: 20px;
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
.info-item {
display: flex;
align-items: center;
margin-bottom: 10px;
font-size: 15px;
.info-label {
color: rgba(124, 208, 255, 0.8);
width: 100px;
text-align: right;
margin-right: 15px;
}
.info-value {
color: white;
flex: 1;
}
.level-tag.p0 {
padding: 4px 12px;
background: #ff4d4f;
border-radius: 4px;
font-weight: bold;
}
}
.confirm-btn {
position: absolute;
bottom: 20px; // 20px
left: 50%; //
transform: translateX(-50%); //
padding: 8px 20px;
background: rgba(124, 208, 255, 0.2);
border: 1px solid #7cd0ff;
border-radius: 4px;
color: #7cd0ff;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: rgba(124, 208, 255, 0.4);
}
}
}
}
}
.center-bottom {
height: 48vh;
display: flex;
gap: 15px;
padding: 0 20px 20px;
.bottom-column {
flex: 1;
height: 100%;
.alarm-list {
height: calc(100% - 50px);
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
align-items: center;
&::-webkit-scrollbar {
display: none;
}
}
}
}
}
.column-header {
width: 95%;
display: flex;
flex-direction: row;
padding: 20px;
}
.column-title {
font-size: 16px;
color: #7cd0ff;
margin-left: 10px;
}
.level-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
&.p0 {
background: #ff4d4f;
color: white;
}
&.p3 {
background: #00ffc0;
color: #0a1d3c;
}
&.p1 {
background: #FFA500; //
color: white;
}
&.p2 {
background: #FFFF00; //
color: #0a1d3c;
}
}
.notice-list {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
// style
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.confirm-dialog {
width: 400px;
background: #0a1d3c;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
overflow: hidden;
.dialog-header {
padding: 15px 20px;
font-size: 18px;
color: white;
background: rgba(16, 42, 87, 0.8);
border-bottom: 1px solid rgba(124, 208, 255, 0.2);
}
.dialog-content {
padding: 20px;
.input-group {
display: flex;
align-items: center;
.input-label {
color: white;
width: 80px;
text-align: right;
margin-right: 15px;
}
.id-input {
flex: 1;
padding: 8px 12px;
background: rgba(16, 42, 87, 0.6);
border: 1px solid #7cd0ff;
border-radius: 4px;
color: white;
&::placeholder {
color: rgba(124, 208, 255, 0.5);
}
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding: 15px 20px;
border-top: 1px solid rgba(124, 208, 255, 0.2);
button {
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&.cancel-btn {
background: transparent;
border: 1px solid #7cd0ff;
color: #7cd0ff;
margin-right: 15px;
&:hover {
background: rgba(124, 208, 255, 0.2);
}
}
&.confirm-btn {
background: #03051a;
border: 1px solid #00ffc0;
color: white;
font-weight: bold;
&:hover {
background: #00e6ac;
}
}
}
}
}
</style>

View File

@ -0,0 +1,35 @@
<!-- src/components/alarm/NoticeItem.vue -->
<template>
<div class="notice-item">
<div class="notice-content">
{{ notice.content }}
</div>
</div>
</template>
<script setup>
defineProps({
notice: {
type: Object,
required: true
}
})
</script>
<style scoped>
.notice-item {
width: 90%;
padding: 15px;
margin-bottom: 10px;
background: #081f50;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid #09d2ff;
}
.notice-content {
color: white;
font-size: 14px;
}
</style>

View File

@ -0,0 +1,102 @@
<!-- src/components/algorithm/AlgorithmCard.vue -->
<template>
<dv-border-box-13 class="algorithm-card">
<div class="card-content">
<div class="algorithm-name">{{ algorithm.name }}</div>
<div class="task-types">
<div class="task-title">任务类型</div>
<div class="type-list">
<div v-for="(type, index) in algorithm.taskTypes"
:key="index"
class="task-type">
{{ type }}
</div>
</div>
</div>
<div class="detection-count">
<span class="count-text">成功检测</span>
<span class="count-number">{{ algorithm.detectionCount }}</span>
<span class="count-unit"></span>
</div>
</div>
</dv-border-box-13>
</template>
<script setup>
defineProps({
algorithm: {
type: Object,
required: true,
default: () => ({
name: '',
taskTypes: [],
detectionCount: 0
})
}
})
</script>
<style scoped>
.algorithm-card {
width: 100%;
height: 100%;
padding: 15px;
::v-deep .border-box-content {
height: 100%;
display: flex;
flex-direction: column;
}
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.algorithm-name {
font-size: 20px;
font-weight: bold;
color: #7cd0ff;
margin-bottom: 15px;
}
.task-types {
flex: 1;
width: 90%;
.task-title {
font-size: 20px;
color: white;
margin-bottom: 8px;
}
.type-list{
display: flex;
flex-direction: row;
.task-type {
border: 1px solid #7cd0ff; /* 浅蓝色边框 */
color: #00ffc0; /* 蓝绿色文字 */
background: rgba(16, 42, 87, 0.5); /* 半透明深蓝背景 */
padding: 4px 20px;
border-radius: 4px;
text-align: center;
margin-right: 10px;
font-size: 12px;
}
}
}
.detection-count {
width: 90%;
display: flex;
align-items: baseline;
margin-top: 30px;
align-items: center;
.count-number {
font-size: 40px;
font-weight: bold;
color: #7cd0ff; /* 大号蓝色数字 */
margin: 0 10px;
}
}
</style>

View File

@ -0,0 +1,95 @@
<!-- src/components/algorithm/AlgorithmCenter.vue -->
<template>
<div class="algorithm-center">
<div class="algorithm-grid">
<AlgorithmCard
v-for="(algorithm, index) in algorithms"
:key="index"
:algorithm="algorithm"
/>
</div>
</div>
</template>
<script setup>
import AlgorithmCard from './AlgorithmCard.vue'
const algorithms = [
{
name: '非法登轮识别算法',
taskTypes: ['区域入侵检测', '行为识别'],
detectionCount: '68,000'
},
{
name: '靠泊状态检测算法',
taskTypes: ['靠泊检测', '静态目标跟踪'],
detectionCount: '5,124'
},
{
name: '打瞌睡检测算法',
taskTypes: ['姿态识别', '行为识别'],
detectionCount: '1,025'
},
{
name: '值班离岗检测算法',
taskTypes: ['手部动作识别', '行为识别'],
detectionCount: '90,128'
},
{
name: '佩戴证件识别算法',
taskTypes: ['属性识别', '目标特征比对'],
detectionCount: '6,094'
},
{
name: '非法律行识别算法',
taskTypes: ['人体轨迹跟踪', '滞留时长分析'],
detectionCount: '5,080'
},
{
name: '暴力动作检测算法',
taskTypes: ['多人姿态动作识别', '非正常行为判定'],
detectionCount: '8,300'
},
{
name: '异常靠近设备算法',
taskTypes: ['电脑未锁屏', '人脸识别'],
detectionCount: '3,456'
},
{
name: '违章品携带检测',
taskTypes: ['接近行为追踪', '人体-物体关系识别'],
detectionCount: '7,890'
},
{
name: '注意力评估算法',
taskTypes: ['可疑动作追踪', '头部朝向识别'],
detectionCount: '4,321'
},
{
name: '危险区域闯入检测',
taskTypes: ['行为持续性分析', '区域入侵检测'],
detectionCount: '9,876'
},
{
name: '异常聚集检测算法',
taskTypes: ['人群密度分析', '行为识别'],
detectionCount: '5,678'
}
]
</script>
<style scoped>
.algorithm-center {
width: 100%;
height: 100%;
padding: 20px;
}
.algorithm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 15px;
height: calc(100vh - 100px);
}
</style>

View File

@ -0,0 +1,150 @@
<template>
<el-dialog :model-value="visible" @update:modelValue="$emit('update:visible', $event)" title="视频设备接入设置"
width="900px" :class="['custom-dialog', 'add-scene-dialog']">
<div class="dialog-content">
<div class="form-section">
<div class="form-item">
<label>摄像头名称:</label>
<el-input v-model="formData.name" placeholder="请输入" />
</div>
<div class="form-item">
<label>摄像头ID:</label>
<el-input v-model="formData.id" placeholder="请输入" />
</div>
<div class="form-item">
<label>摄像头IP地址:</label>
<el-input v-model="formData.ip" placeholder="请输入" />
</div>
<div class="form-item">
<label>摄像头通道号:</label>
<el-input v-model="formData.channel" placeholder="请输入" />
</div>
<div class="form-item">
<label>所属分组与场景:</label>
<el-select v-model="formData.group" placeholder="请选择">
<el-option v-for="scene in scenes" :key="scene.id" :label="scene.name" :value="scene.id" />
</el-select>
</div>
</div>
<div class="video-preview">
<div class="video-placeholder">
<svg viewBox="0 0 24 24" width="60" height="60">
<path fill="#7cd0ff"
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
</svg>
<p>视频区域</p>
</div>
</div>
</div>
<template #footer>
<el-button class="cancel-btn" @click="$emit('update:visible', false)">测试接入效果</el-button>
<el-button class="confirm-btn" @click="saveDevice">保存应用设置</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
visible: Boolean,
scenes: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:visible', 'save'])
const formData = ref({
name: '',
id: '',
ip: '',
channel: '',
group: '',
algorithm: ''
})
const saveDevice = () => {
emit('save', formData.value)
emit('update:visible', false)
resetForm()
}
const resetForm = () => {
formData.value = {
name: '',
id: '',
ip: '',
channel: '',
group: '',
algorithm: ''
}
}
</script>
<style scoped>
.dialog-content {
display: flex;
gap: 30px;
.form-section {
flex: 1;
.form-item {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
label {
width: 150px;
display: block;
margin-bottom: 8px;
color: #7cd0ff;
font-size: 14px;
}
}
}
.video-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 42, 87, 0.6);
border: 1px dashed #7cd0ff;
border-radius: 4px;
.video-placeholder {
text-align: center;
color: #7cd0ff;
p {
margin-top: 10px;
}
}
}
}
.el-dialog__footer {
.cancel-btn {
background: transparent;
border-color: #7cd0ff;
color: #7cd0ff;
&:hover {
background: rgba(124, 208, 255, 0.1);
}
}
.confirm-btn {
background: #00d2ff;
border-color: #00d2ff;
color: white;
&:hover {
opacity: 0.9;
}
}
}
</style>

View File

@ -0,0 +1,666 @@
<!-- src/components/device/DeviceManagement.vue -->
<template>
<div class="device-management">
<template v-if="!editingDevice">
<!-- 左侧导航栏 -->
<div class="device-nav">
<dv-border-box-12>
<div class="nav-header">
<div class="nav-title">分组与场景管理</div>
</div>
<div class="nav-actions">
<div class="all-scenes" :class="{ active: activeScene === 'all' }" @click="selectScene('all')">
全部
</div>
<div class="add-scene" @click="showAddSceneDialog">
<svg viewBox="0 0 30 30" width="40" height="40">
<path fill="#ffffff" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
</div>
</div>
<div class="scene-list">
<div v-for="scene in scenes" :key="scene.id" class="scene-item"
:class="{ active: activeScene === scene.id }" @click="selectScene(scene.id)">
<div class="scene-name">{{ scene.name }}</div>
<div class="scene-indicator" v-if="activeScene === scene.id"></div>
</div>
</div>
</dv-border-box-12>
</div>
<!-- 右侧设备列表 -->
<div class="device-list">
<dv-border-box-8>
<!-- 顶部操作栏 -->
<div class="device-header">
<div class="search-box">
<input type="text" placeholder="搜索设备名称、IP或分组" v-model="searchText" />
<div class="search-icon">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor"
d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.505 6.505 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
</div>
</div>
<button class="add-device-btn" @click="showAddDeviceDialog">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
接入视频设备
</button>
</div>
<!-- 设备表格 -->
<div class="device-table-container">
<table class="device-table">
<thead>
<tr>
<th>分组</th>
<th>设备ID</th>
<th>IP地址</th>
<th>状态</th>
<th>算法任务</th>
<th>算法状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<template v-for="device in filteredDevices" :key="device.id">
<tr v-for="(algorithm, index) in device.algorithms" :key="index">
<td v-if="index === 0" :rowspan="device.algorithms.length">{{ device.group }}
</td>
<td v-if="index === 0" :rowspan="device.algorithms.length">{{ device.id }}</td>
<td v-if="index === 0" :rowspan="device.algorithms.length">{{ device.ip }}</td>
<td v-if="index === 0" :rowspan="device.algorithms.length">
<span class="status-badge" :class="device.status">
{{ device.status === 'online' ? '在线' : '离线' }}
</span>
</td>
<td>{{ algorithm.name }}</td>
<td>
<span class="algorithm-status" :class="{ enabled: algorithm.enabled }">
{{ algorithm.enabled ? '启用' : '停用' }}
</span>
</td>
<td v-if="index === 0" :rowspan="device.algorithms.length">
<button class="action-btn view-btn" @click="viewDevice(device)">
<svg viewBox="0 0 24 24" width="14" height="14">
<path fill="currentColor"
d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
</svg>
</button>
<button class="action-btn edit-btn" @click="editDevice(device)">
<svg viewBox="0 0 24 24" width="14" height="14">
<path fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
</button>
<button class="action-btn delete-btn" @click="deleteDevice(device)">
<svg viewBox="0 0 24 24" width="14" height="14">
<path fill="currentColor"
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</dv-border-box-8>
</div>
<!-- 添加场景对话框 -->
<el-dialog v-model="showSceneDialog" title="添加新场景" width="30%"
:class="['custom-dialog', 'add-scene-dialog']">
<div class="dialog-content">
<div class="form-item">
<label>场景名称</label>
<el-input v-model="newScene.name" placeholder="例如XXXXX场景007" />
</div>
</div>
<template #footer>
<el-button class="cancel-btn" @click="showSceneDialog = false">取消</el-button>
<el-button class="confirm-btn" type="primary" @click="confirmAddScene">确认添加</el-button>
</template>
</el-dialog>
<!-- 添加设备对话框 -->
<AddDeviceDialog v-model:visible="showAddDialog" :scenes="scenes" @save="handleSaveDevice" />
</template>
<!-- 当处于编辑状态时显示事件编辑界面 -->
<EventEditor v-else :device-data="editingDevice" @close="cancelEdit" @save="saveEvent" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import AddDeviceDialog from './AddDeviceDialog.vue'
import EventEditor from './EventEditor.vue'
const searchText = ref('')
const activeScene = ref('all')
const showSceneDialog = ref(false)
const showAddDialog = ref(false)
const editingDevice = ref(null)
const newScene = ref({
name: '',
description: ''
})
//
const scenes = ref([
{ id: 'scene-001', name: 'XXXXX场景001' },
{ id: 'scene-002', name: 'XXXXX场景002' },
{ id: 'scene-003', name: 'XXXXX场景003' },
{ id: 'scene-004', name: 'XXXXX场景004' },
{ id: 'scene-005', name: 'XXXXX场景005' },
{ id: 'scene-006', name: 'XXXXX场景006' }
])
//
const devices = ref([
{
id: 'VIDEO_214IOISHEK454',
group: '港口区',
ip: '192.268.0.1',
status: 'online',
algorithms: [
{ name: '目标检测(如靠泊、登轮)', enabled: true },
{ name: '行为识别(如玩手机、离岗)', enabled: true },
{ name: '目标检测(计数)', enabled: false }
]
},
{
id: 'CAM_002',
group: 'XXXXX场景002',
ip: '192.168.1.102',
status: 'online',
algorithms: [
{ name: '越界检测', enabled: true },
{ name: '车辆识别', enabled: false }
]
},
{
id: 'CAM_003',
group: 'XXXXX场景003',
ip: '192.168.1.103',
status: 'offline',
algorithms: [
{ name: '货物识别', enabled: false }
]
}
])
//
const filteredDevices = computed(() => {
let result = devices.value
if (activeScene.value && activeScene.value !== 'all') {
const activeSceneName = scenes.value.find(s => s.id === activeScene.value)?.name || ''
result = result.filter(device => device.group === activeSceneName)
}
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter(device =>
device.id.toLowerCase().includes(search) ||
device.ip.toLowerCase().includes(search) ||
device.group.toLowerCase().includes(search) ||
device.algorithm.toLowerCase().includes(search)
)
}
return result
})
//
const selectScene = (sceneId) => {
activeScene.value = sceneId
}
//
const showAddSceneDialog = () => {
newScene.value = {
name: '',
description: ''
}
showSceneDialog.value = true
}
//
const confirmAddScene = () => {
const newId = `scene-${String(scenes.value.length + 1).padStart(3, '0')}`
scenes.value.push({
id: newId,
name: newScene.value.name || `XXXXX场景${String(scenes.value.length + 1).padStart(3, '0')}`,
description: newScene.value.description
})
showSceneDialog.value = false
}
//
const showAddDeviceDialog = () => {
showAddDialog.value = true
}
//
const handleSaveDevice = (deviceData) => {
devices.value.push({
id: deviceData.id,
group: scenes.value.find(s => s.id === deviceData.group)?.name || '',
ip: deviceData.ip,
status: 'online',
algorithms: []
})
}
//
const viewDevice = (device) => {
console.log('查看设备:', device)
}
const editDevice = (device) => {
editingDevice.value = {
...device,
eventName: device.eventName || '',
ip: device.ip || '192.168.5.44', // IP
status: device.status || '在线'
}
}
const cancelEdit = () => {
editingDevice.value = null
}
const saveEvent = (eventData) => {
//
const deviceIndex = devices.value.findIndex(d => d.id === editingDevice.value.id)
if (deviceIndex !== -1) {
devices.value[deviceIndex] = {
...devices.value[deviceIndex],
...eventData
}
}
editingDevice.value = null
}
const deleteDevice = (device) => {
console.log('删除设备:', device)
const index = devices.value.findIndex(d => d.id === device.id)
if (index !== -1) {
devices.value.splice(index, 1)
}
}
</script>
<style scoped>
.device-management {
display: flex;
height: 85vh;
background: #0a1d3c;
}
/* 左侧导航栏 */
.device-nav {
width: 300px;
padding: 15px;
.nav-header {
padding: 15px 0;
display: flex;
align-items: center;
justify-content: center;
.nav-title {
font-size: 16px;
color: #7cd0ff;
font-weight: 500;
}
}
.nav-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid #09D2FF;
padding: 5px 0;
width: 90%;
margin: 0 auto;
.all-scenes {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
padding: 4px 12px;
border-radius: 4px;
font-size: 14px;
transition: all 0.3s;
}
.add-scene {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
color: rgba(255, 255, 255, 0.8);
&:hover {
background: rgba(124, 208, 255, 0.1);
}
}
}
.scene-list {
padding: 15px 0;
width: 90%;
margin: 0 auto;
.scene-item {
position: relative;
padding: 12px 15px;
margin: 8px 0;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
background: rgba(124, 208, 255, 0.1);
}
&.active {
background: rgba(124, 208, 255, 0.2);
color: white;
.scene-indicator {
position: absolute;
right: -15px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background-color: #FFD700;
border-radius: 2px;
}
}
.scene-name {
flex: 1;
}
}
}
.scene-tip {
margin-top: 20px;
padding: 15px 10px;
border-top: 1px dashed rgba(124, 208, 255, 0.2);
.tip-content {
display: flex;
align-items: center;
gap: 10px;
color: #FFD700;
font-size: 14px;
padding: 10px;
border-radius: 4px;
background: rgba(255, 215, 0, 0.1);
.tip-text {
flex: 1;
.tip-signature {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
}
}
}
}
/* 右侧设备列表 */
.device-list {
flex: 1;
padding: 15px;
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 0 15px;
}
.search-box {
position: relative;
margin-top: 15px;
width: 300px;
input {
width: 100%;
padding: 8px 35px 8px 15px;
background: rgba(16, 42, 87, 0.6);
border: 1px solid #7cd0ff;
border-radius: 4px;
color: white;
font-size: 14px;
&::placeholder {
color: rgba(124, 208, 255, 0.5);
}
}
.search-icon {
position: absolute;
right: 10px;
top: 55%;
transform: translateY(-50%);
color: #7cd0ff;
pointer-events: none;
}
}
.add-device-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
background: linear-gradient(90deg, #3a7bd5, #00d2ff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
margin-top: 15px;
&:hover {
opacity: 0.9;
}
}
.device-table-container {
height: calc(100vh - 150px);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.device-table {
width: 98%;
color: white;
margin: 0 auto;
border: 1px solid #7cd0ff;
font-size: 14px;
th,
td {
padding: 6px 10px;
text-align: center;
border-bottom: 1px solid rgba(124, 208, 255, 0.2);
border-right: 1px solid rgba(124, 208, 255, 0.3);
}
td {
vertical-align: middle;
}
th {
background-color: rgba(16, 42, 87, 0.8);
font-weight: 500;
}
tr:hover {
background-color: rgba(124, 208, 255, 0.05);
}
.algorithm-status {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
&.enabled {
background-color: rgba(0, 255, 0, 0.2);
color: #00ff00;
}
&:not(.enabled) {
background-color: rgba(255, 0, 0, 0.2);
color: #ff4d4f;
}
}
}
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
&.online {
background-color: #00ffc0;
color: #0a1d3c;
}
&.offline {
background-color: #ff4d4f;
}
}
.action-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
margin-right: 6px;
cursor: pointer;
transition: all 0.3s;
&.view-btn {
background-color: rgba(124, 208, 255, 0.2);
color: #7cd0ff;
&:hover {
background-color: rgba(124, 208, 255, 0.4);
}
}
&.edit-btn {
background-color: rgba(255, 193, 7, 0.2);
color: #ffc107;
&:hover {
background-color: rgba(255, 193, 7, 0.4);
}
}
&.delete-btn {
background-color: rgba(255, 77, 79, 0.2);
color: #ff4d4f;
&:hover {
background-color: rgba(255, 77, 79, 0.4);
}
}
}
/* 对话框样式 */
:deep(.custom-dialog.add-scene-dialog) {
background: #0a1d3c;
border: 1px solid #1a5fb4;
border-radius: 4px;
.el-dialog__header {
border-bottom: 1px solid rgba(124, 208, 255, 0.2);
padding: 15px 20px;
.el-dialog__title {
color: white;
font-size: 16px;
font-weight: 500;
}
.el-dialog__headerbtn {
color: white;
&:hover {
color: white;
}
}
}
.el-dialog__body {
padding: 20px;
.form-item {
margin-bottom: 20px;
label {
display: block;
margin-bottom: 8px;
color: #7cd0ff;
}
}
}
.el-dialog__footer {
border-top: 1px solid rgba(124, 208, 255, 0.2);
padding: 10px 20px;
text-align: right;
.el-button.cancel-btn {
background: transparent;
border-color: #7cd0ff;
color: #7cd0ff;
&:hover {
background: rgba(124, 208, 255, 0.1);
}
}
.el-button.confirm-btn {
background: #00d2ff;
border-color: #00d2ff;
color: white;
&:hover {
opacity: 0.9;
}
}
}
}
</style>

View File

@ -0,0 +1,343 @@
<template>
<div class="event-editor-container">
<div class="editor-content">
<!-- 左侧视频区域 -->
<div class="video-section">
<!-- 上部视频预览区域 (55%) -->
<div class="video-preview">
<div class="video-placeholder">
<svg viewBox="0 0 24 24" width="60" height="60">
<path fill="#7cd0ff"
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
</svg>
<p>视频区域</p>
</div>
<div class="video-actions">
<el-button class="upload-btn">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
上传模拟视频
</el-button>
<el-button class="clear-btn">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
清空模拟视频
</el-button>
</div>
</div>
<!-- 下部设备信息区域 (45%) -->
<div class="device-info">
<h4>设备默认信息显示区域</h4>
<div class="info-item">
<span class="label">分组</span>
<span class="value">港口区</span>
</div>
<div class="info-item">
<span class="label">摄像头ID</span>
<span class="value">VIDEO_214IOISHEK454</span>
</div>
<div class="info-item">
<span class="label">设备状态</span>
<span class="value">在线</span>
</div>
<div class="info-item">
<span class="label">IP地址</span>
<span class="value">192.168.5.44</span>
</div>
</div>
</div>
<!-- 右侧事件设置区域 -->
<div class="event-section">
<!-- 事件1 -->
<div class="event-group">
<div class="event-header">
<h4>事件绑定设置</h4>
<el-button class="add-event-btn">新增事件</el-button>
</div>
<div class="form-item">
<label>事件名称</label>
<el-input v-model="eventData.name" placeholder="例如:船舶登轮" />
</div>
<div class="form-item">
<label>电子围栏区域</label>
<el-select v-model="eventData.area" placeholder="例如:整个监控画面">
<el-option label="整个监控画面" value="整个监控画面" />
<el-option label="自定义画面" value="自定义画面" />
</el-select>
</div>
<div class="form-item">
<label>告警等级</label>
<el-select v-model="eventData.level" placeholder="例如P0紧急告警">
<el-option label="P0紧急告警" value="P0" />
<el-option label="P1重要告警" value="P1" />
<el-option label="P2一般告警" value="P2" />
</el-select>
</div>
<div class="form-item">
<label>电子围栏名称</label>
<el-input v-model="eventData.fenceName" placeholder="请输入" />
</div>
<div class="form-item">
<label>算法绑定</label>
<el-select v-model="eventData.algorithm" placeholder="例如算法XXX001">
<el-option v-for="alg in algorithms" :key="alg.id" :label="alg.name" :value="alg.id" />
</el-select>
</div>
<div class="form-hint">
<p>触发算法条件待定</p>
</div>
</div>
<!-- 底部操作按钮 -->
<div class="editor-footer">
<el-button class="preview-btn">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor"
d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
</svg>
查看模拟效果
</el-button>
<el-button class="save-btn" @click="saveEvent">
保存应用设置
</el-button>
<el-button class="cancel-btn" @click="closeEditor">
返回设备列表
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
deviceData: {
type: Object,
required: true
}
})
const emit = defineEmits(['close', 'save'])
const algorithms = ref([
{ id: 'alg-001', name: '算法XXX001' },
{ id: 'alg-002', name: '算法XXX002' },
{ id: 'alg-003', name: '算法XXX003' }
])
const eventData = ref({
name: props.deviceData.eventName || '船舶登轮',
area: '整个监控画面',
level: 'P0',
fenceName: '',
algorithm: 'alg-001',
crossAreaName: '',
crossAlgorithm: 'alg-001'
})
const closeEditor = () => {
emit('close')
}
const saveEvent = () => {
emit('save', eventData.value)
}
</script>
<style scoped>
.event-editor-container {
height: 100vh;
background: #0a1d3c;
padding: 20px;
width: 100vw;
}
.editor-content {
display: flex;
height: calc(100% - 40px);
/* 减去上下padding */
width: 100%;
}
/* 左侧视频区域 */
.video-section {
width: 25%;
display: flex;
flex-direction: column;
gap: 15px;
}
/* 视频预览区域 (55%) */
.video-preview {
height: 55%;
display: flex;
flex-direction: column;
}
.video-placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(16, 42, 87, 0.6);
border: 1px dashed #7cd0ff;
border-radius: 4px;
color: #7cd0ff;
}
.video-placeholder p {
margin-top: 10px;
color: white;
font-size: 14px;
}
.video-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.video-actions .el-button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
}
.upload-btn {
background: rgba(124, 208, 255, 0.2);
border-color: #7cd0ff;
color: #7cd0ff;
}
.clear-btn {
background: rgba(255, 77, 79, 0.2);
border-color: #ff4d4f;
color: #ff4d4f;
}
/* 设备信息区域 (45%) */
.device-info {
height: 45%;
background: rgba(16, 42, 87, 0.6);
border-radius: 4px;
padding: 15px;
color: white;
}
.device-info h4 {
color: #7cd0ff;
margin-top: 0;
margin-bottom: 15px;
font-size: 16px;
}
.info-item {
display: flex;
margin-bottom: 12px;
font-size: 14px;
}
.info-item .label {
color: #7cd0ff;
width: 100px;
}
.info-item .value {
color: white;
flex: 1;
}
.event-section {
flex: 2;
display: flex;
flex-direction: column;
.event-group {
background: rgba(16, 42, 87, 0.6);
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
h4 {
color: #7cd0ff;
margin: 0;
font-size: 16px;
}
.add-event-btn {
background: rgba(124, 208, 255, 0.2);
border-color: #7cd0ff;
color: #7cd0ff;
}
}
.form-item {
margin-bottom: 15px;
label {
display: block;
margin-bottom: 8px;
color: #7cd0ff;
font-size: 14px;
}
}
.form-hint {
color: rgba(124, 208, 255, 0.7);
font-size: 12px;
margin-top: 10px;
}
}
}
.editor-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
.el-button {
&.preview-btn {
background: rgba(0, 210, 255, 0.2);
border-color: #00d2ff;
color: #00d2ff;
}
&.save-btn {
background: #00d2ff;
border-color: #00d2ff;
color: white;
}
&.cancel-btn {
background: transparent;
border-color: #7cd0ff;
color: #7cd0ff;
}
}
}
</style>

View File

@ -0,0 +1,457 @@
<template>
<div class="event-center">
<!-- 左侧年月日导航 -->
<div class="year-month-nav">
<dv-border-box-12>
<div class="nav-header">事件记录时间</div>
<!-- 年份列表 - 一级菜单 -->
<div class="year-list">
<div
v-for="year in years"
:key="year"
class="year-item"
:class="{ active: selectedYear === year }"
@click="toggleYear(year)"
>
<div class="item-content">
{{ year }}
<span class="arrow-icon" v-if="selectedYear === year"></span>
<span class="arrow-icon" v-else></span>
</div>
<!-- 月份列表 - 二级菜单 -->
<div class="month-list" v-if="selectedYear === year">
<div
v-for="month in months"
:key="month"
class="month-item"
:class="{ active: selectedMonth === month }"
@click.stop="toggleMonth(month)"
>
<div class="item-content">
{{ month }}
<span class="arrow-icon" v-if="selectedMonth === month"></span>
<span class="arrow-icon" v-else></span>
</div>
<!-- 日期列表 - 三级菜单 -->
<div class="day-list" v-if="selectedMonth === month">
<div
v-for="day in days"
:key="day"
class="day-item"
:class="{ active: selectedDay === day }"
@click.stop="selectDay(day)"
>
<div class="item-content">
{{ day }}
<span class="check-icon" v-if="selectedDay === day"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</dv-border-box-12>
</div>
<!-- 右侧数据列表 -->
<div class="event-list">
<dv-border-box-8>
<div class="search-bar">
<input
type="text"
v-model="searchText"
placeholder="请输入"
class="search-input"
/>
</div>
<div class="table-container">
<table class="event-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.title }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(event, index) in filteredEvents" :key="index">
<td>{{ event.time }}</td>
<td>{{ event.name }}</td>
<td>{{ event.algorithm }}</td>
<td>{{ event.deviceId }}</td>
<td>{{ event.ip }}</td>
<td>
<span class="status-tag" :class="event.status">
{{ statusText[event.status] }}
</span>
</td>
<td>
<button class="action-btn view" @click="viewDetail(event)">查看</button>
<button class="action-btn delete" @click="deleteEvent(event)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</dv-border-box-8>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
//
const selectedYear = ref('2025')
const selectedMonth = ref('7')
const selectedDay = ref('10')
const years = ref(['2025', '2024', '2023', '2022', '2021', '2020'])
const months = ref(['12', '11', '10', '9', '8','7', '6', '5', '4', '3', '2', '1'])
const days = ref(['10', '9', '8', '7', '6','10', '9', '8', '7', '6'])
//
const searchText = ref('')
const columns = ref([
{ key: 'time', title: '时间' },
{ key: 'name', title: '事件名称' },
{ key: 'algorithm', title: '算法任务' },
{ key: 'deviceId', title: '设备ID' },
{ key: 'ip', title: 'IP地址' },
{ key: 'status', title: '事件状态' },
{ key: 'actions', title: '操作' }
])
const events = ref([
{
time: '14:45:26',
name: '目标检测',
algorithm: '靠泊、登轮',
deviceId: 'VIDEO_214IOISHEK454',
ip: '192.268.0.1',
status: 'pending'
},
{
time: '13:30:15',
name: '暴力行为识别',
algorithm: '异常行为检测',
deviceId: 'VIDEO_315IOISHEK455',
ip: '192.268.0.2',
status: 'processed'
},
{
time: '11:20:45',
name: '越界检测',
algorithm: '区域入侵检测',
deviceId: 'VIDEO_516IOISHEK456',
ip: '192.268.0.3',
status: 'ignored'
}
])
const statusText = {
pending: '待处理',
processed: '已处理',
ignored: '已忽略'
}
//
const filteredEvents = computed(() => {
return events.value.filter(event =>
event.name.includes(searchText.value) ||
event.algorithm.includes(searchText.value) ||
event.deviceId.includes(searchText.value)
)
})
//
const toggleYear = (year) => {
if (selectedYear.value === year) {
selectedYear.value = ''
selectedMonth.value = ''
selectedDay.value = ''
} else {
selectedYear.value = year
selectedMonth.value = ''
selectedDay.value = ''
}
}
const toggleMonth = (month) => {
if (selectedMonth.value === month) {
selectedMonth.value = ''
selectedDay.value = ''
} else {
selectedMonth.value = month
selectedDay.value = ''
}
}
const selectDay = (day) => {
selectedDay.value = day
}
const viewDetail = (event) => {
console.log('查看事件:', event)
}
const deleteEvent = (event) => {
console.log('删除事件:', event)
}
</script>
<style scoped>
.event-center {
display: flex;
height: 85vh;
background: #0a1d3c;
}
/* 左侧年月日导航 */
.year-month-nav {
width: 300px;
padding: 15px;
.nav-header {
padding: 15px;
font-size: 16px;
color: #7cd0ff;
text-align: center;
}
/* 一级菜单 - 年份 */
.year-list {
width: 90%;
margin: 0 auto;
.year-item {
margin-bottom: 5px;
> .item-content {
border: 1px solid #09d2ff;
padding: 8px 15px;
color: rgba(124, 208, 255, 0.8);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
&:hover, .active & {
color: white;
background: rgba(124, 208, 255, 0.2);
}
.arrow-icon {
font-size: 12px;
}
}
/* 二级菜单 - 月份 */
.month-list {
margin-left: 20px;
/* border-left: 1px solid rgba(124, 208, 255, 0.2); */
padding-left: 15px;
max-height: 400px;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.month-item {
margin-bottom: 5px;
.item-content {
padding: 8px 15px;
color: rgba(124, 208, 255, 0.8);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
&:hover, .active & {
color: white;
/* background: rgba(124, 208, 255, 0.2); */
}
.check-icon {
color: #00ffc0;
}
}
/* 三级菜单 - 日期 */
.day-list {
margin-left: 20px;
/* border-left: 1px solid rgba(124, 208, 255, 0.2); */
padding-left: 15px;
max-height: 300px;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.day-item {
margin-bottom: 5px;
.item-content {
padding: 8px 15px;
color: rgba(124, 208, 255, 0.8);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
&:hover, &.active {
color: white;
background: rgba(124, 208, 255, 0.2);
}
.check-icon {
color: #00ffc0;
}
}
}
}
}
}
}
}
}
/* 右侧数据列表 */
.event-list {
flex: 1;
padding: 15px;
.search-bar {
position: relative;
padding: 15px;
width: 300px;
.search-input {
width: 100%;
padding: 8px 40px 8px 12px;
background: rgba(16, 42, 87, 0.6);
border: 1px solid #7cd0ff;
border-radius: 4px;
color: white;
font-size: 14px;
&::placeholder {
color: rgba(124, 208, 255, 0.5);
}
}
&::after {
content: "🔍";
position: absolute;
right: 25px;
top: 50%;
transform: translateY(-50%);
color: #7cd0ff;
pointer-events: none;
}
}
.table-container {
height: calc(100% - 60px);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.event-table {
width: 98%;
border-collapse: separate;
border-spacing: 0;
margin: 0 auto;
border: 1px solid #7cd0ff;
font-size: 14px;
th {
position: sticky;
top: 0;
padding: 6px 10px;
text-align: center;
background-color: rgba(16, 42, 87, 0.8);
color: #fff;
font-weight: 500;
border-bottom: 1px solid #7cd0ff;
border-right: 1px solid rgba(124, 208, 255, 0.3);
&:last-child {
border-right: none;
}
}
td {
padding: 12px 15px;
color: white;
border-bottom: 1px solid rgba(124, 208, 255, 0.2);
border-right: 1px solid rgba(124, 208, 255, 0.1);
text-align: center;
&:last-child {
border-right: none;
}
}
tr {
&:hover {
background: rgba(124, 208, 255, 0.05);
}
&:last-child td {
border-bottom: none;
}
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&.pending {
background: #ff4d4f;
}
&.processed {
background: #00ffc0;
color: #0a1d3c;
}
&.ignored {
background: #888;
}
}
.action-btn {
padding: 4px 10px;
margin-right: 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
border: none;
font-size: 12px;
&.view {
background: linear-gradient(to right, #3a7bd5, #00d2ff);
color: white;
&:hover {
background: linear-gradient(to right, #2c5fb3, #00b7e1);
}
}
&.delete {
background: linear-gradient(to right, #ff416c, #ff4b2b);
color: white;
&:hover {
background: linear-gradient(to right, #e0355c, #e03d26);
}
}
}
}
}
</style>

View File

@ -0,0 +1,98 @@
<template>
<div v-if="visible" class="monitor-detail">
<dv-border-box-11 :title="monitor.location">
<div class="detail-content">
<div class="video-container">
<video autoplay loop muted controls>
<source :src="monitor.videoSrc" type="video/mp4">
</video>
</div>
</div>
</dv-border-box-11>
<button class="close-btn" @click="close">×</button>
</div>
</template>
<script setup>
defineProps({
visible: Boolean,
monitor: {
type: Object,
required: true,
default: () => ({
location: '港口区',
videoSrc: ''
})
}
})
const emit = defineEmits(['close'])
const close = () => {
emit('close')
}
</script>
<style scoped>
.monitor-detail {
width: 60vw;
height: 70vh;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
display: flex;
flex-direction: column;
}
/* 关键修改:使边框背景不透明 */
::v-deep .dv-border-box-11 {
background-color: #0a1d3c !important; /* 使用与界面一致的深蓝色 */
background-image: none !important;
}
/* 调整边框内部内容样式 */
::v-deep .border-box-content {
padding: 0;
background: transparent;
}
.close-btn {
position: absolute;
top: 35px;
right: 10px;
font-size: 28px;
color: white;
background: transparent;
border: none;
cursor: pointer;
padding: 0 15px;
z-index: 1001;
&:hover {
color: #ff4d4f;
}
}
.detail-content {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
.video-container {
width: 96%;
height: 88%;
background: #000;
margin-bottom: 10px;
border-radius: 0 7px 7px 0;
overflow: hidden;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<dv-border-box-11 class="monitor-thumbnail" :title="monitor.location">
<div class="monitor-content">
<div class="video-container">
<video autoplay loop muted>
<source :src="monitor.videoSrc" type="video/mp4">
</video>
</div>
</div>
</dv-border-box-11>
</template>
<script setup>
defineProps({
monitor: {
type: Object,
required: true,
default: () => ({
location: '港口区',
videoSrc: ''
})
}
})
</script>
<style scoped>
.monitor-thumbnail {
width: 100%;
height: 100%;
position: relative;
cursor: pointer;
}
.monitor-content {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
}
.video-container {
width: 94%;
height: 74%;
background: #000;
margin-bottom: 10px;
border-radius: 0 7px 7px 0;
overflow: hidden;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
</style>

16
web/src/main.js Normal file
View File

@ -0,0 +1,16 @@
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as echarts from 'echarts'
import DataV from '@kjgl77/datav-vue3'
import '@kjgl77/datav-vue3/dist/style.css'
const app = createApp(App)
app.use(DataV);
app.use(ElementPlus)
app.config.globalProperties.$echarts = echarts
app.mount('#app')

79
web/src/style.css Normal file
View File

@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

24
web/vite.config.js Normal file
View File

@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
find: /^@dataview\/datav-vue3$/,
replacement: '@kjgl77/datav-vue3'
}
},
optimizeDeps: {
include: ['@kjgl77/datav-vue3']
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/assets/styles/main" as *;`
}
}
}
})