feat: 优化提醒功能,修复状态保存问题

- 优化提醒分组逻辑,精确判断过期时间
- 已完成但未过期的提醒仍显示在主列表(划掉状态)
- 修复 checkbox 点击事件处理
- 添加乐观更新,UI 即时响应
- 添加归档页和设置页路由
- 修复后端 is_completed 字段验证问题
This commit is contained in:
ddshi 2026-02-03 13:19:06 +08:00
parent 250c05e85e
commit 9e4b4022bd
31 changed files with 397 additions and 132 deletions

View File

@ -4,6 +4,7 @@ import { IconPlus } from '@tabler/icons-react';
import { AnniversaryCard } from './AnniversaryCard';
import { getHolidaysForYear } from '../../constants/holidays';
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
import { useAppStore } from '../../stores';
import type { Event } from '../../types';
interface AnniversaryListProps {
@ -26,9 +27,12 @@ interface BuiltInHolidayEvent {
export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) {
const anniversaries = events.filter((e) => e.type === 'anniversary');
const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true);
// 获取内置节假日
const builtInHolidays = useMemo(() => {
if (!showHolidays) return [];
const now = new Date();
const year = now.getFullYear();
const holidays = getHolidaysForYear(year);
@ -39,13 +43,13 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
(a, b) => a.date.getTime() - b.date.getTime()
);
// 只取未来30天内的节假日
// 只取未来90天内的节假日显示最近3个
const cutoffDate = new Date(now);
cutoffDate.setDate(cutoffDate.getDate() + 30);
cutoffDate.setDate(cutoffDate.getDate() + 90);
return allHolidays
.filter((h) => h.date >= now && h.date <= cutoffDate)
.slice(0, 5)
.slice(0, 3)
.map((h): BuiltInHolidayEvent => ({
id: `builtin-${h.id}`,
title: h.name,
@ -56,7 +60,7 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
type: 'anniversary',
is_builtin: true,
}));
}, []);
}, [showHolidays]);
// 合并用户纪念日和内置节假日
const allAnniversaries = useMemo(() => {

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { Paper, Text, Checkbox, Group, Stack, ActionIcon } from '@mantine/core';
import { IconCheck, IconDots } from '@tabler/icons-react';
import { IconDots } from '@tabler/icons-react';
import type { Event } from '../../types';
interface ReminderCardProps {
@ -45,7 +45,6 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
<Paper
p="sm"
radius={2}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
@ -67,12 +66,11 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
size="xs"
color="#1a1a1a"
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} onClick={onClick}>
{/* Title */}
<Text
fw={400}
@ -105,26 +103,16 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
{/* Quick actions */}
<Group gap={4}>
{isHovered && !isCompleted && (
<ActionIcon
size="sm"
variant="subtle"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
style={{ color: '#666' }}
>
<IconCheck size={12} />
</ActionIcon>
)}
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{ color: '#999' }}
title="编辑"
>
<IconDots size={12} />
</ActionIcon>

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import {
Stack,
Text,
@ -6,16 +6,12 @@ import {
Group,
Button,
Alert,
ActionIcon,
Tooltip,
} from '@mantine/core';
import {
IconPlus,
IconAlertCircle,
IconArchive,
} from '@tabler/icons-react';
import { ReminderCard } from './ReminderCard';
import { ArchiveReminderModal } from './ArchiveReminderModal';
import type { Event } from '../../types';
interface ReminderListProps {
@ -35,44 +31,47 @@ export function ReminderList({
onDelete,
onRestore,
}: ReminderListProps) {
const [archiveOpened, setArchiveOpened] = useState(false);
const grouped = useMemo(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date(today);
nextWeek.setDate(nextWeek.getDate() + 7);
tomorrow.setHours(0, 0, 0, 0);
const dayAfterTomorrow = new Date(tomorrow);
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);
dayAfterTomorrow.setHours(0, 0, 0, 0);
const reminders = events.filter((e) => e.type === 'reminder');
const result = {
today: [] as Event[],
tomorrow: [] as Event[],
thisWeek: [] as Event[],
later: [] as Event[],
missed: [] as Event[],
completed: [] as Event[],
};
reminders.forEach((event) => {
const eventDate = new Date(event.date);
// 已完成的放最后
if (event.is_completed) {
result.completed.push(event);
// 已过期且已完成的去归档页
if (event.is_completed && eventDate < now) {
return;
}
// 未完成的按时间分组
if (eventDate < today) {
// 未过期或已完成未过期的,按时间分组
if (eventDate < now) {
// 已过期未完成
result.missed.push(event);
} else if (eventDate < tomorrow) {
// 今天
result.today.push(event);
} else if (eventDate < nextWeek) {
result.thisWeek.push(event);
} else if (eventDate < dayAfterTomorrow) {
// 明天
result.tomorrow.push(event);
} else {
// 更久之后
result.later.push(event);
}
});
@ -83,10 +82,9 @@ export function ReminderList({
result.today.sort(sortByDate);
result.tomorrow.sort(sortByDate);
result.thisWeek.sort(sortByDate);
result.later.sort(sortByDate);
result.missed.sort(sortByDate);
result.completed.sort(sortByDate);
// 已过期按时间倒序(最近的在上面)
result.missed.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return result;
}, [events]);
@ -94,7 +92,6 @@ export function ReminderList({
const hasActiveReminders =
grouped.today.length > 0 ||
grouped.tomorrow.length > 0 ||
grouped.thisWeek.length > 0 ||
grouped.later.length > 0 ||
grouped.missed.length > 0;
@ -146,33 +143,18 @@ export function ReminderList({
</Text>
)}
</Group>
<Group gap={4}>
{grouped.completed.length > 0 && (
<Tooltip label="查看已归档">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={() => setArchiveOpened(true)}
style={{ color: '#999' }}
>
<IconArchive size={12} />
</ActionIcon>
</Tooltip>
)}
<Button
variant="subtle"
size="xs"
leftSection={<IconPlus size={12} />}
onClick={onAddClick}
style={{
color: '#666',
borderRadius: 2,
}}
>
</Button>
</Group>
<Button
variant="subtle"
size="xs"
leftSection={<IconPlus size={12} />}
onClick={onAddClick}
style={{
color: '#666',
borderRadius: 2,
}}
>
</Button>
</Group>
{/* Content */}
@ -245,32 +227,12 @@ export function ReminderList({
</>
)}
{/* 本周 */}
{grouped.thisWeek.length > 0 && (
<>
<Group gap={4}>
<Text size="xs" c="#888" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text>
</Group>
{grouped.thisWeek.map((event) => (
<ReminderCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
/>
))}
</>
)}
{/* 更久 */}
{/* 更久之后 */}
{grouped.later.length > 0 && (
<>
<Group gap={4}>
<Text size="xs" c="#999" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text>
</Group>
{grouped.later.map((event) => (
@ -286,15 +248,6 @@ export function ReminderList({
)}
</Stack>
</Paper>
{/* Archive Modal */}
<ArchiveReminderModal
opened={archiveOpened}
onClose={() => setArchiveOpened(false)}
completedReminders={grouped.completed}
onRestore={onRestore || (() => {})}
onDelete={onDelete || (() => {})}
/>
</>
);
}

View File

@ -199,7 +199,8 @@ export function getHolidaysForYear(year: number): Array<Holiday & { date: Date }
for (const holiday of HOLIDAYS) {
if (holiday.isLunar && holiday.lunarMonth && holiday.lunarDay) {
try {
const lunar = Lunar.fromYmd(year, holiday.lunarMonth, holiday.lunarDay);
// 使用 Lunar 构造函数创建农历对象,再获取对应的公历日期
const lunar = new Lunar(year, holiday.lunarMonth, holiday.lunarDay);
const solar = lunar.getSolar();
result.push({
...holiday,

164
src/pages/ArchivePage.tsx Normal file
View File

@ -0,0 +1,164 @@
import { useEffect } from 'react';
import {
Container,
Title,
Text,
Stack,
Paper,
Group,
Button,
ActionIcon,
} from '@mantine/core';
import { IconArrowLeft, IconRotateClockwise, IconTrash, IconArchive } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores';
import type { Event } from '../types';
export function ArchivePage() {
const navigate = useNavigate();
const events = useAppStore((state) => state.events);
const updateEventById = useAppStore((state) => state.updateEventById);
const deleteEventById = useAppStore((state) => state.deleteEventById);
// 页面加载时检查登录状态
useEffect(() => {
const isAuthenticated = useAppStore.getState().isAuthenticated;
if (!isAuthenticated) {
navigate('/login', { replace: true });
}
}, [navigate]);
// 获取已归档的提醒(已过期且已勾选的)
const archivedReminders = events.filter(
(e) => e.type === 'reminder' && e.is_completed
).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const handleRestore = async (event: Event) => {
await updateEventById(event.id, { is_completed: false });
};
const handleDelete = async (event: Event) => {
await deleteEventById(event.id);
};
return (
<div
style={{
minHeight: '100vh',
background: '#faf9f7',
}}
>
<Container size="xl" py="md">
{/* Header */}
<Group mb="lg" style={{ flexShrink: 0 }}>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate(-1)}
style={{
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
</Button>
<Group gap="sm">
<IconArchive size={20} color="#666" />
<Title
order={2}
style={{
fontWeight: 300,
fontSize: '1.25rem',
letterSpacing: '0.15em',
color: '#1a1a1a',
}}
>
</Title>
</Group>
</Group>
{/* Content */}
<Paper p="md" withBorder radius={4} style={{ maxWidth: 600 }}>
{archivedReminders.length === 0 ? (
<Stack align="center" justify="center" py="xl">
<Text c="#999" size="sm" ta="center" style={{ letterSpacing: '0.05em' }}>
</Text>
<Text size="xs" c="#bbb" ta="center" mt={4}>
</Text>
</Stack>
) : (
<Stack gap="sm">
{archivedReminders.map((event) => (
<Paper
key={event.id}
p="sm"
radius={2}
withBorder
style={{
background: 'rgba(0, 0, 0, 0.02)',
borderColor: 'rgba(0, 0, 0, 0.06)',
}}
>
<Group justify="space-between" wrap="nowrap">
<Stack gap={4} style={{ flex: 1 }}>
<Text
size="sm"
fw={400}
lineClamp={1}
style={{
textDecoration: 'line-through',
color: '#999',
letterSpacing: '0.03em',
}}
>
{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',
})}
</Text>
{event.content && (
<Text size="xs" c="#bbb" lineClamp={1}>
{event.content}
</Text>
)}
</Stack>
<Group gap={4}>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => handleRestore(event)}
style={{ color: '#666' }}
title="恢复"
>
<IconRotateClockwise size={14} />
</ActionIcon>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => handleDelete(event)}
style={{ color: '#999' }}
title="删除"
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
</Paper>
</Container>
</div>
);
}

View File

@ -14,8 +14,9 @@ import {
Stack,
} from '@mantine/core';
import { DatePickerInput, TimeInput } from '@mantine/dates';
import { IconLogout } from '@tabler/icons-react';
import { IconLogout, IconSettings, IconArchive } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores';
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
import { ReminderList } from '../components/reminder/ReminderList';
@ -24,6 +25,7 @@ import { FloatingAIChat } from '../components/ai/FloatingAIChat';
import type { Event, EventType, RepeatType } from '../types';
export function HomePage() {
const navigate = useNavigate();
const user = useAppStore((state) => state.user);
const logout = useAppStore((state) => state.logout);
const checkAuth = useAppStore((state) => state.checkAuth);
@ -124,10 +126,15 @@ export function HomePage() {
const handleToggleComplete = async (event: Event) => {
if (event.type !== 'reminder') return;
await updateEventById(event.id, {
is_completed: !event.is_completed,
// 使用当前期望的状态(取反)
const newCompleted = !event.is_completed;
const result = await updateEventById(event.id, {
is_completed: newCompleted,
});
fetchEvents();
if (result.error) {
console.error('更新失败:', result.error);
}
// 乐观更新已处理 UI 响应,无需 fetchEvents
};
const handleDelete = async (event: Event) => {
@ -183,6 +190,34 @@ 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"
color="gray"
size="xs"
leftSection={<IconSettings size={12} />}
onClick={() => navigate('/settings')}
style={{
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
</Button>
<Text
size="xs"
c="#888"

View File

@ -0,0 +1,92 @@
import { useEffect } from 'react';
import {
Container,
Title,
Text,
Switch,
Stack,
Paper,
Group,
Button,
} from '@mantine/core';
import { IconArrowLeft, IconSettings } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores';
export function SettingsPage() {
const navigate = useNavigate();
const settings = useAppStore((state) => state.settings);
const updateSettings = useAppStore((state) => state.updateSettings);
// 页面加载时检查登录状态
useEffect(() => {
const isAuthenticated = useAppStore.getState().isAuthenticated;
if (!isAuthenticated) {
navigate('/login', { replace: true });
}
}, [navigate]);
return (
<div
style={{
minHeight: '100vh',
background: '#faf9f7',
}}
>
<Container size="xl" py="md">
{/* Header */}
<Group mb="lg" style={{ flexShrink: 0 }}>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate(-1)}
style={{
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
</Button>
<Title
order={2}
style={{
fontWeight: 300,
fontSize: '1.25rem',
letterSpacing: '0.15em',
color: '#1a1a1a',
}}
>
</Title>
</Group>
<Paper p="lg" withBorder radius={4} style={{ maxWidth: 500 }}>
<Stack gap="lg">
{/* 节假日设置 */}
<Group justify="space-between">
<Group gap="sm">
<IconSettings size={18} color="#666" />
<Stack gap={2}>
<Text size="sm" fw={400} style={{ letterSpacing: '0.05em', color: '#1a1a1a' }}>
</Text>
<Text size="xs" c="#999" style={{ letterSpacing: '0.03em' }}>
3
</Text>
</Stack>
</Group>
<Switch
checked={settings.showHolidays}
onChange={(e) => updateSettings({ showHolidays: e.currentTarget.checked })}
size="sm"
color="#1a1a1a"
/>
</Group>
</Stack>
</Paper>
</Container>
</div>
);
}

View File

@ -3,6 +3,8 @@ import { LandingPage } from './pages/LandingPage';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { HomePage } from './pages/HomePage';
import { SettingsPage } from './pages/SettingsPage';
import { ArchivePage } from './pages/ArchivePage';
import { useAppStore } from './stores';
import { useEffect, useRef } from 'react';
@ -143,4 +145,20 @@ export const router = createBrowserRouter([
</ProtectedRoute>
),
},
{
path: '/settings',
element: (
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
),
},
{
path: '/archive',
element: (
<ProtectedRoute>
<ArchivePage />
</ProtectedRoute>
),
},
]);

View File

@ -1,14 +1,28 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { User, Event, Note, AIConversation, EventType } from '../types';
import { api } from '../services/api';
// 应用设置类型
interface AppSettings {
showHolidays: boolean; // 是否显示节假日
}
// 默认设置
const defaultSettings: AppSettings = {
showHolidays: true,
};
interface AppState {
// Auth state
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
// Settings state
settings: AppSettings;
updateSettings: (settings: Partial<AppSettings>) => void;
// Data state
events: Event[];
notes: Note | null;
@ -48,10 +62,17 @@ export const useAppStore = create<AppState>()(
user: null,
isAuthenticated: false,
isLoading: true,
settings: defaultSettings,
events: [],
notes: null,
conversations: [],
// Settings
updateSettings: (newSettings) =>
set((state) => ({
settings: { ...state.settings, ...newSettings },
})),
// Setters
setUser: (user) => set({ user, isAuthenticated: !!user }),
setLoading: (isLoading) => set({ isLoading }),
@ -113,12 +134,21 @@ export const useAppStore = create<AppState>()(
updateEventById: async (id, event) => {
try {
const updated = await api.events.update(id, event);
// 乐观更新:立即更新本地状态
set((state) => ({
events: state.events.map((e) => (e.id === id ? updated : e)),
events: state.events.map((e) =>
e.id === id ? { ...e, ...event } : e
),
}));
// 发送 API 请求
await api.events.update(id, event);
// 乐观更新已生效,不需要用 API 返回数据覆盖
// 避免 API 返回数据不完整导致状态丢失
return { error: null };
} catch (error: any) {
// 失败时回滚,重新获取数据
const events = await api.events.list();
set({ events });
return { error: error.message || '更新失败' };
}
},
@ -143,7 +173,6 @@ export const useAppStore = create<AppState>()(
set({ user, isAuthenticated: true });
return { error: null };
} catch (error: any) {
// 确保返回字符串错误信息
const errorMessage = error.message || '登录失败,请检查邮箱和密码';
return { error: errorMessage };
}
@ -156,7 +185,6 @@ export const useAppStore = create<AppState>()(
set({ user, isAuthenticated: true });
return { error: null };
} catch (error: any) {
// 确保返回字符串错误信息
const errorMessage = error.message || '注册失败,请稍后重试';
return { error: errorMessage };
}
@ -201,9 +229,11 @@ export const useAppStore = create<AppState>()(
}),
{
name: 'qia-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
settings: state.settings,
}),
}
)

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client

View File

@ -1 +0,0 @@
/e/qia/client