feat(reminder): 完成P4提醒功能

- 实现提醒按时间分组显示(今天/明天/本周/更久/已错过)
- 添加逾期提醒红色Alert提示
- 优化提醒卡片交互(悬停显示操作按钮)
- 修复DateInput日期类型处理问题

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-01-29 17:44:03 +08:00
parent c08f5aa4aa
commit ccfa763657
3 changed files with 281 additions and 73 deletions

View File

@ -1,4 +1,23 @@
import { Card, Text, Checkbox, Group, Stack } from '@mantine/core';
import { useMemo, useState } from 'react';
import {
Card,
Text,
Checkbox,
Group,
Stack,
ThemeIcon,
Tooltip,
ActionIcon,
Menu,
} from '@mantine/core';
import {
IconBell,
IconCheck,
IconDots,
IconTrash,
IconEdit,
IconClock,
} from '@tabler/icons-react';
import type { Event } from '../../types';
interface ReminderCardProps {
@ -9,6 +28,34 @@ interface ReminderCardProps {
export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
const isCompleted = event.is_completed ?? false;
const [isHovered, setIsHovered] = useState(false);
// 计算距离提醒时间的相关显示
const timeInfo = useMemo(() => {
const now = new Date();
const eventDate = new Date(event.date);
const diff = eventDate.getTime() - now.getTime();
const isPast = diff < 0;
const isToday = eventDate.toDateString() === now.toDateString();
// 格式化时间显示
const timeStr = eventDate.toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return { isPast, isToday, timeStr, diff };
}, [event.date]);
// 颜色主题
const getThemeColor = () => {
if (isCompleted) return 'gray';
if (timeInfo.isPast) return 'red';
if (timeInfo.isToday) return 'orange';
return 'blue';
};
return (
<Card
@ -16,38 +63,110 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
padding="sm"
radius="md"
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
cursor: 'pointer',
opacity: isCompleted ? 0.6 : 1,
transition: 'transform 0.2s',
textDecoration: isCompleted ? 'line-through' : 'none',
opacity: isCompleted ? 0.5 : 1,
transition: 'all 0.2s ease',
transform: isHovered ? 'translateY(-2px)' : 'translateY(0)',
borderLeft: `3px solid var(--mantine-color-${getThemeColor()}-6)`,
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
>
<Group justify="space-between" wrap="nowrap">
<Stack gap={4}>
<Text fw={500} size="sm" lineClamp={1}>
{event.title}
</Text>
<Text size="xs" c="dimmed">
{new Date(event.date).toLocaleString('zh-CN')}
</Text>
{event.content && (
<Text size="xs" c="dimmed" lineClamp={1}>
{event.content}
</Text>
)}
</Stack>
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
{/* Checkbox */}
<Checkbox
checked={isCompleted}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
color="green"
size="sm"
/>
<Checkbox
checked={isCompleted}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{/* Title */}
<Text
fw={500}
size="sm"
lineClamp={1}
style={{
textDecoration: isCompleted ? 'line-through' : 'none',
color: isCompleted ? 'dimmed' : undefined,
}}
>
{event.title}
</Text>
{/* Time and content */}
<Group gap="xs">
<ThemeIcon size="xs" variant="light" color={getThemeColor()}>
<IconClock size={10} />
</ThemeIcon>
<Text size="xs" c={getThemeColor()}>
{timeInfo.timeStr}
</Text>
</Group>
{/* Content preview */}
{event.content && !isCompleted && (
<Text size="xs" c="dimmed" lineClamp={1}>
{event.content}
</Text>
)}
</Stack>
</Group>
{/* Quick actions */}
<Group gap={4}>
{isHovered && !isCompleted && (
<Tooltip label="完成">
<ActionIcon
size="sm"
variant="light"
color="green"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<IconCheck size={14} />
</ActionIcon>
</Tooltip>
)}
<Menu shadow="md" width={120}>
<Menu.Target>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={(e) => e.stopPropagation()}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={onClick}
>
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={onToggle}
>
{isCompleted ? '恢复' : '删除'}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</Card>
);

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Stack, Text, Paper, Group, Button } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { Stack, Text, Paper, Group, Button, Badge, ThemeIcon, Alert } from '@mantine/core';
import { IconPlus, IconBell, IconAlertCircle } from '@tabler/icons-react';
import { ReminderCard } from './ReminderCard';
import type { Event } from '../../types';
@ -22,52 +22,76 @@ export function ReminderList({
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date(today);
nextWeek.setDate(nextWeek.getDate() + 7);
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) return;
// 已完成的放最后
if (event.is_completed) {
result.completed.push(event);
return;
}
// 未完成的按时间分组
if (eventDate < today) {
result.missed.push(event);
} else if (eventDate < tomorrow) {
result.today.push(event);
} else if (eventDate < nextWeek) {
result.thisWeek.push(event);
} else {
result.later.push(event);
}
});
// Sort by date
Object.keys(result).forEach((key) => {
result[key as keyof typeof result].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
});
// 按时间排序
const sortByDate = (a: Event, b: Event) =>
new Date(a.date).getTime() - new Date(b.date).getTime();
result.today.sort(sortByDate);
result.tomorrow.sort(sortByDate);
result.thisWeek.sort(sortByDate);
result.later.sort(sortByDate);
result.missed.sort(sortByDate);
result.completed.sort(sortByDate);
return result;
}, [events]);
const hasReminders =
const hasActiveReminders =
grouped.today.length > 0 ||
grouped.tomorrow.length > 0 ||
grouped.thisWeek.length > 0 ||
grouped.later.length > 0 ||
grouped.missed.length > 0;
if (!hasReminders) {
// 空状态
if (!hasActiveReminders) {
return (
<Paper p="md" withBorder radius="md" h="100%">
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
<ThemeIcon size={40} variant="light" color="gray" radius="xl">
<IconBell size={20} />
</ThemeIcon>
<Text c="dimmed" size="sm" ta="center">
<br />
<Text size="xs" c="dimmed" mt={4}>
</Text>
</Text>
<Button
variant="light"
@ -82,12 +106,26 @@ export function ReminderList({
);
}
// 已过提醒数量提示
const missedCount = grouped.missed.length;
return (
<Paper p="md" withBorder radius="md" h="100%">
<Group justify="space-between" mb="sm">
<Text fw={500} size="sm">
</Text>
<Paper p="md" withBorder radius="md" h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
<Group gap={8}>
<ThemeIcon size="sm" variant="light" color="orange">
<IconBell size={12} />
</ThemeIcon>
<Text fw={500} size="sm">
</Text>
{missedCount > 0 && (
<Badge size="xs" variant="filled" color="red">
{missedCount}
</Badge>
)}
</Group>
<Button
variant="subtle"
size="xs"
@ -98,30 +136,46 @@ export function ReminderList({
</Button>
</Group>
<Stack gap="xs" style={{ maxHeight: 'calc(100% - 40px)', overflowY: 'auto' }}>
{/* Missed reminders */}
{/* Content */}
<Stack gap="xs" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{/* 逾期提醒 */}
{grouped.missed.length > 0 && (
<>
<Text size="xs" c="red" fw={500}>
</Text>
{grouped.missed.map((event) => (
<ReminderCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
/>
))}
</>
<Alert
icon={<IconAlertCircle size={14} />}
color="red"
variant="light"
p="xs"
title={<Text size="xs" fw={500}> {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)}
/>
))}
{grouped.missed.length > 3 && (
<Text size="xs" c="dimmed" ta="center">
{grouped.missed.length - 3} ...
</Text>
)}
</Stack>
</Alert>
)}
{/* Today's reminders */}
{/* 今天 */}
{grouped.today.length > 0 && (
<>
<Text size="xs" c="blue" fw={500}>
</Text>
<Group gap={4}>
<Text size="xs" c="red" fw={600}>
</Text>
<Badge size="xs" variant="light" color="red">
{grouped.today.length}
</Badge>
</Group>
{grouped.today.map((event) => (
<ReminderCard
key={event.id}
@ -133,12 +187,17 @@ export function ReminderList({
</>
)}
{/* Tomorrow's reminders */}
{/* 明天 */}
{grouped.tomorrow.length > 0 && (
<>
<Text size="xs" c="teal" fw={500}>
</Text>
<Group gap={4}>
<Text size="xs" c="teal" fw={600}>
</Text>
<Badge size="xs" variant="light" color="teal">
{grouped.tomorrow.length}
</Badge>
</Group>
{grouped.tomorrow.map((event) => (
<ReminderCard
key={event.id}
@ -150,12 +209,39 @@ export function ReminderList({
</>
)}
{/* Later reminders */}
{/* 本周 */}
{grouped.thisWeek.length > 0 && (
<>
<Group gap={4}>
<Text size="xs" c="blue" fw={600}>
</Text>
<Badge size="xs" variant="light" color="blue">
{grouped.thisWeek.length}
</Badge>
</Group>
{grouped.thisWeek.map((event) => (
<ReminderCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
/>
))}
</>
)}
{/* 更久 */}
{grouped.later.length > 0 && (
<>
<Text size="xs" c="gray" fw={500}>
</Text>
<Group gap={4}>
<Text size="xs" c="gray" fw={600}>
</Text>
<Badge size="xs" variant="light" color="gray">
{grouped.later.length}
</Badge>
</Group>
{grouped.later.map((event) => (
<ReminderCard
key={event.id}

View File

@ -87,9 +87,12 @@ export function HomePage() {
const handleSubmit = async () => {
if (!formTitle.trim() || !formDate) return;
// 确保 date 是 Date 对象
const dateObj = formDate instanceof Date ? formDate : new Date(formDate as unknown as string);
const dateStr = formTime
? new Date(formDate.setHours(parseInt(formTime.split(':')[0]), parseInt(formTime.split(':')[1])))
: formDate;
? new Date(dateObj.setHours(parseInt(formTime.split(':')[0]), parseInt(formTime.split(':')[1])))
: dateObj;
const eventData = {
type: formType,