feat: 优化提醒时间选择器和 SW 宽限期

- 缩短 SW 宽限期从10分钟改为3分钟
- 新增 PopoverTimePicker 弹出式时间选择器
- 支持数字输入和30分钟间隔选择
- 替换原有的 WheelTimePicker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-02-11 14:54:17 +08:00
parent 2feb02becf
commit 62aa5cd54c
3 changed files with 232 additions and 56 deletions

View File

@ -52,15 +52,15 @@ function checkReminders(events) {
for (const reminderTime of event.reminder_times) { for (const reminderTime of event.reminder_times) {
const rt = new Date(reminderTime); const rt = new Date(reminderTime);
// 检查是否在最近10分钟内(宽限期,处理后台运行不稳定的情况) // 检查是否在最近3分钟内(宽限期,处理后台运行不稳定的情况)
const diffMs = now.getTime() - rt.getTime(); const diffMs = now.getTime() - rt.getTime();
const diffMinutes = diffMs / (1000 * 60); const diffMinutes = diffMs / (1000 * 60);
// 跳过还未到的提醒 // 跳过还未到的提醒
if (diffMinutes < 0) continue; if (diffMinutes < 0) continue;
// 如果在10分钟宽限期内,且还没发送过通知 // 如果在3分钟宽限期内,且还没发送过通知
if (diffMinutes < 10) { if (diffMinutes < 3) {
const tag = `reminder-${event.id}-${reminderTime}`; const tag = `reminder-${event.id}-${reminderTime}`;
// 避免重复发送同一通知 // 避免重复发送同一通知

View File

@ -0,0 +1,222 @@
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

@ -15,11 +15,10 @@ import {
ActionIcon, ActionIcon,
} from '@mantine/core'; } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates'; import { DatePickerInput } from '@mantine/dates';
import { Popover } from '@mantine/core';
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 { WheelTimePicker } from '../components/common/WheelTimePicker'; import { PopoverTimePicker } from '../components/common/PopoverTimePicker';
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,58 +550,13 @@ export function HomePage() {
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
{/* Time selector (only for reminders) - Wheel picker */} {/* Time selector (only for reminders) - Popover picker */}
{formType === 'reminder' && ( {formType === 'reminder' && (
<Popover <PopoverTimePicker
position="bottom" value={formTime}
withArrow onChange={(time) => setFormTime(time)}
shadow="md" size="sm"
> />
<Popover.Target>
<Group gap={4} style={{ flex: 1 }}>
<TextInput
placeholder="选择时间"
value={formTime}
leftSection={<IconClock size={14} />}
leftSectionPointerEvents="none"
rightSection={
formTime ? (
<ActionIcon
variant="subtle"
size="xs"
radius="md"
onClick={() => setFormTime('')}
style={{ color: '#9ca3af' }}
>
<IconX size={12} />
</ActionIcon>
) : null
}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
borderColor: '#ddd',
color: '#666',
height: 32,
paddingLeft: 36,
paddingRight: formTime ? 28 : 8,
cursor: 'pointer',
},
section: {
paddingLeft: 8,
},
}}
/>
</Group>
</Popover.Target>
<Popover.Dropdown style={{ background: 'transparent', border: 'none', padding: 0, boxShadow: 'none' }}>
<WheelTimePicker
value={formTime}
onChange={(time) => setFormTime(time)}
/>
</Popover.Dropdown>
</Popover>
)} )}
</Group> </Group>
</Box> </Box>