fix: 优化事件编辑体验

- 时间选择器使用滚轮选择,隐藏滚动条
- 内容输入框替换为原生 textarea
- 删除图标移到时间输入框内
- 修复日期选择器位置问题
- Tab 键不再导致页面跳转
This commit is contained in:
ddshi 2026-02-06 16:19:23 +08:00
parent 9f67ae50ed
commit 79ef45b4ad
5 changed files with 723 additions and 62 deletions

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

@ -389,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

@ -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 { IconCalendar, IconClock, IconSettings, IconLogout } 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,23 +445,39 @@ 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 size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text> </Text>
} <Box
style={{
position: 'relative',
background: '#faf9f7',
borderRadius: 2,
border: '1px solid #ddd',
}}
>
<textarea
id="content-input"
placeholder="输入详细内容" placeholder="输入详细内容"
value={formContent} value={formContent}
onChange={(e) => setFormContent(e.target.value)} onChange={(e) => setFormContent(e.target.value)}
rows={3} rows={3}
styles={{ style={{
input: { width: '100%',
borderRadius: 2, border: 'none',
background: '#faf9f7', background: 'transparent',
}, padding: '8px 12px',
fontSize: '14px',
resize: 'vertical',
fontFamily: 'inherit',
outline: 'none',
minHeight: '72px',
boxSizing: 'border-box',
}} }}
/> />
</Box>
</Box>
)} )}
{/* Date & Time - Combined selector */} {/* Date & Time - Combined selector */}
@ -466,11 +486,22 @@ export function HomePage() {
</Text> </Text>
<Group gap={8} grow> <Group gap={8} grow>
{/* Date selector */} {/* Date selector - Fixed 6-row calendar */}
<DatePickerInput <Popover
position="bottom"
withArrow
shadow="md"
onOpenChange={(opened) => {
if (!opened && formDate) {
// Keep the date
}
}}
>
<Popover.Target>
<TextInput
placeholder="选择日期" placeholder="选择日期"
value={formDate} value={formDate ? `${formDate.getFullYear()}-${String(formDate.getMonth() + 1).padStart(2, '0')}-${String(formDate.getDate()).padStart(2, '0')}` : ''}
onChange={(value) => setFormDate(value as Date | null)} readOnly
leftSection={<IconCalendar size={14} />} leftSection={<IconCalendar size={14} />}
leftSectionPointerEvents="none" leftSectionPointerEvents="none"
styles={{ styles={{
@ -482,21 +513,49 @@ export function HomePage() {
height: 32, height: 32,
paddingLeft: 36, paddingLeft: 36,
paddingRight: 8, paddingRight: 8,
cursor: 'pointer',
}, },
section: { section: {
paddingLeft: 8, 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) */} {/* Time selector (only for reminders) - Wheel picker */}
{formType === 'reminder' && ( {formType === 'reminder' && (
<TimeInput <Popover
position="bottom"
withArrow
shadow="md"
>
<Popover.Target>
<Group gap={4} style={{ flex: 1 }}>
<TextInput
placeholder="选择时间" placeholder="选择时间"
value={formTime} value={formTime}
onChange={(e) => setFormTime(e.target.value)}
leftSection={<IconClock size={14} />} leftSection={<IconClock size={14} />}
leftSectionPointerEvents="none" leftSectionPointerEvents="none"
rightSection={
formTime ? (
<ActionIcon
variant="subtle"
size="xs"
radius="md"
onClick={() => setFormTime('')}
style={{ color: '#9ca3af' }}
>
<IconX size={12} />
</ActionIcon>
) : null
}
styles={{ styles={{
input: { input: {
borderRadius: 2, borderRadius: 2,
@ -505,13 +564,23 @@ export function HomePage() {
color: '#666', color: '#666',
height: 32, height: 32,
paddingLeft: 36, paddingLeft: 36,
paddingRight: 8, paddingRight: formTime ? 28 : 8,
cursor: 'pointer',
}, },
section: { section: {
paddingLeft: 8, 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> </Group>
</Box> </Box>