feat: 完善提醒卡片右键菜单功能

- 优化checkbox样式:缩小尺寸(14px)、移除阴影、添加白色填充
- 调整布局:标题和内容左对齐
- 重构右键菜单为垂直分类布局:调整时间/颜色/操作
- 添加菜单边缘保护:自动计算位置避免超出浏览器
- 添加点击外部和ESC键关闭菜单
- 编辑弹窗优先级改为颜色圆点选择器
- 添加priority类型定义

🤖 Generated with Claude Code
This commit is contained in:
ddshi 2026-02-05 17:51:59 +08:00
parent 1559e603b0
commit f0cbd0e33c
5 changed files with 722 additions and 224 deletions

View File

@ -1,8 +1,47 @@
import { useMemo, useState } from 'react';
import { Paper, Text, Checkbox, Group, Stack, ActionIcon, Box } from '@mantine/core';
import { IconDots, IconArrowForward, IconRepeat, IconRepeatOff } from '@tabler/icons-react';
import type { Event, RepeatType } from '../../types';
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import {
Paper,
Text,
Group,
Stack,
ActionIcon,
Box,
Menu,
Divider,
Portal,
} from '@mantine/core';
import {
IconDots,
IconArrowForward,
IconRepeat,
IconCalendar,
IconEdit,
IconTrash,
IconCircleFilled,
IconCheck,
} from '@tabler/icons-react';
import type { Event, PriorityType } from '../../types';
import { getRepeatTypeLabel } from '../../utils/repeatCalculator';
import { useContextMenuStore } from '../../stores/contextMenu';
// 重复图标颜色常量
const REPEAT_ICON_COLOR = '#999';
// 优先级颜色映射
const PRIORITY_COLORS: Record<PriorityType, string> = {
none: 'rgba(0, 0, 0, 0.15)',
red: '#dc2626',
green: '#16a34a',
yellow: '#ca8a04',
};
// 优先级名称映射
const PRIORITY_NAMES: Record<PriorityType, string> = {
none: '默认',
red: '红色',
green: '绿色',
yellow: '黄色',
};
interface ReminderCardProps {
event: Event;
@ -12,12 +51,99 @@ interface ReminderCardProps {
onPostpone?: () => void;
isMissed?: boolean;
onMissedToggle?: () => void;
onDateChange?: (date: string, repeatType: string, repeatInterval: number | null) => void;
onPriorityChange?: (priority: PriorityType) => void;
}
export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, isMissed = false, onMissedToggle }: ReminderCardProps) {
export function ReminderCard({
event,
onToggle,
onClick,
onDelete,
onPostpone,
isMissed = false,
onMissedToggle,
onDateChange,
onPriorityChange,
}: ReminderCardProps) {
const isCompleted = event.is_completed ?? false;
const [isHovered, setIsHovered] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
// 使用全局状态管理右键菜单
const openEventId = useContextMenuStore((state) => state.openEventId);
const { openMenu, closeMenu } = useContextMenuStore();
const isMenuOpen = openEventId === event.id;
// 获取优先级颜色
const getPriorityColor = (priority?: PriorityType): string => {
return PRIORITY_COLORS[priority || 'none'];
};
// 右键点击处理
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
console.log('[ContextMenu] 右键点击事件, eventId:', event.id);
// 打开菜单
openMenu(event.id);
}, [event.id, openMenu]);
// 点击外部关闭菜单
useEffect(() => {
if (!isMenuOpen) return;
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 如果点击不在当前卡片和菜单内,关闭菜单
if (!target.closest(`[data-reminder-card="${event.id}"]`) &&
!target.closest('[data-context-menu]')) {
closeMenu();
}
};
// ESC键关闭菜单
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeMenu();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isMenuOpen, event.id, closeMenu]);
// 计算菜单位置(带边缘保护)
const getMenuPosition = () => {
if (!cardRef.current) return { left: 0, top: 0 };
const cardRect = cardRef.current.getBoundingClientRect();
const menuWidth = 180; // 菜单宽度
const menuHeight = 150; // 估算菜单高度
const padding = 8; // 边缘padding
// 计算左侧位置
let left = cardRect.left;
if (left + menuWidth > window.innerWidth - padding) {
left = window.innerWidth - menuWidth - padding;
}
if (left < padding) left = padding;
// 计算顶部位置(优先显示在下方)
let top = cardRect.bottom + 4;
if (top + menuHeight > window.innerHeight - padding) {
// 如果下方空间不足,显示在上方
top = cardRect.top - menuHeight - 4;
}
if (top < padding) top = padding;
return { left, top };
};
// 计算时间信息
const timeInfo = useMemo(() => {
@ -33,7 +159,6 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
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;
@ -45,7 +170,6 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
// 格式化显示
let timeStr: string;
if (hasExplicitTime) {
// 有设置时间,显示日期+时间
timeStr = eventDate.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
@ -54,7 +178,6 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
hour12: false,
});
} else {
// 没有设置时间00:00只显示日期
timeStr = eventDate.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
@ -92,156 +215,213 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
return 'rgba(0, 0, 0, 0.06)';
};
// 获取循环图标颜色
const getRepeatIconColor = (type: RepeatType) => {
const colors: Record<RepeatType, string> = {
daily: '#3b82f6', // 蓝色
weekly: '#22c55e', // 绿色
monthly: '#a855f7', // 紫色
yearly: '#f59e0b', // 橙色
none: '#999',
// 获取优先级的checkbox边框样式
const getCheckboxBorderStyle = () => {
const priority = event.priority || 'none';
const priorityColor = getPriorityColor(priority);
const isHighPriority = priority !== 'none';
return {
borderColor: priorityColor,
borderWidth: isHighPriority ? '2px' : '1px',
borderStyle: 'solid' as const,
};
return colors[type] || '#999';
};
// 处理日期调整
const handleDateAdjust = useCallback((days: number) => {
const now = new Date();
const targetDate = new Date(now);
targetDate.setDate(targetDate.getDate() + days);
// 格式化为 YYYY-MM-DD
const formattedDate = targetDate.toISOString().split('T')[0];
onDateChange?.(formattedDate, event.repeat_type, event.repeat_interval ?? null);
closeMenu();
}, [onDateChange, event.repeat_type, event.repeat_interval, closeMenu]);
// 处理优先级设置
const handlePriorityChange = useCallback((priority: PriorityType) => {
onPriorityChange?.(priority);
closeMenu();
}, [onPriorityChange, closeMenu]);
return (
<Paper
p="sm"
radius={2}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
cursor: 'pointer',
opacity: isAnimating ? 0 : isCompleted ? 0.4 : 1,
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
background: getBackground(),
border: `1px solid ${getBorderColor()}`,
borderLeft: isMissed && !isCompleted ? '3px solid #c41c1c' : undefined,
transition: 'all 0.3s ease',
animation: isAnimating ? 'reminder-card-fadeOut 0.3s ease-out forwards' : 'none',
}}
>
<style>{`
@keyframes reminder-card-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
@keyframes reminder-card-fadeOut {
0% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(20px); }
}
`}</style>
{/* 正常提醒卡片:左右分栏布局 */}
{!isMissed ? (
<Group justify="space-between" wrap="nowrap" align="flex-start">
{/* 左侧Checkbox + 标题 + 内容 */}
<Box
style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}
onClick={(e) => {
// 阻止事件冒泡到 Card避免重复触发
e.stopPropagation();
onClick();
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
{/* Checkbox - 单独处理,不触发卡片点击 */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
paddingTop: 2,
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none',
}}
onClick={(e) => {
// 阻止事件冒泡,避免触发卡片点击
e.stopPropagation();
}}
>
<Checkbox
checked={isCompleted}
onChange={(e) => {
<>
<Paper
ref={cardRef}
p="sm"
radius={2}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onContextMenu={handleContextMenu}
data-reminder-card={event.id}
style={{
cursor: 'pointer',
opacity: isAnimating ? 0 : isCompleted ? 0.4 : 1,
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
background: getBackground(),
border: `1px solid ${getBorderColor()}`,
borderLeft: isMissed && !isCompleted ? '3px solid #c41c1c' : undefined,
transition: 'all 0.3s ease',
animation: isAnimating ? 'reminder-card-fadeOut 0.3s ease-out forwards' : 'none',
}}
>
<style>{`
@keyframes reminder-card-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
@keyframes reminder-card-fadeOut {
0% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(20px); }
}
/* 自定义checkbox样式去掉默认边框 */
.reminder-checkbox input[type="checkbox"] {
border-radius: 50% !important;
border: none !important;
background: transparent !important;
}
.reminder-checkbox .mantine-Checkbox-icon {
color: inherit !important;
}
`}</style>
{/* 正常提醒卡片:左右分栏布局 */}
{!isMissed ? (
<Group justify="space-between" wrap="nowrap" align="flex-start">
{/* 左侧Checkbox + 标题 + 内容 */}
<Box
style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Box style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* Checkbox */}
<div
className="reminder-checkbox"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none',
width: 14,
height: 14,
borderRadius: '50%',
border: `1px solid ${getPriorityColor(event.priority || 'none')}`,
background: isCompleted ? getPriorityColor(event.priority || 'none') : '#ffffff',
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0,
position: 'relative',
marginTop: 2,
}}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
size="xs"
color="#1a1a1a"
>
{/* 已完成时显示的对勾 */}
{isCompleted && (
<span
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#fff',
fontSize: '8px',
fontWeight: 'bold',
lineHeight: 1,
}}
>
</span>
)}
</div>
{/* 标题和内容垂直排列 */}
<Box style={{ flex: 1, marginLeft: 8 }}>
{/* 标题 */}
<Text
fw={400}
size="xs"
lineClamp={1}
style={{
textDecoration: isCompleted ? 'line-through' : 'none',
color: getTextColor(),
letterSpacing: '0.03em',
transition: 'color 0.2s ease',
}}
>
{event.title}
</Text>
{/* 内容在标题下方 */}
{event.content && (
<Text
size="xs"
c={isCompleted ? '#bbb' : '#999'}
lineClamp={1}
style={{
marginTop: 2,
opacity: isCompleted ? 0.6 : 1,
transition: 'opacity 0.2s ease',
}}
>
{event.content}
</Text>
)}
</Box>
</Box>
</Box>
{/* 右侧:循环图标 + 日期时间 */}
<Box style={{ flex: '0 0 auto', minWidth: 0, paddingLeft: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
{event.repeat_type !== 'none' && (
<Box
style={{
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none',
display: 'flex',
alignItems: 'center',
color: REPEAT_ICON_COLOR,
}}
/>
</div>
{/* 标题 */}
<Text
fw={400}
size="xs"
lineClamp={1}
style={{
textDecoration: isCompleted ? 'line-through' : 'none',
color: getTextColor(),
letterSpacing: '0.03em',
transition: 'color 0.2s ease',
}}
>
{event.title}
</Text>
</Group>
{/* 内容在标题下方 */}
{event.content && (
title={getRepeatTypeLabel(event.repeat_type)}
>
<IconRepeat size={12} />
</Box>
)}
<Text
size="xs"
c={isCompleted ? '#bbb' : '#999'}
lineClamp={1}
style={{
marginLeft: 28,
opacity: isCompleted ? 0.6 : 1,
transition: 'opacity 0.2s ease',
}}
c={getTimeColor()}
style={{ letterSpacing: '0.05em', whiteSpace: 'nowrap' }}
>
{event.content}
{timeInfo.timeStr}
</Text>
)}
</Box>
{/* 右侧:循环图标 + 日期时间 */}
<Box style={{ flex: '0 0 auto', minWidth: 0, paddingLeft: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
{event.repeat_type !== 'none' && (
<Box
</Box>
</Group>
) : (
/* 逾期提醒卡片 */
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
<div
className="reminder-checkbox"
style={{
display: 'flex',
alignItems: 'center',
color: getRepeatIconColor(event.repeat_type),
justifyContent: 'center',
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none',
width: 14,
height: 14,
borderRadius: '50%',
border: `1px solid ${getPriorityColor(event.priority || 'none')}`,
background: isCompleted ? getPriorityColor(event.priority || 'none') : '#ffffff',
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0,
position: 'relative',
}}
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 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) => {
onClick={(e) => {
e.stopPropagation();
if (isMissed && !isCompleted) {
setIsAnimating(true);
@ -254,103 +434,257 @@ export function ReminderCard({ event, onToggle, onClick, onDelete, onPostpone, i
onToggle();
}
}}
size="xs"
color="#1a1a1a"
/>
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} onClick={onClick}>
<Group gap={4} wrap="nowrap">
<Text
fw={400}
size="xs"
lineClamp={1}
style={{
textDecoration: isCompleted ? 'line-through' : 'none',
color: getTextColor(),
letterSpacing: '0.03em',
transition: 'color 0.2s ease',
}}
>
{event.title}
</Text>
{event.repeat_type !== 'none' && (
<Box
style={{
display: 'flex',
alignItems: 'center',
flexShrink: 0,
color: getRepeatIconColor(event.repeat_type),
}}
title={getRepeatTypeLabel(event.repeat_type)}
>
<IconRepeat size={10} />
</Box>
)}
</Group>
<Text
size="xs"
c={getTimeColor()}
style={{ letterSpacing: '0.05em' }}
>
{timeInfo.timeStr}
</Text>
{event.content && (
{/* 已完成时显示的对勾 */}
{isCompleted && (
<span
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#fff',
fontSize: '10px',
fontWeight: 'bold',
lineHeight: 1,
}}
>
</span>
)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} onClick={onClick}>
<Group gap={4} wrap="nowrap">
<Text
fw={400}
size="xs"
lineClamp={1}
style={{
textDecoration: isCompleted ? 'line-through' : 'none',
color: getTextColor(),
letterSpacing: '0.03em',
transition: 'color 0.2s ease',
}}
>
{event.title}
</Text>
{event.repeat_type !== 'none' && (
<Box
style={{
display: 'flex',
alignItems: 'center',
flexShrink: 0,
color: REPEAT_ICON_COLOR,
}}
title={getRepeatTypeLabel(event.repeat_type)}
>
<IconRepeat size={10} />
</Box>
)}
</Group>
<Text
size="xs"
c={isCompleted ? '#bbb' : '#999'}
lineClamp={1}
style={{
opacity: isCompleted ? 0.6 : 1,
transition: 'opacity 0.2s ease',
}}
c={getTimeColor()}
style={{ letterSpacing: '0.05em' }}
>
{event.content}
{timeInfo.timeStr}
</Text>
)}
</Stack>
</Group>
{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 && (
<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="orange"
color="gray"
onClick={(e) => {
e.stopPropagation();
onPostpone();
onClick();
}}
style={{
color: '#e67e22',
color: '#999',
opacity: isHovered ? 1 : 0,
transition: 'all 0.2s ease',
}}
title="顺延到今天"
title="编辑"
>
<IconArrowForward size={12} />
<IconDots 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>
)}
</Paper>
{/* 右键菜单 - 使用Portal渲染到body */}
{isMenuOpen && cardRef.current && (
<Portal>
<Box
data-context-menu
style={{
position: 'absolute',
left: getMenuPosition().left,
top: getMenuPosition().top,
zIndex: 1000,
background: '#fff',
border: '1px solid rgba(0, 0, 0, 0.08)',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
padding: '6px 8px',
minWidth: 180,
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 调整时间 */}
<Box style={{ marginBottom: 6 }}>
<Text size="xs" c="#999" fw={400} style={{ marginBottom: 4, paddingLeft: 4, letterSpacing: '0.05em' }}>
</Text>
<Group gap={4}>
<ActionIcon
variant="subtle"
size="xs"
style={{
flex: 1,
height: 24,
color: '#666',
background: 'transparent',
}}
onClick={() => handleDateAdjust(0)}
>
<Text size="xs"></Text>
</ActionIcon>
<ActionIcon
variant="subtle"
size="xs"
style={{
flex: 1,
height: 24,
color: '#666',
background: 'transparent',
}}
onClick={() => handleDateAdjust(1)}
>
<Text size="xs"></Text>
</ActionIcon>
<ActionIcon
variant="subtle"
size="xs"
style={{
flex: 1,
height: 24,
color: '#666',
background: 'transparent',
}}
onClick={() => handleDateAdjust(7)}
>
<Text size="xs"></Text>
</ActionIcon>
</Group>
</Box>
<Divider my={4} style={{ borderColor: 'rgba(0, 0, 0, 0.06)' }} />
{/* 颜色 */}
<Box style={{ marginBottom: 6 }}>
<Text size="xs" c="#999" fw={400} style={{ marginBottom: 4, paddingLeft: 4, letterSpacing: '0.05em' }}>
</Text>
<Group gap={4}>
{(Object.keys(PRIORITY_COLORS) as PriorityType[]).map((priority) => (
<ActionIcon
key={priority}
variant={event.priority === priority ? 'filled' : 'subtle'}
size="xs"
style={{
flex: 1,
height: 24,
background: event.priority === priority ? PRIORITY_COLORS[priority] : 'transparent',
color: event.priority === priority ? '#fff' : '#666',
border: `1px solid ${event.priority === priority ? 'transparent' : 'rgba(0, 0, 0, 0.1)'}`,
}}
onClick={() => handlePriorityChange(priority)}
>
<Text size="xs" fw={400}>
{PRIORITY_NAMES[priority]}
</Text>
</ActionIcon>
))}
</Group>
</Box>
<Divider my={4} style={{ borderColor: 'rgba(0, 0, 0, 0.06)' }} />
{/* 操作 */}
<Group gap={4}>
<ActionIcon
variant="subtle"
size="xs"
style={{
flex: 1,
height: 24,
color: '#666',
background: 'transparent',
}}
onClick={() => {
onClick();
closeMenu();
}}
>
<Text size="xs" fw={400}></Text>
</ActionIcon>
<ActionIcon
variant="subtle"
size="xs"
style={{
flex: 1,
height: 24,
color: '#dc2626',
background: 'transparent',
}}
onClick={() => {
onDelete?.();
closeMenu();
}}
>
<Text size="xs" fw={400}></Text>
</ActionIcon>
</Group>
</Box>
</Portal>
)}
</Paper>
</>
);
}

View File

@ -1,4 +1,4 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import { useMemo, useRef, useState } from 'react';
import {
Stack,
Text,
@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { ReminderCard } from './ReminderCard';
import type { Event } from '../../types';
import type { Event, PriorityType } from '../../types';
interface ReminderListProps {
events: Event[];
@ -27,6 +27,8 @@ interface ReminderListProps {
onAddClick: () => void;
onDelete?: (event: Event) => void;
onPostpone?: (event: Event) => void;
onDateChange?: (event: Event, date: string, repeatType: string, repeatInterval: number | null) => void;
onPriorityChange?: (event: Event, priority: PriorityType) => void;
}
export function ReminderList({
@ -36,6 +38,8 @@ export function ReminderList({
onAddClick,
onDelete,
onPostpone,
onDateChange,
onPriorityChange,
}: ReminderListProps) {
const navigate = useNavigate();
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -299,6 +303,8 @@ export function ReminderList({
onDelete={onDelete ? () => onDelete(event) : undefined}
onPostpone={onPostpone ? () => onPostpone(event) : undefined}
onMissedToggle={triggerArchiveShake}
onDateChange={onDateChange ? (date, repeatType, repeatInterval) => onDateChange(event, date, repeatType, repeatInterval) : undefined}
onPriorityChange={onPriorityChange ? (priority) => onPriorityChange(event, priority) : undefined}
isMissed={true}
/>
))}
@ -329,6 +335,8 @@ export function ReminderList({
onDelete={onDelete ? () => onDelete(event) : undefined}
onPostpone={onPostpone ? () => onPostpone(event) : undefined}
onMissedToggle={triggerArchiveShake}
onDateChange={onDateChange ? (date, repeatType, repeatInterval) => onDateChange(event, date, repeatType, repeatInterval) : undefined}
onPriorityChange={onPriorityChange ? (priority) => onPriorityChange(event, priority) : undefined}
isMissed={true}
/>
))}
@ -376,6 +384,8 @@ export function ReminderList({
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
onDateChange={onDateChange ? (date, repeatType, repeatInterval) => onDateChange(event, date, repeatType, repeatInterval) : undefined}
onPriorityChange={onPriorityChange ? (priority) => onPriorityChange(event, priority) : undefined}
/>
))}
</>
@ -405,6 +415,8 @@ export function ReminderList({
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
onDateChange={onDateChange ? (date, repeatType, repeatInterval) => onDateChange(event, date, repeatType, repeatInterval) : undefined}
onPriorityChange={onPriorityChange ? (priority) => onPriorityChange(event, priority) : undefined}
/>
))}
</>
@ -434,6 +446,8 @@ export function ReminderList({
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
onDateChange={onDateChange ? (date, repeatType, repeatInterval) => onDateChange(event, date, repeatType, repeatInterval) : undefined}
onPriorityChange={onPriorityChange ? (priority) => onPriorityChange(event, priority) : undefined}
/>
))}
</>

View File

@ -11,6 +11,7 @@ import {
Switch,
Select,
Stack,
Box,
} from '@mantine/core';
import { DatePickerInput, TimeInput } from '@mantine/dates';
import { IconLogout, IconSettings } from '@tabler/icons-react';
@ -21,7 +22,7 @@ import { AnniversaryList } from '../components/anniversary/AnniversaryList';
import { ReminderList } from '../components/reminder/ReminderList';
import { NoteEditor } from '../components/note/NoteEditor';
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
import type { Event, EventType, RepeatType } from '../types';
import type { Event, EventType, RepeatType, PriorityType } from '../types';
import { calculateNextReminderDate } from '../utils/repeatCalculator';
export function HomePage() {
@ -48,6 +49,7 @@ export function HomePage() {
const [formIsLunar, setFormIsLunar] = useState(false);
const [formRepeatType, setFormRepeatType] = useState<RepeatType>('none');
const [formIsHoliday, setFormIsHoliday] = useState(false);
const [formPriority, setFormPriority] = useState<PriorityType>('none');
// Initialize auth and data on mount
useEffect(() => {
@ -70,6 +72,7 @@ export function HomePage() {
setFormIsLunar(event.is_lunar);
setFormRepeatType(event.repeat_type);
setFormIsHoliday(event.is_holiday || false);
setFormPriority(event.priority || 'none');
// 提取时间(如果日期中包含时间信息)
const eventDate = new Date(event.date);
const hours = eventDate.getHours();
@ -89,6 +92,7 @@ export function HomePage() {
setFormIsLunar(false);
setFormRepeatType('none');
setFormIsHoliday(type === 'anniversary');
setFormPriority('none');
open();
};
@ -123,6 +127,7 @@ export function HomePage() {
// 计算下一次提醒日期
next_reminder_date: calculateNextReminderDate(dateStr, formRepeatType, undefined),
is_holiday: formIsHoliday ?? false,
priority: formPriority,
};
// 只有当 content 有值时才包含
@ -180,14 +185,80 @@ export function HomePage() {
// 构建新的本地时间字符串
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, {
// 构建更新数据
const updateData: Record<string, any> = {
date: newDateStr,
});
priority: event.priority || 'none',
};
// 如果是重复提醒,同步更新 next_reminder_date
if (event.repeat_type && event.repeat_type !== 'none') {
const nextReminderDate = calculateNextReminderDate(
newDateStr,
event.repeat_type as RepeatType,
event.repeat_interval || undefined
);
updateData.next_reminder_date = nextReminderDate;
}
const result = await updateEventById(event.id, updateData);
if (result.error) {
console.error('顺延失败:', result.error);
}
};
const handleDateChange = async (
event: Event,
date: string,
repeatType: string,
repeatInterval: number | null
) => {
if (event.type !== 'reminder') return;
// 保留原事件的时间信息
const originalDate = new Date(event.date);
const hours = originalDate.getHours();
const minutes = originalDate.getMinutes();
// 构建新的本地时间字符串
const newDateStr = `${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
// 构建更新数据
const updateData: Record<string, any> = {
date: newDateStr,
priority: event.priority || 'none',
};
// 如果是重复提醒,同步更新 next_reminder_date
if (repeatType && repeatType !== 'none') {
const nextReminderDate = calculateNextReminderDate(
newDateStr,
repeatType as RepeatType,
repeatInterval || undefined
);
updateData.next_reminder_date = nextReminderDate;
}
// 合并更新
const result = await updateEventById(event.id, updateData);
if (result.error) {
console.error('日期调整失败:', result.error);
}
fetchEvents(); // 刷新列表以更新UI
};
const handlePriorityChange = async (event: Event, priority: PriorityType) => {
if (event.type !== 'reminder') return;
// 合并更新 priority 和 date确保 updated_at 被更新
const result = await updateEventById(event.id, {
priority,
date: event.date,
});
if (result.error) {
console.error('优先级设置失败:', result.error);
}
fetchEvents(); // 刷新列表以更新UI
};
const resetForm = () => {
setFormType('anniversary');
setFormTitle('');
@ -197,6 +268,7 @@ export function HomePage() {
setFormIsLunar(false);
setFormRepeatType('none');
setFormIsHoliday(false);
setFormPriority('none');
setSelectedEvent(null);
setIsEdit(false);
};
@ -286,6 +358,8 @@ export function HomePage() {
onAddClick={() => handleAddClick('reminder')}
onDelete={handleDelete}
onPostpone={handlePostpone}
onDateChange={handleDateChange}
onPriorityChange={handlePriorityChange}
/>
</div>
@ -460,6 +534,38 @@ export function HomePage() {
}}
/>
{/* Priority (only for reminders) */}
{formType === 'reminder' && (
<Box>
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 8 }}>
</Text>
<Group gap={8}>
{([
{ value: 'none' as const, color: 'rgba(0, 0, 0, 0.15)' },
{ value: 'red' as const, color: '#dc2626' },
{ value: 'green' as const, color: '#16a34a' },
{ value: 'yellow' as const, color: '#ca8a04' },
].map((item) => (
<Box
key={item.value}
onClick={() => setFormPriority(item.value)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: item.color,
border: formPriority === item.value ? '2px solid #1a1a1a' : '1px solid rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
transition: 'all 0.2s ease',
transform: formPriority === item.value ? 'scale(1.1)' : 'scale(1)',
}}
/>
)))}
</Group>
</Box>
)}
{/* Holiday switch (only for anniversaries) */}
{formType === 'anniversary' && (
<Switch

40
src/stores/contextMenu.ts Normal file
View File

@ -0,0 +1,40 @@
import { create } from 'zustand';
// 右键菜单状态管理
interface ContextMenuState {
// 当前打开菜单的事件IDnull表示没有菜单打开
openEventId: string | null;
// 打开菜单
openMenu: (eventId: string) => void;
// 关闭菜单
closeMenu: () => void;
// 切换菜单
toggleMenu: (eventId: string) => void;
// 点击其他位置关闭菜单
closeOnClickOutside: (eventId: string) => void;
}
export const useContextMenuStore = create<ContextMenuState>((set) => ({
openEventId: null,
openMenu: (eventId: string) => {
set({ openEventId: eventId });
},
closeMenu: () => {
set({ openEventId: null });
},
toggleMenu: (eventId: string) => {
set((state) => ({
openEventId: state.openEventId === eventId ? null : eventId,
}));
},
// 点击其他位置时如果点击的是不同的事件ID则关闭菜单
closeOnClickOutside: (clickedEventId: string) => {
set((state) => ({
openEventId: state.openEventId === clickedEventId ? state.openEventId : null,
}));
},
}));

View File

@ -13,6 +13,9 @@ export type EventType = 'anniversary' | 'reminder';
// Repeat types: 'daily'每天, 'weekly'每周, 'monthly'每月, 'yearly'每年, 'none'不重复
export type RepeatType = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'none';
// Priority types: 'none'无色, 'red'红色, 'green'绿色, 'yellow'黄色
export type PriorityType = 'none' | 'red' | 'green' | 'yellow';
// Unified event type (matches backend API)
export interface Event {
id: string;
@ -27,6 +30,7 @@ export interface Event {
next_reminder_date?: string | null; // 下一次提醒日期(计算用)
is_holiday?: boolean; // Only for anniversaries
is_completed?: boolean; // Only for reminders
priority?: PriorityType; // Priority level for reminders
created_at: string;
updated_at: string;
}