Compare commits

..

No commits in common. "bc627544d891c7a07a9aebbbaceb0dd7fcee8e5e" and "2feb02becf6a83bc5b82150747951d9f8a6aa22a" have entirely different histories.

4 changed files with 56 additions and 300 deletions

View File

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

View File

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

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

View File

@ -291,12 +291,7 @@ export function calculateReminderTimes(
hasTime: boolean,
reminderValue: string
): string[] {
// 如果日期无效,返回空数组
const eventDate = new Date(eventDateStr);
if (isNaN(eventDate.getTime())) {
return [];
}
const reminderTimes: string[] = [];
if (!hasTime) {
@ -426,10 +421,6 @@ 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',