feat: 实现滴答清单风格的时间选择器

- 新增 TimePicker 组件,支持 30 分钟间隔选择和数字输入
- 删除旧的 PopoverTimePicker 组件
- 修复无效日期导致的 RangeError 错误
- 时间选择器 UI 与表单其他输入项保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-02-11 16:38:26 +08:00
parent ddd8ef88bf
commit bc627544d8
4 changed files with 293 additions and 226 deletions

View File

@ -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 (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
withArrow
shadow="md"
radius="md"
>
<Popover.Target>
<TextInput
value={displayValue}
placeholder={placeholder}
readOnly
size={size}
style={{ cursor: 'pointer' }}
onClick={() => setOpened(true)}
styles={{
input: {
cursor: 'pointer',
letterSpacing: '0.05em',
},
}}
/>
</Popover.Target>
<Popover.Dropdown
style={{
padding: '12px 16px',
background: '#fff',
}}
>
<Stack gap="sm">
{/* 数字输入区域 */}
<Group gap="xs" justify="center">
{/* 小时输入 */}
<TextInput
value={hourInput}
onChange={(e) => 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',
},
}}
/>
<Text size="lg" fw={400} style={{ color: '#666' }}>
:
</Text>
{/* 分钟输入 */}
<TextInput
value={minuteInput}
onChange={(e) => 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',
},
}}
/>
</Group>
{/* 快速选择分钟 */}
<Group gap={4} justify="center">
{minuteOptions.map((m) => (
<Button
key={m}
variant={minuteInput === padZero(m) ? 'filled' : 'light'}
size="xs"
onClick={() => selectMinute(m)}
style={{
minWidth: 44,
fontSize: 12,
}}
>
{padZero(m)}
</Button>
))}
</Group>
{/* 操作按钮 */}
<Group gap="xs" justify="center" mt={4}>
<Button
variant="subtle"
color="gray"
size="xs"
onClick={() => setOpened(false)}
>
</Button>
<Button
size="xs"
onClick={applyTime}
style={{
background: '#1a1a1a',
}}
>
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
);
}

View File

@ -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<HTMLInputElement>(null);
const lastValidTimeRef = useRef(value);
// 同步外部值
useEffect(() => {
setInputValue(value);
}, [value]);
// 处理输入框直接编辑
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 按 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 (
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
withArrow
shadow="md"
radius="md"
zIndex={1000}
>
<Popover.Target>
<TextInput
ref={inputRef}
placeholder={placeholder}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onClick={() => setOpened(true)}
leftSection={<IconClock size={14} />}
rightSection={
inputValue ? (
<IconX
size={14}
style={{ cursor: 'pointer', color: '#999' }}
onClick={(e) => {
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,
},
}}
/>
</Popover.Target>
<Popover.Dropdown
style={{
padding: 0,
background: '#fff',
borderRadius: 4,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.12)',
}}
>
<ScrollArea
h={ITEM_HEIGHT * MAX_VISIBLE}
scrollbars="y"
type="auto"
offsetScrollbars
styles={{
scrollbar: {
width: 0,
opacity: 0,
},
}}
>
<Stack gap={0}>
{TIME_OPTIONS.map((time) => {
const isSelected = time === selectedTime;
return (
<Box
key={time}
onClick={() => 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';
}
}}
>
<Text
size="sm"
style={{
color: isSelected ? '#007AFF' : '#1a1a1a',
fontWeight: isSelected ? 500 : 400,
fontSize: 14,
letterSpacing: '0.02em',
}}
>
{time}
</Text>
{isSelected && (
<Box
style={{
width: 16,
height: 16,
borderRadius: '50%',
background: '#007AFF',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text size={10} c="white" fw={600}>
</Text>
</Box>
)}
</Box>
);
})}
</Stack>
</ScrollArea>
</Popover.Dropdown>
</Popover>
);
}

View File

@ -19,7 +19,7 @@ import { DatePickerInput } from '@mantine/dates';
import { IconCalendar, IconClock, IconSettings, IconLogout, IconX } from '@tabler/icons-react'; import { IconCalendar, IconClock, IconSettings, IconLogout, IconX } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { FixedCalendar } from '../components/common/FixedCalendar'; 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 { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores'; import { useAppStore } from '../stores';
import { AnniversaryList } from '../components/anniversary/AnniversaryList'; import { AnniversaryList } from '../components/anniversary/AnniversaryList';
@ -551,12 +551,11 @@ export function HomePage() {
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
{/* Time selector (only for reminders) - Popover picker */} {/* Time selector (only for reminders) - Time picker */}
{formType === 'reminder' && ( {formType === 'reminder' && (
<PopoverTimePicker <TimePicker
value={formTime} value={formTime}
onChange={(time) => setFormTime(time)} onChange={(time) => setFormTime(time)}
size="sm"
/> />
)} )}
</Group> </Group>

View File

@ -291,7 +291,12 @@ export function calculateReminderTimes(
hasTime: boolean, hasTime: boolean,
reminderValue: string reminderValue: string
): string[] { ): string[] {
// 如果日期无效,返回空数组
const eventDate = new Date(eventDateStr); const eventDate = new Date(eventDateStr);
if (isNaN(eventDate.getTime())) {
return [];
}
const reminderTimes: string[] = []; const reminderTimes: string[] = [];
if (!hasTime) { if (!hasTime) {
@ -421,6 +426,10 @@ export function formatReminderTimeDisplay(reminderTimes: string[] | undefined):
} }
const reminderTime = new Date(reminderTimes[0]); const reminderTime = new Date(reminderTimes[0]);
// 如果日期无效,返回空字符串
if (isNaN(reminderTime.getTime())) {
return '';
}
return reminderTime.toLocaleString('zh-CN', { return reminderTime.toLocaleString('zh-CN', {
month: 'numeric', month: 'numeric',
day: 'numeric', day: 'numeric',