qia-client/src/components/common/WheelTimePicker.tsx
ddshi 79ef45b4ad fix: 优化事件编辑体验
- 时间选择器使用滚轮选择,隐藏滚动条
- 内容输入框替换为原生 textarea
- 删除图标移到时间输入框内
- 修复日期选择器位置问题
- Tab 键不再导致页面跳转
2026-02-06 16:19:23 +08:00

313 lines
9.6 KiB
TypeScript

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<HTMLDivElement>(null);
const minuteScrollRef = useRef<HTMLDivElement>(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 (
<Box
style={{
background: '#fff',
borderRadius: 12,
padding: '8px 0',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
}}
>
<Group gap={0} justify="center">
{/* 小时列 */}
<Box
ref={hourScrollRef}
style={{
width: 48,
height: CONTAINER_HEIGHT,
overflowY: 'auto',
textAlign: 'center',
position: 'relative',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
css={{
'&::-webkit-scrollbar': {
display: 'none',
},
}}
onWheel={(e) => {
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));
}
}}
>
{/* 选中区域背景 */}
<Box
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: ITEM_HEIGHT,
transform: 'translateY(-50%)',
background: 'rgba(0, 122, 255, 0.08)',
borderRadius: 8,
pointerEvents: 'none',
}}
/>
<Box style={{ paddingTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2 }}>
{extendedHours.map((item, idx) => (
<Box
key={`hour-${idx}`}
onClick={() => 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)}
</Box>
))}
</Box>
</Box>
<Text
size="lg"
fw={400}
style={{
color: '#666',
padding: '0 8px',
fontFeatureSettings: '"tnum"',
lineHeight: `${CONTAINER_HEIGHT}px`,
alignSelf: 'flex-start',
marginTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2,
}}
>
:
</Text>
{/* 分钟列 */}
<Box
ref={minuteScrollRef}
style={{
width: 48,
height: CONTAINER_HEIGHT,
overflowY: 'auto',
textAlign: 'center',
position: 'relative',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
css={{
'&::-webkit-scrollbar': {
display: 'none',
},
}}
onWheel={(e) => {
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));
}
}}
>
{/* 选中区域背景 */}
<Box
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: ITEM_HEIGHT,
transform: 'translateY(-50%)',
background: 'rgba(0, 122, 255, 0.08)',
borderRadius: 8,
pointerEvents: 'none',
}}
/>
<Box style={{ paddingTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2 }}>
{extendedMinutes.map((item, idx) => (
<Box
key={`minute-${idx}`}
onClick={() => 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)}
</Box>
))}
</Box>
</Box>
</Group>
</Box>
);
}