485 lines
14 KiB
Vue
485 lines
14 KiB
Vue
<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>
|