winupdate-admin/src/components/TimelineSlider.vue
2025-06-27 09:22:19 +08:00

236 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- src/components/TimelineSlider.vue -->
<template>
<div class="timeline-slider" ref="sliderRef" @touchstart.prevent
@pointerdown.prevent="onPointerDown">
<!-- 底部轨道 -->
<div class="slider-track" :style="trackStyle">
<!-- mode segments 根据 segments 数据渲染各段颜色 -->
<template v-if="mode === 'segments' && segments.length">
<div v-for="(segment, index) in segments" :key="index"
class="slider-segment" :style="getSegmentStyle(segment)"></div>
</template>
<!-- 当 mode 为 ticks 时,渲染刻度标记 -->
<template v-if="mode === 'ticks' && markers.length">
<div v-for="(marker, index) in markers" :key="index"
class="slider-marker" :style="getMarkerStyle(marker)"></div>
</template>
</div>
<!-- 可拖动的滑块 -->
<div class="slider-handle" :style="handleStyle" @touchstart.prevent
@pointerdown.prevent="onPointerDown">
<!-- 拖动时显示 tooltip -->
<div v-if="showTooltip" class="slider-tooltip" :style="tooltipStyle">
{{ formattedValue }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { format } from 'date-fns';
interface Segment {
start: number;
end: number;
active: boolean;
}
interface Marker {
time: number;
label?: string;
active?: boolean;
}
const props = defineProps({
minTime: { type: Number, required: true },
maxTime: { type: Number, required: true },
modelValue: { type: Number, required: true },
mode: { type: String, default: 'default' }, // 可传 'segments' 或 'ticks'
segments: { type: Array as () => Segment[], default: () => [] },
markers: { type: Array as () => Marker[], default: () => [] }
});
const emit = defineEmits(['update:modelValue', 'change']);
const sliderRef = ref<HTMLElement | null>(null);
const dragging = ref(false);
const internalValue = ref(props.modelValue);
const showTooltip = ref(false);
watch(() => props.modelValue, (newVal) => {
internalValue.value = newVal;
});
watch(internalValue, (newVal) => {
emit('update:modelValue', newVal);
});
// 格式化当前值显示为时间字符串
const formattedValue = computed(() => {
return format(new Date(internalValue.value), 'HH:mm:ss');
});
// 当前值在整个区间内的百分比
const percentage = computed(() => {
return ((internalValue.value - props.minTime) / (props.maxTime - props.minTime)) * 100;
});
const handleStyle = computed(() => {
return {
position: 'absolute' as any,
left: `${percentage.value}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: '#4ADE80', // 绿色
border: '2px solid white',
boxShadow: '0 0 2px rgba(0,0,0,0.5)',
zIndex: 2,
cursor: 'pointer'
};
});
const trackStyle = computed(() => {
return {
position: 'absolute' as any,
top: '50%',
left: 0,
right: 0,
height: '6px',
backgroundColor: '#E5E7EB', // 灰色
transform: 'translateY(-50%)',
borderRadius: '3px'
};
});
// 根据每个 segment 计算其位置及宽度
const getSegmentStyle = (segment: Segment) => {
const startPercent = ((segment.start - props.minTime) / (props.maxTime - props.minTime)) * 100;
const endPercent = ((segment.end - props.minTime) / (props.maxTime - props.minTime)) * 100;
const widthPercent = endPercent - startPercent;
return {
position: 'absolute' as any,
left: `${startPercent}%`,
width: `${widthPercent}%`,
height: '100%',
backgroundColor: segment.active ? '#4ADE80' : '#E5E7EB',
borderLeft: '1px solid #E5E7EB',
borderRight: '1px solid #E5E7EB',
};
};
// 根据 marker 计算其位置
const getMarkerStyle = (marker: Marker) => {
const pos = ((marker.time - props.minTime) / (props.maxTime - props.minTime)) * 100;
return {
position: 'absolute' as any,
left: `${pos}%`,
top: '50%',
width: '2px',
height: '100%',
backgroundColor: marker.active ? '#4ADE80' : '#9CA3AF',
transform: 'translate(-50%, -50%)'
};
};
const tooltipStyle = computed(() => {
return {
position: 'absolute' as any,
bottom: '100%', // 显示在滑块上方
left: '50%',
transform: 'translateX(-50%)',
marginBottom: '8px',
padding: '4px 8px',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap'
};
});
const pointerMoveHandler = (event: PointerEvent) => {
if (!dragging.value || !sliderRef.value) return;
const rect = sliderRef.value.getBoundingClientRect();
let x = event.clientX - rect.left;
x = Math.max(0, Math.min(x, rect.width));
const newValue = props.minTime + (x / rect.width) * (props.maxTime - props.minTime);
internalValue.value = newValue;
};
const pointerUpHandler = () => {
dragging.value = false;
showTooltip.value = false;
window.removeEventListener('pointermove', pointerMoveHandler);
window.removeEventListener('pointerup', pointerUpHandler);
// 根据当前模式吸附到最近的点上
const snapped = getSnapValue(internalValue.value);
internalValue.value = snapped;
emit('change', snapped);
};
/**
* 根据当前的 internalValue 计算吸附snap后的值
* - 若 mode 为 'ticks' 且 markers 不为空,则吸附到最近的 marker.time
* - 若 mode 为 'segments' 且存在 active 的 segment则吸附到最近 active segment 的中点;
* - 否则直接返回当前值。
*/
const getSnapValue = (value: number): number => {
if (props.mode === 'ticks' && props.markers.length > 0) {
let closest = props.markers[0].time;
let minDiff = Math.abs(value - props.markers[0].time);
for (const marker of props.markers) {
const diff = Math.abs(value - marker.time);
if (diff < minDiff) {
minDiff = diff;
closest = marker.time;
}
}
return closest;
} else if (props.mode === 'segments' && props.segments.length > 0) {
// 遍历所有 active 的 segments计算其 start 和 end 的中点,并返回与 value 最近的那个中点
let closestMidpoint: number | null = null;
let minDiff = Infinity;
for (const segment of props.segments) {
if (segment.active) {
const midpoint = (segment.start + segment.end) / 2;
const diff = Math.abs(value - midpoint);
if (diff < minDiff) {
minDiff = diff;
closestMidpoint = midpoint;
}
}
}
if (closestMidpoint !== null) {
return closestMidpoint;
}
}
return value;
};
const onPointerDown = (event: PointerEvent) => {
event.stopPropagation();
dragging.value = true;
showTooltip.value = true;
window.addEventListener('pointermove', pointerMoveHandler);
window.addEventListener('pointerup', pointerUpHandler);
if (!dragging.value || !sliderRef.value) return;
const rect = sliderRef.value.getBoundingClientRect();
let x = event.clientX - rect.left;
x = Math.max(0, Math.min(x, rect.width));
const newValue = props.minTime + (x / rect.width) * (props.maxTime - props.minTime);
internalValue.value = newValue;
};
onMounted(() => {
// 初始化操作(如有需要)……
});
</script>
<style scoped>
.timeline-slider {
position: relative;
height: 40px;
user-select: none;
touch-action: none;
}
</style>