import { useState, useRef, useEffect, useCallback } from 'react'; import { Box, Group, Text } from '@mantine/core'; interface WheelTimePickerProps { value: string; // "HH:mm" format onChange: (time: string) => void; } function padZero(num: number): string { return String(num).padStart(2, '0'); } const ITEM_HEIGHT = 36; const VISIBLE_ITEMS = 5; const CONTAINER_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS; export function WheelTimePicker({ value, onChange }: WheelTimePickerProps) { // 处理空值情况 const parseInitialValue = () => { if (!value || value === ':') return { hours: 0, minutes: 0 }; const parts = value.split(':'); if (parts.length !== 2) return { hours: 0, minutes: 0 }; const h = parseInt(parts[0], 10); const m = parseInt(parts[1], 10); return { hours: isNaN(h) || h < 0 || h > 23 ? 0 : h, minutes: isNaN(m) || m < 0 || m > 59 ? 0 : m, }; }; const { hours: initialHours, minutes: initialMinutes } = parseInitialValue(); const [hours, setHours] = useState(initialHours); const [minutes, setMinutes] = useState(initialMinutes); const hourScrollRef = useRef(null); const minuteScrollRef = useRef(null); // 获取中心位置对应的索引 const getIndexFromScroll = (scrollTop: number): number => { const centerOffset = (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2; return Math.round((scrollTop - centerOffset) / ITEM_HEIGHT); }; // 获取索引对应的滚动位置 const getScrollFromIndex = (index: number): number => { const centerOffset = (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2; return index * ITEM_HEIGHT + centerOffset; }; // 同步滚动到选中值 const syncScrollToValue = useCallback(() => { if (hourScrollRef.current) { hourScrollRef.current.scrollTop = getScrollFromIndex(hours); } if (minuteScrollRef.current) { minuteScrollRef.current.scrollTop = getScrollFromIndex(minutes); } }, [hours, minutes]); // 初始化滚动位置 useEffect(() => { syncScrollToValue(); }, [syncScrollToValue]); // 处理滚动事件 const handleScroll = useCallback((type: 'hour' | 'minute') => { const scrollRef = type === 'hour' ? hourScrollRef : minuteScrollRef; if (!scrollRef.current) return; const scrollTop = scrollRef.current.scrollTop; const index = getIndexFromScroll(scrollTop); if (type === 'hour') { const clampedIndex = Math.max(0, Math.min(23, index)); if (clampedIndex !== hours) { setHours(clampedIndex); onChange(`${padZero(clampedIndex)}:${padZero(minutes)}`); } } else { const clampedIndex = Math.max(0, Math.min(59, index)); if (clampedIndex !== minutes) { setMinutes(clampedIndex); onChange(`${padZero(hours)}:${padZero(clampedIndex)}`); } } }, [hours, minutes, minutes, onChange]); // 绑定滚动事件 useEffect(() => { const hourEl = hourScrollRef.current; const minuteEl = minuteScrollRef.current; if (hourEl) { hourEl.addEventListener('scroll', () => handleScroll('hour')); } if (minuteEl) { minuteEl.addEventListener('scroll', () => handleScroll('minute')); } return () => { if (hourEl) { hourEl.removeEventListener('scroll', () => handleScroll('hour')); } if (minuteEl) { minuteEl.removeEventListener('scroll', () => handleScroll('minute')); } }; }, [handleScroll]); // 点击选择 const handleClick = (index: number, type: 'hour' | 'minute') => { if (type === 'hour') { setHours(index); onChange(`${padZero(index)}:${padZero(minutes)}`); } else { setMinutes(index); onChange(`${padZero(hours)}:${padZero(index)}`); } }; const hourOptions = Array.from({ length: 24 }, (_, i) => i); const minuteOptions = Array.from({ length: 60 }, (_, i) => i); // 生成带填充的选项数组(用于循环滚动效果) const getExtendedOptions = (options: number[], selected: number) => { // 让当前选中项居中显示 const paddingBefore = Math.floor(VISIBLE_ITEMS / 2); const paddingAfter = VISIBLE_ITEMS - 1 - paddingBefore; const extended: { value: number; isSelected: boolean }[] = []; // 添加前面的填充(使用末尾元素) for (let i = paddingBefore - 1; i >= 0; i--) { const value = options[options.length - 1 - i % options.length]; extended.push({ value, isSelected: false }); } // 添加当前选项 options.forEach((opt) => { extended.push({ value: opt, isSelected: opt === selected }); }); // 添加后面的填充(使用开头元素) for (let i = 1; i <= paddingAfter; i++) { const value = options[i % options.length]; extended.push({ value, isSelected: false }); } return extended; }; const extendedHours = getExtendedOptions(hourOptions, hours); const extendedMinutes = getExtendedOptions(minuteOptions, minutes); return ( {/* 小时列 */} { e.preventDefault(); const delta = e.deltaY > 0 ? ITEM_HEIGHT : -ITEM_HEIGHT; const scrollRef = hourScrollRef.current; if (scrollRef) { const newScrollTop = scrollRef.scrollTop + delta; scrollRef.scrollTop = Math.max(0, Math.min(newScrollTop, 24 * ITEM_HEIGHT)); } }} > {/* 选中区域背景 */} {extendedHours.map((item, idx) => ( handleClick(item.value % 24, 'hour')} style={{ height: ITEM_HEIGHT, display: 'flex', alignItems: 'center', justifyContent: 'center', color: item.isSelected ? '#007AFF' : '#374151', fontSize: 14, fontWeight: item.isSelected ? 600 : 400, letterSpacing: '0.02em', cursor: 'pointer', userSelect: 'none', }} > {padZero(item.value % 24)} ))} : {/* 分钟列 */} { e.preventDefault(); const delta = e.deltaY > 0 ? ITEM_HEIGHT : -ITEM_HEIGHT; const scrollRef = minuteScrollRef.current; if (scrollRef) { const newScrollTop = scrollRef.scrollTop + delta; scrollRef.scrollTop = Math.max(0, Math.min(newScrollTop, 60 * ITEM_HEIGHT)); } }} > {/* 选中区域背景 */} {extendedMinutes.map((item, idx) => ( handleClick(item.value % 60, 'minute')} style={{ height: ITEM_HEIGHT, display: 'flex', alignItems: 'center', justifyContent: 'center', color: item.isSelected ? '#007AFF' : '#374151', fontSize: 14, fontWeight: item.isSelected ? 600 : 400, letterSpacing: '0.02em', cursor: 'pointer', userSelect: 'none', }} > {padZero(item.value % 60)} ))} ); }