HazardInspectUI/src/pages/nav/hazardCheck/HazardCheckResult.vue

542 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { runApi } from '~/composables/api'
interface HazardItem {
隐患编号: string
物体编号: string
物体类型: string
隐患名称: string
隐患等级: string
置信度: string
时间点: string
跳转时间点: number
隐患描述: string
依据: string
整改建议: string
}
interface DataFormat {
隐患列表: [number, string][]
// 隐患列表: [number, string, string][]
隐患范围字典: Record<string, { ranges: number[], level: number, tip: string }>
隐患数据: HazardItem[]
}
interface ResultObject {
tag_id: number // 隐患类型id
base_id: number // 依据id
track_id: string // 物体追踪id
hazard_track_id: number // 隐患追踪id
class_id: number // 物体类型id
conf: number // 置信度 0: 疑似 1: 确信
level: number // 隐患等级 0: 一般隐患 1: 重大隐患
start_frame: number // 开始帧
end_frame: number // 结束帧
start_sec: number // 开始时间点
location: string // 位置
recommend: string // 整改建议
}
interface ResultData {
class_list: string[]
tag: string[]
base: string[]
objects: ResultObject[]
}
// =============== 2. 响应式数据 ===============
const router = useRouter()
const vidUrl = ref('')
const resultData = ref<ResultData>({
class_list: [],
tag: [],
base: [],
objects: [],
})
const videoRef = ref<HTMLVideoElement>()
const data = ref<DataFormat>({
隐患列表: [],
隐患范围字典: {},
隐患数据: [],
})
// const data = ref({
// 隐患列表: ['隐患1', '隐患2'],
// 物体列表: ['物体1', '物体2', '物体3', '物体4', '物体5'],
// 隐患数据: [
// {
// 隐患名称: '隐患1',
// 时间点: '00:00:00',
// 跳转时间点: 0,
// 隐患描述: '隐患1的描述',
// 依据: '依据1',
// 整改建议: '整改建议1',
// },
// {
// 隐患名称: '隐患2',
// 时间点: '00:00:03',
// 跳转时间点: 3,
// 隐患描述: '隐患2的描述',
// 依据: '依据2',
// 整改建议: '整改建议2',
// },
// ],
// })
const selectedHazard = ref(-1)
const currentFrame = ref(0)
const videoDuration = ref(0)
const FPS = 30
const totalFrames = computed(() => {
if (videoDuration.value > 0) {
return Math.floor(videoDuration.value * FPS)
}
const objects = resultData.value.objects
if (!objects || objects.length === 0)
return 300
const maxEndFrame = Math.max(...objects.map(obj => obj.end_frame || obj.start_frame))
return maxEndFrame + 100
})
function handleTimelineHazardClick(hazardId: string) {
selectedHazard.value = Number(hazardId)
handleJumpToTimePoint(Number(hazardId))
}
function handleFrameChange(frame: number) {
const videoEl = videoRef.value
if (!videoEl)
return
const seconds = frame / FPS
videoEl.currentTime = Math.min(seconds, videoEl.duration)
}
function updateCurrentFrame() {
const videoEl = videoRef.value
if (!videoEl)
return
currentFrame.value = Math.floor(videoEl.currentTime * 30)
}
function handleVideoLoadedMetadata() {
const videoEl = videoRef.value
if (!videoEl)
return
videoDuration.value = videoEl.duration
}
function getData() {
const { tag, base, objects } = resultData.value
data.value.隐患列表 = (objects || []).map((obj: any) => {
return [
obj.level,
// resultData.value?.class_list?.[obj.class_id] || '',
tag?.[obj.tag_id] || '',
]
})
data.value.隐患范围字典 = {}
;(objects || []).forEach((obj: any) => {
const 编号 = String(obj.hazard_track_id)
if (!data.value.隐患范围字典[编号]) {
data.value.隐患范围字典[编号] = {
ranges: [obj.start_frame, obj.end_frame],
level: obj.level,
tip: `${tag?.[obj.tag_id] || ''}`, // 隐患名称
// tip: `(${resultData.value.class_list?.[obj.class_id] || ''}) ${tag?.[obj.tag_id] || ''}`,
}
}
})
// data.value.物体列表 = (objects || []).map((_: any, i: number) => `物体${i + 1}`)
data.value.隐患数据 = (objects || []).map((obj: any) => {
const jumpPoint = obj.start_sec || 0
const hh = String(Math.floor(jumpPoint / 3600)).padStart(2, '0')
const mm = String(Math.floor((jumpPoint % 3600) / 60)).padStart(2, '0')
const ss = String(Math.floor(jumpPoint % 60)).padStart(2, '0')
return {
隐患编号: obj.hazard_track_id || '',
物体编号: obj.track_id || '',
物体类型: resultData.value.class_list?.[obj.class_id] || '',
隐患名称: tag?.[obj.tag_id] || '',
隐患等级: obj.level === 0 ? '一般隐患' : '重大隐患',
置信度: obj.conf === 0 ? '疑似' : '确信',
时间点: `${hh}:${mm}:${ss}`,
跳转时间点: jumpPoint,
隐患描述: obj.location || '',
依据: base?.[obj.base_id] || '',
整改建议: obj.recommend || '',
}
})
}
// 点击隐患列表项,更新选中隐患
function handleHazardClick(item: string, index: number) {
selectedHazard.value = index
handleJumpToTimePoint(selectedHazard.value)
}
// 点击跳转到隐患时间点,更新视频播放
function handleJumpToTimePoint(index: number) {
const seconds = data.value.隐患数据[index].跳转时间点
// 实现跳转到指定时间点的逻辑
// console.log(`跳转到时间点: ${seconds}`)
// 校验必须是数字且大于等于0
if (Number.isNaN(seconds) || seconds < 0)
return
const videoEl = videoRef.value
if (!videoEl)
return // 确保 DOM 已加载
// 直接设置 currentTime 实现跳转
videoEl.currentTime = seconds
// 跳转后自动暂停
videoEl.pause()
}
// 隐患列表项字段配置
// label: 显示名称
// key: 数据字段名
// group: 所属分组(行)
// transform: 数据转换函数
const hazardFields = [
{ label: '隐患编号', key: '隐患编号', group: 1, transform: (val: any) => val !== undefined ? val + 1 : '无' },
{ label: '物体编号', key: '物体编号', group: 1 },
{ label: '物体类型', key: '物体类型', group: 1 },
{ label: '隐患等级', key: '隐患等级', group: 2 },
{ label: '置信度', key: '置信度', group: 2 },
{ label: '时间点', key: '时间点', group: 2 },
{ label: '隐患名称', key: '隐患名称', group: 3 },
{ label: '隐患描述', key: '隐患描述', group: 4 },
{ label: '依据', key: '依据', group: 5 },
{ label: '整改建议', key: '整改建议', group: 6 },
]
const groupedFields = computed(() => {
const groups: Record<number, typeof hazardFields> = {}
hazardFields.forEach((field) => {
const group = field.group || 0
if (!groups[group])
groups[group] = []
groups[group].push(field)
})
return Object.values(groups).filter(g => g.length > 0)
})
function getFieldValue(field: typeof hazardFields[0]) {
const value = data.value.隐患数据[selectedHazard.value]?.[field.key as keyof HazardItem]
return field.transform ? field.transform(value) : (value || '无')
}
onMounted(() => {
// 从路由参数中获取数据
const vidFile = router.currentRoute.value.query.vid_file as string
if (vidFile) {
runApi('/get_full_vid_path', {
vid_file: vidFile,
}).then((res) => {
// console.log('接口调用成功,返回数据:', res)
vidUrl.value = (res as string[])[0]
// 截取文件名
const fileName = vidUrl.value.split('\\').pop() || ''
// 去掉文件扩展名
const fileNameNoExt = fileName.split('.')[0]
// 文件扩展名
const fileExt = fileName.split('.')[1]
// 临时拼接视频路径
vidUrl.value = `http://localhost:8086/${fileNameNoExt}_h264.${fileExt}`
})
// runApi('/run', {
// vid_file: vidFile,
// run_sam3: false,
// run_inspection: false,
// gen_report: false,
// }).then((res) => {
// // console.log('接口调用成功,返回数据:', res)
// // 处理返回数据
// // data.value = res
// console.log('接口调用成功,返回数据:', JSON.stringify(res))
// })
resultData.value = {
class_list: [
'消火栓',
'插座',
'配电箱',
],
tag: [
'消火栓未点检',
'插座无漏电保护',
'配电箱门口堆放杂物/易燃物',
'配电箱门损坏/缺失',
'消火栓随意堆放杂物',
],
base: [
'*227.消防设施设备缺失损坏,未定期开展检验,安装、配置不合理,处于不良好可用状态。',
'车间、潮湿场所插座回路未安装漏电保护器',
'*83.存在“两个通道”堵塞情形:存在严重占用防火间距、严重破坏防火分区、严重影响人员安全疏散和消防救援的情形。',
'*89.企业在用的特种设备存在重大事故隐患的。',
],
objects: [
{
tag_id: 0,
base_id: 0,
track_id: '18',
hazard_track_id: 0,
class_id: 0,
conf: 0,
level: 0,
start_frame: 551,
end_frame: 581,
start_sec: 18.4,
location: '画面中央偏右的墙壁上',
recommend: '',
},
{
tag_id: 1,
base_id: 1,
track_id: '115',
hazard_track_id: 1,
class_id: 1,
conf: 2,
level: 1,
start_frame: 3618,
end_frame: 3648,
start_sec: 120.6,
location: '画面右侧墙壁上的白色插座面板',
recommend: '',
},
{
tag_id: 2,
base_id: 2,
track_id: '125',
hazard_track_id: 2,
class_id: 2,
conf: 1,
level: 1,
start_frame: 3733,
end_frame: 3763,
start_sec: 124.4,
location: '画面中央偏左墙壁上的配电箱正下方紧贴放置有一台不锈钢设备导致配电箱前1米内被占用。',
recommend: '',
},
{
tag_id: 1,
base_id: 1,
track_id: '127',
hazard_track_id: 3,
class_id: 1,
conf: 1,
level: 1,
start_frame: 3767,
end_frame: 3900,
start_sec: 125.6,
location: '画面左侧墙壁上,操作台上方区域',
recommend: '',
},
{
tag_id: 1,
base_id: 1,
track_id: '130',
hazard_track_id: 4,
class_id: 1,
conf: 1,
level: 1,
start_frame: 3812,
end_frame: 3842,
start_sec: 127.1,
location: '画面中央偏右墙壁上,配电箱下方区域',
recommend: '',
},
{
tag_id: 2,
base_id: 2,
track_id: '196',
hazard_track_id: 5,
class_id: 2,
conf: 1,
level: 1,
start_frame: 5199,
end_frame: 5229,
start_sec: 173.3,
location: '画面中央偏右的蓝色配电箱正下方及周围区域',
recommend: '',
},
{
tag_id: 3,
base_id: 3,
track_id: '206',
hazard_track_id: 6,
class_id: 2,
conf: 1,
level: 1,
start_frame: 5409,
end_frame: 5439,
start_sec: 180.3,
location: '画面左侧墙壁上的灰色配电箱箱门缺失,内部元器件裸露',
recommend: '',
},
{
tag_id: 4,
base_id: 2,
track_id: '221',
hazard_track_id: 7,
class_id: 0,
conf: 1,
level: 1,
start_frame: 5654,
end_frame: 5684,
start_sec: 188.5,
location: '画面右侧的蓝色消火栓箱内部',
recommend: '',
},
],
}
getData()
if (data.value.隐患列表.length > 0) {
selectedHazard.value = 0
handleJumpToTimePoint(0)
}
}
})
</script>
<template>
<!-- 最外层容器占满整个视口 -->
<el-container style="margin: 0; height: 100%; flex-direction: column;">
<el-header style="height: 30px; display: flex; align-items: center; border-bottom: 1px solid var(--ep-border-color);">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">
主页
</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/nav/hazardCheck/' }">
隐患检查
</el-breadcrumb-item>
<el-breadcrumb-item>隐患检查结果</el-breadcrumb-item>
</el-breadcrumb>
</el-header>
<!-- 内层容器自动填充剩余高度 -->
<el-container style="flex: 1; min-height: 0;">
<el-aside width="200px" style="border-right: 1px solid var(--ep-border-color);">
<!-- <el-row class="flex flex-col" style="border-bottom: 1px solid var(--ep-border-color);"> -->
<el-row class="flex flex-col">
<ItemList title="隐患列表" :data="data.隐患列表" @click="handleHazardClick" />
</el-row>
<!-- <el-row style="flex: 1; overflow-y: auto;">
<ItemList title="物体列表" :data="data.物体列表" />
</el-row> -->
</el-aside>
<el-container style="border-right: 1px solid var(--ep-border-color)">
<el-main style="border-bottom: 1px solid var(--ep-border-color); padding: 0;">
<div class="vid_box">
<video
ref="videoRef"
:src="vidUrl"
controls
class="video"
@timeupdate="updateCurrentFrame"
@loadedmetadata="handleVideoLoadedMetadata"
/>
</div>
</el-main>
<el-footer style="height: 200px; padding: 0px;">
<Timeline
style="height: 100%;"
:current-frame="currentFrame"
:total-frames="totalFrames"
:hazard-ranges="data.隐患范围字典 as Record<string, { ranges: number[]; level: number; tip: string }>"
@hazard-click="(id: number) => handleTimelineHazardClick(String(id))"
@frame-change="handleFrameChange"
/>
</el-footer>
</el-container>
<el-aside width="300px">
<!-- <el-row style="border-bottom: 1px solid var(--ep-border-color);"> -->
<el-row class="px-3 py-2">
<el-col v-if="selectedHazard >= 0">
<template v-for="group in groupedFields" :key="group[0].group">
<el-row :gutter="12">
<el-col v-for="field in group" :key="field.key" :span="24 / group.length">
<el-row class="result-title">
<el-text type="info" size="small">
{{ field.label }}
</el-text>
</el-row>
<el-row class="result-content">
<el-text v-if="field.key === '隐患等级' && getFieldValue(field) === '重大隐患'" type="danger">
{{ getFieldValue(field) }}
</el-text>
<el-text v-else-if="field.key === '置信度' && getFieldValue(field) === '疑似'" type="warning">
{{ getFieldValue(field) }}
</el-text>
<el-text v-else>
{{ getFieldValue(field) }}
</el-text>
</el-row>
</el-col>
</el-row>
<div class="py-1" />
</template>
<el-row>
<el-button style="width: 100%;" @click="handleJumpToTimePoint(selectedHazard)">
跳转到隐患时间点
</el-button>
</el-row>
</el-col>
<el-col v-else>
<el-row class="px-1 py-1">
无选中隐患
</el-row>
</el-col>
</el-row>
<!-- <el-row class="px-1 py-1">
<el-button>
查看报告
</el-button>
</el-row> -->
</el-aside>
</el-container>
</el-container>
</template>
<style scoped lang="scss">
.vid_box {
width: 100%;
height: 100%;
display: flex;
}
.vid_box video {
width: 100%;
height: 100%;
object-fit: contain; /* 关键:保持比例 + 完整显示 + 自适应父盒子 */
}
.item-btn {
width: 100%;
}
.result-title {
padding: 0.25rem 0px;
}
.result-content {
text-align: left;
// line-height: 1.5rem;
}
</style>