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'; import type { Event } from '../../types';
interface ReminderCardProps { interface ReminderCardProps {
@ -9,6 +28,34 @@ interface ReminderCardProps {
export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) { export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
const isCompleted = event.is_completed ?? false; 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 ( return (
<Card <Card
@ -16,38 +63,110 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
padding="sm" padding="sm"
radius="md" radius="md"
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
opacity: isCompleted ? 0.6 : 1, opacity: isCompleted ? 0.5 : 1,
transition: 'transform 0.2s', transition: 'all 0.2s ease',
textDecoration: isCompleted ? 'line-through' : 'none', 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"> <Group justify="space-between" wrap="nowrap">
<Stack gap={4}> <Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm" lineClamp={1}> {/* Checkbox */}
{event.title} <Checkbox
</Text> checked={isCompleted}
<Text size="xs" c="dimmed"> onChange={(e) => {
{new Date(event.date).toLocaleString('zh-CN')} e.stopPropagation();
</Text> onToggle();
{event.content && ( }}
<Text size="xs" c="dimmed" lineClamp={1}> onClick={(e) => e.stopPropagation()}
{event.content} color="green"
</Text> size="sm"
)} />
</Stack>
<Checkbox <Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
checked={isCompleted} {/* Title */}
onChange={(e) => { <Text
e.stopPropagation(); fw={500}
onToggle(); size="sm"
}} lineClamp={1}
onClick={(e) => e.stopPropagation()} 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> </Group>
</Card> </Card>
); );

View File

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

View File

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