feat: 优化提醒时间选择器和 SW 宽限期
- 缩短 SW 宽限期从10分钟改为3分钟 - 新增 PopoverTimePicker 弹出式时间选择器 - 支持数字输入和30分钟间隔选择 - 替换原有的 WheelTimePicker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2feb02becf
commit
62aa5cd54c
@ -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}`;
|
||||||
|
|
||||||
// 避免重复发送同一通知
|
// 避免重复发送同一通知
|
||||||
|
|||||||
222
src/components/common/PopoverTimePicker.tsx
Normal file
222
src/components/common/PopoverTimePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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"
|
|
||||||
withArrow
|
|
||||||
shadow="md"
|
|
||||||
>
|
|
||||||
<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}
|
value={formTime}
|
||||||
onChange={(time) => setFormTime(time)}
|
onChange={(time) => setFormTime(time)}
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user