1. **gradio 接口调用支持**: 新增 Gradio 接口调用,为 AI 模型集成提供更灵活的方案
2. **功能完善**: 新增完整的隐患检查流程(配置 → 检测 → 结果展示) - 删除物体列表 - 完成隐患列表读取 - 完成隐患信息展示 3. **组件丰富**: 新增 `ItemList` 和 `Timeline` 等可复用组件 4. **架构优化**: 路由结构重组,主题色统一
This commit is contained in:
parent
d69013ec50
commit
f04b1aed41
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@labelbee/lb-annotation": "^1.27.0",
|
||||
"@labelbee/lb-utils": "^1.19.0",
|
||||
"@gradio/client": "^2.2.0",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"element-plus": "^2.10.5",
|
||||
"vue": "^3.5.18",
|
||||
|
|
|
|||
|
|
@ -17,11 +17,18 @@ declare module 'vue' {
|
|||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||
ElForItem: typeof import('element-plus/es')['ElForItem']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
|
|
@ -29,15 +36,25 @@ declare module 'vue' {
|
|||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||
ItemList: typeof import('./components/hazard_inspect/ItemList.vue')['default']
|
||||
Left_bar: typeof import('./components/hazard_inspect/left_bar.vue')['default']
|
||||
Logos: typeof import('./components/Logos.vue')['default']
|
||||
MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Timeline: typeof import('./components/hazard_inspect/timeline.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 项目列表展示组件
|
||||
* 接收两个参数:title(字符串)和data(字符串数组)
|
||||
*/
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '项目列表',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
// 定义输出事件
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
// 点击项,向外抛出数据
|
||||
function handleItemClick(item: string, index: number) {
|
||||
emit('click', item, index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hazard-list-container">
|
||||
<!-- 标题栏 -->
|
||||
<el-row class="px-3 pb-1 pt-3">
|
||||
<el-text type="info" size="small">
|
||||
{{ props.title }}
|
||||
</el-text>
|
||||
</el-row>
|
||||
|
||||
<!-- 滚动列表区域 -->
|
||||
<el-row style="flex: 1; overflow-y: auto;">
|
||||
<el-col>
|
||||
<el-row v-for="(item, index) in props.data" :key="index">
|
||||
<el-button class="item-btn" text @click="handleItemClick(item as string, index)">
|
||||
<el-text type="info" size="small">
|
||||
{{ index + 1 }}
|
||||
</el-text>
|
||||
<div class="w-2" />
|
||||
<el-text class="item-text">
|
||||
{{ item }}
|
||||
</el-text>
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hazard-list-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-btn {
|
||||
width: 100%;
|
||||
padding: 4px 0.75rem;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
min-height: 28px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
<script setup>
|
||||
import { ElButton, ElScrollbar } from 'element-plus'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
currentFrame: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
totalFrames: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
objects: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['zoom', 'frameChange'])
|
||||
|
||||
const pxPerFrame = ref(2)
|
||||
const timelineContainer = ref(null)
|
||||
|
||||
// 播放头位置
|
||||
const playheadPos = computed(() => {
|
||||
return 100 + props.currentFrame * pxPerFrame.value
|
||||
})
|
||||
|
||||
// 轨道总宽度
|
||||
const trackWidth = computed(() => {
|
||||
return `${props.totalFrames * pxPerFrame.value}px`
|
||||
})
|
||||
|
||||
// 缩放
|
||||
function handleZoom() {
|
||||
emit('zoom')
|
||||
}
|
||||
|
||||
// 鼠标更新帧
|
||||
function updatePlayhead(e) {
|
||||
if (!timelineContainer.value)
|
||||
return
|
||||
const rect = timelineContainer.value.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left - 100, props.totalFrames * pxPerFrame.value))
|
||||
const frame = Math.floor(x / pxPerFrame.value)
|
||||
emit('frameChange', frame)
|
||||
}
|
||||
|
||||
// 帧变化自动滚动
|
||||
watch(() => props.currentFrame, () => {
|
||||
if (!timelineContainer.value)
|
||||
return
|
||||
const container = timelineContainer.value
|
||||
const playhead = document.getElementById('playhead')
|
||||
if (playhead) {
|
||||
container.scrollLeft = playhead.offsetLeft - container.clientWidth / 2
|
||||
}
|
||||
})
|
||||
|
||||
// 拖拽播放头
|
||||
onMounted(() => {
|
||||
const progressContainer = timelineContainer.value
|
||||
if (!progressContainer)
|
||||
return
|
||||
let isDragging = false
|
||||
|
||||
progressContainer.addEventListener('mousedown', (e) => {
|
||||
isDragging = true
|
||||
updatePlayhead(e)
|
||||
})
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (isDragging)
|
||||
updatePlayhead(e)
|
||||
})
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
isDragging = false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline-wrapper h-[33.333%] flex flex-col border-t border-slate-700 bg-slate-900">
|
||||
<!-- 工具栏 -->
|
||||
<div class="h-8 flex items-center gap-2 border-b border-slate-800 bg-slate-800/50 px-2">
|
||||
<ElButton
|
||||
text
|
||||
class="!h-auto !p-1 !text-slate-400 hover:!bg-slate-700 hover:!text-white"
|
||||
title="缩放"
|
||||
@click="handleZoom"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
||||
/>
|
||||
</svg>
|
||||
</ElButton>
|
||||
<div class="mx-1 h-4 w-px bg-slate-700" />
|
||||
<span class="text-xs text-slate-500">轨道视图</span>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴主体:横向滚动容器 -->
|
||||
<div
|
||||
ref="timelineContainer"
|
||||
class="custom-scrollbar relative flex-1 overflow-x-auto bg-slate-900"
|
||||
>
|
||||
<!-- 播放头:固定垂直,不随轨道上下滚 -->
|
||||
<div
|
||||
id="playhead"
|
||||
class="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-red-500"
|
||||
:style="{ left: `${playheadPos}px` }"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 h-3 w-3 rotate-45 bg-red-500 -mt-1.5 -translate-x-1/2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 网格背景:固定不动 -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-10"
|
||||
style="
|
||||
background-image: linear-gradient(to right, #fff 1px, transparent 1px);
|
||||
background-size: 50px 100%;
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 内容层:宽度撑开横向滚动 -->
|
||||
<div class="relative" :style="{ width: trackWidth }">
|
||||
<!-- 左侧标签占位 -->
|
||||
<div class="absolute bottom-0 left-0 top-0 z-10 w-20 bg-slate-900" />
|
||||
|
||||
<!-- 轨道列表:仅这里垂直滚动 -->
|
||||
<ElScrollbar class="ml-20" :style="{ height: '100%' }">
|
||||
<div id="tracks-list" class="pb-10 pt-4">
|
||||
<div
|
||||
v-for="obj in objects"
|
||||
:key="obj.id"
|
||||
class="relative h-12 flex items-center border-b border-slate-800/50 hover:bg-slate-800/30"
|
||||
>
|
||||
<!-- 轨道标签:固定左侧,不横向滚动 -->
|
||||
<div
|
||||
class="absolute left-2 z-10 w-20 truncate text-[10px] text-slate-400"
|
||||
:style="{ transform: 'translateX(-100%)' }"
|
||||
>
|
||||
{{ obj.name }}
|
||||
</div>
|
||||
|
||||
<!-- 轨道进度条 -->
|
||||
<div
|
||||
class="absolute h-6 cursor-pointer border border-opacity-50 rounded bg-opacity-20 hover:bg-opacity-30"
|
||||
:style="{
|
||||
left: '0px',
|
||||
width: trackWidth,
|
||||
backgroundColor: obj.color,
|
||||
borderColor: obj.color,
|
||||
top: '12px',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="i in Math.floor(props.totalFrames / 30)"
|
||||
:key="i"
|
||||
class="absolute top-1/2 h-1 w-1 rounded-full bg-white opacity-50 -translate-y-1/2"
|
||||
:style="{ left: `${i * 30 * pxPerFrame}px` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.timeline-wrapper {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* 让 ElScrollbar 填满高度 */
|
||||
:deep(.el-scrollbar) {
|
||||
height: 100% !important;
|
||||
}
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Client } from '@gradio/client'
|
||||
|
||||
export async function runApi(funcName: string, params: Record<string, any>) {
|
||||
// 声明 client 变量,用于后续释放连接
|
||||
try {
|
||||
// 1. 连接客户端(修复:function 是关键字,不能做变量名)
|
||||
const client = await Client.connect('http://127.0.0.1:7860')
|
||||
|
||||
// 2. 调用预测接口,传入方法名和参数
|
||||
const result = await client.predict(funcName, params)
|
||||
|
||||
// 3. 打印返回结果
|
||||
// console.log('接口调用成功,返回数据:', result.data)
|
||||
|
||||
return result.data
|
||||
}
|
||||
catch (error) {
|
||||
// 捕获异常,打印错误信息
|
||||
console.error('接口调用失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,15 @@
|
|||
安责险隐患检查
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<el-button class="bg-gray-200 p-4" @click="$router.push('/nav/1_隐患检查')">
|
||||
<el-button class="bg-gray-200 p-4" @click="$router.push('/nav/hazardCheck')">
|
||||
隐患检查
|
||||
</el-button>
|
||||
<el-button class="bg-gray-200 p-4" @click="$router.push('/nav/VideoTrackDemo')">
|
||||
物体跟踪 demo
|
||||
</el-button>
|
||||
<el-button class="bg-gray-200 p-4" @click="$router.push('/nav/track_2')">
|
||||
物体跟踪 demo2
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { dividerProps } from 'element-plus/lib'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const 隐患列表 = ref(['隐患1', '隐患2', '隐患3', '隐患4', '隐患5'])
|
||||
const 物体列表 = ref(['物体1', '物体2', '物体3', '物体4', '物体5'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 最外层容器:占满整个视口 -->
|
||||
<el-container style="margin: 0; height: 100%; flex-direction: column;">
|
||||
<el-header style="height: 60px; 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 style="min-height: 20px;">
|
||||
隐患列表
|
||||
</el-row>
|
||||
<el-row style="flex: 1; overflow-y: auto;">
|
||||
<el-row v-for="item in 隐患列表" :key="item">
|
||||
<el-button>
|
||||
{{ item }}
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-row>
|
||||
</el-row>
|
||||
<el-row>物体列表</el-row>
|
||||
<el-row style="flex: 1; overflow-y: auto;">
|
||||
<el-row v-for="item in 物体列表" :key="item">
|
||||
<el-button>
|
||||
{{ item }}
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-row>
|
||||
</el-aside>
|
||||
<el-main style="border-right: 1px solid var(--ep-border-color); padding: 0;">
|
||||
<div class="vid_box" style="border-bottom: 1px solid var(--ep-border-color);">
|
||||
视频区
|
||||
</div>
|
||||
<div class="vid_track">
|
||||
轨道
|
||||
</div>
|
||||
</el-main>
|
||||
<el-aside width="300px">
|
||||
<el-row style="border-bottom: 1px solid var(--ep-border-color);">
|
||||
<el-col>
|
||||
<el-row class="text-left">
|
||||
隐患描述
|
||||
</el-row>
|
||||
<el-row class="text-left">
|
||||
依据
|
||||
</el-row>
|
||||
<el-row class="text-left">
|
||||
整改建议
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-button>
|
||||
查看报告
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-aside>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const videoRef = ref(null)
|
||||
const currentTime = ref(0)
|
||||
const currentBoxes = ref([])
|
||||
const selectedBox = ref(null)
|
||||
|
||||
// 内置跟踪数据
|
||||
const trackData = ref([
|
||||
{
|
||||
id: 1,
|
||||
label: '人物',
|
||||
color: '#FF4D4F',
|
||||
frames: [
|
||||
{ time: 0, x: 60, y: 40, width: 160, height: 280 },
|
||||
{ time: 1, x: 80, y: 40, width: 160, height: 280 },
|
||||
{ time: 2, x: 100, y: 40, width: 160, height: 280 },
|
||||
{ time: 3, x: 120, y: 40, width: 160, height: 280 },
|
||||
{ time: 4, x: 140, y: 40, width: 160, height: 280 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '车辆',
|
||||
color: '#1890FF',
|
||||
frames: [
|
||||
{ time: 0, x: 300, y: 150, width: 240, height: 140 },
|
||||
{ time: 1, x: 320, y: 150, width: 240, height: 140 },
|
||||
{ time: 2, x: 340, y: 150, width: 240, height: 140 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// 视频时间更新
|
||||
function onTimeUpdate() {
|
||||
const t = videoRef.value.currentTime
|
||||
currentTime.value = t
|
||||
|
||||
const boxes = []
|
||||
trackData.value.forEach((item) => {
|
||||
const frame = item.frames.findLast(f => f.time <= t)
|
||||
if (frame) {
|
||||
boxes.push({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
color: item.color,
|
||||
...frame,
|
||||
})
|
||||
}
|
||||
})
|
||||
currentBoxes.value = boxes
|
||||
}
|
||||
|
||||
// 点击标注框
|
||||
function handleBoxClick(box) {
|
||||
selectedBox.value = box
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-container" style="max-width: 1200px; margin: 20px auto">
|
||||
<el-card header="视频实时物体跟踪(支持点击标注框)">
|
||||
<div class="video-wrapper">
|
||||
<video
|
||||
ref="videoRef"
|
||||
src="https://www.w3school.com.cn/i/movie.mp4"
|
||||
controls
|
||||
class="video"
|
||||
@timeupdate="onTimeUpdate"
|
||||
/>
|
||||
|
||||
<!-- 标注层 -->
|
||||
<div class="annotations-layer">
|
||||
<div
|
||||
v-for="box in currentBoxes"
|
||||
:key="box.id"
|
||||
class="box"
|
||||
:class="{ active: selectedBox?.id === box.id }"
|
||||
:style="{
|
||||
left: `${box.x}px`,
|
||||
top: `${box.y}px`,
|
||||
width: `${box.width}px`,
|
||||
height: `${box.height}px`,
|
||||
borderColor: box.color,
|
||||
}"
|
||||
@click.stop="handleBoxClick(box)"
|
||||
>
|
||||
<span class="label">{{ box.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; display: flex; gap: 10px">
|
||||
<el-tag type="primary">
|
||||
当前时间:{{ currentTime.toFixed(2) }}s
|
||||
</el-tag>
|
||||
<el-tag v-if="selectedBox" type="warning">
|
||||
已选中 ID:{{ selectedBox.id }} | 标签:{{ selectedBox.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.annotations-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: absolute;
|
||||
border: 2px solid;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.box.active {
|
||||
border-width: 4px;
|
||||
background: rgba(255, 255, 0, 0.1);
|
||||
transform: translate(-1px, -1px); /* 完美抵消边框变粗偏移 */
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
left: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,864 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ProVision - 智能视频标注编辑器</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Inter", sans-serif;
|
||||
background-color: #0f172a; /* Slate 900 */
|
||||
color: #e2e8f0; /* Slate 200 */
|
||||
overflow: hidden; /* App-like feel */
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* Range Input Styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
input[type="range"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
background-image:
|
||||
linear-gradient(45deg, #1e293b 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1e293b 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1e293b 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1e293b 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 10px,
|
||||
10px -10px,
|
||||
-10px 0px;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen w-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="h-14 border-b border-slate-700 bg-slate-900 flex items-center justify-between px-4 shrink-0 z-20"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="font-semibold text-lg tracking-tight text-white">
|
||||
ProVision
|
||||
<span class="text-slate-500 font-normal text-sm"
|
||||
>| 视频标注编辑器</span
|
||||
>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-xs text-slate-400 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
系统就绪
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
导出数据
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left Sidebar: Object Thumbnails -->
|
||||
<aside
|
||||
class="w-64 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-10"
|
||||
>
|
||||
<div class="p-3 border-b border-slate-800">
|
||||
<h2 class="text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||
当前帧物体 (Frame <span id="frame-counter">0</span>)
|
||||
</h2>
|
||||
</div>
|
||||
<div id="object-list" class="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
<!-- Dynamic Object Items will be injected here -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Center: Player & Timeline -->
|
||||
<section class="flex-1 flex flex-col min-w-0 bg-black relative">
|
||||
<!-- Video Player Area (Top 2/3) -->
|
||||
<div
|
||||
class="h-[66.666%] relative bg-black flex items-center justify-center overflow-hidden group"
|
||||
>
|
||||
<!-- Canvas Layer (Simulates Video + Overlays) -->
|
||||
<canvas
|
||||
id="video-canvas"
|
||||
class="max-w-full max-h-full shadow-2xl"
|
||||
></canvas>
|
||||
|
||||
<!-- Video Controls Overlay -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col gap-2"
|
||||
>
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
class="relative w-full h-1.5 bg-slate-700 rounded-full cursor-pointer group/progress"
|
||||
id="progress-container"
|
||||
>
|
||||
<div
|
||||
id="progress-fill"
|
||||
class="absolute top-0 left-0 h-full bg-blue-500 rounded-full w-0 transition-all duration-75"
|
||||
></div>
|
||||
<div
|
||||
id="progress-handle"
|
||||
class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/progress:opacity-100 transition-opacity"
|
||||
style="left: 0%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
id="play-btn"
|
||||
class="text-white hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="text-sm font-mono text-slate-300">
|
||||
<span id="current-time">00:00</span> /
|
||||
<span id="total-time">00:10</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-slate-400">
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
></path>
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value="80"
|
||||
class="w-20 h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Area (Bottom 1/3) -->
|
||||
<div
|
||||
class="h-[33.333%] bg-slate-900 border-t border-slate-700 flex flex-col"
|
||||
>
|
||||
<!-- Timeline Toolbar -->
|
||||
<div
|
||||
class="h-8 border-b border-slate-800 flex items-center px-2 gap-2 bg-slate-800/50"
|
||||
>
|
||||
<button
|
||||
class="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
|
||||
title="缩放"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="h-4 w-px bg-slate-700 mx-1"></div>
|
||||
<span class="text-xs text-slate-500">轨道视图</span>
|
||||
</div>
|
||||
|
||||
<!-- Tracks Container -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto relative custom-scrollbar bg-slate-900"
|
||||
id="timeline-container"
|
||||
>
|
||||
<!-- Playhead Line -->
|
||||
<div
|
||||
id="playhead"
|
||||
class="absolute top-0 bottom-0 w-px bg-red-500 z-20 pointer-events-none"
|
||||
style="left: 0px"
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 bg-red-500 transform -translate-x-1/2 rotate-45 -mt-1.5 absolute top-0"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Grid Background -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none opacity-10"
|
||||
style="
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
#fff 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 50px 100%;
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- Dynamic Tracks -->
|
||||
<div id="tracks-list" class="relative pt-4 pb-10">
|
||||
<!-- Tracks injected via JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right Sidebar: Properties -->
|
||||
<aside
|
||||
class="w-80 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-10 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="p-4 border-b border-slate-800 sticky top-0 bg-slate-900 z-10"
|
||||
>
|
||||
<h2 class="text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||
属性面板
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div id="properties-panel" class="p-4 space-y-6">
|
||||
<!-- Empty State -->
|
||||
<div id="empty-state" class="text-center py-10 opacity-50">
|
||||
<svg
|
||||
class="w-12 h-12 mx-auto text-slate-600 mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="text-sm text-slate-400">
|
||||
请在左侧列表或视频中选择一个物体
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content (Hidden by default) -->
|
||||
<div id="properties-content" class="hidden space-y-6">
|
||||
<!-- Header Info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
id="prop-color-indicator"
|
||||
class="w-3 h-10 rounded-full bg-blue-500"
|
||||
></div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="prop-name"
|
||||
value="Object Name"
|
||||
class="bg-transparent text-lg font-bold text-white border-b border-transparent hover:border-slate-600 focus:border-blue-500 focus:outline-none w-full transition-colors"
|
||||
/>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<span id="prop-id" class="text-xs font-mono text-slate-500"
|
||||
>ID: #001</span
|
||||
>
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-400 border border-slate-700"
|
||||
>Person</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bounding Box Info -->
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700">
|
||||
<h3
|
||||
class="text-xs font-semibold text-slate-400 mb-3 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
></path>
|
||||
</svg>
|
||||
边界框坐标 (Box)
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-[10px] text-slate-500 uppercase"
|
||||
>X1 (Left)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="prop-x1"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-200 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] text-slate-500 uppercase"
|
||||
>Y1 (Top)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="prop-y1"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-200 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] text-slate-500 uppercase"
|
||||
>X2 (Right)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="prop-x2"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-200 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] text-slate-500 uppercase"
|
||||
>Y2 (Bottom)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="prop-y2"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-200 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inspection Items -->
|
||||
<div>
|
||||
<h3
|
||||
class="text-xs font-semibold text-slate-400 mb-3 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
></path>
|
||||
</svg>
|
||||
AI 检查项 (Inspection)
|
||||
</h3>
|
||||
<div id="inspection-list" class="space-y-2">
|
||||
<!-- Inspection items injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-slate-800 flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded transition-colors"
|
||||
>
|
||||
删除关键帧
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 text-xs rounded transition-colors"
|
||||
>
|
||||
插值补全
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Application State & Mock Data
|
||||
*/
|
||||
const TOTAL_FRAMES = 300; // 10 seconds @ 30fps
|
||||
const FPS = 30;
|
||||
const VIDEO_WIDTH = 1280;
|
||||
const VIDEO_HEIGHT = 720;
|
||||
|
||||
// Mock Objects Data
|
||||
const objects = [
|
||||
{
|
||||
id: "obj_001",
|
||||
name: "行人 (Person A)",
|
||||
type: "person",
|
||||
color: "#3b82f6", // Blue
|
||||
frames: Array.from({ length: TOTAL_FRAMES }, (_, i) => {
|
||||
// Simulate movement: walking from left to right
|
||||
const progress = i / TOTAL_FRAMES;
|
||||
const x = 100 + progress * 800;
|
||||
const y = 200 + Math.sin(progress * 10) * 20; // Slight bobbing
|
||||
return {
|
||||
x1: x,
|
||||
y1: y,
|
||||
x2: x + 120,
|
||||
y2: y + 240,
|
||||
inspections: [
|
||||
{ label: "安全帽佩戴", confidence: 0.98, status: "pass" },
|
||||
{ label: "反光衣穿戴", confidence: 0.85, status: "pass" },
|
||||
],
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "obj_002",
|
||||
name: "车辆 (Car B)",
|
||||
type: "vehicle",
|
||||
color: "#ef4444", // Red
|
||||
frames: Array.from({ length: TOTAL_FRAMES }, (_, i) => {
|
||||
// Simulate movement: driving
|
||||
const progress = i / TOTAL_FRAMES;
|
||||
const x = 600 - progress * 400;
|
||||
return {
|
||||
x1: x,
|
||||
y1: 400,
|
||||
x2: x + 200,
|
||||
y2: 550,
|
||||
inspections: [
|
||||
{ label: "车牌识别", confidence: 0.92, status: "pass" },
|
||||
{ label: "超速检测", confidence: 0.15, status: "fail" },
|
||||
],
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "obj_003",
|
||||
name: "警示牌 (Sign)",
|
||||
type: "object",
|
||||
color: "#10b981", // Green
|
||||
frames: Array.from({ length: TOTAL_FRAMES }, (_, i) => ({
|
||||
// Static object
|
||||
x1: 1000,
|
||||
y1: 100,
|
||||
x2: 1100,
|
||||
y2: 200,
|
||||
inspections: [
|
||||
{ label: "完好度", confidence: 0.99, status: "pass" },
|
||||
],
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
let state = {
|
||||
currentFrame: 0,
|
||||
isPlaying: false,
|
||||
selectedObjectId: null,
|
||||
scale: 1, // Canvas scale relative to video resolution
|
||||
};
|
||||
|
||||
let animationFrameId;
|
||||
|
||||
// DOM Elements
|
||||
const canvas = document.getElementById("video-canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const objectListEl = document.getElementById("object-list");
|
||||
const tracksListEl = document.getElementById("tracks-list");
|
||||
const playBtn = document.getElementById("play-btn");
|
||||
const progressFill = document.getElementById("progress-fill");
|
||||
const progressHandle = document.getElementById("progress-handle");
|
||||
const currentTimeEl = document.getElementById("current-time");
|
||||
const frameCounterEl = document.getElementById("frame-counter");
|
||||
const playhead = document.getElementById("playhead");
|
||||
|
||||
// Property Panel Elements
|
||||
const emptyStateEl = document.getElementById("empty-state");
|
||||
const propContentEl = document.getElementById("properties-content");
|
||||
const propNameInput = document.getElementById("prop-name");
|
||||
const propIdSpan = document.getElementById("prop-id");
|
||||
const propColorInd = document.getElementById("prop-color-indicator");
|
||||
const propX1 = document.getElementById("prop-x1");
|
||||
const propY1 = document.getElementById("prop-y1");
|
||||
const propX2 = document.getElementById("prop-x2");
|
||||
const propY2 = document.getElementById("prop-y2");
|
||||
const inspectionListEl = document.getElementById("inspection-list");
|
||||
|
||||
/**
|
||||
* Initialization
|
||||
*/
|
||||
function init() {
|
||||
// Set canvas resolution
|
||||
canvas.width = VIDEO_WIDTH;
|
||||
canvas.height = VIDEO_HEIGHT;
|
||||
|
||||
// Initial Render
|
||||
renderFrame();
|
||||
renderSidebar();
|
||||
renderTimeline();
|
||||
|
||||
// Event Listeners
|
||||
playBtn.addEventListener("click", togglePlay);
|
||||
|
||||
// Progress Bar Interaction
|
||||
const progressContainer = document.getElementById("progress-container");
|
||||
let isDragging = false;
|
||||
|
||||
const updateProgress = (e) => {
|
||||
const rect = progressContainer.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percent = x / rect.width;
|
||||
state.currentFrame = Math.floor(percent * TOTAL_FRAMES);
|
||||
updateUI();
|
||||
};
|
||||
|
||||
progressContainer.addEventListener("mousedown", (e) => {
|
||||
isDragging = true;
|
||||
state.isPlaying = false;
|
||||
updatePlayBtn();
|
||||
updateProgress(e);
|
||||
});
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (isDragging) updateProgress(e);
|
||||
});
|
||||
window.addEventListener("mouseup", () => (isDragging = false));
|
||||
|
||||
// Start loop
|
||||
loop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Logic: Rendering the Canvas (Video Simulation)
|
||||
*/
|
||||
function renderFrame() {
|
||||
// 1. Clear
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 2. Draw Background (Simulated Video Content)
|
||||
// Create a moving gradient to simulate motion
|
||||
const offset = (state.currentFrame * 2) % VIDEO_WIDTH;
|
||||
const grd = ctx.createLinearGradient(
|
||||
-offset,
|
||||
0,
|
||||
VIDEO_WIDTH - offset,
|
||||
0,
|
||||
);
|
||||
grd.addColorStop(0, "#1e293b");
|
||||
grd.addColorStop(0.5, "#334155");
|
||||
grd.addColorStop(1, "#1e293b");
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw some "Environment" shapes
|
||||
ctx.fillStyle = "#0f172a";
|
||||
ctx.beginPath();
|
||||
ctx.arc(VIDEO_WIDTH / 2, VIDEO_HEIGHT / 2, 100, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 3. Draw Objects & Bounding Boxes
|
||||
objects.forEach((obj) => {
|
||||
const frameData = obj.frames[state.currentFrame];
|
||||
if (!frameData) return;
|
||||
|
||||
const isSelected = state.selectedObjectId === obj.id;
|
||||
const { x1, y1, x2, y2 } = frameData;
|
||||
|
||||
// Draw Box
|
||||
ctx.strokeStyle = obj.color;
|
||||
ctx.lineWidth = isSelected ? 4 : 2;
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
// Draw Label Background
|
||||
const label = `${obj.name}`;
|
||||
ctx.font = "bold 14px Inter";
|
||||
const textWidth = ctx.measureText(label).width;
|
||||
ctx.fillStyle = obj.color;
|
||||
ctx.fillRect(x1, y1 - 24, textWidth + 10, 24);
|
||||
|
||||
// Draw Label Text
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(label, x1 + 5, y1 - 7);
|
||||
|
||||
// Draw Highlight if selected
|
||||
if (isSelected) {
|
||||
ctx.strokeStyle = "white";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeRect(x1 - 5, y1 - 5, x2 - x1 + 10, y2 - y1 + 10);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw Corner Handles
|
||||
drawHandle(x1, y1);
|
||||
drawHandle(x2, y1);
|
||||
drawHandle(x1, y2);
|
||||
drawHandle(x2, y2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawHandle(x, y) {
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(x - 4, y - 4, 8, 8);
|
||||
ctx.strokeStyle = "black";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x - 4, y - 4, 8, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Rendering: Sidebar & Timeline
|
||||
*/
|
||||
function renderSidebar() {
|
||||
objectListEl.innerHTML = "";
|
||||
|
||||
objects.forEach((obj) => {
|
||||
const currentBox = obj.frames[state.currentFrame];
|
||||
const isSelected = state.selectedObjectId === obj.id;
|
||||
|
||||
const item = document.createElement("div");
|
||||
item.className = `p-2 rounded cursor-pointer transition-all border ${isSelected ? "bg-slate-800 border-blue-500" : "bg-slate-800/30 border-transparent hover:bg-slate-800"}`;
|
||||
item.onclick = () => selectObject(obj.id);
|
||||
|
||||
// Mini Canvas for Thumbnail
|
||||
const thumbCanvas = document.createElement("canvas");
|
||||
thumbCanvas.width = 60;
|
||||
thumbCanvas.height = 40;
|
||||
thumbCanvas.className =
|
||||
"rounded bg-slate-900 w-full mb-2 object-cover border border-slate-700";
|
||||
const tCtx = thumbCanvas.getContext("2d");
|
||||
|
||||
// Draw simple thumb representation
|
||||
tCtx.fillStyle = "#1e293b";
|
||||
tCtx.fillRect(0, 0, 60, 40);
|
||||
tCtx.strokeStyle = obj.color;
|
||||
tCtx.strokeRect(10, 10, 40, 20); // Fake box
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="flex gap-3 items-center">
|
||||
<div class="w-12 h-8 rounded bg-slate-950 border border-slate-700 shrink-0 relative overflow-hidden">
|
||||
<div class="absolute inset-0 flex items-center justify-center text-[8px] text-slate-600">IMG</div>
|
||||
<div class="absolute inset-0 border-2" style="border-color: ${obj.color}"></div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-slate-200 truncate">${obj.name}</div>
|
||||
<div class="text-[10px] text-slate-500 truncate">ID: ${obj.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
objectListEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
tracksListEl.innerHTML = "";
|
||||
|
||||
// Scale: 50px per second (approx)
|
||||
const pxPerFrame = 2;
|
||||
|
||||
objects.forEach((obj, index) => {
|
||||
const trackRow = document.createElement("div");
|
||||
trackRow.className =
|
||||
"h-12 border-b border-slate-800/50 flex items-center relative hover:bg-slate-800/30 transition-colors";
|
||||
|
||||
// Label
|
||||
const label = document.createElement("div");
|
||||
label.className =
|
||||
"absolute left-2 text-[10px] text-slate-400 w-20 truncate z-10 pointer-events-none";
|
||||
label.innerText = obj.name;
|
||||
trackRow.appendChild(label);
|
||||
|
||||
// Track Bar (Simplified as a full length bar for this demo, usually keyframes)
|
||||
const bar = document.createElement("div");
|
||||
bar.className =
|
||||
"absolute h-6 rounded bg-opacity-20 border border-opacity-50 cursor-pointer hover:bg-opacity-30 transition-all";
|
||||
bar.style.left = "100px"; // Start offset
|
||||
bar.style.width = `${TOTAL_FRAMES * pxPerFrame}px`;
|
||||
bar.style.backgroundColor = obj.color;
|
||||
bar.style.borderColor = obj.color;
|
||||
bar.style.top = "12px";
|
||||
|
||||
// Keyframe dots simulation
|
||||
for (let i = 0; i < TOTAL_FRAMES; i += 30) {
|
||||
const dot = document.createElement("div");
|
||||
dot.className =
|
||||
"absolute top-1/2 -translate-y-1/2 w-1 h-1 bg-white rounded-full opacity-50";
|
||||
dot.style.left = `${i * pxPerFrame}px`;
|
||||
bar.appendChild(dot);
|
||||
}
|
||||
|
||||
trackRow.appendChild(bar);
|
||||
tracksListEl.appendChild(trackRow);
|
||||
});
|
||||
|
||||
// Update Playhead position in timeline
|
||||
const timelineScroll = document.getElementById("timeline-container");
|
||||
// Sync scroll or position if needed, here we just map frame to px
|
||||
// Assuming timeline starts at frame 0 at left 100px
|
||||
const playheadPos = 100 + state.currentFrame * pxPerFrame;
|
||||
playhead.style.left = `${playheadPos}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interaction Logic
|
||||
*/
|
||||
function selectObject(id) {
|
||||
state.selectedObjectId = id;
|
||||
updateUI();
|
||||
updatePropertiesPanel();
|
||||
}
|
||||
|
||||
function updatePropertiesPanel() {
|
||||
if (!state.selectedObjectId) {
|
||||
emptyStateEl.classList.remove("hidden");
|
||||
propContentEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
emptyStateEl.classList.add("hidden");
|
||||
propContentEl.classList.remove("hidden");
|
||||
|
||||
const obj = objects.find((o) => o.id === state.selectedObjectId);
|
||||
const frameData = obj.frames[state.currentFrame];
|
||||
|
||||
// Fill Data
|
||||
propNameInput.value = obj.name;
|
||||
propIdSpan.innerText = `ID: ${obj.id}`;
|
||||
propColorInd.style.backgroundColor = obj.color;
|
||||
|
||||
propX1.value = Math.round(frameData.x1);
|
||||
propY1.value = Math.round(frameData.y1);
|
||||
propX2.value = Math.round(frameData.x2);
|
||||
propY2.value = Math.round(frameData.y2);
|
||||
|
||||
// Inspections
|
||||
inspectionListEl.innerHTML = "";
|
||||
frameData.inspections.forEach((item) => {
|
||||
const div = document.createElement("div");
|
||||
div.className =
|
||||
"bg-slate-800 rounded p-2 border border-slate-700 flex items-center justify-between";
|
||||
|
||||
const confidenceColor =
|
||||
item.confidence > 0.8
|
||||
? "text-green-400"
|
||||
: item.confidence > 0.5
|
||||
? "text-yellow-400"
|
||||
: "text-red-400";
|
||||
const statusIcon =
|
||||
item.status === "pass"
|
||||
? `<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>`
|
||||
: `<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>`;
|
||||
|
||||
div.innerHTML = `
|
||||
<div>
|
||||
<div class="text-xs text-slate-300">${item.label}</div>
|
||||
<div class="text-[10px] ${confidenceColor} font-mono mt-0.5">Conf: ${(item.confidence * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 p-1 rounded border border-slate-700">
|
||||
${statusIcon}
|
||||
</div>
|
||||
`;
|
||||
inspectionListEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
state.isPlaying = !state.isPlaying;
|
||||
updatePlayBtn();
|
||||
}
|
||||
|
||||
function updatePlayBtn() {
|
||||
playBtn.innerHTML = state.isPlaying
|
||||
? '<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>'
|
||||
: '<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
// Progress Bar
|
||||
const percent = (state.currentFrame / TOTAL_FRAMES) * 100;
|
||||
progressFill.style.width = `${percent}%`;
|
||||
progressHandle.style.left = `${percent}%`;
|
||||
|
||||
// Time Text
|
||||
const seconds = Math.floor(state.currentFrame / FPS);
|
||||
const ms = Math.floor(((state.currentFrame % FPS) / FPS) * 100); // Fake ms
|
||||
currentTimeEl.innerText = `00:0${seconds}:${ms < 10 ? "0" + ms : ms}`; // Simple formatting
|
||||
frameCounterEl.innerText = state.currentFrame;
|
||||
|
||||
renderFrame();
|
||||
renderSidebar(); // Update selection highlight
|
||||
renderTimeline(); // Update playhead
|
||||
if (state.selectedObjectId) updatePropertiesPanel(); // Update coords if playing
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (state.isPlaying) {
|
||||
state.currentFrame++;
|
||||
if (state.currentFrame >= TOTAL_FRAMES) {
|
||||
state.currentFrame = 0;
|
||||
state.isPlaying = false;
|
||||
updatePlayBtn();
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// Start
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// =============== 1. 定义 TS 类型 ===============
|
||||
/** 单个帧位置信息 */
|
||||
interface TrackFrame {
|
||||
time: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 单个跟踪目标 */
|
||||
interface TrackItem {
|
||||
id: number
|
||||
label: string
|
||||
color: string
|
||||
frames: TrackFrame[]
|
||||
}
|
||||
|
||||
/** 渲染到页面的标注框 */
|
||||
interface RenderBox extends TrackFrame {
|
||||
id: number
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
// =============== 2. 响应式数据 ===============
|
||||
const 隐患列表 = ref<string[]>(['隐患1', '隐患2', '隐患3', '隐患4', '隐患5'])
|
||||
const 物体列表 = ref<string[]>(['物体1', '物体2', '物体3', '物体4', '物体5'])
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const currentTime = ref<number>(0)
|
||||
const currentBoxes = ref<RenderBox[]>([])
|
||||
const selectedBox = ref<RenderBox | null>(null)
|
||||
|
||||
// 内置跟踪数据
|
||||
const trackData = ref<TrackItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
label: '人物',
|
||||
color: '#FF4D4F',
|
||||
frames: [
|
||||
{ time: 0, x: 60, y: 40, width: 160, height: 280 },
|
||||
{ time: 1, x: 80, y: 40, width: 160, height: 280 },
|
||||
{ time: 2, x: 100, y: 40, width: 160, height: 280 },
|
||||
{ time: 3, x: 120, y: 40, width: 160, height: 280 },
|
||||
{ time: 4, x: 140, y: 40, width: 160, height: 280 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '车辆',
|
||||
color: '#1890FF',
|
||||
frames: [
|
||||
{ time: 0, x: 300, y: 150, width: 240, height: 140 },
|
||||
{ time: 1, x: 320, y: 150, width: 240, height: 140 },
|
||||
{ time: 2, x: 340, y: 150, width: 240, height: 140 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// =============== 3. 函数 ===============
|
||||
/** 视频时间更新 */
|
||||
function onTimeUpdate(): void {
|
||||
if (!videoRef.value)
|
||||
return
|
||||
|
||||
const t = videoRef.value.currentTime
|
||||
currentTime.value = t
|
||||
|
||||
const boxes: RenderBox[] = []
|
||||
trackData.value.forEach((item) => {
|
||||
const frame = item.frames.findLast(f => f.time <= t)
|
||||
if (frame) {
|
||||
boxes.push({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
color: item.color,
|
||||
...frame,
|
||||
})
|
||||
}
|
||||
})
|
||||
currentBoxes.value = boxes
|
||||
}
|
||||
|
||||
/** 点击标注框 */
|
||||
function handleBoxClick(box: RenderBox): void {
|
||||
selectedBox.value = box
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 最外层容器:占满整个视口 -->
|
||||
<el-container style="margin: 0; height: 100%; flex-direction: column;">
|
||||
<el-header style="height: 60px; 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 style="min-height: 20px;">
|
||||
隐患列表
|
||||
</el-row>
|
||||
<el-row style="flex: 1; overflow-y: auto;">
|
||||
<el-row v-for="item in 隐患列表" :key="item">
|
||||
<el-button>
|
||||
{{ item }}
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-row>
|
||||
</el-row>
|
||||
<el-row>物体列表</el-row>
|
||||
<el-row style="flex: 1; overflow-y: auto;">
|
||||
<el-row v-for="item in 物体列表" :key="item">
|
||||
<el-button>
|
||||
{{ item }}
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-row>
|
||||
</el-aside>
|
||||
<el-main style="border-right: 1px solid var(--ep-border-color); padding: 0;">
|
||||
<div class="vid_box" style="border-bottom: 1px solid var(--ep-border-color);">
|
||||
<div class="video-wrapper">
|
||||
<video
|
||||
ref="videoRef"
|
||||
src="https://www.w3school.com.cn/i/movie.mp4"
|
||||
controls
|
||||
class="video"
|
||||
@timeupdate="onTimeUpdate"
|
||||
/>
|
||||
|
||||
<!-- 标注层 -->
|
||||
<div class="annotations-layer">
|
||||
<div
|
||||
v-for="box in currentBoxes"
|
||||
:key="box.id"
|
||||
class="box"
|
||||
:class="{ active: selectedBox?.id === box.id }"
|
||||
:style="{
|
||||
left: `${box.x}px`,
|
||||
top: `${box.y}px`,
|
||||
width: `${box.width}px`,
|
||||
height: `${box.height}px`,
|
||||
borderColor: box.color,
|
||||
}"
|
||||
@click.stop="handleBoxClick(box)"
|
||||
>
|
||||
<span class="label">{{ box.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vid_track">
|
||||
轨道
|
||||
</div>
|
||||
</el-main>
|
||||
<el-aside width="300px">
|
||||
<el-row style="border-bottom: 1px solid var(--ep-border-color);">
|
||||
<el-col>
|
||||
<el-row class="text-left">
|
||||
隐患描述
|
||||
</el-row>
|
||||
<el-row class="text-left">
|
||||
依据
|
||||
</el-row>
|
||||
<el-row class="text-left">
|
||||
整改建议
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-button>
|
||||
查看报告
|
||||
</el-button>
|
||||
</el-row>
|
||||
</el-aside>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
width: calc(100% - 8px);
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.annotations-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: absolute;
|
||||
border: 2px solid;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.box.active {
|
||||
border-width: 4px;
|
||||
background: rgba(255, 255, 0, 0.1);
|
||||
transform: translate(-1px, -1px); /* 完美抵消边框变粗偏移 */
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
left: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
<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
|
||||
跳转时间点: number
|
||||
隐患描述: string
|
||||
依据: string
|
||||
整改建议: string
|
||||
}
|
||||
|
||||
interface DataFormat {
|
||||
隐患列表: string[]
|
||||
// 物体列表: string[]
|
||||
隐患数据: HazardItem[]
|
||||
}
|
||||
|
||||
interface ResultObject {
|
||||
tag_id: number
|
||||
base_id: number
|
||||
track_id: string
|
||||
hazard_track_id: number
|
||||
class_id: number
|
||||
level: number
|
||||
start_frame: number
|
||||
start_sec: number
|
||||
location: 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 objects = ref([
|
||||
{
|
||||
id: 'obj_001',
|
||||
name: '行人 (Person A)',
|
||||
type: 'person',
|
||||
color: '#3b82f6',
|
||||
frames: [...Array.from({ length: 300 }).keys()],
|
||||
},
|
||||
{
|
||||
id: 'obj_001',
|
||||
name: '行人 (Person A)',
|
||||
type: 'person',
|
||||
color: '#3b82f6',
|
||||
frames: [...Array.from({ length: 300 }).keys()],
|
||||
},
|
||||
])
|
||||
|
||||
const selectedHazard = ref(-1)
|
||||
|
||||
function getData() {
|
||||
const { tag, base, objects } = resultData.value
|
||||
|
||||
data.value.隐患列表 = (objects || []).map((obj: any) => {
|
||||
return `(${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 totalSeconds = obj.start_sec || 0
|
||||
const hh = String(Math.floor(totalSeconds / 3600)).padStart(2, '0')
|
||||
const mm = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0')
|
||||
const ss = String(Math.floor(totalSeconds % 60)).padStart(2, '0')
|
||||
|
||||
return {
|
||||
隐患编号: obj.hazard_track_id || '',
|
||||
物体类型: resultData.value.class_list?.[obj.class_id] || '',
|
||||
隐患名称: tag?.[obj.tag_id] || '',
|
||||
时间点: `${hh}:${mm}:${ss}`,
|
||||
跳转时间点: totalSeconds,
|
||||
隐患描述: obj.location || '',
|
||||
依据: base?.[obj.base_id] || '',
|
||||
整改建议: '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 点击隐患列表项,更新选中隐患
|
||||
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: 2 },
|
||||
{ label: '时间点', key: '时间点', group: 2 },
|
||||
{ label: '隐患描述', key: '隐患描述', group: 3 },
|
||||
{ label: '依据', key: '依据', group: 4 },
|
||||
{ label: '整改建议', key: '整改建议', group: 5 },
|
||||
]
|
||||
|
||||
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_vidUrl', {
|
||||
// vid_file: vidFile,
|
||||
// }).then((res) => {
|
||||
// // console.log('接口调用成功,返回数据:', res)
|
||||
// // vidUrl.value = res[0]
|
||||
// vidUrl.value = 'http://localhost:8086/santai5_h264.mp4'
|
||||
// console.log('完整视频路径:', vidUrl.value)
|
||||
// })
|
||||
// 测试用视频路径
|
||||
vidUrl.value = 'http://localhost:8086/santai5_h264.mp4'
|
||||
// console.log('完整视频路径:', vidUrl.value)
|
||||
|
||||
// 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,
|
||||
level: 1,
|
||||
start_frame: 551,
|
||||
start_sec: 18.4,
|
||||
location: '画面中央偏右的墙壁上',
|
||||
},
|
||||
{
|
||||
tag_id: 1,
|
||||
base_id: 1,
|
||||
track_id: '115',
|
||||
hazard_track_id: 1,
|
||||
class_id: 1,
|
||||
level: 2,
|
||||
start_frame: 3618,
|
||||
start_sec: 120.6,
|
||||
location: '画面右侧墙壁上的白色插座面板',
|
||||
},
|
||||
{
|
||||
tag_id: 2,
|
||||
base_id: 2,
|
||||
track_id: '125',
|
||||
hazard_track_id: 2,
|
||||
class_id: 2,
|
||||
level: 1,
|
||||
start_frame: 3733,
|
||||
start_sec: 124.4,
|
||||
location: '画面中央偏左墙壁上的配电箱正下方紧贴放置有一台不锈钢设备,导致配电箱前1米内被占用。',
|
||||
},
|
||||
{
|
||||
tag_id: 1,
|
||||
base_id: 1,
|
||||
track_id: '127',
|
||||
hazard_track_id: 3,
|
||||
class_id: 1,
|
||||
level: 1,
|
||||
start_frame: 3767,
|
||||
start_sec: 125.6,
|
||||
location: '画面左侧墙壁上,操作台上方区域',
|
||||
},
|
||||
{
|
||||
tag_id: 1,
|
||||
base_id: 1,
|
||||
track_id: '130',
|
||||
hazard_track_id: 4,
|
||||
class_id: 1,
|
||||
level: 1,
|
||||
start_frame: 3812,
|
||||
start_sec: 127.1,
|
||||
location: '画面中央偏右墙壁上,配电箱下方区域',
|
||||
},
|
||||
{
|
||||
tag_id: 2,
|
||||
base_id: 2,
|
||||
track_id: '196',
|
||||
hazard_track_id: 5,
|
||||
class_id: 2,
|
||||
level: 1,
|
||||
start_frame: 5199,
|
||||
start_sec: 173.3,
|
||||
location: '画面中央偏右的蓝色配电箱正下方及周围区域',
|
||||
},
|
||||
{
|
||||
tag_id: 3,
|
||||
base_id: 3,
|
||||
track_id: '206',
|
||||
hazard_track_id: 6,
|
||||
class_id: 2,
|
||||
level: 1,
|
||||
start_frame: 5409,
|
||||
start_sec: 180.3,
|
||||
location: '画面左侧墙壁上的灰色配电箱箱门缺失,内部元器件裸露',
|
||||
},
|
||||
{
|
||||
tag_id: 4,
|
||||
base_id: 2,
|
||||
track_id: '221',
|
||||
hazard_track_id: 7,
|
||||
class_id: 0,
|
||||
level: 1,
|
||||
start_frame: 5654,
|
||||
start_sec: 188.5,
|
||||
location: '画面右侧的蓝色消火栓箱内部',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
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: 60px; 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"
|
||||
/>
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer style="height: 200px; padding: 0px;">
|
||||
<Timeline
|
||||
style="height: 100%;"
|
||||
:current-frame="0"
|
||||
:total-frames="300"
|
||||
:objects="objects"
|
||||
/>
|
||||
</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>
|
||||
{{ 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>
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
import { runApi } from '~/composables/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const runCheckBtnRef = ref()
|
||||
const isRunningCheck = ref(false)
|
||||
|
||||
interface RuleForm {
|
||||
vidPath: string
|
||||
function: string[]
|
||||
}
|
||||
const ruleFormRef = ref<FormInstance>()
|
||||
const ruleForm = reactive<RuleForm>({
|
||||
vidPath: '',
|
||||
function: [
|
||||
'runSam3',
|
||||
'runHazardCheck',
|
||||
'runGenerateReport',
|
||||
// 'runAudioRecognition',
|
||||
],
|
||||
})
|
||||
|
||||
const rules = reactive<FormRules<RuleForm>>({
|
||||
vidPath: [
|
||||
{
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: '请选择视频',
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
async function runCheck(formEl: FormInstance | undefined) {
|
||||
if (!formEl)
|
||||
return
|
||||
const valid = await formEl.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
// console.log('submit!', ruleForm)
|
||||
}
|
||||
else {
|
||||
console.error('error submit!', fields)
|
||||
}
|
||||
})
|
||||
if (valid) {
|
||||
isRunningCheck.value = true
|
||||
await runApi('/run', {
|
||||
vid_file: 'Miehhuoxqih.AVI',
|
||||
run_sam3: ruleForm.function.includes('runSam3'),
|
||||
run_inspection: false,
|
||||
gen_report: false,
|
||||
})
|
||||
isRunningCheck.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(formEl: FormInstance | undefined) {
|
||||
if (!formEl)
|
||||
return
|
||||
formEl.resetFields()
|
||||
}
|
||||
|
||||
async function goToResult(formEl: FormInstance | undefined) {
|
||||
if (!formEl)
|
||||
return
|
||||
const valid = await formEl.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
// console.log('跳转结果页')
|
||||
router.push('/nav/hazardCheck/hazardCheckResult')
|
||||
}
|
||||
else {
|
||||
console.error('error submit!', fields)
|
||||
}
|
||||
})
|
||||
if (valid) {
|
||||
isRunningCheck.value = true
|
||||
router.push({
|
||||
path: '/nav/hazardCheck/hazardCheckResult',
|
||||
query: { vid_file: 'santai5.mp4' },
|
||||
})
|
||||
// isRunningCheck.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 最外层容器:占满整个视口 -->
|
||||
<el-container style="margin: 0; height: 100%; flex-direction: column;">
|
||||
<el-header style="height: 60px; 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-main class="main" style="flex: 1; min-height: 0;">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
style="max-width: 600px"
|
||||
:model="ruleForm"
|
||||
:rules="rules"
|
||||
label-width="auto"
|
||||
>
|
||||
<el-form-item label="视频" prop="vidPath">
|
||||
<el-select v-model="ruleForm.vidPath" placeholder="视频">
|
||||
<el-option label="视频1" value="1" />
|
||||
<el-option label="视频2" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="功能选择" prop="function">
|
||||
<el-checkbox-group v-model="ruleForm.function">
|
||||
<el-checkbox value="runSam3" name="function">
|
||||
物体识别
|
||||
</el-checkbox>
|
||||
<el-checkbox value="runHazardCheck" name="function">
|
||||
隐患检查
|
||||
</el-checkbox>
|
||||
<el-checkbox value="runGenerateReport" name="function">
|
||||
生成报告
|
||||
</el-checkbox>
|
||||
<el-checkbox value="runAudioRecognition" name="function">
|
||||
音频识别
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="resetForm(ruleFormRef)">
|
||||
重置表单
|
||||
</el-button>
|
||||
<el-button @click="goToResult(ruleFormRef)">
|
||||
查看结果
|
||||
</el-button>
|
||||
<el-button
|
||||
ref="runCheckBtnRef"
|
||||
type="primary"
|
||||
:loading="isRunningCheck"
|
||||
@click="runCheck(ruleFormRef)"
|
||||
>
|
||||
运行检查
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.main {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,19 +3,19 @@ $--colors: (
|
|||
'base': #409eff,
|
||||
),
|
||||
'success': (
|
||||
'base': #21ba45,
|
||||
'base': #67c23a,
|
||||
),
|
||||
'warning': (
|
||||
'base': #f2711c,
|
||||
'base': #e6a23c,
|
||||
),
|
||||
'danger': (
|
||||
'base': #db2828,
|
||||
'base': #f56c6c,
|
||||
),
|
||||
'error': (
|
||||
'base': #db2828,
|
||||
),
|
||||
'info': (
|
||||
'base': #42b8dd,
|
||||
'base': #909399,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ declare module 'vue-router/auto-routes' {
|
|||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||
'/nav/1_隐患检查': RouteRecordInfo<'/nav/1_隐患检查', '/nav/1_隐患检查', Record<never, never>, Record<never, never>>,
|
||||
'/nav/hazardCheck/': RouteRecordInfo<'/nav/hazardCheck/', '/nav/hazardCheck', Record<never, never>, Record<never, never>>,
|
||||
'/nav/hazardCheck/1_隐患检查 copy': RouteRecordInfo<'/nav/hazardCheck/1_隐患检查 copy', '/nav/hazardCheck/1_隐患检查 copy', Record<never, never>, Record<never, never>>,
|
||||
'/nav/hazardCheck/HazardCheckResult': RouteRecordInfo<'/nav/hazardCheck/HazardCheckResult', '/nav/hazardCheck/HazardCheckResult', Record<never, never>, Record<never, never>>,
|
||||
'/nav/VideoTrackDemo': RouteRecordInfo<'/nav/VideoTrackDemo', '/nav/VideoTrackDemo', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue