feat: 实现重复提醒完成移除设置、逾期列表展开收起功能
- 重复提醒完成流程优化: - 勾选完成重复提醒后,自动移除repeat_type、repeat_interval、next_reminder_date - 自动创建下一周期的新提醒记录 - 合并API调用,确保状态更新原子性 - 逾期列表展开/收起功能: - 默认收起,最多显示3条逾期提醒 - 超过3条时显示"还有 X 个逾期提醒..."链接 - 展开后底部显示"收起"按钮 - 时间显示优化: - 无时间提醒(00:00)只显示日期,不显示时间 - 归档列表同样适用此规则 - 其他优化: - 归档抖动动画反馈 - 分类折叠功能 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3fdee5cab4
commit
1559e603b0
@ -19,6 +19,31 @@ interface ArchiveReminderModalProps {
|
||||
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({
|
||||
opened,
|
||||
onClose,
|
||||
@ -91,12 +116,7 @@ export function ArchiveReminderModal({
|
||||
{event.title}
|
||||
</Text>
|
||||
<Text size="xs" c="#bbb">
|
||||
{new Date(event.date).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{formatArchiveDate(event.date)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={4}>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Paper, Text, Checkbox, Group, Stack, ActionIcon, Box } from '@mantine/core';
|
||||
import { IconDots, IconArrowForward } from '@tabler/icons-react';
|
||||
import type { Event } from '../../types';
|
||||
import { IconDots, IconArrowForward, IconRepeat, IconRepeatOff } from '@tabler/icons-react';
|
||||
import type { Event, RepeatType } from '../../types';
|
||||
import { getRepeatTypeLabel } from '../../utils/repeatCalculator';
|
||||
|
||||
interface ReminderCardProps {
|
||||
event: Event;
|
||||
@ -10,9 +11,10 @@ interface ReminderCardProps {
|
||||
onDelete?: () => void;
|
||||
onPostpone?: () => void;
|
||||
isMissed?: boolean;
|
||||
onMissedToggle?: () => void;
|
||||
}
|
||||
|
||||
export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, isMissed = false }: ReminderCardProps) {
|
||||
export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, isMissed = false, onMissedToggle }: ReminderCardProps) {
|
||||
const isCompleted = event.is_completed ?? false;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
@ -25,26 +27,16 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// 解析日期,处理本地时间格式(无时区)和ISO格式
|
||||
let eventDate: Date;
|
||||
const dateStr = event.date as string;
|
||||
// 如果是本地时间格式(YYYY-MM-DDTHH:mm:ss),添加时区信息避免UTC转换
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(dateStr)) {
|
||||
// 本地时间格式,直接使用
|
||||
eventDate = new Date(dateStr);
|
||||
} else {
|
||||
// ISO格式,可能有时区
|
||||
eventDate = new Date(dateStr);
|
||||
}
|
||||
|
||||
// 判断是否是有意设置的时间(不是默认的00:00)
|
||||
// 对于本地格式的日期,检查日期字符串中是否包含非00:00的时间
|
||||
const timeMatch = dateStr.match(/T(\d{2}):(\d{2})/);
|
||||
const hasExplicitTime = timeMatch && (timeMatch[1] !== '00' || timeMatch[2] !== '00');
|
||||
// 对于ISO格式,检查小时和分钟
|
||||
// 统一使用 Date 对象处理
|
||||
const eventDate = new Date(dateStr);
|
||||
|
||||
// 检查是否包含有效时间(小时和分钟不全为0)
|
||||
// 对于本地格式(如 "2025-02-05T00:00:00")和 ISO 格式都适用
|
||||
const hours = eventDate.getHours();
|
||||
const minutes = eventDate.getMinutes();
|
||||
const isoHasTime = hours !== 0 || minutes !== 0;
|
||||
const hasExplicitTime = hours !== 0 || minutes !== 0;
|
||||
|
||||
const diff = eventDate.getTime() - now.getTime();
|
||||
const isPast = diff < 0;
|
||||
@ -52,8 +44,7 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
|
||||
|
||||
// 格式化显示
|
||||
let timeStr: string;
|
||||
// 优先使用 hasExplicitTime(本地格式),如果没有显式时间才检查 ISO 格式
|
||||
if (hasExplicitTime || isoHasTime) {
|
||||
if (hasExplicitTime) {
|
||||
// 有设置时间,显示日期+时间
|
||||
timeStr = eventDate.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
@ -63,14 +54,14 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
|
||||
hour12: false,
|
||||
});
|
||||
} else {
|
||||
// 没有设置时间,只显示日期
|
||||
// 没有设置时间(00:00),只显示日期
|
||||
timeStr = eventDate.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
return { isPast, isToday, timeStr, diff, hasTime: hasExplicitTime || isoHasTime };
|
||||
return { isPast, isToday, timeStr, diff, hasTime: hasExplicitTime };
|
||||
}, [event.date]);
|
||||
|
||||
// 获取文字颜色
|
||||
@ -101,6 +92,18 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
|
||||
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 (
|
||||
<Paper
|
||||
p="sm"
|
||||
@ -202,8 +205,20 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 右侧:日期时间 */}
|
||||
<Box style={{ flex: '0 0 auto', minWidth: 0, paddingLeft: 12 }}>
|
||||
{/* 右侧:循环图标 + 日期时间 */}
|
||||
<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()}
|
||||
@ -233,6 +248,7 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
|
||||
setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
onToggle();
|
||||
onMissedToggle?.();
|
||||
}, 300);
|
||||
} else {
|
||||
onToggle();
|
||||
@ -244,19 +260,34 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
|
||||
</div>
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} onClick={onClick}>
|
||||
<Text
|
||||
fw={400}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
style={{
|
||||
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||
color: getTextColor(),
|
||||
letterSpacing: '0.03em',
|
||||
transition: 'color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text
|
||||
fw={400}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
style={{
|
||||
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||
color: getTextColor(),
|
||||
letterSpacing: '0.03em',
|
||||
transition: 'color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.repeat_type !== 'none' && (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
color: getRepeatIconColor(event.repeat_type),
|
||||
}}
|
||||
title={getRepeatTypeLabel(event.repeat_type)}
|
||||
>
|
||||
<IconRepeat size={10} />
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c={getTimeColor()}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
@ -11,7 +11,12 @@ import {
|
||||
import {
|
||||
IconPlus,
|
||||
IconAlertCircle,
|
||||
IconArchive,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ReminderCard } from './ReminderCard';
|
||||
import type { Event } from '../../types';
|
||||
|
||||
@ -32,8 +37,48 @@ export function ReminderList({
|
||||
onDelete,
|
||||
onPostpone,
|
||||
}: 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 {
|
||||
@ -171,12 +216,10 @@ export function ReminderList({
|
||||
);
|
||||
}
|
||||
|
||||
// 已过提醒数量提示
|
||||
const missedCount = grouped.missed.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{scrollbarStyle}</style>
|
||||
<style>{shakeAnimationStyle}</style>
|
||||
<Paper p="md" withBorder radius={4} data-scroll-container="reminder" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
|
||||
@ -184,11 +227,29 @@ export function ReminderList({
|
||||
<Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
|
||||
提醒
|
||||
</Text>
|
||||
{missedCount > 0 && (
|
||||
<Text size="xs" c="#c41c1c" fw={500}>
|
||||
{missedCount}个逾期
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={
|
||||
<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>
|
||||
<Button
|
||||
variant="subtle"
|
||||
@ -226,21 +287,66 @@ export function ReminderList({
|
||||
title={<Text size="xs" fw={500} c="#c41c1c">已错过 {grouped.missed.length}个</Text>}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{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}
|
||||
isMissed={true}
|
||||
/>
|
||||
))}
|
||||
{grouped.missed.length > 3 && (
|
||||
<Text size="xs" c="#999" ta="center">
|
||||
还有 {grouped.missed.length - 3} 个逾期提醒...
|
||||
</Text>
|
||||
{/* 展开时显示所有逾期提醒 */}
|
||||
{missedExpanded ? (
|
||||
<>
|
||||
{grouped.missed.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}
|
||||
/>
|
||||
))}
|
||||
{/* 收起按钮 */}
|
||||
<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>
|
||||
</Alert>
|
||||
@ -249,12 +355,21 @@ export function ReminderList({
|
||||
{/* 今天 */}
|
||||
{grouped.today.length > 0 && (
|
||||
<>
|
||||
<Group gap={4}>
|
||||
<Group
|
||||
gap={4}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => toggleCollapse('today')}
|
||||
>
|
||||
<Text size="xs" c="#c41c1c" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
今天
|
||||
</Text>
|
||||
{collapsed.today ? (
|
||||
<IconChevronRight size={12} color="#999" />
|
||||
) : (
|
||||
<IconChevronDown size={12} color="#999" />
|
||||
)}
|
||||
</Group>
|
||||
{grouped.today.map((event) => (
|
||||
{!collapsed.today && grouped.today.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
@ -269,12 +384,21 @@ export function ReminderList({
|
||||
{/* 明天 */}
|
||||
{grouped.tomorrow.length > 0 && (
|
||||
<>
|
||||
<Group gap={4}>
|
||||
<Group
|
||||
gap={4}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => toggleCollapse('tomorrow')}
|
||||
>
|
||||
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
明天
|
||||
</Text>
|
||||
{collapsed.tomorrow ? (
|
||||
<IconChevronRight size={12} color="#999" />
|
||||
) : (
|
||||
<IconChevronDown size={12} color="#999" />
|
||||
)}
|
||||
</Group>
|
||||
{grouped.tomorrow.map((event) => (
|
||||
{!collapsed.tomorrow && grouped.tomorrow.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
@ -289,12 +413,21 @@ export function ReminderList({
|
||||
{/* 更久之后 */}
|
||||
{grouped.later.length > 0 && (
|
||||
<>
|
||||
<Group gap={4}>
|
||||
<Group
|
||||
gap={4}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => toggleCollapse('later')}
|
||||
>
|
||||
<Text size="xs" c="#999" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
更久之后
|
||||
</Text>
|
||||
{collapsed.later ? (
|
||||
<IconChevronRight size={12} color="#999" />
|
||||
) : (
|
||||
<IconChevronDown size={12} color="#999" />
|
||||
)}
|
||||
</Group>
|
||||
{grouped.later.map((event) => (
|
||||
{!collapsed.later && grouped.later.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
|
||||
@ -15,6 +15,31 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const events = useAppStore((state) => state.events);
|
||||
@ -132,12 +157,7 @@ export function ArchivePage() {
|
||||
{event.title}
|
||||
</Text>
|
||||
<Text size="xs" c="#bbb">
|
||||
{new Date(event.date).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{formatArchiveDate(event.date)}
|
||||
</Text>
|
||||
{event.content && (
|
||||
<Text size="xs" c="#bbb" lineClamp={1}>
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { DatePickerInput, TimeInput } from '@mantine/dates';
|
||||
import { IconLogout, IconSettings, IconArchive } from '@tabler/icons-react';
|
||||
import { IconLogout, IconSettings } from '@tabler/icons-react';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores';
|
||||
@ -22,6 +22,7 @@ import { ReminderList } from '../components/reminder/ReminderList';
|
||||
import { NoteEditor } from '../components/note/NoteEditor';
|
||||
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
|
||||
import type { Event, EventType, RepeatType } from '../types';
|
||||
import { calculateNextReminderDate } from '../utils/repeatCalculator';
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
@ -56,6 +57,7 @@ export function HomePage() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/landing');
|
||||
};
|
||||
|
||||
const handleEventClick = (event: Event) => {
|
||||
@ -111,16 +113,23 @@ export function HomePage() {
|
||||
dateStr = `${year}-${month}-${day}T00:00:00`;
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
// 构建事件数据,确保不包含 undefined 值
|
||||
const eventData: Record<string, any> = {
|
||||
type: formType,
|
||||
title: formTitle,
|
||||
content: formContent || undefined,
|
||||
date: dateStr,
|
||||
is_lunar: formIsLunar,
|
||||
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) {
|
||||
await updateEventById(selectedEvent.id, eventData);
|
||||
} else {
|
||||
@ -220,20 +229,6 @@ export function HomePage() {
|
||||
掐日子
|
||||
</Title>
|
||||
<Group>
|
||||
{/* 归档入口 - 一直显示 */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconArchive size={12} />}
|
||||
onClick={() => navigate('/archive')}
|
||||
style={{
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
{/* 设置入口 */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
@ -450,8 +445,10 @@ export function HomePage() {
|
||||
}
|
||||
data={[
|
||||
{ value: 'none', label: '不重复' },
|
||||
{ value: 'yearly', label: '每年' },
|
||||
{ value: 'daily', label: '每天' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'monthly', label: '每月' },
|
||||
{ value: 'yearly', label: '每年' },
|
||||
]}
|
||||
value={formRepeatType}
|
||||
onChange={(value) => value && setFormRepeatType(value as RepeatType)}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { User, Event, Note, AIConversation, EventType } from '../types';
|
||||
import type { User, Event, Note, AIConversation, EventType, RepeatType } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateReminder } from '../utils/repeatCalculator';
|
||||
|
||||
// 应用设置类型
|
||||
interface AppSettings {
|
||||
@ -134,16 +135,100 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
updateEventById: async (id, event) => {
|
||||
try {
|
||||
// 乐观更新:立即更新本地状态
|
||||
set((state) => ({
|
||||
events: state.events.map((e) =>
|
||||
e.id === id ? { ...e, ...event } : e
|
||||
),
|
||||
}));
|
||||
// 发送 API 请求
|
||||
await api.events.update(id, event);
|
||||
// 乐观更新已生效,不需要用 API 返回数据覆盖
|
||||
// 避免 API 返回数据不完整导致状态丢失
|
||||
// 使用 set 的回调函数获取当前状态
|
||||
let currentEvent: Event | undefined;
|
||||
let allEvents: Event[] = [];
|
||||
set((state) => {
|
||||
allEvents = state.events;
|
||||
currentEvent = state.events.find((e) => e.id === id);
|
||||
return {
|
||||
events: state.events.map((e) =>
|
||||
e.id === id ? { ...e, ...event } : e
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
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 };
|
||||
} catch (error: any) {
|
||||
// 失败时回滚,重新获取数据
|
||||
|
||||
@ -10,7 +10,8 @@ export interface User {
|
||||
// Event types - for both Anniversary and Reminder
|
||||
export type EventType = 'anniversary' | 'reminder';
|
||||
|
||||
export type RepeatType = 'yearly' | 'monthly' | 'none';
|
||||
// Repeat types: 'daily'每天, 'weekly'每周, 'monthly'每月, 'yearly'每年, 'none'不重复
|
||||
export type RepeatType = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'none';
|
||||
|
||||
// Unified event type (matches backend API)
|
||||
export interface Event {
|
||||
@ -18,12 +19,14 @@ export interface Event {
|
||||
user_id: string;
|
||||
type: EventType;
|
||||
title: string;
|
||||
content?: string; // Only for reminders
|
||||
date: string; // For anniversaries: date, For reminders: reminder time
|
||||
content?: string; // Only for reminders
|
||||
date: string; // 当前提醒日期(展示用)
|
||||
is_lunar: boolean;
|
||||
repeat_type: RepeatType;
|
||||
is_holiday?: boolean; // Only for anniversaries
|
||||
is_completed?: boolean; // Only for reminders
|
||||
repeat_interval?: number | null; // 周数间隔(仅 weekly 类型使用)
|
||||
next_reminder_date?: string | null; // 下一次提醒日期(计算用)
|
||||
is_holiday?: boolean; // Only for anniversaries
|
||||
is_completed?: boolean; // Only for reminders
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
233
src/utils/repeatCalculator.ts
Normal file
233
src/utils/repeatCalculator.ts
Normal file
@ -0,0 +1,233 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user