From bc627544d891c7a07a9aebbbaceb0dd7fcee8e5e Mon Sep 17 00:00:00 2001 From: ddshi <8811906+ddshi@user.noreply.gitee.com> Date: Wed, 11 Feb 2026 16:38:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=BB=B4=E7=AD=94?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E9=A3=8E=E6=A0=BC=E7=9A=84=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TimePicker 组件,支持 30 分钟间隔选择和数字输入 - 删除旧的 PopoverTimePicker 组件 - 修复无效日期导致的 RangeError 错误 - 时间选择器 UI 与表单其他输入项保持一致 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/common/PopoverTimePicker.tsx | 222 ---------------- src/components/common/TimePicker.tsx | 281 ++++++++++++++++++++ src/pages/HomePage.tsx | 7 +- src/utils/repeatCalculator.ts | 9 + 4 files changed, 293 insertions(+), 226 deletions(-) delete mode 100644 src/components/common/PopoverTimePicker.tsx create mode 100644 src/components/common/TimePicker.tsx diff --git a/src/components/common/PopoverTimePicker.tsx b/src/components/common/PopoverTimePicker.tsx deleted file mode 100644 index 9f57d37..0000000 --- a/src/components/common/PopoverTimePicker.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useState, useRef, useEffect } from 'react'; -import { - Box, - Group, - Text, - Popover, - TextInput, - Stack, - Button, -} from '@mantine/core'; - -interface PopoverTimePickerProps { - value: string; // "HH:mm" format - onChange: (time: string) => void; - placeholder?: string; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; -} - -function padZero(num: number): string { - return String(num).padStart(2, '0'); -} - -// 验证小时格式 -const validateHour = (h: string): number => { - const num = parseInt(h, 10); - if (isNaN(num) || num < 0) return 0; - if (num > 23) return 23; - return num; -}; - -// 验证分钟格式(30分钟间隔) -const validateMinute = (m: string): number => { - const num = parseInt(m, 10); - if (isNaN(num) || num < 0) return 0; - if (num > 59) return 59; - return Math.round(num / 30) * 30; // 30分钟间隔 -}; - -export function PopoverTimePicker({ - value, - onChange, - placeholder = '选择时间', - size = 'sm', -}: PopoverTimePickerProps) { - const [opened, setOpened] = useState(false); - const [hourInput, setHourInput] = useState('00'); - const [minuteInput, setMinuteInput] = useState('00'); - - // 初始化输入值 - useEffect(() => { - if (value && value.includes(':')) { - const [h, m] = value.split(':'); - setHourInput(padZero(parseInt(h, 10) || 0)); - setMinuteInput(padZero(parseInt(m, 10) || 0)); - } - }, [value]); - - // 分钟选项(30分钟间隔) - const minuteOptions = [0, 30]; - - // 处理小时输入 - const handleHourChange = (h: string) => { - // 只允许输入数字,且最多2位 - const filtered = h.replace(/[^0-9]/g, '').slice(0, 2); - setHourInput(filtered); - }; - - // 处理分钟输入 - const handleMinuteChange = (m: string) => { - // 只允许输入数字,且最多2位 - const filtered = m.replace(/[^0-9]/g, '').slice(0, 2); - setMinuteInput(filtered); - }; - - // 应用时间 - const applyTime = () => { - const h = validateHour(hourInput); - const m = validateMinute(minuteInput); - const time = `${padZero(h)}:${padZero(m)}`; - onChange(time); - setHourInput(padZero(h)); - setMinuteInput(padZero(m)); - setOpened(false); - }; - - // 快速选择分钟 - const selectMinute = (m: number) => { - setMinuteInput(padZero(m)); - }; - - // 处理键盘事件 - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - applyTime(); - } else if (e.key === 'Escape') { - setOpened(false); - } - }; - - // 显示值 - const displayValue = value && value !== ':' ? value : ''; - - return ( - - - setOpened(true)} - styles={{ - input: { - cursor: 'pointer', - letterSpacing: '0.05em', - }, - }} - /> - - - - - {/* 数字输入区域 */} - - {/* 小时输入 */} - handleHourChange(e.currentTarget.value)} - onKeyDown={handleKeyDown} - placeholder="时" - size="sm" - style={{ width: 56 }} - maxLength={2} - styles={{ - input: { - textAlign: 'center', - fontSize: 16, - fontWeight: 500, - letterSpacing: '0.1em', - }, - }} - /> - - : - - {/* 分钟输入 */} - handleMinuteChange(e.currentTarget.value)} - onKeyDown={handleKeyDown} - placeholder="分" - size="sm" - style={{ width: 56 }} - maxLength={2} - styles={{ - input: { - textAlign: 'center', - fontSize: 16, - fontWeight: 500, - letterSpacing: '0.1em', - }, - }} - /> - - - {/* 快速选择分钟 */} - - {minuteOptions.map((m) => ( - - ))} - - - {/* 操作按钮 */} - - - - - - - - ); -} diff --git a/src/components/common/TimePicker.tsx b/src/components/common/TimePicker.tsx new file mode 100644 index 0000000..979455a --- /dev/null +++ b/src/components/common/TimePicker.tsx @@ -0,0 +1,281 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + Box, + Text, + Popover, + TextInput, + Stack, + ScrollArea, +} from '@mantine/core'; +import { IconClock, IconX } from '@tabler/icons-react'; + +interface TimePickerProps { + value: string; // "HH:mm" format + onChange: (time: string) => void; + placeholder?: string; +} + +function padZero(num: number): string { + return String(num).padStart(2, '0'); +} + +// 生成30分钟间隔的时间选项(00:00, 00:30, 01:00, ..., 23:30) +function generateTimeOptions(): string[] { + const options: string[] = []; + for (let h = 0; h < 24; h++) { + options.push(`${padZero(h)}:00`); + options.push(`${padZero(h)}:30`); + } + return options; +} + +const TIME_OPTIONS = generateTimeOptions(); +const MAX_VISIBLE = 7; +const ITEM_HEIGHT = 36; + +export function TimePicker({ + value, + onChange, + placeholder = '请选择时间', +}: TimePickerProps) { + const [opened, setOpened] = useState(false); + const [inputValue, setInputValue] = useState(value); + const inputRef = useRef(null); + const lastValidTimeRef = useRef(value); + + // 同步外部值 + useEffect(() => { + setInputValue(value); + }, [value]); + + // 处理输入框直接编辑 + const handleInputChange = (e: React.ChangeEvent) => { + let val = e.target.value; + + // 只允许输入数字和冒号 + val = val.replace(/[^0-9:]/g, ''); + + // 确保只有一个冒号 + const colonCount = (val.match(/:/g) || []).length; + if (colonCount > 1) { + const parts = val.split(':'); + val = `${parts[0]}:${parts.slice(1).join('')}`; + } + + // 冒号位置限制 + if (val.includes(':')) { + const colonIndex = val.indexOf(':'); + // 冒号前最多2位,冒号后最多2位 + const before = val.substring(0, colonIndex); + const after = val.substring(colonIndex + 1); + const beforeLimit = before.slice(0, 2); + const afterLimit = after.slice(0, 2); + val = beforeLimit + ':' + afterLimit; + } + + // 如果输入完整,验证范围 + if (val.includes(':') && val.length === 5) { + const [h, m] = val.split(':').map(Number); + if (h > 23) { + val = `23:${padZero(m)}`; + } + if (m > 59) { + val = `${padZero(h)}:59`; + } + lastValidTimeRef.current = val; + } + + // 如果值被清空 + if (val === '' || val === ':') { + lastValidTimeRef.current = ''; + } + + setInputValue(val); + + // 如果格式正确(HH:mm),同步到外部 + if (/^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/.test(val)) { + onChange(val); + } + }; + + // 处理按键事件 + const handleKeyDown = (e: React.KeyboardEvent) => { + // 按 Enter 验证并关闭 + if (e.key === 'Enter') { + // 确保值有效 + if (/^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/.test(inputValue)) { + onChange(inputValue); + lastValidTimeRef.current = inputValue; + setOpened(false); + } else if (lastValidTimeRef.current) { + setInputValue(lastValidTimeRef.current); + } + } + // 按 Escape 关闭 + if (e.key === 'Escape') { + setOpened(false); + } + }; + + // 选择时间 + const selectTime = (time: string) => { + onChange(time); + setInputValue(time); + lastValidTimeRef.current = time; + setOpened(false); + inputRef.current?.focus(); + }; + + // 清除时间 + const clearTime = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(''); + setInputValue(''); + lastValidTimeRef.current = ''; + inputRef.current?.focus(); + }; + + // 检查是否是有效时间 + const isValidTime = (t: string): boolean => { + return TIME_OPTIONS.includes(t); + }; + + // 当前选中的时间 + const selectedTime = isValidTime(value) ? value : null; + + return ( + + + setOpened(true)} + leftSection={} + rightSection={ + inputValue ? ( + { + e.stopPropagation(); + clearTime(e); + }} + /> + ) : null + } + styles={{ + input: { + borderRadius: 2, + background: '#faf9f7', + borderColor: '#ddd', + color: '#666', + height: 32, + paddingLeft: 36, + paddingRight: inputValue ? 32 : 8, + cursor: 'pointer', + fontSize: 14, + letterSpacing: '0.05em', + }, + section: { + paddingLeft: 8, + }, + }} + /> + + + + + + {TIME_OPTIONS.map((time) => { + const isSelected = time === selectedTime; + return ( + selectTime(time)} + style={{ + height: ITEM_HEIGHT, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 16px', + cursor: 'pointer', + background: isSelected ? 'rgba(0, 122, 255, 0.08)' : 'transparent', + transition: 'background 0.15s ease', + }} + onMouseEnter={(e) => { + if (!isSelected) { + e.currentTarget.style.background = 'rgba(0, 0, 0, 0.04)'; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.background = 'transparent'; + } + }} + > + + {time} + + {isSelected && ( + + + ✓ + + + )} + + ); + })} + + + + + ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index ea3d4b7..7f116ae 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -19,7 +19,7 @@ import { DatePickerInput } from '@mantine/dates'; import { IconCalendar, IconClock, IconSettings, IconLogout, IconX } from '@tabler/icons-react'; import { useDisclosure } from '@mantine/hooks'; import { FixedCalendar } from '../components/common/FixedCalendar'; -import { PopoverTimePicker } from '../components/common/PopoverTimePicker'; +import { TimePicker } from '../components/common/TimePicker'; import { useNavigate } from 'react-router-dom'; import { useAppStore } from '../stores'; import { AnniversaryList } from '../components/anniversary/AnniversaryList'; @@ -551,12 +551,11 @@ export function HomePage() { - {/* Time selector (only for reminders) - Popover picker */} + {/* Time selector (only for reminders) - Time picker */} {formType === 'reminder' && ( - setFormTime(time)} - size="sm" /> )} diff --git a/src/utils/repeatCalculator.ts b/src/utils/repeatCalculator.ts index 7481cf9..d77389a 100644 --- a/src/utils/repeatCalculator.ts +++ b/src/utils/repeatCalculator.ts @@ -291,7 +291,12 @@ export function calculateReminderTimes( hasTime: boolean, reminderValue: string ): string[] { + // 如果日期无效,返回空数组 const eventDate = new Date(eventDateStr); + if (isNaN(eventDate.getTime())) { + return []; + } + const reminderTimes: string[] = []; if (!hasTime) { @@ -421,6 +426,10 @@ export function formatReminderTimeDisplay(reminderTimes: string[] | undefined): } const reminderTime = new Date(reminderTimes[0]); + // 如果日期无效,返回空字符串 + if (isNaN(reminderTime.getTime())) { + return ''; + } return reminderTime.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric',