qia-client/src/components/reminder/ReminderCard.tsx
ddshi 306cb41516 feat: 优化编辑窗口UI
- 优化日期时间选择器为组合布局(同一行按钮)
- 优先级改名为颜色
- 提醒类型移除农历选项
- 使用Popover优化选择器交互
2026-02-06 13:44:07 +08:00

702 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
onToggle: () => void;
onClick: () => void;
onDelete?: () => void;
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,
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 menuPosition = useContextMenuStore((state) => state.menuPosition);
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, 'x:', e.clientX, 'y:', e.clientY);
// 打开菜单,传入鼠标位置
openMenu(event.id, { x: e.clientX, y: e.clientY });
}, [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 = () => {
const menuWidth = 180; // 菜单宽度
const menuHeight = 150; // 估算菜单高度
const padding = 8; // 边缘padding
// 如果有鼠标位置,使用鼠标位置
let left = padding;
let top = padding;
if (menuPosition) {
// 水平位置跟随鼠标,添加偏移避免遮挡
left = menuPosition.x + 4;
// 垂直位置优先显示在鼠标下方
top = menuPosition.y + 4;
} else if (cardRef.current) {
// 回退到卡片位置
const cardRect = cardRef.current.getBoundingClientRect();
left = cardRect.left;
top = cardRect.bottom + 4;
}
// 边缘保护:确保不超出视口
if (left + menuWidth > window.innerWidth - padding) {
left = window.innerWidth - menuWidth - padding;
}
if (left < padding) left = padding;
if (top + menuHeight > window.innerHeight - padding) {
// 如果下方空间不足,显示在上方
top = menuPosition ? menuPosition.y - menuHeight - 4 : top;
}
if (top < padding) top = padding;
return { left, top };
};
// 计算时间信息
const timeInfo = useMemo(() => {
// 处理空日期情况
if (!event.date) {
return { isPast: false, isToday: true, timeStr: '未设置时间', diff: 0, hasTime: false };
}
const now = new Date();
const dateStr = event.date as string;
// 统一使用 Date 对象处理
const eventDate = new Date(dateStr);
// 检查是否包含有效时间小时和分钟不全为0
const hours = eventDate.getHours();
const minutes = eventDate.getMinutes();
const hasExplicitTime = hours !== 0 || minutes !== 0;
const diff = eventDate.getTime() - now.getTime();
const isPast = diff < 0;
const isToday = eventDate.toDateString() === now.toDateString();
// 格式化显示
let timeStr: string;
if (hasExplicitTime) {
timeStr = eventDate.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} else {
timeStr = eventDate.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
}
return { isPast, isToday, timeStr, diff, hasTime: hasExplicitTime };
}, [event.date]);
// 获取文字颜色
const getTextColor = () => {
if (isCompleted) return '#999';
if (timeInfo.isPast) return '#666';
return '#1a1a1a';
};
// 获取时间颜色
const getTimeColor = () => {
if (isCompleted) return '#bbb';
if (timeInfo.isPast) return '#c41c1c';
return '#666';
};
// 获取背景色
const getBackground = () => {
if (isCompleted) return 'rgba(0, 0, 0, 0.02)';
if (isMissed) return 'rgba(196, 28, 28, 0.03)';
return 'rgba(0, 0, 0, 0.02)';
};
// 获取边框颜色
const getBorderColor = () => {
if (isCompleted) return 'rgba(0, 0, 0, 0.06)';
if (isMissed) return 'rgba(196, 28, 28, 0.15)';
return 'rgba(0, 0, 0, 0.06)';
};
// 获取优先级的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,
};
};
// 处理日期调整
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
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();
}}
>
{/* 已完成时显示的对勾 */}
{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={{
display: 'flex',
alignItems: 'center',
color: REPEAT_ICON_COLOR,
}}
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
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',
}}
onClick={(e) => {
e.stopPropagation();
if (isMissed && !isCompleted) {
setIsAnimating(true);
setTimeout(() => {
setIsAnimating(false);
onToggle();
onMissedToggle?.();
}, 300);
} else {
onToggle();
}
}}
>
{/* 已完成时显示的对勾 */}
{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={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>
)}
</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>
)}
</>
);
}