隐患列表

- 新增物体标签
- 标签可区分隐患等级
时间线
- 新增时间线操作说明
- 隐患按钮可区分隐患等级
This commit is contained in:
yueliuli 2026-04-22 15:47:00 +08:00
parent aaeb0c4f4f
commit 4b20be4b7a
10 changed files with 160 additions and 927 deletions

1
src/components.d.ts vendored
View File

@ -48,6 +48,7 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
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']

View File

@ -41,8 +41,15 @@ function handleItemClick(item: string, index: number) {
{{ index + 1 }}
</el-text>
<div class="w-2" />
<el-tag v-if="(item as number[])[0] === 0" type="primary" size="small">
{{ (item as number[])[1] }}
</el-tag>
<el-tag v-else-if="(item as number[])[0] === 1" type="danger" size="small">
{{ (item as number[])[1] }}
</el-tag>
<div class="w-2" />
<el-text class="item-text">
{{ item }}
{{ (item as number[])[2] }}
</el-text>
</el-button>
</el-row>

View File

@ -15,9 +15,15 @@
padding: 0 8px;
}
.timeline-title-container {
display: flex;
align-items: center;
gap: 4px;
color: var(--ep-text-color-secondary);
}
.timeline-title {
font-size: 12px;
color: var(--ep-text-color-secondary);
}
.timeline-zoom-controls {
@ -168,17 +174,29 @@
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: var(--ep-color-primary);
/* background-color: var(--ep-color-primary); */
color: #fff;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.hazard-block:hover {
.hazard-block.primary {
background-color: var(--ep-color-primary);
}
.hazard-block.primary:hover {
background-color: color-mix(in srgb, var(--ep-color-primary) 80%, transparent);
}
.hazard-block.danger {
background-color: var(--ep-color-danger);
}
.hazard-block.danger:hover {
background-color: color-mix(in srgb, var(--ep-color-danger) 80%, transparent);
}
.timeline-container::-webkit-scrollbar {
width: 6px;
height: 6px;

View File

@ -1,10 +1,17 @@
import type { Action } from 'element-plus'
import type { ComputedRef, Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, ref, watch } from 'vue'
export interface HazardData {
ranges: number[]
level: number
}
export interface TimelineProps {
currentFrame: number
totalFrames: number
hazardRanges: Record<string, number[]>
hazardRanges: Record<string, HazardData>
}
export interface TimelineEmits {
@ -23,14 +30,15 @@ export interface HazardItem {
id: string
start: number
end: number
ranges: number[][]
ranges: number[]
}
export interface HazardRow {
id: string
start: number
end: number
ranges: number[][]
ranges: number[]
level: number
}
export function useTimeline(
@ -152,36 +160,44 @@ export function useTimeline(
const trackRows: ComputedRef<HazardRow[][]> = computed(() => {
const ranges = props.hazardRanges
const sortedHazards = Object.entries(ranges)
.map(([id, frames]) => ({
id,
ranges: frames.reduce<number[][]>((acc, _, i) => {
if (i % 2 === 0) {
acc.push([frames[i], frames[i + 1]])
.map(([id, frames]) => {
// 处理旧数据结构:[start, end, level]
if (Array.isArray(frames)) {
return {
id,
level: frames[2] || 0, // 从第三个元素获取level
ranges: [[frames[0], frames[1]]], // 前两个元素是范围
}
return acc
}, []),
}))
}
// 处理新数据结构:{ ranges: number[][], level: number }
return {
id,
level: frames.level || 0,
ranges: frames.ranges,
}
})
.sort((a, b) => {
const aStart = a.ranges[0]?.[0] || 0
const bStart = b.ranges[0]?.[0] || 0
return aStart - bStart
const aStart = a.ranges[0] || 0
const bStart = b.ranges[0] || 0
return (aStart as number) - (bStart as number)
})
const rows: HazardRow[][] = []
sortedHazards.forEach((hazard) => {
let assigned = false
for (let i = 0; i < rows.length; i++) {
const canPlace = hazard.ranges.every(([start, end]) => {
const canPlace = hazard.ranges.every((start) => {
return rows[i].every((existing) => {
return end <= existing.start || start >= existing.end
return (start as number) <= (existing.start as number) || (start as number) >= (existing.end as number)
})
})
if (canPlace) {
rows[i].push({
id: hazard.id,
start: hazard.ranges[0][0],
end: hazard.ranges[0][1],
ranges: hazard.ranges,
start: hazard.ranges[0] as number,
end: hazard.ranges[1] as number,
ranges: hazard.ranges.map(range => range as number),
level: hazard.level || 0,
})
assigned = true
break
@ -190,9 +206,10 @@ export function useTimeline(
if (!assigned) {
rows.push([{
id: hazard.id,
start: hazard.ranges[0][0],
end: hazard.ranges[0][1],
ranges: hazard.ranges,
start: hazard.ranges[0] as number,
end: hazard.ranges[1] as number,
ranges: hazard.ranges.map(range => range as number),
level: hazard.level || 0,
}])
}
})
@ -364,6 +381,20 @@ export function useTimeline(
window.removeEventListener('mouseup', handleMouseUp)
}
const openMessageBox = (message: string, title: string) => {
ElMessageBox.alert(message, title, {
// if you want to disable its autofocus
// autofocus: false,
confirmButtonText: '确认',
// callback: (action: Action) => {
// ElMessage({
// type: 'info',
// message: `action: ${action}`,
// })
// },
})
}
return {
pxPerFrame,
minPxPerFrame,
@ -388,5 +419,6 @@ export function useTimeline(
handlePlayheadDrag,
initMounted,
initUnmounted,
openMessageBox,
}
}

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import type { Arrayable } from '@vueuse/core'
import type { TimelineEmits, TimelineProps } from './timeline'
import { InfoFilled } from '@element-plus/icons-vue'
import { ElScrollbar, ElSlider } from 'element-plus'
import { computed, onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { useTimeline } from './timeline'
import './timeline.css'
@ -28,6 +29,7 @@ const {
handlePlayheadMouseDown,
handleRulerClick,
initMounted,
openMessageBox,
initUnmounted,
} = useTimeline(props, emit)
@ -43,7 +45,18 @@ onUnmounted(() => {
<template>
<div class="timeline-wrapper">
<div class="timeline-toolbar">
<span class="timeline-title">隐患时间线 ( {{ Object.keys(hazardRanges).length }} 个隐患)</span>
<div class="timeline-title-container">
<span class="timeline-title">隐患时间线 ( {{ Object.keys(hazardRanges).length }} 个隐患)</span>
<el-button
type="info" dashed :icon="InfoFilled" size="small"
@click="openMessageBox(
'拖动播放头控制播放进度。滚轮/拖动时间轴前后移动。点击隐患编号跳转对应隐患。',
'操作说明',
)"
>
操作说明
</el-button>
</div>
<div class="timeline-zoom-controls">
<div class="timeline-zoom-label">
<el-text type="info" size="small">
@ -54,7 +67,7 @@ onUnmounted(() => {
v-model="pxPerFrame"
:min="minPxPerFrame"
:max="maxPxPerFrame"
:step="0.5"
:step="0.1"
:show-tooltip="true"
:format-tooltip="(val: number) => `缩放: ${val.toFixed(2)}`"
class="zoom-slider"
@ -115,6 +128,7 @@ onUnmounted(() => {
v-for="hazard in row"
:key="hazard.id"
class="hazard-block"
:class="{ primary: hazard.level === 0, danger: hazard.level === 1 }"
:style="getHazardStyle(hazard.start, hazard.end)"
@click="handleHazardClick(hazard.id)"
>

View File

@ -1,19 +1,19 @@
import type { UserModule } from './types'
import { ViteSSG } from 'vite-ssg'
// import "~/styles/element/index.scss";
import { routes } from 'vue-router/auto-routes'
import { ViteSSG } from 'vite-ssg'
// or use cdn, uncomment cdn link in `index.html`
import App from './App.vue'
import { routes } from 'vue-router/auto-routes'
// import ElementPlus from "element-plus";
// import all element css, uncommented next line
// import "element-plus/dist/index.css";
import '~/styles/index.scss'
import App from './App.vue'
import '~/styles/index.scss'
import 'uno.css'
// If you want to use ElMessage, import it.
import 'element-plus/theme-chalk/src/message.scss'

View File

@ -7,14 +7,11 @@
</div>
<div class="grid grid-cols-3 gap-4">
<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>

View File

@ -1,864 +0,0 @@
<!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>

View File

@ -8,6 +8,8 @@ interface HazardItem {
隐患编号: string
物体类型: string
隐患名称: string
隐患等级: string
置信度: string
时间点: string
跳转时间点: number
隐患描述: string
@ -16,22 +18,24 @@ interface HazardItem {
}
interface DataFormat {
隐患列表: string[]
隐患范围字典: Record<string, number[]>
隐患列表: [number, string, string][]
隐患范围字典: Record<string, { ranges: number[], level: number }>
隐患数据: HazardItem[]
}
interface ResultObject {
tag_id: number
base_id: number
track_id: string
hazard_track_id: number
class_id: number
level: number
start_frame: number
end_frame: number
start_sec: number
location: string
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 {
@ -127,15 +131,19 @@ 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] || ''}`
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.隐患范围字典[编号] = []
data.value.隐患范围字典[编号].push(obj.start_frame, obj.end_frame)
data.value.隐患范围字典[编号] = { ranges: [], level: obj.level }
data.value.隐患范围字典[编号].ranges = [obj.start_frame, obj.end_frame]
})
// data.value. = (objects || []).map((_: any, i: number) => `${i + 1}`)
@ -149,11 +157,13 @@ function getData() {
隐患编号: obj.hazard_track_id || '',
物体类型: resultData.value.class_list?.[obj.class_id] || '',
隐患名称: tag?.[obj.tag_id] || '',
隐患等级: obj.level === 0 ? '一般隐患' : '重大隐患',
置信度: obj.conf === 0 ? '疑似' : '确信',
时间点: `${hh}:${mm}:${ss}`,
跳转时间点: jumpPoint,
隐患描述: obj.location || '',
依据: base?.[obj.base_id] || '',
整改建议: '',
整改建议: obj.recommend || '',
}
})
}
@ -194,11 +204,13 @@ function handleJumpToTimePoint(index: number) {
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 },
{ label: '隐患等级', key: '隐患等级', group: 2 },
{ label: '置信度', key: '置信度', group: 2 },
{ label: '隐患名称', key: '隐患名称', group: 3 },
{ label: '时间点', key: '时间点', group: 3 },
{ label: '隐患描述', key: '隐患描述', group: 4 },
{ label: '依据', key: '依据', group: 5 },
{ label: '整改建议', key: '整改建议', group: 6 },
]
const groupedFields = computed(() => {
@ -271,11 +283,13 @@ onMounted(() => {
track_id: '18',
hazard_track_id: 0,
class_id: 0,
level: 1,
conf: 1,
level: 0,
start_frame: 551,
end_frame: 581,
start_sec: 18.4,
location: '画面中央偏右的墙壁上',
recommend: '',
},
{
tag_id: 1,
@ -283,11 +297,13 @@ onMounted(() => {
track_id: '115',
hazard_track_id: 1,
class_id: 1,
level: 2,
conf: 2,
level: 1,
start_frame: 3618,
end_frame: 3648,
start_sec: 120.6,
location: '画面右侧墙壁上的白色插座面板',
recommend: '',
},
{
tag_id: 2,
@ -295,11 +311,13 @@ onMounted(() => {
track_id: '125',
hazard_track_id: 2,
class_id: 2,
conf: 1,
level: 1,
start_frame: 3733,
end_frame: 3763,
start_sec: 124.4,
location: '画面中央偏左墙壁上的配电箱正下方紧贴放置有一台不锈钢设备导致配电箱前1米内被占用。',
recommend: '',
},
{
tag_id: 1,
@ -307,11 +325,13 @@ onMounted(() => {
track_id: '127',
hazard_track_id: 3,
class_id: 1,
conf: 1,
level: 1,
start_frame: 3767,
end_frame: 3900,
start_sec: 125.6,
location: '画面左侧墙壁上,操作台上方区域',
recommend: '',
},
{
tag_id: 1,
@ -319,11 +339,13 @@ onMounted(() => {
track_id: '130',
hazard_track_id: 4,
class_id: 1,
conf: 1,
level: 1,
start_frame: 3812,
end_frame: 3842,
start_sec: 127.1,
location: '画面中央偏右墙壁上,配电箱下方区域',
recommend: '',
},
{
tag_id: 2,
@ -331,11 +353,13 @@ onMounted(() => {
track_id: '196',
hazard_track_id: 5,
class_id: 2,
conf: 1,
level: 1,
start_frame: 5199,
end_frame: 5229,
start_sec: 173.3,
location: '画面中央偏右的蓝色配电箱正下方及周围区域',
recommend: '',
},
{
tag_id: 3,
@ -343,11 +367,13 @@ onMounted(() => {
track_id: '206',
hazard_track_id: 6,
class_id: 2,
conf: 1,
level: 1,
start_frame: 5409,
end_frame: 5439,
start_sec: 180.3,
location: '画面左侧墙壁上的灰色配电箱箱门缺失,内部元器件裸露',
recommend: '',
},
{
tag_id: 4,
@ -355,11 +381,13 @@ onMounted(() => {
track_id: '221',
hazard_track_id: 7,
class_id: 0,
conf: 1,
level: 1,
start_frame: 5654,
end_frame: 5684,
start_sec: 188.5,
location: '画面右侧的蓝色消火栓箱内部',
recommend: '',
},
],
}
@ -418,8 +446,8 @@ onMounted(() => {
style="height: 100%;"
:current-frame="currentFrame"
:total-frames="totalFrames"
:hazard-ranges="data.隐患范围字典"
@hazard-click="handleTimelineHazardClick"
:hazard-ranges="data.隐患范围字典 as Record<string, { ranges: number[]; level: number; }>"
@hazard-click="(id: number) => handleTimelineHazardClick(String(id))"
@frame-change="handleFrameChange"
/>
</el-footer>

View File

@ -20,7 +20,7 @@ const ruleForm = reactive<RuleForm>({
function: [
'runSam3',
'runHazardCheck',
'runGenerateReport',
// 'runGenerateReport',
// 'runAudioRecognition',
],
})