Compare commits
3 Commits
f0cbd0e33c
...
79ef45b4ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79ef45b4ad | ||
|
|
9f67ae50ed | ||
|
|
306cb41516 |
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
245
src/components/common/FixedCalendar.tsx
Normal file
245
src/components/common/FixedCalendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
src/components/common/WheelTimePicker.tsx
Normal file
312
src/components/common/WheelTimePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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={{
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
<DatesProvider settings={{ firstDayOfWeek: 0, locale: 'zh-cn' }}>
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<App />
|
<App />
|
||||||
|
</DatesProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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,65 +445,148 @@ 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 */}
|
{/* Date & Time - Combined selector */}
|
||||||
<DatePickerInput
|
<Box>
|
||||||
label={
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 8 }}>
|
||||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
|
||||||
日期
|
日期
|
||||||
</Text>
|
</Text>
|
||||||
|
<Group gap={8} grow>
|
||||||
|
{/* Date selector - Fixed 6-row calendar */}
|
||||||
|
<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
|
||||||
required
|
leftSection={<IconCalendar size={14} />}
|
||||||
|
leftSectionPointerEvents="none"
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
background: '#faf9f7',
|
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 (only for reminders) */}
|
{/* Time selector (only for reminders) - Wheel picker */}
|
||||||
{formType === 'reminder' && (
|
{formType === 'reminder' && (
|
||||||
<TimeInput
|
<Popover
|
||||||
label={
|
position="bottom"
|
||||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
withArrow
|
||||||
时间
|
shadow="md"
|
||||||
</Text>
|
>
|
||||||
}
|
<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} />}
|
||||||
|
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,
|
||||||
background: '#faf9f7',
|
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 */}
|
{/* Lunar switch (only for anniversaries) */}
|
||||||
|
{formType === 'anniversary' && (
|
||||||
<Switch
|
<Switch
|
||||||
label={
|
label={
|
||||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
|
||||||
@ -509,6 +596,7 @@ export function HomePage() {
|
|||||||
checked={formIsLunar}
|
checked={formIsLunar}
|
||||||
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
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}>
|
||||||
{([
|
{([
|
||||||
|
|||||||
@ -1,33 +1,43 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// 右键菜单位置类型
|
||||||
|
interface MenuPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 右键菜单状态管理
|
// 右键菜单状态管理
|
||||||
interface ContextMenuState {
|
interface ContextMenuState {
|
||||||
// 当前打开菜单的事件ID,null表示没有菜单打开
|
// 当前打开菜单的事件ID,null表示没有菜单打开
|
||||||
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),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user