diff --git a/src/components/common/FixedCalendar.tsx b/src/components/common/FixedCalendar.tsx new file mode 100644 index 0000000..77fc10f --- /dev/null +++ b/src/components/common/FixedCalendar.tsx @@ -0,0 +1,245 @@ +import { useState, useMemo } from 'react'; +import { + Box, + Text, + Group, + ActionIcon, +} from '@mantine/core'; +import { + IconChevronLeft, + IconChevronRight, +} from '@tabler/icons-react'; + +interface FixedCalendarProps { + value: Date | null; + onChange: (date: Date | null) => void; + minDate?: Date; + maxDate?: Date; +} + +// 获取某月的第一天是星期几 +function getFirstDayOfMonth(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), 1).getDay(); +} + +// 获取某月有多少天 +function getDaysInMonth(date: Date): number { + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); +} + +// 检查两个日期是否是同一天 +function isSameDay(d1: Date | null, d2: Date | null): boolean { + if (!d1 || !d2) return false; + return d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate(); +} + +// 检查日期是否在范围内 +function isDateInRange(date: Date, min?: Date, max?: Date): boolean { + if (min && date < min) return false; + if (max && date > max) return false; + return true; +} + +const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; +const MONTH_NAMES = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; + +export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalendarProps) { + const [currentMonth, setCurrentMonth] = useState(value || new Date()); + + // 生成日历数据(固定6行7列 = 42天) + const calendarDays = useMemo(() => { + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); + + const firstDay = getFirstDayOfMonth(new Date(year, month, 1)); + const daysInMonth = getDaysInMonth(new Date(year, month, 1)); + + const days: Array<{ + date: Date; + isCurrentMonth: boolean; + isToday: boolean; + isSelected: boolean; + isDisabled: boolean; + }> = []; + + // 上月剩余的天数 + const prevMonthDays = getDaysInMonth(new Date(year, month - 1, 1)); + for (let i = firstDay - 1; i >= 0; i--) { + const date = new Date(year, month - 1, prevMonthDays - i); + days.push({ + date, + isCurrentMonth: false, + isToday: isSameDay(date, new Date()), + isSelected: isSameDay(date, value), + isDisabled: !isDateInRange(date, minDate, maxDate), + }); + } + + // 当月的天数 + for (let i = 1; i <= daysInMonth; i++) { + const date = new Date(year, month, i); + days.push({ + date, + isCurrentMonth: true, + isToday: isSameDay(date, new Date()), + isSelected: isSameDay(date, value), + isDisabled: !isDateInRange(date, minDate, maxDate), + }); + } + + // 下月需要填充的天数 + const remainingDays = 42 - days.length; + for (let i = 1; i <= remainingDays; i++) { + const date = new Date(year, month + 1, i); + days.push({ + date, + isCurrentMonth: false, + isToday: isSameDay(date, new Date()), + isSelected: isSameDay(date, value), + isDisabled: !isDateInRange(date, minDate, maxDate), + }); + } + + return days; + }, [currentMonth, value, minDate, maxDate]); + + const handlePrevMonth = () => { + setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)); + }; + + const handleNextMonth = () => { + setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)); + }; + + const handleDateClick = (date: Date, isDisabled: boolean) => { + if (!isDisabled) { + onChange(date); + } + }; + + return ( + + {/* 头部:月份选择 */} + + + + + + {MONTH_NAMES[currentMonth.getMonth()]} {currentMonth.getFullYear()} + + + + + + + {/* 星期行 */} + + {WEEKDAYS.map((day) => ( + + {day} + + ))} + + + {/* 日期网格 - 固定6行7列 */} + + {Array.from({ length: 6 }).map((_, weekIndex) => ( + + {Array.from({ length: 7 }).map((_, dayIndex) => { + const dayIndex_ = weekIndex * 7 + dayIndex; + const day = calendarDays[dayIndex_]; + if (!day) return null; + + return ( + handleDateClick(day.date, day.isDisabled)} + style={{ + flex: 1, + aspectRatio: '1', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 13, + cursor: day.isDisabled ? 'not-allowed' : 'pointer', + borderRadius: 8, + background: day.isSelected + ? '#007AFF' + : day.isToday + ? 'rgba(0, 122, 255, 0.12)' + : 'transparent', + color: day.isCurrentMonth + ? day.isDisabled + ? '#d1d5db' + : day.isSelected + ? '#fff' + : '#374151' + : day.isDisabled + ? '#e5e7eb' + : '#9ca3af', + fontWeight: day.isSelected ? 600 : 500, + transition: 'all 0.15s ease', + }} + onMouseEnter={(e) => { + if (!day.isDisabled && !day.isSelected) { + e.currentTarget.style.background = 'rgba(0, 122, 255, 0.06)'; + } + }} + onMouseLeave={(e) => { + if (!day.isDisabled && !day.isSelected) { + e.currentTarget.style.background = 'transparent'; + } + }} + > + {day.date.getDate()} + + ); + })} + + ))} + + + ); +} diff --git a/src/components/common/WheelTimePicker.tsx b/src/components/common/WheelTimePicker.tsx new file mode 100644 index 0000000..5706510 --- /dev/null +++ b/src/components/common/WheelTimePicker.tsx @@ -0,0 +1,312 @@ +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)} + + ))} + + + + + ); +} diff --git a/src/components/reminder/ReminderCard.tsx b/src/components/reminder/ReminderCard.tsx index 5021e16..315de68 100644 --- a/src/components/reminder/ReminderCard.tsx +++ b/src/components/reminder/ReminderCard.tsx @@ -389,7 +389,13 @@ export function ReminderCard({ {/* 右侧:循环图标 + 日期时间 */} - + { + e.stopPropagation(); + onClick(); + }} + > {event.repeat_type !== 'none' && ( - 内容 - - } - placeholder="输入详细内容" - value={formContent} - onChange={(e) => setFormContent(e.target.value)} - rows={3} - styles={{ - input: { - borderRadius: 2, + + + 内容 + + + borderRadius: 2, + border: '1px solid #ddd', + }} + > +