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',