Compare commits

...

3 Commits

Author SHA1 Message Date
ddshi
79ef45b4ad fix: 优化事件编辑体验
- 时间选择器使用滚轮选择,隐藏滚动条
- 内容输入框替换为原生 textarea
- 删除图标移到时间输入框内
- 修复日期选择器位置问题
- Tab 键不再导致页面跳转
2026-02-06 16:19:23 +08:00
ddshi
9f67ae50ed fix: 修复日期选择器样式和中文支持
- 添加 @mantine/dates 样式导入
- 添加 dayjs 中文语言包
- 使用 DatePickerInput 直接点击展开日历
- 日期时间选择器统一使用 Input 组件样式
2026-02-06 13:55:31 +08:00
ddshi
306cb41516 feat: 优化编辑窗口UI
- 优化日期时间选择器为组合布局(同一行按钮)
- 优先级改名为颜色
- 提醒类型移除农历选项
- 使用Popover优化选择器交互
2026-02-06 13:44:07 +08:00
10 changed files with 808 additions and 93 deletions

4
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@supabase/supabase-js": "^2.93.2", "@supabase/supabase-js": "^2.93.2",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"lunar-javascript": "^1.7.7", "lunar-javascript": "^1.7.7",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
@ -2419,8 +2420,7 @@
"version": "1.11.19", "version": "1.11.19",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",

View File

@ -17,6 +17,7 @@
"@supabase/supabase-js": "^2.93.2", "@supabase/supabase-js": "^2.93.2",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"lunar-javascript": "^1.7.7", "lunar-javascript": "^1.7.7",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",

View File

@ -0,0 +1,245 @@
import { useState, useMemo } from 'react';
import {
Box,
Text,
Group,
ActionIcon,
} from '@mantine/core';
import {
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
interface FixedCalendarProps {
value: Date | null;
onChange: (date: Date | null) => void;
minDate?: Date;
maxDate?: Date;
}
// 获取某月的第一天是星期几
function getFirstDayOfMonth(date: Date): number {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
}
// 获取某月有多少天
function getDaysInMonth(date: Date): number {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
}
// 检查两个日期是否是同一天
function isSameDay(d1: Date | null, d2: Date | null): boolean {
if (!d1 || !d2) return false;
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
}
// 检查日期是否在范围内
function isDateInRange(date: Date, min?: Date, max?: Date): boolean {
if (min && date < min) return false;
if (max && date > max) return false;
return true;
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
const MONTH_NAMES = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalendarProps) {
const [currentMonth, setCurrentMonth] = useState(value || new Date());
// 生成日历数据固定6行7列 = 42天
const calendarDays = useMemo(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDay = getFirstDayOfMonth(new Date(year, month, 1));
const daysInMonth = getDaysInMonth(new Date(year, month, 1));
const days: Array<{
date: Date;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isDisabled: boolean;
}> = [];
// 上月剩余的天数
const prevMonthDays = getDaysInMonth(new Date(year, month - 1, 1));
for (let i = firstDay - 1; i >= 0; i--) {
const date = new Date(year, month - 1, prevMonthDays - i);
days.push({
date,
isCurrentMonth: false,
isToday: isSameDay(date, new Date()),
isSelected: isSameDay(date, value),
isDisabled: !isDateInRange(date, minDate, maxDate),
});
}
// 当月的天数
for (let i = 1; i <= daysInMonth; i++) {
const date = new Date(year, month, i);
days.push({
date,
isCurrentMonth: true,
isToday: isSameDay(date, new Date()),
isSelected: isSameDay(date, value),
isDisabled: !isDateInRange(date, minDate, maxDate),
});
}
// 下月需要填充的天数
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i);
days.push({
date,
isCurrentMonth: false,
isToday: isSameDay(date, new Date()),
isSelected: isSameDay(date, value),
isDisabled: !isDateInRange(date, minDate, maxDate),
});
}
return days;
}, [currentMonth, value, minDate, maxDate]);
const handlePrevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
};
const handleNextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
};
const handleDateClick = (date: Date, isDisabled: boolean) => {
if (!isDisabled) {
onChange(date);
}
};
return (
<Box
style={{
background: '#fff',
borderRadius: 12,
padding: 16,
width: 280,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
}}
>
{/* 头部:月份选择 */}
<Group justify="space-between" mb={12}>
<ActionIcon
variant="subtle"
size="sm"
radius="md"
onClick={handlePrevMonth}
style={{ color: '#666' }}
>
<IconChevronLeft size={18} />
</ActionIcon>
<Text
size="sm"
fw={600}
style={{
minWidth: 100,
textAlign: 'center',
color: '#1a1a1a',
letterSpacing: '0.02em',
}}
>
{MONTH_NAMES[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</Text>
<ActionIcon
variant="subtle"
size="sm"
radius="md"
onClick={handleNextMonth}
style={{ color: '#666' }}
>
<IconChevronRight size={18} />
</ActionIcon>
</Group>
{/* 星期行 */}
<Group gap={0} mb={8}>
{WEEKDAYS.map((day) => (
<Box
key={day}
style={{
flex: 1,
textAlign: 'center',
fontSize: 11,
color: '#999',
fontWeight: 500,
padding: '6px 0',
letterSpacing: '0.05em',
}}
>
{day}
</Box>
))}
</Group>
{/* 日期网格 - 固定6行7列 */}
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{Array.from({ length: 6 }).map((_, weekIndex) => (
<Group key={weekIndex} gap={0} style={{ flex: 1 }}>
{Array.from({ length: 7 }).map((_, dayIndex) => {
const dayIndex_ = weekIndex * 7 + dayIndex;
const day = calendarDays[dayIndex_];
if (!day) return null;
return (
<Box
key={dayIndex_}
onClick={() => handleDateClick(day.date, day.isDisabled)}
style={{
flex: 1,
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
cursor: day.isDisabled ? 'not-allowed' : 'pointer',
borderRadius: 8,
background: day.isSelected
? '#007AFF'
: day.isToday
? 'rgba(0, 122, 255, 0.12)'
: 'transparent',
color: day.isCurrentMonth
? day.isDisabled
? '#d1d5db'
: day.isSelected
? '#fff'
: '#374151'
: day.isDisabled
? '#e5e7eb'
: '#9ca3af',
fontWeight: day.isSelected ? 600 : 500,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!day.isDisabled && !day.isSelected) {
e.currentTarget.style.background = 'rgba(0, 122, 255, 0.06)';
}
}}
onMouseLeave={(e) => {
if (!day.isDisabled && !day.isSelected) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{day.date.getDate()}
</Box>
);
})}
</Group>
))}
</Box>
</Box>
);
}

View File

@ -0,0 +1,312 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Box, Group, Text } from '@mantine/core';
interface WheelTimePickerProps {
value: string; // "HH:mm" format
onChange: (time: string) => void;
}
function padZero(num: number): string {
return String(num).padStart(2, '0');
}
const ITEM_HEIGHT = 36;
const VISIBLE_ITEMS = 5;
const CONTAINER_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS;
export function WheelTimePicker({ value, onChange }: WheelTimePickerProps) {
// 处理空值情况
const parseInitialValue = () => {
if (!value || value === ':') return { hours: 0, minutes: 0 };
const parts = value.split(':');
if (parts.length !== 2) return { hours: 0, minutes: 0 };
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
return {
hours: isNaN(h) || h < 0 || h > 23 ? 0 : h,
minutes: isNaN(m) || m < 0 || m > 59 ? 0 : m,
};
};
const { hours: initialHours, minutes: initialMinutes } = parseInitialValue();
const [hours, setHours] = useState(initialHours);
const [minutes, setMinutes] = useState(initialMinutes);
const hourScrollRef = useRef<HTMLDivElement>(null);
const minuteScrollRef = useRef<HTMLDivElement>(null);
// 获取中心位置对应的索引
const getIndexFromScroll = (scrollTop: number): number => {
const centerOffset = (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2;
return Math.round((scrollTop - centerOffset) / ITEM_HEIGHT);
};
// 获取索引对应的滚动位置
const getScrollFromIndex = (index: number): number => {
const centerOffset = (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2;
return index * ITEM_HEIGHT + centerOffset;
};
// 同步滚动到选中值
const syncScrollToValue = useCallback(() => {
if (hourScrollRef.current) {
hourScrollRef.current.scrollTop = getScrollFromIndex(hours);
}
if (minuteScrollRef.current) {
minuteScrollRef.current.scrollTop = getScrollFromIndex(minutes);
}
}, [hours, minutes]);
// 初始化滚动位置
useEffect(() => {
syncScrollToValue();
}, [syncScrollToValue]);
// 处理滚动事件
const handleScroll = useCallback((type: 'hour' | 'minute') => {
const scrollRef = type === 'hour' ? hourScrollRef : minuteScrollRef;
if (!scrollRef.current) return;
const scrollTop = scrollRef.current.scrollTop;
const index = getIndexFromScroll(scrollTop);
if (type === 'hour') {
const clampedIndex = Math.max(0, Math.min(23, index));
if (clampedIndex !== hours) {
setHours(clampedIndex);
onChange(`${padZero(clampedIndex)}:${padZero(minutes)}`);
}
} else {
const clampedIndex = Math.max(0, Math.min(59, index));
if (clampedIndex !== minutes) {
setMinutes(clampedIndex);
onChange(`${padZero(hours)}:${padZero(clampedIndex)}`);
}
}
}, [hours, minutes, minutes, onChange]);
// 绑定滚动事件
useEffect(() => {
const hourEl = hourScrollRef.current;
const minuteEl = minuteScrollRef.current;
if (hourEl) {
hourEl.addEventListener('scroll', () => handleScroll('hour'));
}
if (minuteEl) {
minuteEl.addEventListener('scroll', () => handleScroll('minute'));
}
return () => {
if (hourEl) {
hourEl.removeEventListener('scroll', () => handleScroll('hour'));
}
if (minuteEl) {
minuteEl.removeEventListener('scroll', () => handleScroll('minute'));
}
};
}, [handleScroll]);
// 点击选择
const handleClick = (index: number, type: 'hour' | 'minute') => {
if (type === 'hour') {
setHours(index);
onChange(`${padZero(index)}:${padZero(minutes)}`);
} else {
setMinutes(index);
onChange(`${padZero(hours)}:${padZero(index)}`);
}
};
const hourOptions = Array.from({ length: 24 }, (_, i) => i);
const minuteOptions = Array.from({ length: 60 }, (_, i) => i);
// 生成带填充的选项数组(用于循环滚动效果)
const getExtendedOptions = (options: number[], selected: number) => {
// 让当前选中项居中显示
const paddingBefore = Math.floor(VISIBLE_ITEMS / 2);
const paddingAfter = VISIBLE_ITEMS - 1 - paddingBefore;
const extended: { value: number; isSelected: boolean }[] = [];
// 添加前面的填充(使用末尾元素)
for (let i = paddingBefore - 1; i >= 0; i--) {
const value = options[options.length - 1 - i % options.length];
extended.push({ value, isSelected: false });
}
// 添加当前选项
options.forEach((opt) => {
extended.push({ value: opt, isSelected: opt === selected });
});
// 添加后面的填充(使用开头元素)
for (let i = 1; i <= paddingAfter; i++) {
const value = options[i % options.length];
extended.push({ value, isSelected: false });
}
return extended;
};
const extendedHours = getExtendedOptions(hourOptions, hours);
const extendedMinutes = getExtendedOptions(minuteOptions, minutes);
return (
<Box
style={{
background: '#fff',
borderRadius: 12,
padding: '8px 0',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
}}
>
<Group gap={0} justify="center">
{/* 小时列 */}
<Box
ref={hourScrollRef}
style={{
width: 48,
height: CONTAINER_HEIGHT,
overflowY: 'auto',
textAlign: 'center',
position: 'relative',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
css={{
'&::-webkit-scrollbar': {
display: 'none',
},
}}
onWheel={(e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? ITEM_HEIGHT : -ITEM_HEIGHT;
const scrollRef = hourScrollRef.current;
if (scrollRef) {
const newScrollTop = scrollRef.scrollTop + delta;
scrollRef.scrollTop = Math.max(0, Math.min(newScrollTop, 24 * ITEM_HEIGHT));
}
}}
>
{/* 选中区域背景 */}
<Box
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: ITEM_HEIGHT,
transform: 'translateY(-50%)',
background: 'rgba(0, 122, 255, 0.08)',
borderRadius: 8,
pointerEvents: 'none',
}}
/>
<Box style={{ paddingTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2 }}>
{extendedHours.map((item, idx) => (
<Box
key={`hour-${idx}`}
onClick={() => handleClick(item.value % 24, 'hour')}
style={{
height: ITEM_HEIGHT,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: item.isSelected ? '#007AFF' : '#374151',
fontSize: 14,
fontWeight: item.isSelected ? 600 : 400,
letterSpacing: '0.02em',
cursor: 'pointer',
userSelect: 'none',
}}
>
{padZero(item.value % 24)}
</Box>
))}
</Box>
</Box>
<Text
size="lg"
fw={400}
style={{
color: '#666',
padding: '0 8px',
fontFeatureSettings: '"tnum"',
lineHeight: `${CONTAINER_HEIGHT}px`,
alignSelf: 'flex-start',
marginTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2,
}}
>
:
</Text>
{/* 分钟列 */}
<Box
ref={minuteScrollRef}
style={{
width: 48,
height: CONTAINER_HEIGHT,
overflowY: 'auto',
textAlign: 'center',
position: 'relative',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
css={{
'&::-webkit-scrollbar': {
display: 'none',
},
}}
onWheel={(e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? ITEM_HEIGHT : -ITEM_HEIGHT;
const scrollRef = minuteScrollRef.current;
if (scrollRef) {
const newScrollTop = scrollRef.scrollTop + delta;
scrollRef.scrollTop = Math.max(0, Math.min(newScrollTop, 60 * ITEM_HEIGHT));
}
}}
>
{/* 选中区域背景 */}
<Box
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: ITEM_HEIGHT,
transform: 'translateY(-50%)',
background: 'rgba(0, 122, 255, 0.08)',
borderRadius: 8,
pointerEvents: 'none',
}}
/>
<Box style={{ paddingTop: (CONTAINER_HEIGHT - ITEM_HEIGHT) / 2 }}>
{extendedMinutes.map((item, idx) => (
<Box
key={`minute-${idx}`}
onClick={() => handleClick(item.value % 60, 'minute')}
style={{
height: ITEM_HEIGHT,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: item.isSelected ? '#007AFF' : '#374151',
fontSize: 14,
fontWeight: item.isSelected ? 600 : 400,
letterSpacing: '0.02em',
cursor: 'pointer',
userSelect: 'none',
}}
>
{padZero(item.value % 60)}
</Box>
))}
</Box>
</Box>
</Group>
</Box>
);
}

View File

@ -73,6 +73,7 @@ export function ReminderCard({
// 使用全局状态管理右键菜单 // 使用全局状态管理右键菜单
const openEventId = useContextMenuStore((state) => state.openEventId); const openEventId = useContextMenuStore((state) => state.openEventId);
const menuPosition = useContextMenuStore((state) => state.menuPosition);
const { openMenu, closeMenu } = useContextMenuStore(); const { openMenu, closeMenu } = useContextMenuStore();
const isMenuOpen = openEventId === event.id; const isMenuOpen = openEventId === event.id;
@ -84,10 +85,10 @@ export function ReminderCard({
// 右键点击处理 // 右键点击处理
const handleContextMenu = useCallback((e: React.MouseEvent) => { const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
console.log('[ContextMenu] 右键点击事件, eventId:', event.id); console.log('[ContextMenu] 右键点击事件, eventId:', event.id, 'x:', e.clientX, 'y:', e.clientY);
// 打开菜单 // 打开菜单,传入鼠标位置
openMenu(event.id); openMenu(event.id, { x: e.clientX, y: e.clientY });
}, [event.id, openMenu]); }, [event.id, openMenu]);
// 点击外部关闭菜单 // 点击外部关闭菜单
@ -120,25 +121,35 @@ export function ReminderCard({
// 计算菜单位置(带边缘保护) // 计算菜单位置(带边缘保护)
const getMenuPosition = () => { const getMenuPosition = () => {
if (!cardRef.current) return { left: 0, top: 0 };
const cardRect = cardRef.current.getBoundingClientRect();
const menuWidth = 180; // 菜单宽度 const menuWidth = 180; // 菜单宽度
const menuHeight = 150; // 估算菜单高度 const menuHeight = 150; // 估算菜单高度
const padding = 8; // 边缘padding const padding = 8; // 边缘padding
// 计算左侧位置 // 如果有鼠标位置,使用鼠标位置
let left = cardRect.left; 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) { if (left + menuWidth > window.innerWidth - padding) {
left = window.innerWidth - menuWidth - padding; left = window.innerWidth - menuWidth - padding;
} }
if (left < padding) left = padding; if (left < padding) left = padding;
// 计算顶部位置(优先显示在下方)
let top = cardRect.bottom + 4;
if (top + menuHeight > window.innerHeight - padding) { if (top + menuHeight > window.innerHeight - padding) {
// 如果下方空间不足,显示在上方 // 如果下方空间不足,显示在上方
top = cardRect.top - menuHeight - 4; top = menuPosition ? menuPosition.y - menuHeight - 4 : top;
} }
if (top < padding) top = padding; if (top < padding) top = padding;
@ -378,7 +389,13 @@ export function ReminderCard({
</Box> </Box>
{/* 右侧:循环图标 + 日期时间 */} {/* 右侧:循环图标 + 日期时间 */}
<Box style={{ flex: '0 0 auto', minWidth: 0, paddingLeft: 12, display: 'flex', alignItems: 'center', gap: 6 }}> <Box
style={{ flex: '0 0 auto', minWidth: 0, paddingLeft: 12, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
{event.repeat_type !== 'none' && ( {event.repeat_type !== 'none' && (
<Box <Box
style={{ style={{

View File

@ -37,3 +37,32 @@
placeholder:text-gray-400; placeholder:text-gray-400;
} }
} }
/* 固定日期选择器高度为6行 */
.mantine-DatePickerInput-calendar,
.mantine-DatePicker-calendar {
min-height: 264px !important;
}
.mantine-DatePickerInput-calendarBody,
.mantine-DatePicker-calendarBody {
min-height: 220px;
}
.mantine-DatePickerInput-weekdayRow,
.mantine-DatePicker-weekdayRow {
font-size: 12px;
}
.mantine-DatePickerInput-day,
.mantine-DatePicker-day {
width: 32px !important;
height: 32px !important;
font-size: 13px;
}
/* 确保日历主体高度固定 */
.mantine-DatePickerInput-calendarHeader,
.mantine-DatePicker-calendarHeader {
min-height: 36px;
}

View File

@ -1,16 +1,23 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { MantineProvider, createTheme } from '@mantine/core' import { MantineProvider, createTheme } from '@mantine/core'
import { DatesProvider } from '@mantine/dates'
import { Notifications } from '@mantine/notifications' import { Notifications } from '@mantine/notifications'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import '@mantine/core/styles.css' import '@mantine/core/styles.css'
import '@mantine/dates/styles.css'
import '@mantine/notifications/styles.css' import '@mantine/notifications/styles.css'
import './index.css' import './index.css'
import App from './App' import App from './App'
dayjs.locale('zh-cn')
// Apple-inspired theme // Apple-inspired theme
const theme = createTheme({ const theme = createTheme({
primaryColor: 'blue', primaryColor: 'blue',
fontFamily: '-apple-system, BlinkMacSystemFont, SF Pro Text, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, SF Pro Text, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif',
fontFamilyMonospace: 'Monaco, Consolas, monospace',
defaultRadius: 'md', defaultRadius: 'md',
colors: { colors: {
blue: [ blue: [
@ -48,8 +55,10 @@ const theme = createTheme({
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<MantineProvider theme={theme} defaultColorScheme="light"> <MantineProvider theme={theme} defaultColorScheme="light">
<Notifications position="top-right" /> <DatesProvider settings={{ firstDayOfWeek: 0, locale: 'zh-cn' }}>
<App /> <Notifications position="top-right" />
<App />
</DatesProvider>
</MantineProvider> </MantineProvider>
</StrictMode>, </StrictMode>,
) )

View File

@ -62,8 +62,12 @@ export function ArchivePage() {
if (e.type !== 'reminder' || !e.is_completed) return false; if (e.type !== 'reminder' || !e.is_completed) return false;
if (!e.date) return false; // 跳过无日期的 if (!e.date) return false; // 跳过无日期的
const eventDate = new Date(e.date); const eventDate = new Date(e.date);
const now = new Date(); // 获取今天的开始时间(只比较日期部分)
return eventDate < now; // 仅已过期的 const today = new Date();
today.setHours(0, 0, 0, 0);
const eventDateOnly = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate());
// 仅显示已过期的(日期早于今天)
return eventDateOnly < today;
}).sort((a, b) => { }).sort((a, b) => {
if (!a.date || !b.date) return 0; if (!a.date || !b.date) return 0;
return new Date(b.date).getTime() - new Date(a.date).getTime(); return new Date(b.date).getTime() - new Date(a.date).getTime();

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { import {
Container, Container,
Title, Title,
@ -12,10 +12,14 @@ import {
Select, Select,
Stack, Stack,
Box, Box,
ActionIcon,
} from '@mantine/core'; } from '@mantine/core';
import { DatePickerInput, TimeInput } from '@mantine/dates'; import { DatePickerInput } from '@mantine/dates';
import { IconLogout, IconSettings } from '@tabler/icons-react'; import { Popover } from '@mantine/core';
import { IconCalendar, IconClock, IconSettings, IconLogout, IconX } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { FixedCalendar } from '../components/common/FixedCalendar';
import { WheelTimePicker } from '../components/common/WheelTimePicker';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores'; import { useAppStore } from '../stores';
import { AnniversaryList } from '../components/anniversary/AnniversaryList'; import { AnniversaryList } from '../components/anniversary/AnniversaryList';
@ -441,74 +445,158 @@ export function HomePage() {
{/* Content (only for reminders) */} {/* Content (only for reminders) */}
{formType === 'reminder' && ( {formType === 'reminder' && (
<Textarea <Box style={{ position: 'relative' }}>
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text>
}
placeholder="输入详细内容"
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={3}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
)}
{/* Date */}
<DatePickerInput
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}> <Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text> </Text>
} <Box
placeholder="选择日期" style={{
value={formDate} position: 'relative',
onChange={(value) => setFormDate(value as Date | null)}
required
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
{/* Time (only for reminders) */}
{formType === 'reminder' && (
<TimeInput
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text>
}
placeholder="选择时间"
value={formTime}
onChange={(e) => setFormTime(e.target.value)}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7', background: '#faf9f7',
}, borderRadius: 2,
}} border: '1px solid #ddd',
/> }}
>
<textarea
id="content-input"
placeholder="输入详细内容"
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={3}
style={{
width: '100%',
border: 'none',
background: 'transparent',
padding: '8px 12px',
fontSize: '14px',
resize: 'vertical',
fontFamily: 'inherit',
outline: 'none',
minHeight: '72px',
boxSizing: 'border-box',
}}
/>
</Box>
</Box>
)} )}
{/* Lunar switch */} {/* Date & Time - Combined selector */}
<Switch <Box>
label={ <Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 8 }}>
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
</Text> <Group gap={8} grow>
} {/* Date selector - Fixed 6-row calendar */}
checked={formIsLunar} <Popover
onChange={(e) => setFormIsLunar(e.currentTarget.checked)} position="bottom"
/> withArrow
shadow="md"
onOpenChange={(opened) => {
if (!opened && formDate) {
// Keep the date
}
}}
>
<Popover.Target>
<TextInput
placeholder="选择日期"
value={formDate ? `${formDate.getFullYear()}-${String(formDate.getMonth() + 1).padStart(2, '0')}-${String(formDate.getDate()).padStart(2, '0')}` : ''}
readOnly
leftSection={<IconCalendar size={14} />}
leftSectionPointerEvents="none"
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
borderColor: '#ddd',
color: '#666',
height: 32,
paddingLeft: 36,
paddingRight: 8,
cursor: 'pointer',
},
section: {
paddingLeft: 8,
},
}}
/>
</Popover.Target>
<Popover.Dropdown style={{ background: 'transparent', border: 'none', padding: 0, boxShadow: 'none' }}>
<FixedCalendar
value={formDate}
onChange={(date) => setFormDate(date)}
/>
</Popover.Dropdown>
</Popover>
{/* Time selector (only for reminders) - Wheel picker */}
{formType === 'reminder' && (
<Popover
position="bottom"
withArrow
shadow="md"
>
<Popover.Target>
<Group gap={4} style={{ flex: 1 }}>
<TextInput
placeholder="选择时间"
value={formTime}
leftSection={<IconClock size={14} />}
leftSectionPointerEvents="none"
rightSection={
formTime ? (
<ActionIcon
variant="subtle"
size="xs"
radius="md"
onClick={() => setFormTime('')}
style={{ color: '#9ca3af' }}
>
<IconX size={12} />
</ActionIcon>
) : null
}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
borderColor: '#ddd',
color: '#666',
height: 32,
paddingLeft: 36,
paddingRight: formTime ? 28 : 8,
cursor: 'pointer',
},
section: {
paddingLeft: 8,
},
}}
/>
</Group>
</Popover.Target>
<Popover.Dropdown style={{ background: 'transparent', border: 'none', padding: 0, boxShadow: 'none' }}>
<WheelTimePicker
value={formTime}
onChange={(time) => setFormTime(time)}
/>
</Popover.Dropdown>
</Popover>
)}
</Group>
</Box>
{/* Lunar switch (only for anniversaries) */}
{formType === 'anniversary' && (
<Switch
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
}
checked={formIsLunar}
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
/>
)}
{/* Repeat type */} {/* Repeat type */}
<Select <Select
@ -534,11 +622,11 @@ export function HomePage() {
}} }}
/> />
{/* Priority (only for reminders) */} {/* Color (only for reminders) */}
{formType === 'reminder' && ( {formType === 'reminder' && (
<Box> <Box>
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 8 }}> <Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 8 }}>
</Text> </Text>
<Group gap={8}> <Group gap={8}>
{([ {([

View File

@ -1,33 +1,43 @@
import { create } from 'zustand'; import { create } from 'zustand';
// 右键菜单位置类型
interface MenuPosition {
x: number;
y: number;
}
// 右键菜单状态管理 // 右键菜单状态管理
interface ContextMenuState { interface ContextMenuState {
// 当前打开菜单的事件IDnull表示没有菜单打开 // 当前打开菜单的事件IDnull表示没有菜单打开
openEventId: string | null; openEventId: string | null;
// 菜单位置
menuPosition: MenuPosition | null;
// 打开菜单 // 打开菜单
openMenu: (eventId: string) => void; openMenu: (eventId: string, position?: MenuPosition) => void;
// 关闭菜单 // 关闭菜单
closeMenu: () => void; closeMenu: () => void;
// 切换菜单 // 切换菜单
toggleMenu: (eventId: string) => void; toggleMenu: (eventId: string, position?: MenuPosition) => void;
// 点击其他位置关闭菜单 // 点击其他位置关闭菜单
closeOnClickOutside: (eventId: string) => void; closeOnClickOutside: (eventId: string) => void;
} }
export const useContextMenuStore = create<ContextMenuState>((set) => ({ export const useContextMenuStore = create<ContextMenuState>((set) => ({
openEventId: null, openEventId: null,
menuPosition: null,
openMenu: (eventId: string) => { openMenu: (eventId: string, position?: MenuPosition) => {
set({ openEventId: eventId }); set({ openEventId: eventId, menuPosition: position || null });
}, },
closeMenu: () => { closeMenu: () => {
set({ openEventId: null }); set({ openEventId: null, menuPosition: null });
}, },
toggleMenu: (eventId: string) => { toggleMenu: (eventId: string, position?: MenuPosition) => {
set((state) => ({ set((state) => ({
openEventId: state.openEventId === eventId ? null : eventId, openEventId: state.openEventId === eventId ? null : eventId,
menuPosition: state.openEventId === eventId ? null : (position || null),
})); }));
}, },