starmusic-pc
1<script setup>
2/**
3 * UiSlider — 桌面级滑动条
4 *
5 * 相比原生 input[type=range],这里完全自绘以统一各平台的观感:
6 * - 轨道 + 已填充部分 + 缓冲区间(进度条场景)
7 * - 悬停时滑块放大、显示数值气泡
8 * - 拖动中通过 pointer capture 保持跟手,移出轨道也不丢失
9 */
10import { ref, computed } from 'vue'
11
12const props = defineProps({
13 modelValue: { type: Number, default: 0 },
14 min: { type: Number, default: 0 },
15 max: { type: Number, default: 100 },
16 step: { type: Number, default: 1 },
17 disabled: { type: Boolean, default: false },
18 /** default | progress | volume —— 影响轨道粗细与滑块常显/悬显 */
19 variant: { type: String, default: 'default' },
20 /** 次进度(缓冲/下载进度),0~100 */
21 secondary: { type: Number, default: 0 },
22 /** 悬浮气泡格式化函数 */
23 formatTip: { type: Function, default: null },
24})
25
26const emit = defineEmits(['update:modelValue', 'change', 'dragstart', 'dragend'])
27
28const trackRef = ref(null)
29const dragging = ref(false)
30const hovering = ref(false)
31/** 拖动 / 悬停时的临时值,用于气泡显示 */
32const previewValue = ref(null)
33
34const span = computed(() => props.max - props.min)
35
36const clamp = (v) => Math.min(props.max, Math.max(props.min, v))
37
38const quantize = (v) => {
39 const s = props.step > 0 ? props.step : 1
40 const steps = Math.round((v - props.min) / s)
41 return clamp(props.min + steps * s)
42}
43
44const percentOf = (value) => {
45 if (span.value <= 0) return 0
46 return Math.min(100, Math.max(0, ((value - props.min) / span.value) * 100))
47}
48
49const percent = computed(() => percentOf(props.modelValue))
50
51const tipPercent = computed(() => {
52 if (previewValue.value != null) return percentOf(previewValue.value)
53 return percent.value
54})
55
56const tipText = computed(() => {
57 const value = previewValue.value != null ? previewValue.value : props.modelValue
58 if (props.formatTip) return props.formatTip(value)
59 return String(Math.round(value * 10) / 10)
60})
61
62const valueFromEvent = (e) => {
63 const el = trackRef.value
64 if (!el) return props.modelValue
65 const rect = el.getBoundingClientRect()
66 if (rect.width <= 0) return props.modelValue
67 const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
68 return quantize(props.min + ratio * span.value)
69}
70
71const onPointerDown = (e) => {
72 if (props.disabled) return
73 const value = valueFromEvent(e)
74 dragging.value = true
75 previewValue.value = value
76 emit('update:modelValue', value)
77 emit('dragstart', value)
78 try {
79 e.currentTarget.setPointerCapture(e.pointerId)
80 } catch (err) {
81 /* 忽略 */
82 }
83 e.preventDefault()
84}
85
86const onPointerMove = (e) => {
87 if (props.disabled) return
88 if (dragging.value) {
89 const value = valueFromEvent(e)
90 previewValue.value = value
91 emit('update:modelValue', value)
92 return
93 }
94 // 悬停预览数值
95 if (props.variant !== 'volume') previewValue.value = valueFromEvent(e)
96}
97
98const finishDrag = () => {
99 if (!dragging.value) return
100 const value = previewValue.value != null ? previewValue.value : props.modelValue
101 dragging.value = false
102 previewValue.value = null
103 emit('dragend', value)
104 emit('change', value)
105}
106
107const onKeydown = (e) => {
108 if (props.disabled) return
109 const s = props.step > 0 ? props.step : 1
110 let next = null
111 if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clamp(props.modelValue + s)
112 else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clamp(props.modelValue - s)
113 else if (e.key === 'Home') next = props.min
114 else if (e.key === 'End') next = props.max
115 if (next == null) return
116 e.preventDefault()
117 emit('update:modelValue', next)
118 emit('change', next)
119}
120</script>
121
122<template>
123 <div
124 class="slider"
125 :class="[`v-${variant}`, { 'is-disabled': disabled, 'is-dragging': dragging, 'is-hover': hovering }]"
126 :style="{ '--p': `${percent}%`, '--sp': `${secondary}%`, '--tp': `${tipPercent}%` }"
127 role="slider"
128 :aria-valuemin="min"
129 :aria-valuemax="max"
130 :aria-valuenow="modelValue"
131 :aria-disabled="disabled"
132 :tabindex="disabled ? -1 : 0"
133 @keydown="onKeydown"
134 @mouseenter="hovering = true"
135 @mouseleave="hovering = false"
136 >
137 <div
138 ref="trackRef"
139 class="track"
140 @pointerdown="onPointerDown"
141 @pointermove="onPointerMove"
142 @pointerup="finishDrag"
143 @pointercancel="finishDrag"
144 >
145 <span class="rail" />
146 <span v-if="secondary > 0" class="buffered" />
147 <span class="filled" />
148 <span class="thumb">
149 <span class="thumb-dot" />
150 </span>
151 </div>
152
153 <Transition name="tip">
154 <span v-if="(dragging || hovering) && !disabled" class="tip">{{ tipText }}</span>
155 </Transition>
156 </div>
157</template>
158
159<style scoped>
160.slider {
161 position: relative;
162 display: flex;
163 align-items: center;
164 width: 100%;
165 padding: 6px 0;
166 touch-action: none;
167}
168
169.track {
170 position: relative;
171 width: 100%;
172 height: 18px;
173 display: flex;
174 align-items: center;
175 cursor: pointer;
176}
177
178.rail,
179.buffered,
180.filled {
181 position: absolute;
182 left: 0;
183 border-radius: var(--r-full);
184 pointer-events: none;
185}
186
187.rail {
188 width: 100%;
189 height: 5px;
190 background: var(--surface-4);
191}
192
193.buffered {
194 width: var(--sp);
195 height: 5px;
196 background: rgba(var(--theme-color-rgb), 0.2);
197}
198
199.filled {
200 width: var(--p);
201 height: 5px;
202 background: linear-gradient(90deg, var(--theme-color), var(--theme-color-dark));
203}
204
205.thumb {
206 position: absolute;
207 left: var(--p);
208 display: grid;
209 place-items: center;
210 width: 18px;
211 height: 18px;
212 margin-left: -9px;
213 border-radius: var(--r-full);
214 background: #fff;
215 box-shadow: 0 1px 3px rgba(20, 25, 32, 0.24), 0 0 0 1px rgba(20, 25, 32, 0.06);
216 transform: scale(0);
217 transition: transform var(--t-fast) var(--ease-overshoot);
218 pointer-events: none;
219}
220
221.thumb-dot {
222 width: 7px;
223 height: 7px;
224 border-radius: var(--r-full);
225 background: var(--theme-color);
226}
227
228/* 悬停 / 拖动 / 键盘聚焦时滑块显现 */
229.slider:hover .thumb,
230.slider.is-dragging .thumb,
231.slider:focus-visible .thumb {
232 transform: scale(1);
233}
234
235.slider.is-dragging .thumb {
236 transform: scale(1.18);
237}
238
239/* 轨道加粗反馈 */
240.slider:hover .rail,
241.slider:hover .filled,
242.slider:hover .buffered {
243 height: 6px;
244}
245
246.rail,
247.buffered,
248.filled {
249 transition: height var(--t-fast) var(--ease-out);
250}
251
252/* 音量样式:轨道更细,滑块常显 */
253.v-volume .rail,
254.v-volume .filled {
255 height: 4px;
256}
257
258.v-volume .thumb {
259 width: 13px;
260 height: 13px;
261 margin-left: -6.5px;
262 transform: scale(0.9);
263}
264
265.v-volume:hover .thumb {
266 transform: scale(1.1);
267}
268
269/* 进度样式:默认隐藏滑块,悬停才出现(更沉浸) */
270.v-progress .thumb {
271 transform: scale(0);
272}
273
274.v-progress:hover .thumb,
275.v-progress.is-dragging .thumb {
276 transform: scale(1);
277}
278
279.slider.is-disabled {
280 opacity: 0.45;
281 cursor: not-allowed;
282}
283
284.slider.is-disabled .track {
285 cursor: not-allowed;
286}
287
288/* 数值气泡 */
289.tip {
290 position: absolute;
291 bottom: calc(100% + 6px);
292 left: var(--tp);
293 transform: translateX(-50%);
294 padding: 3px 8px;
295 border-radius: var(--r-xs);
296 background: rgba(24, 28, 34, 0.88);
297 color: #fff;
298 font-size: 11.5px;
299 font-variant-numeric: tabular-nums;
300 white-space: nowrap;
301 pointer-events: none;
302 backdrop-filter: blur(6px);
303}
304
305.tip::after {
306 content: '';
307 position: absolute;
308 top: 100%;
309 left: 50%;
310 transform: translateX(-50%);
311 border: 4px solid transparent;
312 border-top-color: rgba(24, 28, 34, 0.88);
313}
314
315.tip-enter-active,
316.tip-leave-active {
317 transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
318}
319
320.tip-enter-from,
321.tip-leave-to {
322 opacity: 0;
323 transform: translateX(-50%) translateY(4px);
324}
325</style>