Compare commits
No commits in common. "1559e603b0ac6fdae8e82ec163248c33923e1965" and "a8b4f17043328f8a5e23f9b4fcfca5bd5e2d3b4f" have entirely different histories.
1559e603b0
...
a8b4f17043
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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) {
|
||||||
// 失败时回滚,重新获取数据
|
// 失败时回滚,重新获取数据
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user