Compare commits
3 Commits
2feb02becf
...
bc627544d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc627544d8 | ||
|
|
ddd8ef88bf | ||
|
|
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}`;
|
||||||
|
|
||||||
// 避免重复发送同一通知
|
// 避免重复发送同一通知
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,13 +13,13 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Box,
|
Box,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Popover,
|
||||||
} 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 { 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,58 +551,12 @@ export function HomePage() {
|
|||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Time selector (only for reminders) - Wheel picker */}
|
{/* Time selector (only for reminders) - Time picker */}
|
||||||
{formType === 'reminder' && (
|
{formType === 'reminder' && (
|
||||||
<Popover
|
<TimePicker
|
||||||
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)}
|
||||||
/>
|
/>
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user