- 修复农历年度重复的计算bug:正确使用农历月/日查找对应公历日期 - 增强 FixedCalendar:显示农历日期(初一、十五等特殊日期) - 增强 AnniversaryCard:显示详细农历信息(如"正月十五") Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
8.7 KiB
TypeScript
293 lines
8.7 KiB
TypeScript
import { useState, useMemo } from 'react';
|
||
import {
|
||
Box,
|
||
Text,
|
||
Group,
|
||
ActionIcon,
|
||
} from '@mantine/core';
|
||
import {
|
||
IconChevronLeft,
|
||
IconChevronRight,
|
||
} from '@tabler/icons-react';
|
||
import { Lunar } from 'lunar-javascript';
|
||
|
||
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 = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
|
||
|
||
// 获取农历日期简写(用于日历显示)
|
||
function getLunarDayText(date: Date): string {
|
||
try {
|
||
const lunar = Lunar.fromYmd(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||
const day = lunar.getDay();
|
||
// 初一显示为"初一",其他日期只显示日期数字
|
||
if (day === 1) {
|
||
return lunar.getMonthInChinese();
|
||
}
|
||
// 初一、十五、廿五等特殊日子
|
||
const specialDays: Record<number, string> = {
|
||
1: '初一',
|
||
15: '十五',
|
||
20: '廿',
|
||
25: '廿五',
|
||
30: '卅',
|
||
};
|
||
if (specialDays[day]) {
|
||
return specialDays[day];
|
||
}
|
||
return lunar.getDayInChinese();
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
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;
|
||
lunarText?: string; // 农历日期简写
|
||
}> = [];
|
||
|
||
// 上月剩余的天数
|
||
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);
|
||
const lunarText = getLunarDayText(date);
|
||
days.push({
|
||
date,
|
||
isCurrentMonth: false,
|
||
isToday: isSameDay(date, new Date()),
|
||
isSelected: isSameDay(date, value),
|
||
isDisabled: !isDateInRange(date, minDate, maxDate),
|
||
lunarText,
|
||
});
|
||
}
|
||
|
||
// 当月的天数
|
||
for (let i = 1; i <= daysInMonth; i++) {
|
||
const date = new Date(year, month, i);
|
||
const lunarText = getLunarDayText(date);
|
||
days.push({
|
||
date,
|
||
isCurrentMonth: true,
|
||
isToday: isSameDay(date, new Date()),
|
||
isSelected: isSameDay(date, value),
|
||
isDisabled: !isDateInRange(date, minDate, maxDate),
|
||
lunarText,
|
||
});
|
||
}
|
||
|
||
// 下月需要填充的天数
|
||
const remainingDays = 42 - days.length;
|
||
for (let i = 1; i <= remainingDays; i++) {
|
||
const date = new Date(year, month + 1, i);
|
||
const lunarText = getLunarDayText(date);
|
||
days.push({
|
||
date,
|
||
isCurrentMonth: false,
|
||
isToday: isSameDay(date, new Date()),
|
||
isSelected: isSameDay(date, value),
|
||
isDisabled: !isDateInRange(date, minDate, maxDate),
|
||
lunarText,
|
||
});
|
||
}
|
||
|
||
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',
|
||
flexDirection: 'column',
|
||
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';
|
||
}
|
||
}}
|
||
>
|
||
<span>{day.date.getDate()}</span>
|
||
{day.lunarText && day.isCurrentMonth && !day.isDisabled && (
|
||
<span
|
||
style={{
|
||
fontSize: 9,
|
||
color: day.isSelected ? 'rgba(255,255,255,0.8)' : '#FF9500',
|
||
fontWeight: 400,
|
||
lineHeight: 1.2,
|
||
}}
|
||
>
|
||
{day.lunarText}
|
||
</span>
|
||
)}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Group>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|