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:
parent
ddd8ef88bf
commit
bc627544d8
@ -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>
|
||||
);
|
||||
}
|
||||
281
src/components/common/TimePicker.tsx
Normal file
281
src/components/common/TimePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
{/* Time selector (only for reminders) - Popover picker */}
|
||||
{/* Time selector (only for reminders) - Time picker */}
|
||||
{formType === 'reminder' && (
|
||||
<PopoverTimePicker
|
||||
<TimePicker
|
||||
value={formTime}
|
||||
onChange={(time) => setFormTime(time)}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user