diff --git a/src/components/common/FixedCalendar.tsx b/src/components/common/FixedCalendar.tsx
new file mode 100644
index 0000000..77fc10f
--- /dev/null
+++ b/src/components/common/FixedCalendar.tsx
@@ -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 (
+
+ {/* 头部:月份选择 */}
+
+
+
+
+
+ {MONTH_NAMES[currentMonth.getMonth()]} {currentMonth.getFullYear()}
+
+
+
+
+
+
+ {/* 星期行 */}
+
+ {WEEKDAYS.map((day) => (
+
+ {day}
+
+ ))}
+
+
+ {/* 日期网格 - 固定6行7列 */}
+
+ {Array.from({ length: 6 }).map((_, weekIndex) => (
+
+ {Array.from({ length: 7 }).map((_, dayIndex) => {
+ const dayIndex_ = weekIndex * 7 + dayIndex;
+ const day = calendarDays[dayIndex_];
+ if (!day) return null;
+
+ return (
+ 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()}
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/common/WheelTimePicker.tsx b/src/components/common/WheelTimePicker.tsx
new file mode 100644
index 0000000..5706510
--- /dev/null
+++ b/src/components/common/WheelTimePicker.tsx
@@ -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(null);
+ const minuteScrollRef = useRef(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 (
+
+
+ {/* 小时列 */}
+ {
+ 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));
+ }
+ }}
+ >
+ {/* 选中区域背景 */}
+
+
+ {extendedHours.map((item, idx) => (
+ 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)}
+
+ ))}
+
+
+
+
+ :
+
+
+ {/* 分钟列 */}
+ {
+ 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));
+ }
+ }}
+ >
+ {/* 选中区域背景 */}
+
+
+ {extendedMinutes.map((item, idx) => (
+ 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)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/reminder/ReminderCard.tsx b/src/components/reminder/ReminderCard.tsx
index 5021e16..315de68 100644
--- a/src/components/reminder/ReminderCard.tsx
+++ b/src/components/reminder/ReminderCard.tsx
@@ -389,7 +389,13 @@ export function ReminderCard({
{/* 右侧:循环图标 + 日期时间 */}
-
+ {
+ e.stopPropagation();
+ onClick();
+ }}
+ >
{event.repeat_type !== 'none' && (
- 内容
-
- }
- placeholder="输入详细内容"
- value={formContent}
- onChange={(e) => setFormContent(e.target.value)}
- rows={3}
- styles={{
- input: {
- borderRadius: 2,
+
+
+ 内容
+
+
+ borderRadius: 2,
+ border: '1px solid #ddd',
+ }}
+ >
+
+
)}
{/* Date & Time - Combined selector */}
@@ -466,52 +486,101 @@ export function HomePage() {
日期
- {/* Date selector */}
- setFormDate(value as Date | null)}
- leftSection={}
- leftSectionPointerEvents="none"
- styles={{
- input: {
- borderRadius: 2,
- background: '#faf9f7',
- borderColor: '#ddd',
- color: '#666',
- height: 32,
- paddingLeft: 36,
- paddingRight: 8,
- },
- section: {
- paddingLeft: 8,
- },
+ {/* Date selector - Fixed 6-row calendar */}
+ {
+ if (!opened && formDate) {
+ // Keep the date
+ }
}}
- />
+ >
+
+ }
+ leftSectionPointerEvents="none"
+ styles={{
+ input: {
+ borderRadius: 2,
+ background: '#faf9f7',
+ borderColor: '#ddd',
+ color: '#666',
+ height: 32,
+ paddingLeft: 36,
+ paddingRight: 8,
+ cursor: 'pointer',
+ },
+ section: {
+ paddingLeft: 8,
+ },
+ }}
+ />
+
+
+ setFormDate(date)}
+ />
+
+
- {/* Time selector (only for reminders) */}
+ {/* Time selector (only for reminders) - Wheel picker */}
{formType === 'reminder' && (
- setFormTime(e.target.value)}
- leftSection={}
- leftSectionPointerEvents="none"
- styles={{
- input: {
- borderRadius: 2,
- background: '#faf9f7',
- borderColor: '#ddd',
- color: '#666',
- height: 32,
- paddingLeft: 36,
- paddingRight: 8,
- },
- section: {
- paddingLeft: 8,
- },
- }}
- />
+
+
+
+ }
+ leftSectionPointerEvents="none"
+ rightSection={
+ formTime ? (
+ setFormTime('')}
+ style={{ color: '#9ca3af' }}
+ >
+
+
+ ) : null
+ }
+ styles={{
+ input: {
+ borderRadius: 2,
+ background: '#faf9f7',
+ borderColor: '#ddd',
+ color: '#666',
+ height: 32,
+ paddingLeft: 36,
+ paddingRight: formTime ? 28 : 8,
+ cursor: 'pointer',
+ },
+ section: {
+ paddingLeft: 8,
+ },
+ }}
+ />
+
+
+
+ setFormTime(time)}
+ />
+
+
)}