feat:完成智能追踪UI

master
zhoulexin 2 days ago
parent 89cbb1c348
commit aee88680f0

@ -0,0 +1,10 @@
import request from '@/utils/request'
// 获取取证快照列表
export const getEvidenceSnapshotList = (params) => {
return request({
url: '/evidenceSnapshot/list',
method: 'get',
params
})
}

@ -29,6 +29,10 @@
<el-icon><Avatar /></el-icon>
<template #title>人员管理</template>
</el-menu-item>
<el-menu-item index="/smart-tracking">
<el-icon><Aim /></el-icon>
<template #title>智能追踪</template>
</el-menu-item>
</el-menu>
</el-aside>

@ -54,6 +54,12 @@ const routes = [
name: 'Personnel',
component: () => import('@/views/personnel/index.vue'),
meta: { title: '人员管理', requiresAuth: true }
},
{
path: 'smart-tracking',
name: 'SmartTracking',
component: () => import('@/views/smart-tracking/index.vue'),
meta: { title: '智能追踪', requiresAuth: true }
}
]
},

@ -25,3 +25,11 @@ $box-shadow-light: 0 2px 6px rgba(0, 0, 0, 0.05);
$transition-duration: 0.3s;
$transition-function: ease;
//
$tracking-primary: #1a3a5c;
$tracking-hover: #234b6e;
$tracking-dark: #0d2847;
$tracking-disabled: #c8cfd8;
$tracking-accent: #66b1ff;
$tracking-bg: linear-gradient(135deg, $tracking-primary 0%, $tracking-dark 100%);

@ -0,0 +1,176 @@
<template>
<div class="algorithm-section">
<div class="section-header">
<el-icon class="section-icon"><Cpu /></el-icon>
<span>算法类型</span>
</div>
<div class="algorithm-grid">
<div
v-for="algo in algorithmList"
:key="algo.id"
class="algo-card"
:class="{ active: modelValue === algo.id }"
@click="handleSelect(algo.id)"
>
<div class="algo-icon-wrap">
<el-icon :size="22"><component :is="algo.icon" /></el-icon>
</div>
<span class="algo-name">{{ algo.name }}</span>
<p class="algo-desc">{{ algo.desc }}</p>
<div class="algo-check" v-if="modelValue === algo.id">
<el-icon><Check /></el-icon>
</div>
<div class="algo-close-hint" v-if="modelValue === algo.id"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ElMessage } from 'element-plus'
const props = defineProps({
algorithmList: {
type: Array,
required: true
},
modelValue: {
type: String,
required: true
}
})
const emit = defineEmits(['update:modelValue'])
const handleSelect = (algoId) => {
if (props.modelValue === algoId) {
//
emit('update:modelValue', '')
ElMessage.info('算法已关闭')
} else {
emit('update:modelValue', algoId)
}
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.algorithm-section {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.04);
border: 1px solid $border-lighter;
flex-shrink: 0;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 15px;
font-weight: 600;
color: $text-primary;
.section-icon {
color: $tracking-primary;
font-size: 18px;
}
}
.algorithm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.algo-card {
position: relative;
background: #f8fafc;
border: 1.5px solid $border-lighter;
border-radius: 10px;
padding: 16px 12px 14px;
cursor: pointer;
transition: all 0.25s ease;
text-align: center;
overflow: hidden;
&:hover {
border-color: $tracking-primary;
background: #f0f4f8;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba($tracking-primary, 0.1);
}
&.active {
background: linear-gradient(135deg, rgba($tracking-primary, 0.08), rgba($tracking-primary, 0.03));
border-color: $tracking-primary;
box-shadow: 0 0 0 3px rgba($tracking-primary, 0.08);
.algo-icon-wrap {
background: $tracking-primary;
color: #fff;
}
.algo-name { color: $tracking-primary; }
}
.algo-icon-wrap {
width: 42px; height: 42px;
border-radius: 10px;
background: rgba($tracking-primary, 0.08);
color: $tracking-primary;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10px;
transition: all 0.25s ease;
}
.algo-name {
display: block;
font-size: 14px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
transition: color 0.25s ease;
}
.algo-desc {
font-size: 11px;
color: $text-secondary;
margin: 0;
line-height: 1.4;
}
.algo-check {
position: absolute;
top: 6px; right: 6px;
width: 20px; height: 20px;
border-radius: 50%;
background: $tracking-primary;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.algo-close-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
font-size: 10px;
color: #e6a23c;
opacity: 0;
transition: opacity 0.2s ease;
text-align: center;
}
&:hover .algo-close-hint {
opacity: 1;
}
}
</style>

@ -0,0 +1,529 @@
<template>
<transition name="modal-fade">
<div v-if="visible" class="device-modal-overlay">
<div class="device-modal">
<!-- 顶部栏 -->
<div class="modal-header">
<div class="header-left">
<div class="header-icon-wrapper">
<el-icon class="header-icon"><Aim /></el-icon>
</div>
<div class="header-text">
<h2>选择追踪设备</h2>
<p>可选择多台设备同时进行智能追踪</p>
</div>
</div>
<div class="header-right">
<el-button class="refresh-btn" :loading="loading" @click="$emit('refresh')" text>
<el-icon><Refresh /></el-icon>
刷新设备
</el-button>
<span class="device-count"> <strong>{{ deviceList.length }}</strong> 台可用设备</span>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-wrapper">
<el-icon class="loading-icon is-loading"><Loading /></el-icon>
<p>正在加载设备列表...</p>
</div>
<!-- 无数据 -->
<div v-else-if="deviceList.length === 0" class="empty-wrapper">
<el-empty description="暂无可用设备" :image-size="120" />
<el-button type="primary" @click="$emit('refresh')"></el-button>
</div>
<!-- 设备卡片网格 -->
<div v-else class="device-grid-wrapper">
<div class="device-grid">
<div
v-for="device in deviceList"
:key="device.id"
class="device-card"
:class="{
selected: isSelected(device),
online: device.groupStatus === 1,
offline: device.groupStatus !== 1,
disabled: device.groupStatus !== 1
}"
@click="handleSelect(device)"
>
<div class="check-mark" v-if="isSelected(device)">
<el-icon><Check /></el-icon>
</div>
<div class="device-icon-wrapper">
<div class="device-icon" :class="'type-' + device.groupType">
<img v-if="device.imgUrl" :src="fileHttp + device.imgUrl" class="device-img" />
<el-icon v-else-if="device.groupType === 1"><Position /></el-icon>
<el-icon v-else-if="device.groupType === 2"><VideoCamera /></el-icon>
<el-icon v-else-if="device.groupType === 3"><Service /></el-icon>
<el-icon v-else><Camera /></el-icon>
</div>
</div>
<div class="device-info">
<h3 class="device-title">{{ device.groupName }}</h3>
<div class="device-meta">
<el-tag size="small" :type="deviceTypeColor(device.groupType)" effect="plain">
{{ deviceTypeName(device.groupType) }}
</el-tag>
<span class="status-indicator" :class="device.groupStatus === 1 ? 'online' : 'offline'">
<span class="dot"></span>
{{ device.groupStatus === 1 ? '在线' : '离线' }}
</span>
</div>
<div class="device-cameras" v-if="device.cameras?.length">
<el-icon><Connection /></el-icon>
<span>{{ device.cameras.length }} 个摄像头</span>
</div>
</div>
<div class="card-hover-mask" v-if="device.groupStatus === 1">
<span class="mask-text">{{ isSelected(device) ? '取消选择' : '选择此设备' }}</span>
</div>
<div class="card-disabled-tag" v-else>
<el-icon><WarningFilled /></el-icon>
<span>设备离线</span>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="modal-footer">
<div class="footer-left" v-if="selectedDevices.length">
<el-icon><Check /></el-icon>
<span>已选择 <strong>{{ selectedDevices.length }}</strong> 台设备</span>
</div>
<div class="footer-right">
<el-button v-if="activeDevices.length" @click="$emit('cancel')" size="large"></el-button>
<el-button
class="confirm-btn"
size="large"
:disabled="!selectedDevices.length"
@click="$emit('confirm', [...selectedDevices])"
>
<el-icon><Check /></el-icon>
确认选择{{ selectedDevices.length }}
</el-button>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { DeviceTypeName, DeviceTypeColor } from '@/enums'
import fileHttp from '@/utils/fileHttp'
const props = defineProps({
visible: Boolean,
deviceList: { type: Array, default: () => [] },
loading: Boolean,
activeDevices: { type: Array, default: () => [] }
})
const emit = defineEmits(['confirm', 'cancel', 'refresh'])
const selectedDevices = ref([])
//
watch(() => props.visible, (val) => {
if (val) {
selectedDevices.value = [...props.activeDevices]
}
})
const deviceTypeName = (type) => DeviceTypeName[type] || '未知'
const deviceTypeColor = (type) => DeviceTypeColor[type] || 'info'
const isSelected = (device) => selectedDevices.value.some(d => d.id === device.id)
const handleSelect = (device) => {
if (device.groupStatus !== 1) {
ElMessage.warning('该设备当前离线,请开启后刷新再试')
return
}
const idx = selectedDevices.value.findIndex(d => d.id === device.id)
if (idx > -1) {
selectedDevices.value.splice(idx, 1)
} else {
selectedDevices.value.push(device)
}
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.device-modal-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.device-modal {
width: 95vw;
height: 85vh;
max-height: 800px;
background: #fff;
border-radius: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 32px;
background: $tracking-bg;
.header-left {
display: flex;
align-items: center;
gap: 16px;
.header-icon-wrapper {
width: 48px; height: 48px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.2);
.header-icon {
font-size: 24px;
color: $tracking-accent;
}
}
.header-text {
h2 {
font-size: 22px; font-weight: 700;
color: #fff; margin: 0 0 4px 0;
}
p {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
.refresh-btn {
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
&:hover { color: #fff; background: rgba(255, 255, 255, 0.1); }
}
.device-count {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.12);
padding: 8px 16px;
border-radius: 20px;
strong {
color: $tracking-accent;
font-weight: 700;
font-size: 18px;
}
}
}
}
.loading-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: $text-secondary;
.loading-icon { font-size: 40px; color: $tracking-primary; }
}
.empty-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.device-grid-wrapper {
flex: 1;
overflow-y: auto;
padding: 28px 32px;
&::-webkit-scrollbar { width: 6px; }
&::-webkit-scrollbar-thumb { background: $border-base; border-radius: 3px; }
}
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.device-card {
position: relative;
background: #fff;
border: 1.5px solid #e4e7ed;
border-radius: 16px;
padding: 28px 20px 24px;
cursor: pointer;
transition: all 0.3s ease;
overflow: hidden;
text-align: center;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
border-color: #d0d5dd;
.card-hover-mask { opacity: 1; }
}
&.selected {
border-color: $tracking-primary;
box-shadow: 0 0 0 3px rgba($tracking-primary, 0.08), 0 8px 24px rgba(0, 0, 0, 0.04);
.check-mark { opacity: 1; transform: scale(1); }
}
&.offline { opacity: 0.55; }
&.disabled {
cursor: not-allowed;
&:hover {
transform: none; box-shadow: none; border-color: #e4e7ed;
.device-icon { transform: none; }
}
}
}
.check-mark {
position: absolute;
top: 14px; right: 14px;
width: 24px; height: 24px;
border-radius: 50%;
background: $tracking-primary;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.6);
transition: all 0.25s ease;
z-index: 10;
color: #fff;
font-size: 12px;
}
.device-icon-wrapper {
display: flex;
justify-content: center;
margin-bottom: 16px;
position: relative;
z-index: 2;
}
.device-icon {
width: 64px; height: 64px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
transition: all 0.35s ease;
&.type-0 {
background: linear-gradient(135deg, rgba($tracking-primary, 0.12), rgba($tracking-primary, 0.06));
color: $tracking-primary;
}
&.type-1 {
background: linear-gradient(135deg, rgba(103, 194, 58, 0.12), rgba(103, 194, 58, 0.06));
color: $success-color;
}
&.type-2 {
background: linear-gradient(135deg, rgba(230, 162, 60, 0.12), rgba(230, 162, 60, 0.06));
color: $warning-color;
}
&.type-3 {
background: linear-gradient(135deg, rgba(245, 108, 108, 0.12), rgba(245, 108, 108, 0.06));
color: $danger-color;
}
.device-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 18px;
}
}
.device-card:hover .device-icon { transform: scale(1.05); }
.device-info {
position: relative;
z-index: 2;
.device-title {
font-size: 16px; font-weight: 600;
color: $text-primary;
margin: 0 0 12px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
.dot { width: 6px; height: 6px; border-radius: 50%; }
&.online {
color: $success-color;
.dot { background: $success-color; animation: pulse 2s infinite; }
}
&.offline {
color: $danger-color;
.dot { background: $danger-color; }
}
}
.device-cameras {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 12px;
color: $text-secondary;
}
}
.card-hover-mask {
position: absolute;
inset: 0;
background: rgba($tracking-primary, 0.85);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.25s ease;
border-radius: 16px;
z-index: 5;
.mask-text { font-size: 14px; font-weight: 600; color: #fff; }
}
.card-disabled-tag {
position: absolute;
top: 12px; right: 12px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(245, 108, 108, 0.12);
border-radius: 6px;
font-size: 12px;
color: $danger-color;
z-index: 6;
}
// ========== ==========
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 32px;
background: #fafbfc;
border-top: 1px solid $border-lighter;
.footer-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: $tracking-primary;
strong { font-weight: 700; }
}
.footer-right {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
}
.confirm-btn {
background: $tracking-primary;
border-color: $tracking-primary;
color: #fff;
font-weight: 500;
letter-spacing: 1px;
&:hover {
background: $tracking-hover;
border-color: $tracking-hover;
}
&:active {
background: $tracking-dark;
border-color: $tracking-dark;
}
&.is-disabled {
background: $tracking-disabled;
border-color: $tracking-disabled;
color: #fff;
}
}
}
// ========== ==========
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.device-modal { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
.device-modal { transform: scale(0.9); opacity: 0; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

@ -0,0 +1,87 @@
<template>
<div class="selected-device-bar">
<el-icon><Aim /></el-icon>
<span class="device-label">当前设备</span>
<div class="device-tags">
<el-tag
v-for="device in activeDevices"
:key="device.id"
size="default"
class="device-tag"
:type="deviceTypeColor(device.groupType)"
>
<span class="tag-status-dot" :class="device.groupStatus === 1 ? 'online' : 'offline'"></span>
{{ device.groupName }}
</el-tag>
</div>
<span class="device-total"> {{ activeDevices.length }} </span>
<el-button class="switch-btn" type="primary" link @click="$emit('switch-device')">
<el-icon><Switch /></el-icon>
</el-button>
</div>
</template>
<script setup>
import { DeviceTypeName, DeviceTypeColor } from '@/enums'
defineProps({
activeDevices: {
type: Array,
required: true
}
})
defineEmits(['switch-device'])
const deviceTypeName = (type) => DeviceTypeName[type] || '未知'
const deviceTypeColor = (type) => DeviceTypeColor[type] || 'info'
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.selected-device-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid $border-lighter;
margin-bottom: 16px;
flex-shrink: 0;
.device-label {
font-size: 14px;
color: $text-secondary;
white-space: nowrap;
}
.device-tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.device-tag {
display: inline-flex;
align-items: center;
gap: 4px;
.tag-status-dot {
width: 6px; height: 6px; border-radius: 50%;
&.online { background: $success-color; }
&.offline { background: $danger-color; }
}
}
.device-total {
font-size: 13px;
color: $text-secondary;
}
.switch-btn { margin-left: auto; }
}
</style>

@ -0,0 +1,227 @@
<template>
<div class="snapshot-section">
<div class="section-header">
<el-icon class="section-icon"><Picture /></el-icon>
<span>取证快照</span>
<span class="snapshot-count">{{ snapshots.length }} </span>
</div>
<div v-if="loading" class="snapshot-loading">
<el-icon class="is-loading" :size="36"><Loading /></el-icon>
<p>加载取证快照...</p>
</div>
<div v-else class="snapshot-carousel-wrapper">
<el-carousel
v-if="snapshots.length"
:interval="4000"
indicator-position="none"
arrow="always"
height="100%"
class="snapshot-carousel"
>
<el-carousel-item v-for="item in snapshots" :key="item.id">
<div class="snapshot-card">
<div class="snapshot-image">
<img :src="filehttp + item.imageUrl" :alt="item.name || '快照'" />
</div>
<div class="snapshot-info">
<div class="info-row">
<el-icon><Picture /></el-icon>
<span class="info-label">场景类型</span>
<span class="info-value">{{ item.sceneType || '--' }}</span>
</div>
<div class="info-row">
<el-icon><Document /></el-icon>
<span class="info-label">文件名称</span>
<span class="info-value" :title="item.name">{{ item.name || '--' }}</span>
</div>
<div class="info-row">
<el-icon><Clock /></el-icon>
<span class="info-label">取证时间</span>
<span class="info-value">{{ item.time }}</span>
</div>
</div>
</div>
</el-carousel-item>
</el-carousel>
<div v-else class="snapshot-empty">
<el-icon :size="48"><Picture /></el-icon>
<p>暂无取证快照</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getEvidenceSnapshotList } from '@/api/smart-tracking'
import filehttp from '@/utils/filehttp'
const snapshots = ref([])
const loading = ref(false)
const fetchSnapshots = async () => {
loading.value = true
try {
const res = await getEvidenceSnapshotList({
currentPage: 1,
pageSize: 10,
sceneType: 'Anomaly_Recognition'
})
const list = res.data?.list || []
snapshots.value = list.map(item => ({
id: item.id,
name: item.fileName,
sceneType: item.sceneType,
time: item.shotTime || item.createTime,
imageUrl: item.url
}))
} catch (error) {
console.error('获取取证快照失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchSnapshots()
})
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.snapshot-section {
height: 100%;
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.04);
border: 1px solid $border-lighter;
display: flex;
flex-direction: column;
overflow: hidden;
.snapshot-count {
margin-left: auto;
font-size: 12px;
font-weight: 500;
color: $text-secondary;
}
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 15px;
font-weight: 600;
color: $text-primary;
.section-icon {
color: $tracking-primary;
font-size: 18px;
}
}
.snapshot-loading {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: $text-secondary;
font-size: 14px;
}
.snapshot-carousel-wrapper {
flex: 1;
min-height: 0;
}
.snapshot-carousel {
height: 100%;
:deep(.el-carousel__container) {
height: 100%;
border-radius: 10px;
}
:deep(.el-carousel__arrow) {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 34px; height: 34px;
&:hover { background: #fff; }
}
:deep(.el-carousel__arrow i) { color: $text-primary; }
}
.snapshot-card {
height: 100%;
display: flex;
flex-direction: column;
}
.snapshot-image {
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 10px 10px 0 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.snapshot-info {
padding: 16px 18px;
background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
border-radius: 0 0 10px 10px;
display: flex;
flex-direction: column;
gap: 10px;
flex-shrink: 0;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $text-regular;
.el-icon {
color: $tracking-primary;
font-size: 15px;
flex-shrink: 0;
}
.info-label {
color: $text-secondary;
white-space: nowrap;
min-width: 56px;
}
.info-value {
font-weight: 500;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.snapshot-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-placeholder;
gap: 12px;
font-size: 14px;
}
</style>

@ -0,0 +1,259 @@
<template>
<div class="smart-tracking-container">
<!-- 操作页面 -->
<div v-if="!showDeviceModal" class="operation-page">
<!-- 设备信息栏 -->
<SelectedDeviceBar
v-if="activeDevices.length"
:active-devices="activeDevices"
@switch-device="showDeviceModal = true"
/>
<!-- 主体双栏布局 -->
<div class="tracking-layout">
<!-- 左侧算法 + 视频 -->
<div class="left-panel">
<AlgorithmSelector
v-model="activeAlgorithm"
:algorithm-list="algorithmList"
/>
<!-- 摄像头画面区 -->
<div class="camera-section">
<div class="section-header">
<el-icon class="section-icon"><VideoCamera /></el-icon>
<span>监控画面</span>
<span class="camera-tag"> {{ cameras.length }} </span>
</div>
<div v-if="cameras.length" class="camera-grid" :class="gridClass">
<div v-for="cam in cameras" :key="cam.id" class="camera-viewer">
<WebRtcPlayer :src="cam.videoStreaming" />
<div class="camera-label-tag">{{ cam.deviceName }} · {{ cam.name }}</div>
</div>
</div>
<div v-else class="camera-empty">
<el-icon :size="40"><VideoCamera /></el-icon>
<p>暂无监控画面</p>
</div>
</div>
</div>
<!-- 右侧取证快照 -->
<div class="right-panel">
<SnapshotPanel />
</div>
</div>
</div>
<!-- 全屏设备选择弹窗 -->
<DeviceModal
:visible="showDeviceModal"
:device-list="deviceList"
:loading="loading"
:active-devices="activeDevices"
@confirm="handleConfirm"
@cancel="handleCancel"
@refresh="fetchDevices"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getPodList } from '@/api/home'
import { ElMessage } from 'element-plus'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
import SelectedDeviceBar from './components/SelectedDeviceBar.vue'
import AlgorithmSelector from './components/AlgorithmSelector.vue'
import SnapshotPanel from './components/SnapshotPanel.vue'
import DeviceModal from './components/DeviceModal.vue'
// --- ---
const algorithmList = [
{ id: 'single-lock', name: '单目标锁定', desc: '锁定并持续追踪单个目标', icon: 'Aim' },
{ id: 'multi-track', name: '多目标跟踪', desc: '同时追踪画面内多个目标', icon: 'Connection' },
{ id: 'patrol-dynamic', name: '巡检动态跟踪', desc: '自动巡航并捕捉移动物体', icon: 'Refresh' },
{ id: 'spatial-locate', name: '空间轨迹定位', desc: '三维空间还原运动轨迹', icon: 'Position' }
]
const activeAlgorithm = ref('')
// --- ---
const deviceList = ref([])
const activeDevices = ref([])
const showDeviceModal = ref(true)
const loading = ref(false)
//
const cameras = computed(() => {
const all = []
activeDevices.value.forEach(device => {
if (device.cameras?.length) {
device.cameras.forEach((cam, idx) => {
all.push({
...cam,
id: `${device.id}_${cam.id || idx}`,
deviceName: device.groupName,
name: cam.cameraLocation || cam.name || `摄像头 ${idx + 1}`
})
})
}
})
return all
})
//
const gridClass = computed(() => {
const len = cameras.value.length
if (len === 1) return 'grid-1'
if (len === 2) return 'grid-2'
if (len <= 4) return 'grid-4'
return 'grid-many'
})
const fetchDevices = async () => {
loading.value = true
try {
const res = await getPodList()
deviceList.value = res.data || []
} catch (error) {
console.error('获取设备列表失败:', error)
ElMessage.error('获取设备列表失败')
} finally {
loading.value = false
}
}
const handleConfirm = (devices) => {
activeDevices.value = devices
const names = devices.map(d => d.groupName).join('、')
ElMessage.success(`已选择设备:${names}`)
showDeviceModal.value = false
}
const handleCancel = () => {
showDeviceModal.value = false
}
onMounted(() => {
fetchDevices()
})
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.smart-tracking-container {
height: calc(100vh - 120px);
position: relative;
overflow: hidden;
}
.operation-page {
height: 100%;
display: flex;
flex-direction: column;
}
.tracking-layout {
flex: 1;
display: flex;
gap: 16px;
min-height: 0;
}
.left-panel {
flex: 6;
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.right-panel {
flex: 4;
min-width: 360px;
max-width: 460px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 15px;
font-weight: 600;
color: $text-primary;
.section-icon {
color: $tracking-primary;
font-size: 18px;
}
}
.camera-section {
flex: 1;
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.04);
border: 1px solid $border-lighter;
display: flex;
flex-direction: column;
min-height: 0;
.camera-tag {
margin-left: auto;
font-size: 12px;
font-weight: 500;
color: $tracking-primary;
background: rgba($tracking-primary, 0.08);
padding: 2px 10px;
border-radius: 12px;
}
}
.camera-grid {
flex: 1;
display: grid;
gap: 10px;
min-height: 0;
&.grid-1 { grid-template-columns: 1fr; grid-template-rows: 1fr; }
&.grid-2 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; }
&.grid-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
&.grid-many { grid-template-columns: repeat(3, 1fr); grid-auto-rows: 1fr; }
}
.camera-viewer {
position: relative;
border-radius: 10px;
overflow: hidden;
background: #000;
min-height: 0;
}
.camera-label-tag {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
color: #fff;
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
letter-spacing: 1px;
z-index: 5;
}
.camera-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-placeholder;
gap: 10px;
font-size: 14px;
}
</style>
Loading…
Cancel
Save