Compare commits

..

No commits in common. "1559e603b0ac6fdae8e82ec163248c33923e1965" and "a8b4f17043328f8a5e23f9b4fcfca5bd5e2d3b4f" have entirely different histories.

10 changed files with 232 additions and 1073 deletions

View File

@ -1,5 +1,5 @@
import { useMemo, useRef } from 'react'; import { useMemo } from 'react';
import { Stack, Text, Paper, Group, Button, Box } from '@mantine/core'; import { Stack, Text, Paper, Group, Button } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus } from '@tabler/icons-react';
import { AnniversaryCard } from './AnniversaryCard'; import { AnniversaryCard } from './AnniversaryCard';
import { getHolidaysForYear } from '../../constants/holidays'; import { getHolidaysForYear } from '../../constants/holidays';
@ -28,42 +28,6 @@ interface BuiltInHolidayEvent {
export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) { export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) {
const anniversaries = events.filter((e) => e.type === 'anniversary'); const anniversaries = events.filter((e) => e.type === 'anniversary');
const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true); const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 滚动条样式 - 仅在悬停时显示
const scrollbarStyle = `
.anniversary-scroll::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.anniversary-scroll::-webkit-scrollbar-track {
background: transparent;
}
.anniversary-scroll::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 2px;
}
.anniversary-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
`;
// 处理滚轮事件,实现列表独立滚动
const handleWheel = (e: React.WheelEvent) => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
// 使用 1px 缓冲避免浮点数精度问题
const isAtTop = scrollTop <= 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 如果已经滚动到顶部且向下滚动,或者已经滚动到底部且向上滚动,则阻止事件冒泡
if ((isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
// 如果在滚动范围内,允许事件继续传递以实现正常滚动
};
// 获取内置节假日 // 获取内置节假日
const builtInHolidays = useMemo(() => { const builtInHolidays = useMemo(() => {
@ -124,9 +88,8 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
// 空状态 // 空状态
if (allAnniversaries.total === 0) { if (allAnniversaries.total === 0) {
return ( return (
<Paper p="md" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <Paper p="md" withBorder radius={4} h="100%">
<Stack align="center" justify="center" style={{ flex: 1 }}> <Stack align="center" justify="center" h="100%">
<style>{scrollbarStyle}</style>
<Text c="#999" size="sm" style={{ letterSpacing: '0.05em' }}> <Text c="#999" size="sm" style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
@ -149,10 +112,8 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
} }
return ( return (
<> <Paper p="md" withBorder radius={4} h="100%">
<style>{scrollbarStyle}</style> <Group justify="space-between" mb="sm">
<Paper p="md" withBorder radius={4} data-scroll-container="anniversary" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
<Group gap={8}> <Group gap={8}>
<Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}> <Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
@ -175,13 +136,7 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
</Button> </Button>
</Group> </Group>
<Box <Stack gap="xs" style={{ maxHeight: 'calc(100% - 40px)', overflowY: 'auto' }}>
ref={scrollContainerRef}
onWheel={handleWheel}
className="anniversary-scroll"
style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}
>
<Stack gap="xs">
{/* 内置节假日 */} {/* 内置节假日 */}
{allAnniversaries.builtIn.length > 0 && ( {allAnniversaries.builtIn.length > 0 && (
<> <>
@ -238,9 +193,7 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
))} ))}
</> </>
)} )}
</Stack> </Stack>
</Box> </Paper>
</Paper>
</>
); );
} }

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Paper, Paper,
Textarea, Textarea,
@ -47,24 +47,6 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
// Auto-save with 3 second debounce // Auto-save with 3 second debounce
const [debouncedContent] = useDebouncedValue(content, 3000); const [debouncedContent] = useDebouncedValue(content, 3000);
// 滚动条样式 - 仅在悬停时显示
const scrollbarStyle = `
.note-scroll::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.note-scroll::-webkit-scrollbar-track {
background: transparent;
}
.note-scroll::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 2px;
}
.note-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
`;
useEffect(() => { useEffect(() => {
if (debouncedContent !== undefined && notes && debouncedContent !== content) { if (debouncedContent !== undefined && notes && debouncedContent !== content) {
handleSave(debouncedContent); handleSave(debouncedContent);
@ -105,28 +87,8 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
} }
}; };
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 处理滚轮事件,实现列表独立滚动
const handleWheel = (e: React.WheelEvent) => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
// 使用 1px 缓冲避免浮点数精度问题
const isAtTop = scrollTop <= 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 如果已经滚动到顶部且向下滚动,或者已经滚动到底部且向上滚动,则阻止事件冒泡
if ((isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
// 如果在滚动范围内,允许事件继续传递以实现正常滚动
};
return ( return (
<Paper p="md" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<style>{scrollbarStyle}</style>
<Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}> <Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Header */} {/* Header */}
<Group justify="space-between" style={{ flexShrink: 0 }}> <Group justify="space-between" style={{ flexShrink: 0 }}>
@ -157,13 +119,7 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
</Group> </Group>
{/* Editor/Preview Area */} {/* Editor/Preview Area */}
<Box <Box style={{ flex: 1, minHeight: 0, display: 'flex' }}>
ref={scrollContainerRef}
onWheel={handleWheel}
data-scroll-container="note"
className="note-scroll"
style={{ flex: 1, minHeight: 0, display: 'flex', overflowY: 'auto' }}
>
{viewMode === 'edit' && ( {viewMode === 'edit' && (
<Textarea <Textarea
value={content} value={content}
@ -182,11 +138,7 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
'&:focus': { outline: 'none' }, '&:focus': { outline: 'none' },
}, },
}} }}
style={{ style={{ flex: 1, width: '100%' }}
flex: 1,
width: '100%',
overflowY: 'auto',
}}
/> />
)} )}

View File

@ -19,31 +19,6 @@ interface ArchiveReminderModalProps {
onDelete: (event: Event) => void; onDelete: (event: Event) => void;
} }
/**
*
*/
function formatArchiveDate(dateStr: string): string {
const date = new Date(dateStr);
const hours = date.getHours();
const minutes = date.getMinutes();
const hasExplicitTime = hours !== 0 || minutes !== 0;
if (hasExplicitTime) {
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} else {
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
}
}
export function ArchiveReminderModal({ export function ArchiveReminderModal({
opened, opened,
onClose, onClose,
@ -116,7 +91,12 @@ export function ArchiveReminderModal({
{event.title} {event.title}
</Text> </Text>
<Text size="xs" c="#bbb"> <Text size="xs" c="#bbb">
{formatArchiveDate(event.date)} {new Date(event.date).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text> </Text>
</Stack> </Stack>
<Group gap={4}> <Group gap={4}>

View File

@ -1,67 +1,37 @@
import { useMemo, useState } from 'react'; import { useMemo, useState, useEffect } from 'react';
import { Paper, Text, Checkbox, Group, Stack, ActionIcon, Box } from '@mantine/core'; import { Paper, Text, Checkbox, Group, Stack, ActionIcon } from '@mantine/core';
import { IconDots, IconArrowForward, IconRepeat, IconRepeatOff } from '@tabler/icons-react'; import { IconDots } from '@tabler/icons-react';
import type { Event, RepeatType } from '../../types'; import type { Event } from '../../types';
import { getRepeatTypeLabel } from '../../utils/repeatCalculator';
interface ReminderCardProps { interface ReminderCardProps {
event: Event; event: Event;
onToggle: () => void; onToggle: () => void;
onClick: () => void; onClick: () => void;
onDelete?: () => void; onDelete?: () => void;
onPostpone?: () => void;
isMissed?: boolean; isMissed?: boolean;
onMissedToggle?: () => void;
} }
export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, isMissed = false, onMissedToggle }: ReminderCardProps) { export function ReminderCard({ event, onToggle, onClick, isMissed = false }: ReminderCardProps) {
const isCompleted = event.is_completed ?? false; const isCompleted = event.is_completed ?? false;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
// 计算时间信息 // 计算时间信息
const timeInfo = useMemo(() => { const timeInfo = useMemo(() => {
// 处理空日期情况
if (!event.date) {
return { isPast: false, isToday: true, timeStr: '未设置时间', diff: 0, hasTime: false };
}
const now = new Date(); const now = new Date();
const dateStr = event.date as string; const eventDate = new Date(event.date);
// 统一使用 Date 对象处理
const eventDate = new Date(dateStr);
// 检查是否包含有效时间小时和分钟不全为0
// 对于本地格式(如 "2025-02-05T00:00:00")和 ISO 格式都适用
const hours = eventDate.getHours();
const minutes = eventDate.getMinutes();
const hasExplicitTime = hours !== 0 || minutes !== 0;
const diff = eventDate.getTime() - now.getTime(); const diff = eventDate.getTime() - now.getTime();
const isPast = diff < 0; const isPast = diff < 0;
const isToday = eventDate.toDateString() === now.toDateString(); const isToday = eventDate.toDateString() === now.toDateString();
// 格式化显示 const timeStr = eventDate.toLocaleString('zh-CN', {
let timeStr: string; month: 'numeric',
if (hasExplicitTime) { day: 'numeric',
// 有设置时间,显示日期+时间 hour: '2-digit',
timeStr = eventDate.toLocaleString('zh-CN', { minute: '2-digit',
month: 'numeric', });
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} else {
// 没有设置时间00:00只显示日期
timeStr = eventDate.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
}
return { isPast, isToday, timeStr, diff, hasTime: hasExplicitTime }; return { isPast, isToday, timeStr, diff };
}, [event.date]); }, [event.date]);
// 获取文字颜色 // 获取文字颜色
@ -92,18 +62,6 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
return 'rgba(0, 0, 0, 0.06)'; return 'rgba(0, 0, 0, 0.06)';
}; };
// 获取循环图标颜色
const getRepeatIconColor = (type: RepeatType) => {
const colors: Record<RepeatType, string> = {
daily: '#3b82f6', // 蓝色
weekly: '#22c55e', // 绿色
monthly: '#a855f7', // 紫色
yearly: '#f59e0b', // 橙色
none: '#999',
};
return colors[type] || '#999';
};
return ( return (
<Paper <Paper
p="sm" p="sm"
@ -132,70 +90,68 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
100% { opacity: 0; transform: translateX(20px); } 100% { opacity: 0; transform: translateX(20px); }
} }
`}</style> `}</style>
{/* 正常提醒卡片:左右分栏布局 */} <Group justify="space-between" wrap="nowrap">
{!isMissed ? ( <Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" align="flex-start"> {/* Checkbox */}
{/* 左侧Checkbox + 标题 + 内容 */} <div
<Box style={{
style={{ flex: 1, minWidth: 0, cursor: 'pointer' }} display: 'flex',
onClick={(e) => { alignItems: 'center',
// 阻止事件冒泡到 Card避免重复触发 animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none',
e.stopPropagation();
onClick();
}} }}
> >
<Group gap="sm" wrap="nowrap" align="flex-start"> <Checkbox
{/* Checkbox - 单独处理,不触发卡片点击 */} checked={isCompleted}
<div onChange={(e) => {
style={{ e.stopPropagation();
display: 'flex', if (isMissed && !isCompleted) {
alignItems: 'flex-start', // 已过期提醒:先播放动画,动画结束后再触发 toggle
paddingTop: 2, setIsAnimating(true);
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none', setTimeout(() => {
}} setIsAnimating(false);
onClick={(e) => {
// 阻止事件冒泡,避免触发卡片点击
e.stopPropagation();
}}
>
<Checkbox
checked={isCompleted}
onChange={(e) => {
e.stopPropagation();
onToggle(); onToggle();
}} }, 300);
size="xs" } else {
color="#1a1a1a" onToggle();
style={{ }
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none', }}
}} size="xs"
/> color="#1a1a1a"
</div> />
</div>
{/* 标题 */} <Stack gap={2} style={{ flex: 1, minWidth: 0 }} onClick={onClick}>
<Text {/* Title */}
fw={400} <Text
size="xs" fw={400}
lineClamp={1} size="xs"
style={{ lineClamp={1}
textDecoration: isCompleted ? 'line-through' : 'none', style={{
color: getTextColor(), textDecoration: isCompleted ? 'line-through' : 'none',
letterSpacing: '0.03em', color: getTextColor(),
transition: 'color 0.2s ease', letterSpacing: '0.03em',
}} transition: 'color 0.2s ease',
> }}
{event.title} >
</Text> {event.title}
</Group> </Text>
{/* 内容在标题下方 */} {/* Time */}
<Text
size="xs"
c={getTimeColor()}
style={{ letterSpacing: '0.05em' }}
>
{timeInfo.timeStr}
</Text>
{/* Content preview - 始终显示 */}
{event.content && ( {event.content && (
<Text <Text
size="xs" size="xs"
c={isCompleted ? '#bbb' : '#999'} c={isCompleted ? '#bbb' : '#999'}
lineClamp={1} lineClamp={1}
style={{ style={{
marginLeft: 28,
opacity: isCompleted ? 0.6 : 1, opacity: isCompleted ? 0.6 : 1,
transition: 'opacity 0.2s ease', transition: 'opacity 0.2s ease',
}} }}
@ -203,154 +159,30 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
{event.content} {event.content}
</Text> </Text>
)} )}
</Box> </Stack>
{/* 右侧:循环图标 + 日期时间 */}
<Box style={{ flex: '0 0 auto', minWidth: 0, paddingLeft: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
{event.repeat_type !== 'none' && (
<Box
style={{
display: 'flex',
alignItems: 'center',
color: getRepeatIconColor(event.repeat_type),
}}
title={getRepeatTypeLabel(event.repeat_type)}
>
<IconRepeat size={12} />
</Box>
)}
<Text
size="xs"
c={getTimeColor()}
style={{ letterSpacing: '0.05em', whiteSpace: 'nowrap' }}
>
{timeInfo.timeStr}
</Text>
</Box>
</Group> </Group>
) : (
/* 逾期提醒卡片:保持原有结构 */
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none',
}}
>
<Checkbox
checked={isCompleted}
onChange={(e) => {
e.stopPropagation();
if (isMissed && !isCompleted) {
setIsAnimating(true);
setTimeout(() => {
setIsAnimating(false);
onToggle();
onMissedToggle?.();
}, 300);
} else {
onToggle();
}
}}
size="xs"
color="#1a1a1a"
/>
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} onClick={onClick}> {/* Quick actions */}
<Group gap={4} wrap="nowrap"> <Group gap={4}>
<Text <ActionIcon
fw={400} size="sm"
size="xs" variant="subtle"
lineClamp={1} color="gray"
style={{ onClick={(e) => {
textDecoration: isCompleted ? 'line-through' : 'none', e.stopPropagation();
color: getTextColor(), onClick();
letterSpacing: '0.03em', }}
transition: 'color 0.2s ease', style={{
}} color: '#999',
> opacity: isHovered ? 1 : 0,
{event.title} transition: 'all 0.2s ease',
</Text> }}
{event.repeat_type !== 'none' && ( title="编辑"
<Box >
style={{ <IconDots size={12} />
display: 'flex', </ActionIcon>
alignItems: 'center',
flexShrink: 0,
color: getRepeatIconColor(event.repeat_type),
}}
title={getRepeatTypeLabel(event.repeat_type)}
>
<IconRepeat size={10} />
</Box>
)}
</Group>
<Text
size="xs"
c={getTimeColor()}
style={{ letterSpacing: '0.05em' }}
>
{timeInfo.timeStr}
</Text>
{event.content && (
<Text
size="xs"
c={isCompleted ? '#bbb' : '#999'}
lineClamp={1}
style={{
opacity: isCompleted ? 0.6 : 1,
transition: 'opacity 0.2s ease',
}}
>
{event.content}
</Text>
)}
</Stack>
</Group>
<Group gap={4}>
{onPostpone && (
<ActionIcon
size="sm"
variant="subtle"
color="orange"
onClick={(e) => {
e.stopPropagation();
onPostpone();
}}
style={{
color: '#e67e22',
opacity: isHovered ? 1 : 0,
transition: 'all 0.2s ease',
}}
title="顺延到今天"
>
<IconArrowForward size={12} />
</ActionIcon>
)}
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{
color: '#999',
opacity: isHovered ? 1 : 0,
transition: 'all 0.2s ease',
}}
title="编辑"
>
<IconDots size={12} />
</ActionIcon>
</Group>
</Group> </Group>
)} </Group>
</Paper> </Paper>
); );
} }

View File

@ -1,4 +1,4 @@
import { useMemo, useRef, useState, useEffect } from 'react'; import { useMemo } from 'react';
import { import {
Stack, Stack,
Text, Text,
@ -6,17 +6,11 @@ import {
Group, Group,
Button, Button,
Alert, Alert,
Box,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconPlus, IconPlus,
IconAlertCircle, IconAlertCircle,
IconArchive,
IconChevronDown,
IconChevronRight,
IconChevronUp,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { ReminderCard } from './ReminderCard'; import { ReminderCard } from './ReminderCard';
import type { Event } from '../../types'; import type { Event } from '../../types';
@ -26,7 +20,7 @@ interface ReminderListProps {
onToggleComplete: (event: Event) => void; onToggleComplete: (event: Event) => void;
onAddClick: () => void; onAddClick: () => void;
onDelete?: (event: Event) => void; onDelete?: (event: Event) => void;
onPostpone?: (event: Event) => void; onRestore?: (event: Event) => void;
} }
export function ReminderList({ export function ReminderList({
@ -35,96 +29,19 @@ export function ReminderList({
onToggleComplete, onToggleComplete,
onAddClick, onAddClick,
onDelete, onDelete,
onPostpone, onRestore,
}: ReminderListProps) { }: ReminderListProps) {
const navigate = useNavigate();
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 分类折叠状态
const [collapsed, setCollapsed] = useState({
today: false,
tomorrow: false,
later: false,
});
// 逾期列表展开状态(默认收起)
const [missedExpanded, setMissedExpanded] = useState(false);
// 归档图标抖动动画状态
const [archiveShake, setArchiveShake] = useState(false);
// 归档图标抖动动画样式
const shakeAnimationStyle = `
@keyframes archiveShake {
0%, 100% { transform: scale(1); }
25% { transform: scale(1.3); }
50% { transform: scale(1); }
75% { transform: scale(1.3); }
}
`;
// 触发归档图标抖动动画
const triggerArchiveShake = () => {
setArchiveShake(true);
setTimeout(() => {
setArchiveShake(false);
}, 400);
};
// 切换分类折叠状态
const toggleCollapse = (category: keyof typeof collapsed) => {
setCollapsed((prev) => ({
...prev,
[category]: !prev[category],
}));
};
// 滚动条样式 - 仅在悬停时显示
const scrollbarStyle = `
.reminder-scroll::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.reminder-scroll::-webkit-scrollbar-track {
background: transparent;
}
.reminder-scroll::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 2px;
}
.reminder-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
`;
// 处理滚轮事件,实现列表独立滚动
const handleWheel = (e: React.WheelEvent) => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
// 使用 1px 缓冲避免浮点数精度问题
const isAtTop = scrollTop <= 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 如果已经滚动到顶部且向下滚动,或者已经滚动到底部且向上滚动,则阻止事件冒泡
if ((isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
// 如果在滚动范围内,允许事件继续传递以实现正常滚动
};
const grouped = useMemo(() => { const grouped = useMemo(() => {
const now = new Date(); const now = new Date();
// 仅按日期部分判断,获取今天的开始时间 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); today.setHours(0, 0, 0, 0);
todayStart.setHours(0, 0, 0, 0); const tomorrow = new Date(today);
const tomorrowStart = new Date(todayStart); tomorrow.setDate(tomorrow.getDate() + 1);
tomorrowStart.setDate(tomorrowStart.getDate() + 1); tomorrow.setHours(0, 0, 0, 0);
tomorrowStart.setHours(0, 0, 0, 0); const dayAfterTomorrow = new Date(tomorrow);
const dayAfterTomorrowStart = new Date(tomorrowStart); dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);
dayAfterTomorrowStart.setDate(dayAfterTomorrowStart.getDate() + 1); dayAfterTomorrow.setHours(0, 0, 0, 0);
dayAfterTomorrowStart.setHours(0, 0, 0, 0);
const reminders = events.filter((e) => e.type === 'reminder'); const reminders = events.filter((e) => e.type === 'reminder');
@ -136,29 +53,21 @@ export function ReminderList({
}; };
reminders.forEach((event) => { reminders.forEach((event) => {
// 无日期的提醒不视为逾期,放入今天分组
if (!event.date) {
result.today.push(event);
return;
}
const eventDate = new Date(event.date); const eventDate = new Date(event.date);
// 仅取日期部分进行比较(忽略时间)
const eventDateOnly = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate());
// 已过期且已完成的去归档页 // 已过期且已完成的去归档页
if (event.is_completed && eventDateOnly < todayStart) { if (event.is_completed && eventDate < now) {
return; return;
} }
// 按日期部分判断是否逾期 // 未过期或已完成未过期的,按时间分组
if (eventDateOnly < todayStart) { if (eventDate < now) {
// 已过期未完成 // 已过期未完成
result.missed.push(event); result.missed.push(event);
} else if (eventDateOnly < tomorrowStart) { } else if (eventDate < tomorrow) {
// 今天 // 今天
result.today.push(event); result.today.push(event);
} else if (eventDateOnly < dayAfterTomorrowStart) { } else if (eventDate < dayAfterTomorrow) {
// 明天 // 明天
result.tomorrow.push(event); result.tomorrow.push(event);
} else { } else {
@ -189,8 +98,8 @@ export function ReminderList({
// 空状态 // 空状态
if (!hasActiveReminders) { if (!hasActiveReminders) {
return ( return (
<Paper p="md" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <Paper p="md" withBorder radius={4} h="100%">
<Stack align="center" justify="center" style={{ flex: 1 }}> <Stack align="center" justify="center" h="100%">
<Text c="#999" size="sm" ta="center" style={{ letterSpacing: '0.05em' }}> <Text c="#999" size="sm" ta="center" style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
@ -216,40 +125,23 @@ export function ReminderList({
); );
} }
// 已过提醒数量提示
const missedCount = grouped.missed.length;
return ( return (
<> <>
<style>{scrollbarStyle}</style> <Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<style>{shakeAnimationStyle}</style>
<Paper p="md" withBorder radius={4} data-scroll-container="reminder" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */} {/* Header */}
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}> <Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
<Group gap={8}> <Group gap={8}>
<Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}> <Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
</Text> </Text>
<Button {missedCount > 0 && (
variant="subtle" <Text size="xs" c="#c41c1c" fw={500}>
size="xs" {missedCount}
leftSection={ </Text>
<IconArchive )}
size={12}
style={{
transform: archiveShake ? 'scale(1.3)' : 'scale(1)',
transition: 'transform 0.2s ease',
animation: archiveShake ? 'archiveShake 0.4s ease-in-out' : 'none',
}}
/>
}
onClick={() => navigate('/archive')}
style={{
color: '#999',
borderRadius: 2,
padding: '2px 6px',
height: 'auto',
}}
>
</Button>
</Group> </Group>
<Button <Button
variant="subtle" variant="subtle"
@ -266,17 +158,7 @@ export function ReminderList({
</Group> </Group>
{/* Content */} {/* Content */}
<Box <Stack gap="xs" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
ref={scrollContainerRef}
onWheel={handleWheel}
className="reminder-scroll"
style={{
flex: 1,
overflowY: 'auto',
minHeight: 0,
}}
>
<Stack gap="xs">
{/* 逾期提醒 */} {/* 逾期提醒 */}
{grouped.missed.length > 0 && ( {grouped.missed.length > 0 && (
<Alert <Alert
@ -287,66 +169,20 @@ export function ReminderList({
title={<Text size="xs" fw={500} c="#c41c1c"> {grouped.missed.length}</Text>} title={<Text size="xs" fw={500} c="#c41c1c"> {grouped.missed.length}</Text>}
> >
<Stack gap={4}> <Stack gap={4}>
{/* 展开时显示所有逾期提醒 */} {grouped.missed.slice(0, 3).map((event) => (
{missedExpanded ? ( <ReminderCard
<> key={event.id}
{grouped.missed.map((event) => ( event={event}
<ReminderCard onClick={() => onEventClick(event)}
key={event.id} onToggle={() => onToggleComplete(event)}
event={event} onDelete={onDelete ? () => onDelete(event) : undefined}
onClick={() => onEventClick(event)} isMissed={true}
onToggle={() => onToggleComplete(event)} />
onDelete={onDelete ? () => onDelete(event) : undefined} ))}
onPostpone={onPostpone ? () => onPostpone(event) : undefined} {grouped.missed.length > 3 && (
onMissedToggle={triggerArchiveShake} <Text size="xs" c="#999" ta="center">
isMissed={true} {grouped.missed.length - 3} ...
/> </Text>
))}
{/* 收起按钮 */}
<Button
variant="subtle"
size="xs"
leftSection={<IconChevronUp size={12} />}
onClick={() => setMissedExpanded(false)}
style={{
color: '#999',
borderRadius: 2,
marginTop: 4,
}}
>
</Button>
</>
) : (
<>
{/* 收起状态只显示前3个 */}
{grouped.missed.slice(0, 3).map((event) => (
<ReminderCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
onPostpone={onPostpone ? () => onPostpone(event) : undefined}
onMissedToggle={triggerArchiveShake}
isMissed={true}
/>
))}
{grouped.missed.length > 3 && (
<Text
size="xs"
c="#999"
ta="center"
style={{
cursor: 'pointer',
textDecoration: 'underline',
}}
onClick={() => setMissedExpanded(true)}
>
{grouped.missed.length - 3} ...
</Text>
)}
</>
)} )}
</Stack> </Stack>
</Alert> </Alert>
@ -355,21 +191,12 @@ export function ReminderList({
{/* 今天 */} {/* 今天 */}
{grouped.today.length > 0 && ( {grouped.today.length > 0 && (
<> <>
<Group <Group gap={4}>
gap={4}
style={{ cursor: 'pointer' }}
onClick={() => toggleCollapse('today')}
>
<Text size="xs" c="#c41c1c" fw={400} style={{ letterSpacing: '0.05em' }}> <Text size="xs" c="#c41c1c" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
{collapsed.today ? (
<IconChevronRight size={12} color="#999" />
) : (
<IconChevronDown size={12} color="#999" />
)}
</Group> </Group>
{!collapsed.today && grouped.today.map((event) => ( {grouped.today.map((event) => (
<ReminderCard <ReminderCard
key={event.id} key={event.id}
event={event} event={event}
@ -384,21 +211,12 @@ export function ReminderList({
{/* 明天 */} {/* 明天 */}
{grouped.tomorrow.length > 0 && ( {grouped.tomorrow.length > 0 && (
<> <>
<Group <Group gap={4}>
gap={4}
style={{ cursor: 'pointer' }}
onClick={() => toggleCollapse('tomorrow')}
>
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}> <Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
{collapsed.tomorrow ? (
<IconChevronRight size={12} color="#999" />
) : (
<IconChevronDown size={12} color="#999" />
)}
</Group> </Group>
{!collapsed.tomorrow && grouped.tomorrow.map((event) => ( {grouped.tomorrow.map((event) => (
<ReminderCard <ReminderCard
key={event.id} key={event.id}
event={event} event={event}
@ -413,21 +231,12 @@ export function ReminderList({
{/* 更久之后 */} {/* 更久之后 */}
{grouped.later.length > 0 && ( {grouped.later.length > 0 && (
<> <>
<Group <Group gap={4}>
gap={4}
style={{ cursor: 'pointer' }}
onClick={() => toggleCollapse('later')}
>
<Text size="xs" c="#999" fw={400} style={{ letterSpacing: '0.05em' }}> <Text size="xs" c="#999" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
{collapsed.later ? (
<IconChevronRight size={12} color="#999" />
) : (
<IconChevronDown size={12} color="#999" />
)}
</Group> </Group>
{!collapsed.later && grouped.later.map((event) => ( {grouped.later.map((event) => (
<ReminderCard <ReminderCard
key={event.id} key={event.id}
event={event} event={event}
@ -439,7 +248,6 @@ export function ReminderList({
</> </>
)} )}
</Stack> </Stack>
</Box>
</Paper> </Paper>
</> </>
); );

View File

@ -15,31 +15,6 @@ import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores'; import { useAppStore } from '../stores';
import type { Event } from '../types'; import type { Event } from '../types';
/**
*
*/
function formatArchiveDate(dateStr: string): string {
const date = new Date(dateStr);
const hours = date.getHours();
const minutes = date.getMinutes();
const hasExplicitTime = hours !== 0 || minutes !== 0;
if (hasExplicitTime) {
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} else {
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
}
}
export function ArchivePage() { export function ArchivePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const events = useAppStore((state) => state.events); const events = useAppStore((state) => state.events);
@ -60,14 +35,10 @@ export function ArchivePage() {
// 获取已归档的提醒(已过期且已勾选的) // 获取已归档的提醒(已过期且已勾选的)
const archivedReminders = events.filter((e) => { const archivedReminders = events.filter((e) => {
if (e.type !== 'reminder' || !e.is_completed) return false; if (e.type !== 'reminder' || !e.is_completed) return false;
if (!e.date) return false; // 跳过无日期的
const eventDate = new Date(e.date); const eventDate = new Date(e.date);
const now = new Date(); const now = new Date();
return eventDate < now; // 仅已过期的 return eventDate < now; // 仅已过期的
}).sort((a, b) => { }).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (!a.date || !b.date) return 0;
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
const handleRestore = async (event: Event) => { const handleRestore = async (event: Event) => {
await updateEventById(event.id, { is_completed: false }); await updateEventById(event.id, { is_completed: false });
@ -157,7 +128,12 @@ export function ArchivePage() {
{event.title} {event.title}
</Text> </Text>
<Text size="xs" c="#bbb"> <Text size="xs" c="#bbb">
{formatArchiveDate(event.date)} {new Date(event.date).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text> </Text>
{event.content && ( {event.content && (
<Text size="xs" c="#bbb" lineClamp={1}> <Text size="xs" c="#bbb" lineClamp={1}>

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Container, Container,
Grid,
Title, Title,
Button, Button,
Group, Group,
@ -13,7 +14,7 @@ import {
Stack, Stack,
} from '@mantine/core'; } from '@mantine/core';
import { DatePickerInput, TimeInput } from '@mantine/dates'; import { DatePickerInput, TimeInput } from '@mantine/dates';
import { IconLogout, IconSettings } from '@tabler/icons-react'; import { IconLogout, IconSettings, IconArchive } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores'; import { useAppStore } from '../stores';
@ -22,7 +23,6 @@ import { ReminderList } from '../components/reminder/ReminderList';
import { NoteEditor } from '../components/note/NoteEditor'; import { NoteEditor } from '../components/note/NoteEditor';
import { FloatingAIChat } from '../components/ai/FloatingAIChat'; import { FloatingAIChat } from '../components/ai/FloatingAIChat';
import type { Event, EventType, RepeatType } from '../types'; import type { Event, EventType, RepeatType } from '../types';
import { calculateNextReminderDate } from '../utils/repeatCalculator';
export function HomePage() { export function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -57,7 +57,6 @@ export function HomePage() {
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/landing');
}; };
const handleEventClick = (event: Event) => { const handleEventClick = (event: Event) => {
@ -70,11 +69,6 @@ export function HomePage() {
setFormIsLunar(event.is_lunar); setFormIsLunar(event.is_lunar);
setFormRepeatType(event.repeat_type); setFormRepeatType(event.repeat_type);
setFormIsHoliday(event.is_holiday || false); setFormIsHoliday(event.is_holiday || false);
// 提取时间(如果日期中包含时间信息)
const eventDate = new Date(event.date);
const hours = eventDate.getHours();
const minutes = eventDate.getMinutes();
setFormTime(hours === 0 && minutes === 0 ? '' : `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
open(); open();
}; };
@ -98,38 +92,20 @@ export function HomePage() {
// 确保 date 是 Date 对象 // 确保 date 是 Date 对象
const dateObj = formDate instanceof Date ? formDate : new Date(formDate as unknown as string); const dateObj = formDate instanceof Date ? formDate : new Date(formDate as unknown as string);
let dateStr: string; const dateStr = formTime
const year = dateObj.getFullYear(); ? new Date(dateObj.setHours(parseInt(formTime.split(':')[0]), parseInt(formTime.split(':')[1])))
const month = String(dateObj.getMonth() + 1).padStart(2, '0'); : dateObj;
const day = String(dateObj.getDate()).padStart(2, '0');
if (formTime) { const eventData = {
// 有时间:构建本地时间格式
const hours = String(parseInt(formTime.split(':')[0])).padStart(2, '0');
const minutes = String(parseInt(formTime.split(':')[1])).padStart(2, '0');
dateStr = `${year}-${month}-${day}T${hours}:${minutes}:00`;
} else {
// 无时间:只保存日期部分
dateStr = `${year}-${month}-${day}T00:00:00`;
}
// 构建事件数据,确保不包含 undefined 值
const eventData: Record<string, any> = {
type: formType, type: formType,
title: formTitle, title: formTitle,
date: dateStr, content: formContent || undefined,
date: dateStr.toISOString(),
is_lunar: formIsLunar, is_lunar: formIsLunar,
repeat_type: formRepeatType, repeat_type: formRepeatType,
// 计算下一次提醒日期 is_holiday: formIsHoliday || undefined,
next_reminder_date: calculateNextReminderDate(dateStr, formRepeatType, undefined),
is_holiday: formIsHoliday ?? false,
}; };
// 只有当 content 有值时才包含
if (formContent.trim()) {
eventData.content = formContent;
}
if (isEdit && selectedEvent) { if (isEdit && selectedEvent) {
await updateEventById(selectedEvent.id, eventData); await updateEventById(selectedEvent.id, eventData);
} else { } else {
@ -167,25 +143,12 @@ export function HomePage() {
fetchEvents(); fetchEvents();
}; };
const handlePostpone = async (event: Event) => { const handleRestore = async (event: Event) => {
if (event.type !== 'reminder') return; if (event.type !== 'reminder') return;
// 将日期顺延到今天,保留原事件的时间 await updateEventById(event.id, {
const today = new Date(); is_completed: false,
const originalDate = new Date(event.date);
// 提取原时间的小时和分钟
const hours = originalDate.getHours();
const minutes = originalDate.getMinutes();
// 构建新的本地时间字符串
const newDateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
const result = await updateEventById(event.id, {
date: newDateStr,
}); });
if (result.error) { fetchEvents();
console.error('顺延失败:', result.error);
}
}; };
const resetForm = () => { const resetForm = () => {
@ -210,11 +173,9 @@ export function HomePage() {
style={{ style={{
minHeight: '100vh', minHeight: '100vh',
background: '#faf9f7', background: '#faf9f7',
overflow: 'hidden',
height: '100vh',
}} }}
> >
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}>
{/* Header */} {/* Header */}
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}> <Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
<Title <Title
@ -229,6 +190,20 @@ export function HomePage() {
</Title> </Title>
<Group> <Group>
{/* 归档入口 - 一直显示 */}
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconArchive size={12} />}
onClick={() => navigate('/archive')}
style={{
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
</Button>
{/* 设置入口 */} {/* 设置入口 */}
<Button <Button
variant="subtle" variant="subtle"
@ -267,33 +242,39 @@ export function HomePage() {
</Group> </Group>
{/* Main Content - 3 column horizontal layout */} {/* Main Content - 3 column horizontal layout */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 16, overflow: 'hidden' }}> <Grid grow style={{ flex: 1, minHeight: 0 }} gutter="md">
{/* Left column - Anniversary */} {/* Left column - Anniversary */}
<div style={{ flex: 1, minWidth: 0, height: '100%', overflow: 'hidden' }}> <Grid.Col span={4}>
<AnniversaryList <div style={{ height: '100%', minHeight: 0 }}>
events={events} <AnniversaryList
onEventClick={handleEventClick} events={events}
onAddClick={() => handleAddClick('anniversary')} onEventClick={handleEventClick}
/> onAddClick={() => handleAddClick('anniversary')}
</div> />
</div>
</Grid.Col>
{/* Middle column - Reminder */} {/* Middle column - Reminder */}
<div style={{ flex: 1, minWidth: 0, height: '100%', overflow: 'hidden' }}> <Grid.Col span={4}>
<ReminderList <div style={{ height: '100%', minHeight: 0 }}>
events={events} <ReminderList
onEventClick={handleEventClick} events={events}
onToggleComplete={handleToggleComplete} onEventClick={handleEventClick}
onAddClick={() => handleAddClick('reminder')} onToggleComplete={handleToggleComplete}
onDelete={handleDelete} onAddClick={() => handleAddClick('reminder')}
onPostpone={handlePostpone} onDelete={handleDelete}
/> onRestore={handleRestore}
</div> />
</div>
</Grid.Col>
{/* Right column - Note */} {/* Right column - Note */}
<div style={{ flex: 1, minWidth: 0, height: '100%', overflow: 'hidden' }}> <Grid.Col span={4}>
<NoteEditor /> <div style={{ height: '100%', minHeight: 0 }}>
</div> <NoteEditor />
</div> </div>
</Grid.Col>
</Grid>
{/* AI Chat - Floating */} {/* AI Chat - Floating */}
<FloatingAIChat onEventCreated={handleAIEventCreated} /> <FloatingAIChat onEventCreated={handleAIEventCreated} />
@ -445,10 +426,8 @@ export function HomePage() {
} }
data={[ data={[
{ value: 'none', label: '不重复' }, { value: 'none', label: '不重复' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'yearly', label: '每年' }, { value: 'yearly', label: '每年' },
{ value: 'monthly', label: '每月' },
]} ]}
value={formRepeatType} value={formRepeatType}
onChange={(value) => value && setFormRepeatType(value as RepeatType)} onChange={(value) => value && setFormRepeatType(value as RepeatType)}

View File

@ -1,8 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import type { User, Event, Note, AIConversation, EventType, RepeatType } from '../types'; import type { User, Event, Note, AIConversation, EventType } from '../types';
import { api } from '../services/api'; import { api } from '../services/api';
import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateReminder } from '../utils/repeatCalculator';
// 应用设置类型 // 应用设置类型
interface AppSettings { interface AppSettings {
@ -135,100 +134,16 @@ export const useAppStore = create<AppState>()(
updateEventById: async (id, event) => { updateEventById: async (id, event) => {
try { try {
// 使用 set 的回调函数获取当前状态 // 乐观更新:立即更新本地状态
let currentEvent: Event | undefined; set((state) => ({
let allEvents: Event[] = []; events: state.events.map((e) =>
set((state) => { e.id === id ? { ...e, ...event } : e
allEvents = state.events; ),
currentEvent = state.events.find((e) => e.id === id); }));
return { // 发送 API 请求
events: state.events.map((e) => await api.events.update(id, event);
e.id === id ? { ...e, ...event } : e // 乐观更新已生效,不需要用 API 返回数据覆盖
), // 避免 API 返回数据不完整导致状态丢失
};
});
if (!currentEvent) {
return { error: '事件不存在' };
}
// 如果是标记完成,且是重复提醒,自动创建下一周期,并移除当前提醒的重复设置
if (event.is_completed && currentEvent.repeat_type !== 'none') {
// 从 next_reminder_date 开始查找正确的下一个提醒日期
const nextReminderDate = currentEvent.next_reminder_date || currentEvent.date;
const nextValidDate = findNextValidReminderDate(
nextReminderDate,
currentEvent.repeat_type as RepeatType,
currentEvent.repeat_interval
);
// 检查是否已存在相同的提醒(去重)
const exists = isDuplicateReminder(
allEvents,
currentEvent.title,
nextValidDate,
currentEvent.repeat_type as RepeatType
);
if (!exists) {
// 计算新提醒的 next_reminder_date
const newNextReminderDate = calculateNextReminderDate(
nextValidDate,
currentEvent.repeat_type as RepeatType,
currentEvent.repeat_interval
);
// 创建下一周期的新事件 - 确保所有字段都不包含 undefined
const newEventData: Record<string, any> = {
type: currentEvent.type,
title: currentEvent.title,
date: nextValidDate,
is_lunar: currentEvent.is_lunar ?? false,
repeat_type: currentEvent.repeat_type,
// 确保 repeat_interval 为 null 而不是 undefined
repeat_interval: currentEvent.repeat_interval ?? null,
next_reminder_date: newNextReminderDate,
is_holiday: currentEvent.is_holiday ?? false,
};
// 只有当 content 有值时才包含
if (currentEvent.content) {
newEventData.content = currentEvent.content;
}
const newEvent = await api.events.create(newEventData);
// 添加新事件到本地状态
set((state) => ({
events: [...state.events, newEvent],
}));
}
// 发送 API 请求:标记为已完成并移除重复设置(合并为一个请求)
await api.events.update(id, {
is_completed: true,
repeat_type: 'none',
repeat_interval: null,
next_reminder_date: null,
});
// 更新本地状态:标记为已完成并移除重复设置
set((state) => ({
events: state.events.map((e) =>
e.id === id
? {
...e,
is_completed: true,
repeat_type: 'none',
repeat_interval: null,
next_reminder_date: null,
}
: e
),
}));
} else {
// 非完成操作,正常发送 API 请求
await api.events.update(id, event);
}
return { error: null }; return { error: null };
} catch (error: any) { } catch (error: any) {
// 失败时回滚,重新获取数据 // 失败时回滚,重新获取数据

View File

@ -10,8 +10,7 @@ export interface User {
// Event types - for both Anniversary and Reminder // Event types - for both Anniversary and Reminder
export type EventType = 'anniversary' | 'reminder'; export type EventType = 'anniversary' | 'reminder';
// Repeat types: 'daily'每天, 'weekly'每周, 'monthly'每月, 'yearly'每年, 'none'不重复 export type RepeatType = 'yearly' | 'monthly' | 'none';
export type RepeatType = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'none';
// Unified event type (matches backend API) // Unified event type (matches backend API)
export interface Event { export interface Event {
@ -19,14 +18,12 @@ export interface Event {
user_id: string; user_id: string;
type: EventType; type: EventType;
title: string; title: string;
content?: string; // Only for reminders content?: string; // Only for reminders
date: string; // 当前提醒日期(展示用) date: string; // For anniversaries: date, For reminders: reminder time
is_lunar: boolean; is_lunar: boolean;
repeat_type: RepeatType; repeat_type: RepeatType;
repeat_interval?: number | null; // 周数间隔(仅 weekly 类型使用) is_holiday?: boolean; // Only for anniversaries
next_reminder_date?: string | null; // 下一次提醒日期(计算用) is_completed?: boolean; // Only for reminders
is_holiday?: boolean; // Only for anniversaries
is_completed?: boolean; // Only for reminders
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@ -1,233 +0,0 @@
import type { Event, RepeatType } from '../types';
/**
*
* @param currentDate ISO
* @param repeatType
* @param interval weekly 使1
* @returns ISO
*/
export function calculateNextDueDate(
currentDate: string,
repeatType: RepeatType,
interval: number = 1
): string {
const date = new Date(currentDate);
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
switch (repeatType) {
case 'daily':
// 每天加1天
return new Date(year, month, day + 1, hours, minutes).toISOString();
case 'weekly':
// 每周加7天 * interval
return new Date(year, month, day + 7 * interval, hours, minutes).toISOString();
case 'monthly':
// 每月:下月同日
const nextMonth = new Date(year, month + 1, day, hours, minutes);
// 处理月末日期如3月31日 -> 4月30日
if (nextMonth.getDate() !== day) {
return new Date(year, month + 1, 0, hours, minutes).toISOString();
}
return nextMonth.toISOString();
case 'yearly':
// 每年:明年同日
const nextYearDate = new Date(year + 1, month, day, hours, minutes);
// 处理闰年如2月29日 -> 2月28日
if (nextYearDate.getMonth() !== month) {
return new Date(year + 1, month, 0, hours, minutes).toISOString();
}
return nextYearDate.toISOString();
case 'none':
default:
// 不重复:返回原日期
return currentDate;
}
}
/**
*
* next_reminder_date
* @param date
* @param repeatType
* @param interval
*/
export function calculateNextReminderDate(
date: string,
repeatType: RepeatType,
interval?: number | null
): string | null {
if (repeatType === 'none' || repeatType === undefined) {
return null;
}
return calculateNextDueDate(date, repeatType, interval ?? 1);
}
/**
*
* nextReminderDate >= referenceDate
* @param nextReminderDate ISO
* @param repeatType
* @param interval
* @param referenceDate
* @returns ISO
*/
export function findNextValidReminderDate(
nextReminderDate: string,
repeatType: RepeatType,
interval?: number | null,
referenceDate?: Date
): string {
if (repeatType === 'none') {
return nextReminderDate;
}
const refDate = referenceDate ?? new Date();
let currentNextDate = new Date(nextReminderDate);
let loopCount = 0;
const maxLoops = 366; // 防止无限循环
// 循环直到找到 >= referenceDate 的日期
while (currentNextDate < refDate && loopCount < maxLoops) {
currentNextDate = new Date(calculateNextDueDate(
currentNextDate.toISOString(),
repeatType,
interval ?? 1
));
loopCount++;
}
return currentNextDate.toISOString();
}
/**
*
* title + date + repeat_type
*/
export function isDuplicateReminder(
events: Event[],
title: string,
date: string,
repeatType: RepeatType
): boolean {
const normalizedTitle = title.trim();
const normalizedDate = new Date(date).toISOString();
return events.some(event => {
// 只检查未完成的提醒
if (event.is_completed) return false;
return (
event.title.trim() === normalizedTitle &&
new Date(event.date).toISOString() === normalizedDate &&
event.repeat_type === repeatType
);
});
}
/**
*
*/
export function isRecurring(event: Event): boolean {
return event.repeat_type !== 'none';
}
/**
*
*/
export function isOverdue(event: Event): boolean {
if (!event.date) return false;
return new Date(event.date) < new Date();
}
/**
*
*/
export function getRepeatTypeLabel(type: RepeatType): string {
const labels: Record<RepeatType, string> = {
daily: '每天',
weekly: '每周',
monthly: '每月',
yearly: '每年',
none: '不重复',
};
return labels[type] || '不重复';
}
/**
*
*/
export function getRepeatTypeDescription(type: RepeatType): string {
const descriptions: Record<RepeatType, string> = {
daily: '每天同一时间提醒',
weekly: '每周同一时间提醒',
monthly: '每月同一日期提醒',
yearly: '每年同一日期提醒',
none: '仅提醒一次',
};
return descriptions[type] || '仅提醒一次';
}
/**
*
*/
export function isToday(dateStr: string): boolean {
const date = new Date(dateStr);
const today = new Date();
return date.toDateString() === today.toDateString();
}
/**
*
*/
export function isTomorrow(dateStr: string): boolean {
const date = new Date(dateStr);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return date.toDateString() === tomorrow.toDateString();
}
/**
*
* @param dateStr ISO
* @param showTime
*/
export function formatDateDisplay(dateStr: string, showTime: boolean = true): string {
const date = new Date(dateStr);
const hours = date.getHours();
const minutes = date.getMinutes();
// 检查是否为默认时间00:00
const hasTime = hours !== 0 || minutes !== 0;
if (!hasTime) {
// 只显示日期
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
}
if (showTime) {
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
}