qia-client/src/components/common/FixedCalendar.tsx
ddshi eb7aeb586b feat: 完善纪念日农历日期功能
- 修复农历年度重复的计算bug:正确使用农历月/日查找对应公历日期
- 增强 FixedCalendar:显示农历日期(初一、十五等特殊日期)
- 增强 AnniversaryCard:显示详细农历信息(如"正月十五")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:37:30 +08:00

293 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}