完善时间线标尺、缩放与播放头

This commit is contained in:
yueliuli 2026-04-22 10:46:11 +08:00
parent ce29a7c38d
commit cdff0950d9
7 changed files with 670 additions and 542 deletions

2
src/components.d.ts vendored
View File

@ -55,6 +55,6 @@ declare module 'vue' {
MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default'] MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
Timeline: typeof import('./components/hazard_inspect/timeline.vue')['default'] Timeline: typeof import('./components/hazard_inspect/timeline/timeline.vue')['default']
} }
} }

View File

@ -1,535 +0,0 @@
<script setup>
import { ElScrollbar } from 'element-plus'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
currentFrame: {
type: Number,
required: true,
},
totalFrames: {
type: Number,
required: true,
},
hazardRanges: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['hazardClick', 'frameChange'])
const FPS = 30
const pxPerFrame = ref(2)
const minPxPerFrame = 0.5
const maxPxPerFrame = 10
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 trackWidth = computed(() => {
return `${props.totalFrames * pxPerFrame.value + 50}px`
})
const totalSeconds = computed(() => {
return Math.ceil(props.totalFrames / FPS)
})
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() {
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)
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)
}
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, () => {
if (!isPlayheadDragging.value) {
localCurrentFrame.value = props.currentFrame
}
nextTick(() => {
if (!timelineContainer.value)
return
const container = timelineContainer.value
const playhead = document.getElementById('playhead')
if (!playhead)
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(() => {
const progressContainer = timelineContainer.value
if (!progressContainer)
return
updatePxPerFrame()
progressContainer.addEventListener('wheel', handleWheel, { passive: false })
progressContainer.addEventListener('mousedown', handleMouseDown)
window.addEventListener('mousemove', handlePlayheadDrag)
})
watch(() => props.totalFrames, () => {
nextTick(() => {
updatePxPerFrame()
})
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mousemove', handlePlayheadDrag)
window.removeEventListener('mouseup', handleMouseUp)
})
</script>
<template>
<div class="timeline-wrapper">
<div class="timeline-toolbar">
<span class="timeline-title">隐患时间线 ( {{ Object.keys(hazardRanges).length }} 个隐患)</span>
<div class="timeline-zoom-controls">
<button class="zoom-btn" title="缩小" @click="handleZoomOut">
</button>
<button class="zoom-btn" title="放大" @click="handleZoomIn">
+
</button>
</div>
</div>
<div
ref="timelineContainer"
class="timeline-container"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
>
<div
id="playhead"
class="playhead"
:style="{ left: `${playheadPos}px` }"
@mousedown="handlePlayheadMouseDown"
>
<div class="playhead-indicator" />
</div>
<div class="timeline-content" :style="{ width: trackWidth }">
<div class="timeline-ruler" @click="handleRulerClick">
<div
v-for="tick in timeTicks"
:key="tick.second"
class="time-tick"
:style="{ left: `${tick.position}px` }"
>
<div class="tick-mark" />
<span class="tick-label">{{ tick.label }}</span>
</div>
</div>
<ElScrollbar :style="{ height: '100%' }">
<div id="tracks-list" class="tracks-list">
<div
v-for="(row, rowIndex) in trackRows"
:key="rowIndex"
class="track-row"
>
<div
v-for="hazard in row"
:key="hazard.id"
class="hazard-block"
:style="getHazardStyle(hazard.start, hazard.end)"
@click="handleHazardClick(hazard.id)"
>
{{ parseInt(hazard.id) + 1 }}
</div>
</div>
</div>
</ElScrollbar>
</div>
</div>
</div>
</template>
<style scoped>
.timeline-wrapper {
height: 33.333%;
display: flex;
flex-direction: column;
border-top: 1px solid var(--ep-border-color);
font-family: 'Inter', sans-serif;
}
.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) {
height: 100% !important;
}
:deep(.el-scrollbar__wrap) {
height: 100% !important;
}
</style>

View File

@ -0,0 +1,202 @@
.timeline-wrapper {
height: 33.333%;
display: flex;
flex-direction: column;
border-top: 1px solid var(--ep-border-color);
font-family: 'Inter', sans-serif;
}
.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;
align-items: center;
width: 170px;
}
.timeline-zoom-label {
font-size: 12px;
color: var(--ep-text-color-secondary);
margin-right: 8px;
width: 28px;
flex-shrink: 0;
}
.zoom-slider {
flex: 1;
height: 24px;
width: 100px;
padding: 0 8px;
}
.timeline-container {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
position: relative;
background-color: var(--ep-bg-color);
cursor: grab;
}
.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-mark.major {
height: 12px;
background-color: var(--ep-color-info);
}
.tick-mark.minor {
height: 6px;
background-color: var(--ep-border-color-light);
}
.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;
position: relative;
}
.timeline-ruler-click-area {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: pointer;
}
.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) {
height: 100% !important;
}
:deep(.el-scrollbar__wrap) {
height: 100% !important;
}

View File

@ -0,0 +1,330 @@
import type { ComputedRef, Ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
export interface TimelineProps {
currentFrame: number
totalFrames: number
hazardRanges: Record<string, number[]>
}
export interface TimelineEmits {
(e: 'hazardClick', id: number): void
(e: 'frameChange', frame: number): void
}
export interface TimeTick {
second: number
position: number
label: string
type?: 'major' | 'minor'
}
export interface HazardItem {
id: string
start: number
end: number
ranges: number[][]
}
export interface HazardRow {
id: string
start: number
end: number
ranges: number[][]
}
export function useTimeline(
props: TimelineProps,
emit: TimelineEmits,
) {
const FPS = 30
const pxPerFrame = ref(2)
const minPxPerFrame = 0.5
const maxPxPerFrame = 3
const timelineContainer: Ref<HTMLElement | null> = 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 trackWidth: ComputedRef<string> = computed(() => {
return `${props.totalFrames * pxPerFrame.value + 50}px`
})
const totalSeconds: ComputedRef<number> = computed(() => {
return Math.ceil(props.totalFrames / FPS)
})
function updatePxPerFrame(): void {
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 setZoom(value: number): void {
pxPerFrame.value = Math.max(minPxPerFrame, Math.min(value, maxPxPerFrame))
}
const timeTicks: ComputedRef<TimeTick[]> = computed(() => {
const ticks: TimeTick[] = []
for (let s = 0; s <= totalSeconds.value; s += 10) {
ticks.push({
second: s,
position: s * FPS * pxPerFrame.value,
label: formatTime(s),
type: 'major',
})
}
return ticks
})
const secondTicks: ComputedRef<TimeTick[]> = computed(() => {
const ticks: TimeTick[] = []
for (let s = 0; s <= totalSeconds.value; s += 1) {
ticks.push({
second: s,
position: s * FPS * pxPerFrame.value,
label: '',
type: 'minor',
})
}
return ticks
})
function formatTime(seconds: number): string {
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: ComputedRef<number> = computed(() => {
if (isPlayheadDragging.value) {
return localCurrentFrame.value * pxPerFrame.value
}
return props.currentFrame * pxPerFrame.value
})
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]])
}
return acc
}, []),
}))
.sort((a, b) => {
const aStart = a.ranges[0]?.[0] || 0
const bStart = b.ranges[0]?.[0] || 0
return aStart - bStart
})
const rows: HazardRow[][] = []
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: number, end: number): Record<string, string> {
const left = start * pxPerFrame.value
const width = (end - start) * pxPerFrame.value
return {
left: `${left}px`,
width: `${width}px`,
}
}
function handleHazardClick(hazardId: string): void {
emit('hazardClick', Number(hazardId))
}
function handleWheel(e: WheelEvent): void {
if (!timelineContainer.value)
return
e.preventDefault()
const container = timelineContainer.value
const delta = e.deltaY || e.deltaX
container.scrollLeft += delta
}
function handleMouseDown(e: MouseEvent): void {
if (e.button !== 0)
return
const target = e.target as HTMLElement
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: MouseEvent): void {
if (!isDragging.value || !timelineContainer.value)
return
const delta = dragStartX.value - e.clientX
timelineContainer.value.scrollLeft = scrollStartLeft.value + delta
}
function handleMouseUp(): void {
isDragging.value = false
isPlayheadDragging.value = false
isRulerDragging.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
function handlePlayheadMouseDown(e: MouseEvent): void {
e.stopPropagation()
isPlayheadDragging.value = true
dragStartX.value = e.clientX
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}
function handleRulerClick(e: MouseEvent): void {
if (isRulerDragging.value)
return
if (!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)
}
function handlePlayheadDrag(e: MouseEvent): void {
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, () => {
if (!isPlayheadDragging.value) {
localCurrentFrame.value = props.currentFrame
}
nextTick(() => {
if (!timelineContainer.value)
return
const container = timelineContainer.value
const playhead = document.getElementById('playhead')
if (!playhead)
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
}
})
})
function initMounted(): void {
const progressContainer = timelineContainer.value
if (!progressContainer)
return
updatePxPerFrame()
progressContainer.addEventListener('wheel', handleWheel, { passive: false })
progressContainer.addEventListener('mousedown', handleMouseDown)
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mousemove', handlePlayheadDrag)
window.addEventListener('mouseup', handleMouseUp)
}
watch(() => props.totalFrames, () => {
nextTick(() => {
updatePxPerFrame()
})
})
function initUnmounted(): void {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mousemove', handlePlayheadDrag)
window.removeEventListener('mouseup', handleMouseUp)
}
return {
pxPerFrame,
minPxPerFrame,
maxPxPerFrame,
timelineContainer,
isDragging,
isPlayheadDragging,
isRulerDragging,
localCurrentFrame,
trackWidth,
timeTicks,
secondTicks,
playheadPos,
trackRows,
setZoom,
getHazardStyle,
handleHazardClick,
handleMouseMove,
handleMouseUp,
handlePlayheadMouseDown,
handleRulerClick,
handlePlayheadDrag,
initMounted,
initUnmounted,
}
}

View File

@ -0,0 +1,128 @@
<script setup lang="ts">
import type { TimelineEmits, TimelineProps } from './timeline'
import { ElScrollbar, ElSlider } from 'element-plus'
import { computed, onMounted, onUnmounted } from 'vue'
import { useTimeline } from './timeline'
import './timeline.css'
const props = defineProps<TimelineProps>()
const emit = defineEmits<TimelineEmits>()
const {
timelineContainer,
trackWidth,
timeTicks,
secondTicks,
playheadPos,
trackRows,
pxPerFrame,
minPxPerFrame,
maxPxPerFrame,
setZoom,
getHazardStyle,
handleHazardClick,
handleMouseMove,
handleMouseUp,
handlePlayheadMouseDown,
handleRulerClick,
initMounted,
initUnmounted,
} = useTimeline(props, emit)
onMounted(() => {
initMounted()
})
onUnmounted(() => {
initUnmounted()
})
</script>
<template>
<div class="timeline-wrapper">
<div class="timeline-toolbar">
<span class="timeline-title">隐患时间线 ( {{ Object.keys(hazardRanges).length }} 个隐患)</span>
<div class="timeline-zoom-controls">
<div class="timeline-zoom-label">
<el-text type="info" size="small">
缩放
</el-text>
</div>
<ElSlider
v-model="pxPerFrame"
:min="minPxPerFrame"
:max="maxPxPerFrame"
:step="0.5"
:show-tooltip="true"
:format-tooltip="(val: number) => `缩放: ${val}`"
class="zoom-slider"
@change="(val: number | number[]) => setZoom(Array.isArray(val) ? val[0] : val)"
/>
</div>
</div>
<div
ref="timelineContainer"
class="timeline-container"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
>
<div
id="playhead"
class="playhead"
:style="{ left: `${playheadPos}px` }"
@mousedown="handlePlayheadMouseDown"
>
<div class="playhead-indicator" />
</div>
<div class="timeline-content" :style="{ width: trackWidth }">
<div class="timeline-ruler">
<div
v-for="tick in secondTicks"
:key="tick.second"
class="time-tick"
:style="{ left: `${tick.position}px` }"
>
<div class="tick-mark minor" />
</div>
<div
v-for="tick in timeTicks"
:key="tick.second"
class="time-tick"
:style="{ left: `${tick.position}px` }"
>
<div class="tick-mark major" />
<span class="tick-label">{{ tick.label }}</span>
</div>
<div
class="timeline-ruler-click-area"
@click="handleRulerClick"
/>
</div>
<ElScrollbar :style="{ height: '100%' }">
<div id="tracks-list" class="tracks-list">
<div
v-for="(row, rowIndex) in trackRows"
:key="rowIndex"
class="track-row"
>
<div
v-for="hazard in row"
:key="hazard.id"
class="hazard-block"
:style="getHazardStyle(hazard.start, hazard.end)"
@click="handleHazardClick(hazard.id)"
>
{{ parseInt(hazard.id) + 1 }}
</div>
</div>
</div>
</ElScrollbar>
</div>
</div>
</div>
</template>

View File

@ -3,14 +3,14 @@ import { ViteSSG } from 'vite-ssg'
// import "~/styles/element/index.scss"; // import "~/styles/element/index.scss";
// import ElementPlus from "element-plus"; import { routes } from 'vue-router/auto-routes'
// import all element css, uncommented next line
// import "element-plus/dist/index.css";
// or use cdn, uncomment cdn link in `index.html` // or use cdn, uncomment cdn link in `index.html`
import { routes } from 'vue-router/auto-routes'
import App from './App.vue' import App from './App.vue'
// import ElementPlus from "element-plus";
// import all element css, uncommented next line
// import "element-plus/dist/index.css";
import '~/styles/index.scss' import '~/styles/index.scss'
@ -18,7 +18,10 @@ import 'uno.css'
// If you want to use ElMessage, import it. // If you want to use ElMessage, import it.
import 'element-plus/theme-chalk/src/message.scss' import 'element-plus/theme-chalk/src/message.scss'
import 'element-plus/theme-chalk/src/message-box.scss' import 'element-plus/theme-chalk/src/message-box.scss'
import 'element-plus/theme-chalk/src/overlay.scss' // the modal class for message box import 'element-plus/theme-chalk/src/overlay.scss'
import 'element-plus/theme-chalk/src/slider.scss'
import 'element-plus/theme-chalk/src/input-number.scss'
import 'element-plus/theme-chalk/src/input.scss'
// if you do not need ssg: // if you do not need ssg:
// import { createApp } from "vue"; // import { createApp } from "vue";

View File

@ -23,5 +23,5 @@
"vueCompilerOptions": { "vueCompilerOptions": {
"target": 3 "target": 3
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/components/hazard_inspect/timeline/timeline.js"]
} }