完善进度条
This commit is contained in:
parent
f04b1aed41
commit
ce29a7c38d
|
|
@ -28,7 +28,7 @@ function handleItemClick(item: string, index: number) {
|
||||||
<!-- 标题栏 -->
|
<!-- 标题栏 -->
|
||||||
<el-row class="px-3 pb-1 pt-3">
|
<el-row class="px-3 pb-1 pt-3">
|
||||||
<el-text type="info" size="small">
|
<el-text type="info" size="small">
|
||||||
{{ props.title }}
|
{{ props.title }} (共 {{ props.data.length }} 项)
|
||||||
</el-text>
|
</el-text>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ElButton, ElScrollbar } from 'element-plus'
|
import { ElScrollbar } from 'element-plus'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentFrame: {
|
currentFrame: {
|
||||||
|
|
@ -11,167 +11,331 @@ const props = defineProps({
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
objects: {
|
hazardRanges: {
|
||||||
type: Array,
|
type: Object,
|
||||||
default: () => [],
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['zoom', 'frameChange'])
|
const emit = defineEmits(['hazardClick', 'frameChange'])
|
||||||
|
|
||||||
|
const FPS = 30
|
||||||
const pxPerFrame = ref(2)
|
const pxPerFrame = ref(2)
|
||||||
|
const minPxPerFrame = 0.5
|
||||||
|
const maxPxPerFrame = 10
|
||||||
const timelineContainer = ref(null)
|
const timelineContainer = ref(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const isPlayheadDragging = ref(false)
|
||||||
|
const isRulerDragging = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const scrollStartLeft = ref(0)
|
||||||
|
const localCurrentFrame = ref(0)
|
||||||
|
|
||||||
// 播放头位置
|
|
||||||
const playheadPos = computed(() => {
|
|
||||||
return 100 + props.currentFrame * pxPerFrame.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 轨道总宽度
|
|
||||||
const trackWidth = computed(() => {
|
const trackWidth = computed(() => {
|
||||||
return `${props.totalFrames * pxPerFrame.value}px`
|
return `${props.totalFrames * pxPerFrame.value + 50}px`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 缩放
|
const totalSeconds = computed(() => {
|
||||||
function handleZoom() {
|
return Math.ceil(props.totalFrames / FPS)
|
||||||
emit('zoom')
|
})
|
||||||
|
|
||||||
|
function updatePxPerFrame() {
|
||||||
|
if (!timelineContainer.value)
|
||||||
|
return
|
||||||
|
const containerWidth = timelineContainer.value.clientWidth - 20
|
||||||
|
const calculatedPxPerFrame = containerWidth / props.totalFrames
|
||||||
|
pxPerFrame.value = Math.max(minPxPerFrame, Math.min(calculatedPxPerFrame, maxPxPerFrame))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 鼠标更新帧
|
function zoomIn() {
|
||||||
function updatePlayhead(e) {
|
pxPerFrame.value = Math.min(pxPerFrame.value * 1.5, maxPxPerFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
pxPerFrame.value = Math.max(pxPerFrame.value / 1.5, minPxPerFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomIn() {
|
||||||
|
zoomIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomOut() {
|
||||||
|
zoomOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeTicks = computed(() => {
|
||||||
|
const ticks = []
|
||||||
|
for (let s = 0; s <= totalSeconds.value; s += 10) {
|
||||||
|
ticks.push({
|
||||||
|
second: s,
|
||||||
|
position: s * FPS * pxPerFrame.value,
|
||||||
|
label: formatTime(s),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
if (mins > 0) {
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return `${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const playheadPos = computed(() => {
|
||||||
|
return props.currentFrame * pxPerFrame.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const trackRows = computed(() => {
|
||||||
|
const ranges = props.hazardRanges
|
||||||
|
const sortedHazards = Object.entries(ranges)
|
||||||
|
.map(([id, frames]) => ({
|
||||||
|
id,
|
||||||
|
ranges: frames.reduce((acc, _, i) => {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
acc.push([frames[i], frames[i + 1]])
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, []),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aStart = a.ranges[0]?.[0] || 0
|
||||||
|
const bStart = b.ranges[0]?.[0] || 0
|
||||||
|
return aStart - bStart
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = []
|
||||||
|
sortedHazards.forEach((hazard) => {
|
||||||
|
let assigned = false
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const canPlace = hazard.ranges.every(([start, end]) => {
|
||||||
|
return rows[i].every((existing) => {
|
||||||
|
return end <= existing.start || start >= existing.end
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (canPlace) {
|
||||||
|
rows[i].push({
|
||||||
|
id: hazard.id,
|
||||||
|
start: hazard.ranges[0][0],
|
||||||
|
end: hazard.ranges[0][1],
|
||||||
|
ranges: hazard.ranges,
|
||||||
|
})
|
||||||
|
assigned = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!assigned) {
|
||||||
|
rows.push([{
|
||||||
|
id: hazard.id,
|
||||||
|
start: hazard.ranges[0][0],
|
||||||
|
end: hazard.ranges[0][1],
|
||||||
|
ranges: hazard.ranges,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
function getHazardStyle(start, end) {
|
||||||
|
const left = start * pxPerFrame.value
|
||||||
|
const width = (end - start) * pxPerFrame.value
|
||||||
|
return {
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHazardClick(hazardId) {
|
||||||
|
emit('hazardClick', Number(hazardId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e) {
|
||||||
|
if (!timelineContainer.value)
|
||||||
|
return
|
||||||
|
e.preventDefault()
|
||||||
|
const container = timelineContainer.value
|
||||||
|
const delta = e.deltaY || e.deltaX
|
||||||
|
container.scrollLeft += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseDown(e) {
|
||||||
|
if (e.button !== 0)
|
||||||
|
return
|
||||||
|
const target = e.target
|
||||||
|
const playhead = document.getElementById('playhead')
|
||||||
|
if (playhead && (playhead.contains(target) || target === playhead))
|
||||||
|
return
|
||||||
|
const ruler = timelineContainer.value?.querySelector('.timeline-ruler')
|
||||||
|
if (ruler && (ruler.contains(target) || target === ruler)) {
|
||||||
|
isRulerDragging.value = true
|
||||||
|
}
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX.value = e.clientX
|
||||||
|
if (timelineContainer.value)
|
||||||
|
scrollStartLeft.value = timelineContainer.value.scrollLeft
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e) {
|
||||||
|
if (!isDragging.value || !timelineContainer.value)
|
||||||
|
return
|
||||||
|
const delta = dragStartX.value - e.clientX
|
||||||
|
timelineContainer.value.scrollLeft = scrollStartLeft.value + delta
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
isDragging.value = false
|
||||||
|
isPlayheadDragging.value = false
|
||||||
|
isRulerDragging.value = false
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayheadMouseDown(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
isPlayheadDragging.value = true
|
||||||
|
dragStartX.value = e.clientX
|
||||||
|
document.body.style.cursor = 'ew-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRulerClick(e) {
|
||||||
|
if (isRulerDragging.value)
|
||||||
|
return
|
||||||
if (!timelineContainer.value)
|
if (!timelineContainer.value)
|
||||||
return
|
return
|
||||||
const rect = timelineContainer.value.getBoundingClientRect()
|
const rect = timelineContainer.value.getBoundingClientRect()
|
||||||
const x = Math.max(0, Math.min(e.clientX - rect.left - 100, props.totalFrames * pxPerFrame.value))
|
const scrollLeft = timelineContainer.value.scrollLeft
|
||||||
|
const x = e.clientX - rect.left + scrollLeft
|
||||||
const frame = Math.floor(x / pxPerFrame.value)
|
const frame = Math.floor(x / pxPerFrame.value)
|
||||||
emit('frameChange', frame)
|
const clampedFrame = Math.max(0, Math.min(frame, props.totalFrames - 1))
|
||||||
|
localCurrentFrame.value = clampedFrame
|
||||||
|
emit('frameChange', clampedFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayheadDrag(e) {
|
||||||
|
if (!isPlayheadDragging.value || !timelineContainer.value)
|
||||||
|
return
|
||||||
|
const rect = timelineContainer.value.getBoundingClientRect()
|
||||||
|
const scrollLeft = timelineContainer.value.scrollLeft
|
||||||
|
const x = e.clientX - rect.left + scrollLeft
|
||||||
|
const frame = Math.floor(x / pxPerFrame.value)
|
||||||
|
const clampedFrame = Math.max(0, Math.min(frame, props.totalFrames - 1))
|
||||||
|
localCurrentFrame.value = clampedFrame
|
||||||
|
emit('frameChange', clampedFrame)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 帧变化自动滚动
|
|
||||||
watch(() => props.currentFrame, () => {
|
watch(() => props.currentFrame, () => {
|
||||||
|
if (!isPlayheadDragging.value) {
|
||||||
|
localCurrentFrame.value = props.currentFrame
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
if (!timelineContainer.value)
|
if (!timelineContainer.value)
|
||||||
return
|
return
|
||||||
const container = timelineContainer.value
|
const container = timelineContainer.value
|
||||||
const playhead = document.getElementById('playhead')
|
const playhead = document.getElementById('playhead')
|
||||||
if (playhead) {
|
if (!playhead)
|
||||||
container.scrollLeft = playhead.offsetLeft - container.clientWidth / 2
|
return
|
||||||
|
|
||||||
|
const playheadLeft = playhead.offsetLeft
|
||||||
|
const containerWidth = container.clientWidth
|
||||||
|
const containerScrollLeft = container.scrollLeft
|
||||||
|
|
||||||
|
const playheadVisible = playheadLeft >= containerScrollLeft && playheadLeft <= containerScrollLeft + containerWidth - 50
|
||||||
|
|
||||||
|
if (!playheadVisible) {
|
||||||
|
container.scrollLeft = playheadLeft - containerWidth / 2
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// 拖拽播放头
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const progressContainer = timelineContainer.value
|
const progressContainer = timelineContainer.value
|
||||||
if (!progressContainer)
|
if (!progressContainer)
|
||||||
return
|
return
|
||||||
let isDragging = false
|
|
||||||
|
|
||||||
progressContainer.addEventListener('mousedown', (e) => {
|
updatePxPerFrame()
|
||||||
isDragging = true
|
|
||||||
updatePlayhead(e)
|
progressContainer.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
|
progressContainer.addEventListener('mousedown', handleMouseDown)
|
||||||
|
window.addEventListener('mousemove', handlePlayheadDrag)
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
watch(() => props.totalFrames, () => {
|
||||||
if (isDragging)
|
nextTick(() => {
|
||||||
updatePlayhead(e)
|
updatePxPerFrame()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mouseup', () => {
|
onUnmounted(() => {
|
||||||
isDragging = false
|
window.removeEventListener('mousemove', handleMouseMove)
|
||||||
})
|
window.removeEventListener('mousemove', handlePlayheadDrag)
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="timeline-wrapper h-[33.333%] flex flex-col border-t border-slate-700 bg-slate-900">
|
<div class="timeline-wrapper">
|
||||||
<!-- 工具栏 -->
|
<div class="timeline-toolbar">
|
||||||
<div class="h-8 flex items-center gap-2 border-b border-slate-800 bg-slate-800/50 px-2">
|
<span class="timeline-title">隐患时间线 (共 {{ Object.keys(hazardRanges).length }} 个隐患)</span>
|
||||||
<ElButton
|
<div class="timeline-zoom-controls">
|
||||||
text
|
<button class="zoom-btn" title="缩小" @click="handleZoomOut">
|
||||||
class="!h-auto !p-1 !text-slate-400 hover:!bg-slate-700 hover:!text-white"
|
−
|
||||||
title="缩放"
|
</button>
|
||||||
@click="handleZoom"
|
<button class="zoom-btn" title="放大" @click="handleZoomIn">
|
||||||
>
|
+
|
||||||
<svg
|
</button>
|
||||||
class="h-4 w-4"
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
<!-- 时间轴主体:横向滚动容器 -->
|
|
||||||
<div
|
<div
|
||||||
ref="timelineContainer"
|
ref="timelineContainer"
|
||||||
class="custom-scrollbar relative flex-1 overflow-x-auto bg-slate-900"
|
class="timeline-container"
|
||||||
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseup="handleMouseUp"
|
||||||
|
@mouseleave="handleMouseUp"
|
||||||
>
|
>
|
||||||
<!-- 播放头:固定垂直,不随轨道上下滚 -->
|
|
||||||
<div
|
<div
|
||||||
id="playhead"
|
id="playhead"
|
||||||
class="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-red-500"
|
class="playhead"
|
||||||
:style="{ left: `${playheadPos}px` }"
|
:style="{ left: `${playheadPos}px` }"
|
||||||
|
@mousedown="handlePlayheadMouseDown"
|
||||||
>
|
>
|
||||||
<div
|
<div class="playhead-indicator" />
|
||||||
class="absolute top-0 h-3 w-3 rotate-45 bg-red-500 -mt-1.5 -translate-x-1/2"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 网格背景:固定不动 -->
|
<div class="timeline-content" :style="{ width: trackWidth }">
|
||||||
|
<div class="timeline-ruler" @click="handleRulerClick">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-0 opacity-10"
|
v-for="tick in timeTicks"
|
||||||
style="
|
:key="tick.second"
|
||||||
background-image: linear-gradient(to right, #fff 1px, transparent 1px);
|
class="time-tick"
|
||||||
background-size: 50px 100%;
|
:style="{ left: `${tick.position}px` }"
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 内容层:宽度撑开横向滚动 -->
|
|
||||||
<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="tick-mark" />
|
||||||
<div
|
<span class="tick-label">{{ tick.label }}</span>
|
||||||
class="absolute left-2 z-10 w-20 truncate text-[10px] text-slate-400"
|
</div>
|
||||||
:style="{ transform: 'translateX(-100%)' }"
|
|
||||||
>
|
|
||||||
{{ obj.name }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 轨道进度条 -->
|
<ElScrollbar :style="{ height: '100%' }">
|
||||||
|
<div id="tracks-list" class="tracks-list">
|
||||||
<div
|
<div
|
||||||
class="absolute h-6 cursor-pointer border border-opacity-50 rounded bg-opacity-20 hover:bg-opacity-30"
|
v-for="(row, rowIndex) in trackRows"
|
||||||
:style="{
|
:key="rowIndex"
|
||||||
left: '0px',
|
class="track-row"
|
||||||
width: trackWidth,
|
|
||||||
backgroundColor: obj.color,
|
|
||||||
borderColor: obj.color,
|
|
||||||
top: '12px',
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="i in Math.floor(props.totalFrames / 30)"
|
v-for="hazard in row"
|
||||||
:key="i"
|
:key="hazard.id"
|
||||||
class="absolute top-1/2 h-1 w-1 rounded-full bg-white opacity-50 -translate-y-1/2"
|
class="hazard-block"
|
||||||
:style="{ left: `${i * 30 * pxPerFrame}px` }"
|
:style="getHazardStyle(hazard.start, hazard.end)"
|
||||||
/>
|
@click="handleHazardClick(hazard.id)"
|
||||||
|
>
|
||||||
|
{{ parseInt(hazard.id) + 1 }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,29 +346,189 @@ onMounted(() => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.timeline-wrapper {
|
||||||
|
height: 33.333%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--ep-border-color);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 让 ElScrollbar 填满高度 */
|
.timeline-toolbar {
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--ep-border-color-light);
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ep-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--ep-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--ep-bg-color);
|
||||||
|
color: var(--ep-text-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn:hover {
|
||||||
|
background-color: var(--ep-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--ep-bg-color);
|
||||||
|
cursor: grab;
|
||||||
|
/* padding-top: 28px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-mark {
|
||||||
|
width: 1px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--ep-color-info);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ep-text-color-secondary);
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-top: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playhead {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background-color: #ef4444;
|
||||||
|
z-index: 25;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playhead-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -4px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler {
|
||||||
|
background-color: var(--ep-fill-color-light);
|
||||||
|
border-bottom: 1px solid var(--ep-border-color);
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tick-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-line {
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--ep-border-color-lighter);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tick-inner.is-major .tick-line {
|
||||||
|
background-color: var(--ep-color-info);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row {
|
||||||
|
position: relative;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--ep-border-color-lighter);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row:hover {
|
||||||
|
background-color: var(--ep-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard-block {
|
||||||
|
position: absolute;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--ep-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard-block:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--ep-color-primary) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--ep-color-info);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--ep-color-info) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-scrollbar) {
|
:deep(.el-scrollbar) {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-scrollbar__wrap) {
|
:deep(.el-scrollbar__wrap) {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface HazardItem {
|
||||||
|
|
||||||
interface DataFormat {
|
interface DataFormat {
|
||||||
隐患列表: string[]
|
隐患列表: string[]
|
||||||
// 物体列表: string[]
|
隐患范围字典: Record<string, number[]>
|
||||||
隐患数据: HazardItem[]
|
隐患数据: HazardItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,6 +29,7 @@ interface ResultObject {
|
||||||
class_id: number
|
class_id: number
|
||||||
level: number
|
level: number
|
||||||
start_frame: number
|
start_frame: number
|
||||||
|
end_frame: number
|
||||||
start_sec: number
|
start_sec: number
|
||||||
location: string
|
location: string
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +55,7 @@ const resultData = ref<ResultData>({
|
||||||
const videoRef = ref<HTMLVideoElement>()
|
const videoRef = ref<HTMLVideoElement>()
|
||||||
const data = ref<DataFormat>({
|
const data = ref<DataFormat>({
|
||||||
隐患列表: [],
|
隐患列表: [],
|
||||||
// 物体列表: [],
|
隐患范围字典: {},
|
||||||
隐患数据: [],
|
隐患数据: [],
|
||||||
})
|
})
|
||||||
// const data = ref({
|
// const data = ref({
|
||||||
|
|
@ -80,24 +81,47 @@ const data = ref<DataFormat>({
|
||||||
// ],
|
// ],
|
||||||
// })
|
// })
|
||||||
|
|
||||||
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)
|
const selectedHazard = ref(-1)
|
||||||
|
const currentFrame = ref(0)
|
||||||
|
const videoDuration = ref(0)
|
||||||
|
const FPS = 30
|
||||||
|
const totalFrames = computed(() => {
|
||||||
|
if (videoDuration.value > 0) {
|
||||||
|
return Math.floor(videoDuration.value * FPS)
|
||||||
|
}
|
||||||
|
const objects = resultData.value.objects
|
||||||
|
if (!objects || objects.length === 0)
|
||||||
|
return 300
|
||||||
|
const maxEndFrame = Math.max(...objects.map(obj => obj.end_frame || obj.start_frame))
|
||||||
|
return maxEndFrame + 100
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleTimelineHazardClick(hazardId: string) {
|
||||||
|
selectedHazard.value = Number(hazardId)
|
||||||
|
handleJumpToTimePoint(Number(hazardId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFrameChange(frame: number) {
|
||||||
|
const videoEl = videoRef.value
|
||||||
|
if (!videoEl)
|
||||||
|
return
|
||||||
|
const seconds = frame / FPS
|
||||||
|
videoEl.currentTime = Math.min(seconds, videoEl.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentFrame() {
|
||||||
|
const videoEl = videoRef.value
|
||||||
|
if (!videoEl)
|
||||||
|
return
|
||||||
|
currentFrame.value = Math.floor(videoEl.currentTime * 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVideoLoadedMetadata() {
|
||||||
|
const videoEl = videoRef.value
|
||||||
|
if (!videoEl)
|
||||||
|
return
|
||||||
|
videoDuration.value = videoEl.duration
|
||||||
|
}
|
||||||
|
|
||||||
function getData() {
|
function getData() {
|
||||||
const { tag, base, objects } = resultData.value
|
const { tag, base, objects } = resultData.value
|
||||||
|
|
@ -106,19 +130,27 @@ function getData() {
|
||||||
return `(${resultData.value?.class_list?.[obj.class_id] || ''}) ${tag?.[obj.tag_id] || ''}`
|
return `(${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.物体列表 = (objects || []).map((_: any, i: number) => `物体${i + 1}`)
|
// data.value.物体列表 = (objects || []).map((_: any, i: number) => `物体${i + 1}`)
|
||||||
data.value.隐患数据 = (objects || []).map((obj: any) => {
|
data.value.隐患数据 = (objects || []).map((obj: any) => {
|
||||||
const totalSeconds = obj.start_sec || 0
|
const jumpPoint = obj.start_sec || 0
|
||||||
const hh = String(Math.floor(totalSeconds / 3600)).padStart(2, '0')
|
const hh = String(Math.floor(jumpPoint / 3600)).padStart(2, '0')
|
||||||
const mm = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0')
|
const mm = String(Math.floor((jumpPoint % 3600) / 60)).padStart(2, '0')
|
||||||
const ss = String(Math.floor(totalSeconds % 60)).padStart(2, '0')
|
const ss = String(Math.floor(jumpPoint % 60)).padStart(2, '0')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
隐患编号: obj.hazard_track_id || '',
|
隐患编号: obj.hazard_track_id || '',
|
||||||
物体类型: resultData.value.class_list?.[obj.class_id] || '',
|
物体类型: resultData.value.class_list?.[obj.class_id] || '',
|
||||||
隐患名称: tag?.[obj.tag_id] || '',
|
隐患名称: tag?.[obj.tag_id] || '',
|
||||||
时间点: `${hh}:${mm}:${ss}`,
|
时间点: `${hh}:${mm}:${ss}`,
|
||||||
跳转时间点: totalSeconds,
|
跳转时间点: jumpPoint,
|
||||||
隐患描述: obj.location || '',
|
隐患描述: obj.location || '',
|
||||||
依据: base?.[obj.base_id] || '',
|
依据: base?.[obj.base_id] || '',
|
||||||
整改建议: '',
|
整改建议: '',
|
||||||
|
|
@ -241,6 +273,7 @@ onMounted(() => {
|
||||||
class_id: 0,
|
class_id: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
start_frame: 551,
|
start_frame: 551,
|
||||||
|
end_frame: 581,
|
||||||
start_sec: 18.4,
|
start_sec: 18.4,
|
||||||
location: '画面中央偏右的墙壁上',
|
location: '画面中央偏右的墙壁上',
|
||||||
},
|
},
|
||||||
|
|
@ -252,6 +285,7 @@ onMounted(() => {
|
||||||
class_id: 1,
|
class_id: 1,
|
||||||
level: 2,
|
level: 2,
|
||||||
start_frame: 3618,
|
start_frame: 3618,
|
||||||
|
end_frame: 3648,
|
||||||
start_sec: 120.6,
|
start_sec: 120.6,
|
||||||
location: '画面右侧墙壁上的白色插座面板',
|
location: '画面右侧墙壁上的白色插座面板',
|
||||||
},
|
},
|
||||||
|
|
@ -263,6 +297,7 @@ onMounted(() => {
|
||||||
class_id: 2,
|
class_id: 2,
|
||||||
level: 1,
|
level: 1,
|
||||||
start_frame: 3733,
|
start_frame: 3733,
|
||||||
|
end_frame: 3763,
|
||||||
start_sec: 124.4,
|
start_sec: 124.4,
|
||||||
location: '画面中央偏左墙壁上的配电箱正下方紧贴放置有一台不锈钢设备,导致配电箱前1米内被占用。',
|
location: '画面中央偏左墙壁上的配电箱正下方紧贴放置有一台不锈钢设备,导致配电箱前1米内被占用。',
|
||||||
},
|
},
|
||||||
|
|
@ -274,6 +309,7 @@ onMounted(() => {
|
||||||
class_id: 1,
|
class_id: 1,
|
||||||
level: 1,
|
level: 1,
|
||||||
start_frame: 3767,
|
start_frame: 3767,
|
||||||
|
end_frame: 3900,
|
||||||
start_sec: 125.6,
|
start_sec: 125.6,
|
||||||
location: '画面左侧墙壁上,操作台上方区域',
|
location: '画面左侧墙壁上,操作台上方区域',
|
||||||
},
|
},
|
||||||
|
|
@ -285,6 +321,7 @@ onMounted(() => {
|
||||||
class_id: 1,
|
class_id: 1,
|
||||||
level: 1,
|
level: 1,
|
||||||
start_frame: 3812,
|
start_frame: 3812,
|
||||||
|
end_frame: 3842,
|
||||||
start_sec: 127.1,
|
start_sec: 127.1,
|
||||||
location: '画面中央偏右墙壁上,配电箱下方区域',
|
location: '画面中央偏右墙壁上,配电箱下方区域',
|
||||||
},
|
},
|
||||||
|
|
@ -296,6 +333,7 @@ onMounted(() => {
|
||||||
class_id: 2,
|
class_id: 2,
|
||||||
level: 1,
|
level: 1,
|
||||||
start_frame: 5199,
|
start_frame: 5199,
|
||||||
|
end_frame: 5229,
|
||||||
start_sec: 173.3,
|
start_sec: 173.3,
|
||||||
location: '画面中央偏右的蓝色配电箱正下方及周围区域',
|
location: '画面中央偏右的蓝色配电箱正下方及周围区域',
|
||||||
},
|
},
|
||||||
|
|
@ -307,6 +345,7 @@ onMounted(() => {
|
||||||
class_id: 2,
|
class_id: 2,
|
||||||
level: 1,
|
level: 1,
|
||||||
start_frame: 5409,
|
start_frame: 5409,
|
||||||
|
end_frame: 5439,
|
||||||
start_sec: 180.3,
|
start_sec: 180.3,
|
||||||
location: '画面左侧墙壁上的灰色配电箱箱门缺失,内部元器件裸露',
|
location: '画面左侧墙壁上的灰色配电箱箱门缺失,内部元器件裸露',
|
||||||
},
|
},
|
||||||
|
|
@ -318,6 +357,7 @@ onMounted(() => {
|
||||||
class_id: 0,
|
class_id: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
start_frame: 5654,
|
start_frame: 5654,
|
||||||
|
end_frame: 5684,
|
||||||
start_sec: 188.5,
|
start_sec: 188.5,
|
||||||
location: '画面右侧的蓝色消火栓箱内部',
|
location: '画面右侧的蓝色消火栓箱内部',
|
||||||
},
|
},
|
||||||
|
|
@ -337,7 +377,7 @@ onMounted(() => {
|
||||||
<template>
|
<template>
|
||||||
<!-- 最外层容器:占满整个视口 -->
|
<!-- 最外层容器:占满整个视口 -->
|
||||||
<el-container style="margin: 0; height: 100%; flex-direction: column;">
|
<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-header style="height: 30px; display: flex; align-items: center; border-bottom: 1px solid var(--ep-border-color);">
|
||||||
<el-breadcrumb separator="/">
|
<el-breadcrumb separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">
|
<el-breadcrumb-item :to="{ path: '/' }">
|
||||||
主页
|
主页
|
||||||
|
|
@ -368,15 +408,19 @@ onMounted(() => {
|
||||||
:src="vidUrl"
|
:src="vidUrl"
|
||||||
controls
|
controls
|
||||||
class="video"
|
class="video"
|
||||||
|
@timeupdate="updateCurrentFrame"
|
||||||
|
@loadedmetadata="handleVideoLoadedMetadata"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-main>
|
</el-main>
|
||||||
<el-footer style="height: 200px; padding: 0px;">
|
<el-footer style="height: 200px; padding: 0px;">
|
||||||
<Timeline
|
<Timeline
|
||||||
style="height: 100%;"
|
style="height: 100%;"
|
||||||
:current-frame="0"
|
:current-frame="currentFrame"
|
||||||
:total-frames="300"
|
:total-frames="totalFrames"
|
||||||
:objects="objects"
|
:hazard-ranges="data.隐患范围字典"
|
||||||
|
@hazard-click="handleTimelineHazardClick"
|
||||||
|
@frame-change="handleFrameChange"
|
||||||
/>
|
/>
|
||||||
</el-footer>
|
</el-footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ async function goToResult(formEl: FormInstance | undefined) {
|
||||||
<template>
|
<template>
|
||||||
<!-- 最外层容器:占满整个视口 -->
|
<!-- 最外层容器:占满整个视口 -->
|
||||||
<el-container style="margin: 0; height: 100%; flex-direction: column;">
|
<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-header style="height: 30px; display: flex; align-items: center; border-bottom: 1px solid var(--ep-border-color);">
|
||||||
<el-breadcrumb separator="/">
|
<el-breadcrumb separator="/">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">
|
<el-breadcrumb-item :to="{ path: '/' }">
|
||||||
主页
|
主页
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue