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

485 lines
14 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, runApiAudio } from '~/composables/api'
interface Sentence {
begin_time: number
duration: number
end_time: number
speaker: string
text: string
}
interface AudioResult {
sentences: Sentence[]
total_sentences: number
}
const 隐患等级字典: Record<number, string> = {
0: '一般隐患',
1: '重大隐患',
}
const 置信度字典: Record<number, string> = {
0: '疑似',
1: '确信',
}
interface HazardItem {
隐患编号: string
物体编号: string
物体类型: string
隐患名称: string
隐患等级: string
置信度: string
时间点: string
跳转时间点: number
隐患描述: string
依据: string
整改建议: string
}
interface DataFormat {
隐患列表: [number, string][]
对话列表: [number, string, 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)
handleJumpToHazard(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 { class_list, tag, base, objects } = resultData.value
// console.log('检查数据', 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 || '',
物体类型: class_list?.[obj.class_id] || '',
隐患名称: tag?.[obj.tag_id] || '',
隐患等级: 隐患等级字典[obj.level],
置信度: 置信度字典[obj.conf],
时间点: `${hh}:${mm}:${ss}`,
跳转时间点: jumpPoint,
隐患描述: obj.location || '',
依据: base?.[obj.base_id] || '',
整改建议: obj.recommend || '',
}
})
}
// 点击隐患列表项,更新选中隐患
function handleHazardClick(item: string, index: number) {
selectedHazard.value = index
handleJumpToHazard(selectedHazard.value)
}
// 点击跳转到隐患时间点,更新视频播放
function handleJumpToHazard(index: number) {
const seconds = data.value.隐患数据[index].跳转时间点
// 实现跳转到指定时间点的逻辑
// console.log(`跳转到时间点: ${seconds}`)
handleJumpToTimePoint(seconds)
}
function handleJumpToTimePoint(seconds: number) {
// 校验必须是数字且大于等于0
if (Number.isNaN(seconds) || seconds < 0)
return
const videoEl = videoRef.value
if (!videoEl)
return // 确保 DOM 已加载
// 直接设置 currentTime 实现跳转
videoEl.currentTime = seconds
// 跳转后自动暂停
videoEl.pause()
}
function getAudioRecData(res: any) {
if (!res) {
console.error('音频识别结果为空')
return
}
let sentences = res.sentences
if (!sentences && res.data) {
sentences = res.data.sentences
}
if (!sentences) {
console.error('未找到 sentences 字段,完整响应:', res)
return
}
const conversationList: [number, string, string, string][] = []
sentences.forEach((sentence: Sentence) => {
const mins = Math.floor(sentence.begin_time / 60)
const secs = Math.floor(sentence.begin_time % 60)
const timeStr = `${mins}:${secs.toString().padStart(2, '0')}`
conversationList.push([
sentence.begin_time,
timeStr,
sentence.speaker,
sentence.text,
])
})
data.value.对话列表 = conversationList
}
// 隐患列表项字段配置
// 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.mp4`
})
// 临时拼接视频路径
// 去掉文件扩展名
// const fileNameNoExt = vidFile.split('.')[0]
// // 文件扩展名
// const fileExt = vidFile.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[3])
resultData.value = (res as any[])[3] as ResultData
getData()
if (data.value.隐患列表.length > 0) {
selectedHazard.value = 0
handleJumpToHazard(0)
}
})
runApiAudio('result', 'GET', {
path: vidFile,
// path: 'VID_20251104_085655_024.AVI',
}).then((res) => {
getAudioRecData(res as AudioResult)
})
}
})
</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>隐患检查结果</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 style="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="handleJumpToHazard(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 v-if="data.对话列表.length > 0" style="flex: 1; overflow-y: auto; border-top: 1px solid var(--ep-border-color);">
<SubtitleList title="对话" :data="data.对话列表" @click="(item: any[]) => handleJumpToTimePoint(Number(item[0]))" />
</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>