Compare commits

..

9 Commits

Author SHA1 Message Date
ddshi
9e3bd5aba4 fix: 更新 lunar-javascript 类型声明 isLeapMonth 为 isLeap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:06:26 +08:00
ddshi
5a3ef7cde4 fix: 修复 lunar.isLeapMonth 方法名错误
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:00:41 +08:00
ddshi
e27bb64c7a chore: 更新子模块(农历和AI优化)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:39:40 +08:00
ddshi
11a7e2a06c fix: 修复 getLunarFromSolar 导出问题
- 在 lunar.ts 中添加 getLunarFromSolar 函数导出
- 移除 countdown.ts 中的重复定义

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:35:20 +08:00
ddshi
7dd8d15c28 fix: 修复农历日期显示错误
- 修复 getFriendlyDateDescription 函数错误地将公历月/日当作农历参数传入的问题
- 正确使用 getLunarFromSolar 将公历日期转换为农历显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:16:10 +08:00
ddshi
183c88a6ac fix: 修复节假日显示和编辑问题
- 修复内置节假日倒计时显示错误(设置is_lunar为false,因为日期已是公历)
- 修复内置节假日点击行为:显示只读详情弹窗
- 添加内置节假日标识,支持查看详情
- 启用纪念日编辑时的农历开关

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:05:18 +08:00
ddshi
eb7aeb586b feat: 完善纪念日农历日期功能
- 修复农历年度重复的计算bug:正确使用农历月/日查找对应公历日期
- 增强 FixedCalendar:显示农历日期(初一、十五等特殊日期)
- 增强 AnniversaryCard:显示详细农历信息(如"正月十五")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:37:30 +08:00
ddshi
e7b6864b42 feat: 设置中添加节假日配置功能
- 扩展设置选项:显示数量选择(1/3/5/10个)
- 添加仅显示法定节假日开关
- 添加节假日筛选功能:可选择关注特定节假日
- 更新 AnniversaryList 使用新设置进行过滤

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:23:49 +08:00
ddshi
d73a87709c feat: 完善中国节假日和农历数据系统
- 修复 isHoliday 函数逻辑错误(农历日期比较方向错误)
- 扩展节假日数据:添加更多中国传统节日(龙抬头、七夕、中元节、寒衣节、下元节、小年等)
- 添加现代节日(情人节、妇女节、青年节、儿童节、建军节、教师节、圣诞节)
- 新增 utils/lunar.ts 工具模块,提供:
  - 生肖和天干地支获取
  - 二十四节气查询
  - 传统节日查询
  - 农历/公历节日判断
- 扩展 lunar-javascript 类型声明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:14:32 +08:00
12 changed files with 1098 additions and 309 deletions

View File

@ -1,8 +1,9 @@
import { Paper, Text, Group, Stack, Badge } from '@mantine/core';
import { IconRepeat, IconCalendar, IconFlag } from '@tabler/icons-react';
import { IconRepeat } from '@tabler/icons-react';
import type { Event } from '../../types';
import { calculateCountdown } from '../../utils/countdown';
import { getHolidayById } from '../../constants/holidays';
import { getLunarInfo } from '../../utils/lunar';
interface AnniversaryCardProps {
event: Event;
@ -19,7 +20,17 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
const getNextDateText = () => {
if (countdown.isPast) return '已过';
if (countdown.isToday) return '今天';
return `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}${isLunar ? ' (农历)' : ''}`;
const month = countdown.nextDate.getMonth() + 1;
const day = countdown.nextDate.getDate();
if (isLunar) {
// 显示农历信息
const lunarInfo = getLunarInfo(countdown.nextDate);
return `${lunarInfo.monthInChinese}${lunarInfo.dayInChinese}`;
}
return `${month}${day}`;
};
// 获取循环icon颜色

View File

@ -27,7 +27,10 @@ interface BuiltInHolidayEvent {
export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) {
const anniversaries = events.filter((e) => e.type === 'anniversary');
const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true);
const settings = useAppStore((state) => state.settings);
const showHolidays = settings?.showHolidays ?? true;
const holidayDisplayCount = settings?.holidayDisplayCount ?? 3;
const showStatutoryOnly = settings?.showStatutoryOnly ?? false;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [bottomPadding, setBottomPadding] = useState(0);
@ -108,28 +111,42 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
const nextYear = getHolidaysForYear(year + 1);
// 合并今年和明年的节假日,按日期排序
const allHolidays = [...holidays, ...nextYear].sort(
let allHolidays = [...holidays, ...nextYear].sort(
(a, b) => a.date.getTime() - b.date.getTime()
);
// 只取未来90天内的节假日显示最近3个
// 应用过滤规则
// 1. 如果 enabledHolidays 有内容,只显示选中的节假日
const enabledHolidays = settings?.enabledHolidays;
if (enabledHolidays && enabledHolidays.length > 0) {
allHolidays = allHolidays.filter((h) => enabledHolidays.includes(h.id));
}
// 2. 如果开启了仅显示法定节假日
if (showStatutoryOnly) {
allHolidays = allHolidays.filter((h) => h.isStatutory);
}
// 只取未来90天内的节假日
const cutoffDate = new Date(now);
cutoffDate.setDate(cutoffDate.getDate() + 90);
return allHolidays
.filter((h) => h.date >= now && h.date <= cutoffDate)
.slice(0, 3)
.slice(0, holidayDisplayCount)
.map((h): BuiltInHolidayEvent => ({
id: `builtin-${h.id}`,
title: h.name,
date: h.date.toISOString(),
is_holiday: true,
is_lunar: h.isLunar,
// 注意:内置节假日的日期已经是计算后的公历日期,
// 不需要再按农历处理,直接使用公历即可
is_lunar: false,
repeat_type: 'yearly',
type: 'anniversary',
is_builtin: true,
}));
}, [showHolidays]);
}, [showHolidays, holidayDisplayCount, showStatutoryOnly, settings]);
// 合并用户纪念日和内置节假日
const allAnniversaries = useMemo(() => {

View File

@ -9,6 +9,7 @@ import {
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
import { Lunar } from 'lunar-javascript';
interface FixedCalendarProps {
value: Date | null;
@ -45,6 +46,32 @@ function isDateInRange(date: Date, min?: Date, max?: Date): boolean {
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());
@ -62,30 +89,35 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
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,
});
}
@ -93,12 +125,14 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
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,
});
}
@ -200,6 +234,7 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
flex: 1,
aspectRatio: '1',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
@ -233,7 +268,19 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
}
}}
>
{day.date.getDate()}
<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>
);
})}

View File

@ -1,25 +1,85 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Paper,
Textarea,
Group,
Text,
Stack,
Button,
Box,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useAppStore } from '../../stores';
interface NoteEditorProps {
onSave?: () => void;
}
type ViewMode = 'edit' | 'preview';
// Simple Markdown to HTML converter
function parseMarkdown(text: string): string {
if (!text) return '';
let html = text;
// Escape HTML first (but preserve our line breaks)
html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Code blocks (must be before inline code)
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Headers
html = html.replace(/^######\s+(.*)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.*)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.*)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.*)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.*)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.*)$/gm, '<h1>$1</h1>');
// Bold (**text** or __text__)
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic (*text* or _text_)
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/_([^_]+)_/g, '<em>$1</em>');
// Strikethrough (~~text~~)
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Unordered lists (- item or * item)
html = html.replace(/^[-*]\s+(.*)$/gm, '<li>$1</li>');
// Wrap consecutive <li> elements in <ul>
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
// Ordered lists (1. item)
html = html.replace(/^\d+\.\s+(.*)$/gm, '<li>$1</li>');
// Wrap consecutive <li> elements in <ol> (but not if already in <ul>)
html = html.replace(/(<\/ul>\n?)(<li>\d+\.)/g, '$1$2');
html = html.replace(/(<li>\d+\..*<\/li>\n?)+/g, (match) => {
// Only wrap if not already inside a ul
if (!match.includes('<ul>')) {
return `<ol>${match}</ol>`;
}
return match;
});
// Blockquotes (> quote)
html = html.replace(/^&gt;\s+(.*)$/gm, '<blockquote>$1</blockquote>');
// Wrap consecutive blockquotes
html = html.replace(/(<blockquote>.*<\/blockquote>\n?)+/g, (match) => match);
// Horizontal rule
html = html.replace(/^---$/gm, '<hr>');
// Line breaks (convert \n to <br>)
html = html.replace(/\n/g, '<br>');
return html;
}
export function NoteEditor({ onSave }: NoteEditorProps) {
const notes = useAppStore((state) => state.notes);
@ -28,255 +88,141 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
const [content, setContent] = useState('');
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [viewMode] = useState<ViewMode>('edit');
const [lastSaved, setLastSaved] = useState<number | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [now, setNow] = useState(Date.now());
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize content from notes
useEffect(() => {
if (notes) {
if (notes?.content) {
setContent(notes.content);
setHasUnsavedChanges(false);
}
}, [notes]);
// Track unsaved changes
// Timer to update time display every second
useEffect(() => {
if (notes && content !== notes.content) {
setHasUnsavedChanges(true);
}
}, [content, notes]);
const interval = setInterval(() => {
setNow(Date.now());
}, 1000);
return () => clearInterval(interval);
}, []);
// Fetch notes on mount
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
// Auto-save with 3 second debounce
const [debouncedContent] = useDebouncedValue(content, 3000);
// Debounce content for auto-save
const [debouncedContent] = useDebouncedValue(content, 2000);
// 滚动条样式 - 仅在悬停时显示
const scrollbarStyle = `
.note-scroll::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.note-scroll::-webkit-scrollbar-track {
background: transparent;
}
.note-scroll::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 2px;
}
.note-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
`;
// Save function
const handleSave = useCallback(async (value: string) => {
if (!value) return;
if (saving) return;
const handleSave = useCallback(
async (value: string) => {
// 即使 notes 为 nullsaveNotes 也会自动创建便签
if (!value) return;
// 防止重复保存
if (saving) return;
setSaving(true);
try {
await saveNotes(value);
setLastSaved(Date.now());
onSave?.();
} catch (error) {
console.error('Failed to save note:', error);
} finally {
setSaving(false);
}
}, [saving, saveNotes, onSave]);
setSaving(true);
try {
// 注意:不要在这里调用 updateNotesContent
// 因为 saveNotes 会在成功后更新 store 中的 notes
// 如果这里先更新,会触发 notes useEffect导致 content 被重置
await saveNotes(value);
setLastSaved(new Date());
setHasUnsavedChanges(false);
onSave?.();
} catch (error) {
console.error('Failed to save note:', error);
} finally {
setSaving(false);
}
},
[saving, saveNotes, onSave]
);
// Auto-save with debounce
// Auto-save when debounced content changes
useEffect(() => {
// 即使 notes 为 null 也可以自动保存(会创建新便签)
if (debouncedContent !== undefined && debouncedContent !== content) {
if (debouncedContent && debouncedContent !== notes?.content) {
handleSave(debouncedContent);
}
}, [debouncedContent, content, handleSave]);
}, [debouncedContent, handleSave, notes?.content]);
// Format saved time
const formatLastSaved = () => {
if (saving) return '保存中...';
if (hasUnsavedChanges && !lastSaved) return '未保存';
if (saving) return '保存中';
if (!lastSaved) return '';
const now = new Date();
const diff = now.getTime() - lastSaved.getTime();
if (diff < 1000) return '已保存';
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前保存`;
return lastSaved.toLocaleTimeString('zh-CN');
const diff = now - lastSaved;
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前`;
return new Date(lastSaved).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
};
const handleManualSave = () => {
if (content) {
// Enter edit mode
const handleClick = () => {
setIsEditing(true);
setTimeout(() => textareaRef.current?.focus(), 50);
};
// Exit edit mode and save
const handleBlur = () => {
setIsEditing(false);
// Blur 后立即保存
if (content !== notes?.content) {
handleSave(content);
}
};
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 处理滚轮事件,实现列表独立滚动
const handleWheel = (e: React.WheelEvent) => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
// 使用 1px 缓冲避免浮点数精度问题
const isAtTop = scrollTop <= 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// 如果已经滚动到顶部且向下滚动,或者已经滚动到底部且向上滚动,则阻止事件冒泡
if ((isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
// 如果在滚动范围内,允许事件继续传递以实现正常滚动
};
return (
<Paper p="md" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<style>{scrollbarStyle}</style>
<Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Paper p="sm" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fafafa' }}>
<Stack gap="xs" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Group justify="space-between" style={{ flexShrink: 0 }}>
<Group gap="sm">
<Text fw={400} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
便
</Text>
</Group>
<Group gap="sm">
<Button
size="xs"
variant="outline"
leftSection={<IconDeviceFloppy size={12} />}
onClick={handleManualSave}
loading={saving}
style={{
borderColor: '#ccc',
color: '#1a1a1a',
borderRadius: 2,
}}
>
</Button>
<Text size="xs" c={saving ? '#666' : hasUnsavedChanges ? '#e6a23c' : '#999'}>
{formatLastSaved()}
</Text>
</Group>
<Text fw={500} size="sm" c="#333">便</Text>
<Text size="xs" c="#999">{formatLastSaved()}</Text>
</Group>
{/* Editor/Preview Area */}
{/* Content Area */}
<Box
ref={scrollContainerRef}
onWheel={handleWheel}
data-scroll-container="note"
className="note-scroll"
style={{ flex: 1, minHeight: 0, display: 'flex', overflowY: 'auto' }}
style={{ flex: 1, minHeight: 0, position: 'relative' }}
onClick={handleClick}
>
{viewMode === 'edit' && (
<Textarea
{isEditing ? (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="在这里记录你的想法...&#10;&#10;支持 Markdown 语法:&#10;- # 一级标题&#10;- **粗体**&#10;- *斜体*&#10;- [链接](url)"
autosize
minRows={8}
styles={{
input: {
border: 'none',
resize: 'none',
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
fontSize: '13px',
lineHeight: '1.6',
background: 'transparent',
'&:focus': { outline: 'none' },
},
}}
onBlur={handleBlur}
placeholder="随手记录...(支持换行)"
style={{
flex: 1,
width: '100%',
overflowY: 'auto',
}}
/>
)}
{viewMode === 'preview' && (
<Box
style={{
flex: 1,
overflowY: 'auto',
padding: '8px',
height: '100%',
border: 'none',
resize: 'none',
fontFamily: 'Monaco, Menlo, monospace',
fontSize: '13px',
lineHeight: '1.6',
background: '#fff',
padding: '8px',
borderRadius: '4px',
outline: 'none',
}}
/>
) : (
<Box
style={{
width: '100%',
height: '100%',
overflowY: 'auto',
padding: '8px',
background: '#fff',
borderRadius: '4px',
fontSize: '13px',
lineHeight: '1.6',
cursor: 'text',
fontFamily: 'Monaco, Menlo, monospace',
}}
className="markdown-preview"
onClick={handleClick}
>
{content ? (
<Box
style={{
'& h1, & h2, & h3': {
marginTop: '1em',
marginBottom: '0.5em',
fontWeight: 500,
},
'& h1': { fontSize: '1.25em', borderBottom: '1px solid rgba(0,0,0,0.08)', paddingBottom: '0.3em' },
'& h2': { fontSize: '1.1em', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.3em' },
'& p': { marginBottom: '0.8em', color: '#333' },
'& ul, & ol': { paddingLeft: '1.5em', marginBottom: '0.8em' },
'& li': { marginBottom: '0.3em', color: '#333' },
'& blockquote': {
borderLeft: '3px solid rgba(0,0,0,0.1)',
paddingLeft: '1em',
marginLeft: 0,
color: '#888',
},
'& code': {
backgroundColor: 'rgba(0,0,0,0.04)',
padding: '0.2em 0.4em',
borderRadius: '2px',
fontSize: '0.9em',
},
'& pre': {
backgroundColor: 'rgba(0,0,0,0.03)',
padding: '1em',
borderRadius: '2px',
overflow: 'auto',
},
'& pre code': {
backgroundColor: 'transparent',
padding: 0,
},
'& a': {
color: '#1a1a1a',
textDecoration: 'underline',
textUnderlineOffset: 2,
},
'& hr': {
border: 'none',
borderTop: '1px solid rgba(0,0,0,0.06)',
margin: '1em 0',
},
} as any}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
{content}
</ReactMarkdown>
</Box>
<span
dangerouslySetInnerHTML={{ __html: parseMarkdown(content) }}
style={{ display: 'block' }}
/>
) : (
<Text c="#999" ta="center" py="xl" size="sm" style={{ letterSpacing: '0.05em' }}>
<br />
<Text size="xs" c="#bbb" mt={4}>
使 Markdown
</Text>
</Text>
<Text c="#aaa" size="sm">...</Text>
)}
</Box>
)}

View File

@ -18,6 +18,7 @@ export interface Holiday {
}
export const HOLIDAYS: Holiday[] = [
// ========== 公历节日 ==========
// 元旦
{
id: 'new-year',
@ -28,24 +29,24 @@ export const HOLIDAYS: Holiday[] = [
isStatutory: true,
repeatYearly: true,
},
//
// 情人
{
id: 'spring-festival',
name: '节',
isLunar: true,
lunarMonth: 1,
lunarDay: 1,
isStatutory: true,
id: 'valentines-day',
name: '情人节',
month: 2,
day: 14,
isLunar: false,
isStatutory: false,
repeatYearly: true,
},
// 元宵
// 妇女
{
id: 'lantern',
name: '元宵节',
isLunar: true,
lunarMonth: 1,
lunarDay: 15,
isStatutory: false,
id: 'womens-day',
name: '妇女节',
month: 3,
day: 8,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 清明节
@ -68,6 +69,108 @@ export const HOLIDAYS: Holiday[] = [
isStatutory: true,
repeatYearly: true,
},
// 青年节
{
id: 'youth-day',
name: '青年节',
month: 5,
day: 4,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 儿童节
{
id: 'childrens-day',
name: '儿童节',
month: 6,
day: 1,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 建军节
{
id: 'army-day',
name: '建军节',
month: 8,
day: 1,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 教师节
{
id: 'teachers-day',
name: '教师节',
month: 9,
day: 10,
isLunar: false,
isStatutory: false,
repeatYearly: true,
},
// 国庆节
{
id: 'national-day',
name: '国庆节',
month: 10,
day: 1,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 圣诞节
{
id: 'christmas',
name: '圣诞节',
month: 12,
day: 25,
isLunar: false,
isStatutory: false,
repeatYearly: true,
},
// 冬至(使用固定公历日期)
{
id: 'winter-solstice',
name: '冬至',
month: 12,
day: 21,
isLunar: false,
isStatutory: false,
repeatYearly: true,
},
// ========== 农历节日 ==========
// 春节
{
id: 'spring-festival',
name: '春节',
isLunar: true,
lunarMonth: 1,
lunarDay: 1,
isStatutory: true,
repeatYearly: true,
},
// 元宵节
{
id: 'lantern',
name: '元宵节',
isLunar: true,
lunarMonth: 1,
lunarDay: 15,
isStatutory: false,
repeatYearly: true,
},
// 龙抬头(春耕节)
{
id: 'dragon-head',
name: '龙抬头',
isLunar: true,
lunarMonth: 2,
lunarDay: 2,
isStatutory: false,
repeatYearly: true,
},
// 端午节
{
id: 'dragon-boat',
@ -78,6 +181,26 @@ export const HOLIDAYS: Holiday[] = [
isStatutory: true,
repeatYearly: true,
},
// 七夕节
{
id: 'Qixi',
name: '七夕节',
isLunar: true,
lunarMonth: 7,
lunarDay: 7,
isStatutory: false,
repeatYearly: true,
},
// 中元节
{
id: 'ghost',
name: '中元节',
isLunar: true,
lunarMonth: 7,
lunarDay: 15,
isStatutory: false,
repeatYearly: true,
},
// 中秋节
{
id: 'mid-autumn',
@ -88,16 +211,6 @@ export const HOLIDAYS: Holiday[] = [
isStatutory: true,
repeatYearly: true,
},
// 国庆节
{
id: 'national-day',
name: '国庆节',
month: 10,
day: 1,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 重阳节
{
id: 'double-ninth',
@ -108,13 +221,23 @@ export const HOLIDAYS: Holiday[] = [
isStatutory: false,
repeatYearly: true,
},
// 冬至
// 寒衣节
{
id: 'winter-solstice',
name: '冬至',
month: 12,
day: 21,
isLunar: false,
id: 'han-yi',
name: '寒衣节',
isLunar: true,
lunarMonth: 10,
lunarDay: 1,
isStatutory: false,
repeatYearly: true,
},
// 下元节
{
id: 'xia-yuan',
name: '下元节',
isLunar: true,
lunarMonth: 10,
lunarDay: 15,
isStatutory: false,
repeatYearly: true,
},
@ -128,6 +251,16 @@ export const HOLIDAYS: Holiday[] = [
isStatutory: false,
repeatYearly: true,
},
// 小年
{
id: 'little-year',
name: '小年',
isLunar: true,
lunarMonth: 12,
lunarDay: 23,
isStatutory: false,
repeatYearly: true,
},
// 除夕
{
id: 'chinese-new-years-eve',
@ -156,6 +289,7 @@ export function getHolidayById(id: string): Holiday | undefined {
/**
*
*
*/
export function isHoliday(
date: Date,
@ -166,13 +300,14 @@ export function isHoliday(
for (const holiday of HOLIDAYS) {
if (holiday.isLunar && holiday.lunarMonth && holiday.lunarDay) {
// 农历日期需要转换
// 农历日期:把农历节日转换成公历日期再比较
try {
const lunar = Lunar.fromYmd(year, month, day);
const lunar = Lunar.fromYmd(year, holiday.lunarMonth, holiday.lunarDay);
const solar = lunar.getSolar();
if (
solar.getMonth() === holiday.lunarMonth &&
solar.getDay() === holiday.lunarDay
solar.getMonth() === month &&
solar.getDay() === day &&
solar.getYear() === year
) {
return holiday;
}
@ -199,8 +334,8 @@ export function getHolidaysForYear(year: number): Array<Holiday & { date: Date }
for (const holiday of HOLIDAYS) {
if (holiday.isLunar && holiday.lunarMonth && holiday.lunarDay) {
try {
// 使用 Lunar 构造函数创建农历对象,再获取对应的公历日期
const lunar = new Lunar(year, holiday.lunarMonth, holiday.lunarDay);
// 使用 Lunar.fromYmd 创建农历对象,再获取对应的公历日期
const lunar = Lunar.fromYmd(year, holiday.lunarMonth, holiday.lunarDay);
const solar = lunar.getSolar();
result.push({
...holiday,

View File

@ -66,3 +66,111 @@
.mantine-DatePicker-calendarHeader {
min-height: 36px;
}
/* 便签 Markdown 预览样式 */
.markdown-preview h1 {
font-size: 1.5em;
font-weight: 600;
margin: 0.5em 0;
color: #1a1a1a;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.markdown-preview h2 {
font-size: 1.3em;
font-weight: 600;
margin: 0.5em 0;
color: #1a1a1a;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
.markdown-preview h3 {
font-size: 1.1em;
font-weight: 600;
margin: 0.5em 0;
color: #333;
}
.markdown-preview h4, .markdown-preview h5, .markdown-preview h6 {
font-size: 1em;
font-weight: 600;
margin: 0.5em 0;
color: #333;
}
.markdown-preview strong {
font-weight: 700;
}
.markdown-preview em {
font-style: italic;
}
.markdown-preview del {
text-decoration: line-through;
color: #888;
}
.markdown-preview a {
color: #0066cc;
text-decoration: none;
}
.markdown-preview a:hover {
text-decoration: underline;
}
.markdown-preview ul, .markdown-preview ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-preview li {
margin: 0.3em 0;
line-height: 1.6;
}
.markdown-preview ul li {
list-style-type: disc;
}
.markdown-preview ol li {
list-style-type: decimal;
}
.markdown-preview blockquote {
margin: 0.5em 0;
padding: 0.5em 1em;
border-left: 3px solid #ddd;
background: #f9f9f9;
color: #666;
}
.markdown-preview pre {
background: #f5f5f5;
padding: 0.8em;
border-radius: 4px;
overflow-x: auto;
margin: 0.5em 0;
}
.markdown-preview code {
background: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: Monaco, Menlo, monospace;
font-size: 0.9em;
}
.markdown-preview pre code {
background: none;
padding: 0;
}
.markdown-preview hr {
border: none;
border-top: 1px solid #ddd;
margin: 1em 0;
}

View File

@ -46,6 +46,7 @@ export function HomePage() {
const [opened, { open, close }] = useDisclosure(false);
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [isEdit, setIsEdit] = useState(false);
const [isBuiltinHoliday, setIsBuiltinHoliday] = useState(false); // 内置节假日只读模式
// Form state
const [formType, setFormType] = useState<EventType>('anniversary');
@ -71,7 +72,33 @@ export function HomePage() {
};
const handleEventClick = (event: Event) => {
// 检查是否是内置节假日id 以 builtin- 开头)
if (event.id && event.id.startsWith('builtin-')) {
// 内置节假日:显示详情(只读模式)
setSelectedEvent(event);
setIsEdit(false);
setIsBuiltinHoliday(true);
setFormType(event.type);
setFormTitle(event.title);
setFormContent(event.content || '');
setFormDate(new Date(event.date));
setFormIsLunar(event.is_lunar);
setFormRepeatType(event.repeat_type);
setFormIsHoliday(event.is_holiday || false);
setFormPriority(event.priority || 'none');
const eventDate = new Date(event.date);
const hours = eventDate.getHours();
const minutes = eventDate.getMinutes();
const hasTime = hours !== 0 || minutes !== 0;
setFormTime(hasTime ? `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}` : '');
setFormReminderValue('0');
open();
return;
}
// 用户创建的纪念日/提醒:编辑模式
setSelectedEvent(event);
setIsBuiltinHoliday(false);
setIsEdit(true);
setFormType(event.type);
setFormTitle(event.title);
@ -95,6 +122,7 @@ export function HomePage() {
const handleAddClick = (type: EventType) => {
setSelectedEvent(null);
setIsEdit(false);
setIsBuiltinHoliday(false);
setFormType(type);
setFormTitle('');
setFormContent('');
@ -418,7 +446,7 @@ export function HomePage() {
fontSize: '1rem',
}}
>
{isEdit ? '编辑事件' : '添加事件'}
{isBuiltinHoliday ? '节假日详情' : (isEdit ? '编辑事件' : '添加事件')}
</Text>
}
size="md"
@ -576,20 +604,19 @@ export function HomePage() {
</Group>
</Box>
{/* Lunar switch (only for anniversaries, disabled in edit mode) */}
{/* Lunar switch (only for anniversaries) */}
{formType === 'anniversary' && (
<Switch
label={
<Text size="xs" c={isEdit ? '#ccc' : '#666'} style={{ letterSpacing: '0.05em' }}>
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
}
checked={formIsLunar}
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
disabled={isEdit}
styles={{
track: {
opacity: isEdit ? 0.5 : 1,
opacity: 1,
},
}}
/>
@ -701,7 +728,7 @@ export function HomePage() {
{/* Actions */}
<Group justify="space-between" mt="md">
{isEdit && (
{isEdit && !isBuiltinHoliday && (
<Button
color="dark"
variant="light"
@ -713,28 +740,44 @@ export function HomePage() {
</Button>
)}
{isBuiltinHoliday && <div />}
<Group ml="auto">
<Button
variant="subtle"
onClick={close}
style={{
borderRadius: 2,
color: '#666',
}}
>
</Button>
<Button
onClick={handleSubmit}
disabled={!formTitle.trim() || !formDate}
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
borderRadius: 2,
}}
>
{isEdit ? '保存' : '添加'}
</Button>
{isBuiltinHoliday ? (
<Button
onClick={close}
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
borderRadius: 2,
}}
>
</Button>
) : (
<>
<Button
variant="subtle"
onClick={close}
style={{
borderRadius: 2,
color: '#666',
}}
>
</Button>
<Button
onClick={handleSubmit}
disabled={!formTitle.trim() || !formDate}
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
borderRadius: 2,
}}
>
{isEdit ? '保存' : '添加'}
</Button>
</>
)}
</Group>
</Group>
</Stack>

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import {
Container,
Title,
@ -9,13 +9,18 @@ import {
Group,
Button,
Loader,
Select,
Divider,
Checkbox,
ScrollArea,
} from '@mantine/core';
import { IconArrowLeft, IconSettings, IconBell, IconRefresh, IconBellCheck } from '@tabler/icons-react';
import { IconArrowLeft, IconSettings, IconBell, IconRefresh, IconBellCheck, IconChevronDown } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores';
import { requestNotificationPermission, getNotificationPermission, isNotificationSupported } from '../services/notification';
import { syncRemindersToSW, triggerSWCheck } from '../services/swSync';
import { notifications } from '@mantine/notifications';
import { getBuiltInHolidays } from '../constants/holidays';
export function SettingsPage() {
const navigate = useNavigate();
@ -23,6 +28,88 @@ export function SettingsPage() {
const updateSettings = useAppStore((state) => state.updateSettings);
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'default'>('default');
const [isRequesting, setIsRequesting] = useState(false);
const [holidayFilterExpanded, setHolidayFilterExpanded] = useState(false);
// 获取所有节假日列表
const allHolidays = useMemo(() => getBuiltInHolidays(), []);
// 法定节假日
const statutoryHolidays = useMemo(
() => allHolidays.filter((h) => h.isStatutory),
[allHolidays]
);
// 非法定节假日
const nonStatutoryHolidays = useMemo(
() => allHolidays.filter((h) => !h.isStatutory),
[allHolidays]
);
// 处理节假日开关
const handleHolidayToggle = (checked: boolean) => {
updateSettings({ showHolidays: checked });
if (!checked) {
// 关闭时也可以收起筛选面板
setHolidayFilterExpanded(false);
}
};
// 处理显示数量变化
const handleDisplayCountChange = (value: string | null) => {
updateSettings({ holidayDisplayCount: value ? parseInt(value, 10) : 3 });
};
// 处理仅显示法定节假日变化
const handleStatutoryOnlyChange = (checked: boolean) => {
updateSettings({ showStatutoryOnly: checked });
};
// 处理特定节假日选择
const handleHolidaySelection = (holidayId: string, checked: boolean) => {
const currentEnabled = settings.enabledHolidays || [];
let newEnabled: string[];
if (checked) {
newEnabled = [...currentEnabled, holidayId];
} else {
newEnabled = currentEnabled.filter((id) => id !== holidayId);
}
updateSettings({ enabledHolidays: newEnabled });
};
// 检查节假日是否被选中
const isHolidaySelected = (holidayId: string) => {
const enabled = settings.enabledHolidays || [];
return enabled.includes(holidayId);
};
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
updateSettings({ enabledHolidays: allHolidays.map((h) => h.id) });
} else {
updateSettings({ enabledHolidays: [] });
}
};
// 选择所有法定节假日
const handleSelectStatutory = (checked: boolean) => {
if (checked) {
const statutoryIds = statutoryHolidays.map((h) => h.id);
const currentEnabled = settings.enabledHolidays || [];
// 合并现有选择和法定节假日
const newEnabled = [...new Set([...currentEnabled, ...statutoryIds])];
updateSettings({ enabledHolidays: newEnabled });
} else {
// 移除所有法定节假日
const statutoryIds = new Set(statutoryHolidays.map((h) => h.id));
const newEnabled = (settings.enabledHolidays || []).filter(
(id) => !statutoryIds.has(id)
);
updateSettings({ enabledHolidays: newEnabled });
}
};
// 页面加载时检查登录状态
useEffect(() => {
@ -170,18 +257,145 @@ export function SettingsPage() {
</Text>
<Text size="xs" c="#999" style={{ letterSpacing: '0.03em' }}>
3
</Text>
</Stack>
</Group>
<Switch
checked={settings.showHolidays}
onChange={(e) => updateSettings({ showHolidays: e.currentTarget.checked })}
onChange={(e) => handleHolidayToggle(e.currentTarget.checked)}
size="sm"
color="#1a1a1a"
/>
</Group>
{/* 节假日显示数量 */}
{settings.showHolidays && (
<Group justify="space-between">
<Text size="sm" c="#666" style={{ letterSpacing: '0.03em' }}>
</Text>
<Select
value={String(settings.holidayDisplayCount)}
onChange={handleDisplayCountChange}
data={[
{ value: '1', label: '1个' },
{ value: '3', label: '3个' },
{ value: '5', label: '5个' },
{ value: '10', label: '10个' },
]}
size="xs"
style={{ width: 100 }}
styles={{
input: {
background: 'transparent',
borderColor: '#e0e0e0',
},
}}
/>
</Group>
)}
{/* 仅显示法定节假日 */}
{settings.showHolidays && (
<Group justify="space-between">
<Text size="sm" c="#666" style={{ letterSpacing: '0.03em' }}>
</Text>
<Switch
checked={settings.showStatutoryOnly}
onChange={(e) => handleStatutoryOnlyChange(e.currentTarget.checked)}
size="sm"
color="#1a1a1a"
/>
</Group>
)}
{/* 节假日筛选 */}
{settings.showHolidays && (
<>
<Divider />
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={400} style={{ letterSpacing: '0.05em', color: '#1a1a1a' }}>
</Text>
<Button
variant="subtle"
size="xs"
rightSection={<IconChevronDown size={14} />}
onClick={() => setHolidayFilterExpanded(!holidayFilterExpanded)}
style={{
color: '#666',
letterSpacing: '0.03em',
}}
>
{holidayFilterExpanded ? '收起' : '展开'}
</Button>
</Group>
{holidayFilterExpanded && (
<Stack gap="xs">
<Group gap="lg">
<Checkbox
label="全选"
checked={(settings.enabledHolidays || []).length === allHolidays.length}
onChange={(e) => handleSelectAll(e.currentTarget.checked)}
size="xs"
/>
<Checkbox
label="法定节假日"
checked={statutoryHolidays.every((h) => isHolidaySelected(h.id))}
indeterminate={
statutoryHolidays.some((h) => isHolidaySelected(h.id)) &&
!statutoryHolidays.every((h) => isHolidaySelected(h.id))
}
onChange={(e) => handleSelectStatutory(e.currentTarget.checked)}
size="xs"
/>
</Group>
<Divider my="xs" />
<Text size="xs" c="#888" fw={400}>
</Text>
<ScrollArea h={120}>
<Group gap="xs">
{statutoryHolidays.map((holiday) => (
<Checkbox
key={holiday.id}
label={holiday.name}
checked={isHolidaySelected(holiday.id)}
onChange={(e) => handleHolidaySelection(holiday.id, e.currentTarget.checked)}
size="xs"
/>
))}
</Group>
</ScrollArea>
<Text size="xs" c="#888" fw={400} mt="xs">
</Text>
<ScrollArea h={100}>
<Group gap="xs">
{nonStatutoryHolidays.map((holiday) => (
<Checkbox
key={holiday.id}
label={holiday.name}
checked={isHolidaySelected(holiday.id)}
onChange={(e) => handleHolidaySelection(holiday.id, e.currentTarget.checked)}
size="xs"
/>
))}
</Group>
</ScrollArea>
</Stack>
)}
</Stack>
</>
)}
{/* 浏览器通知设置 */}
{isNotificationSupported() && (
<Group justify="space-between" style={{ marginTop: 16 }}>

View File

@ -8,12 +8,18 @@ import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateRemind
// 应用设置类型
interface AppSettings {
showHolidays: boolean; // 是否显示节假日
holidayDisplayCount: number; // 节假日显示数量
showStatutoryOnly: boolean; // 仅显示法定节假日
enabledHolidays: string[]; // 用户关注的节假日ID列表空表示全部
browserNotifications: boolean; // 是否启用浏览器通知
}
// 默认设置
const defaultSettings: AppSettings = {
showHolidays: true,
holidayDisplayCount: 3,
showStatutoryOnly: false,
enabledHolidays: [],
browserNotifications: false,
};

View File

@ -4,13 +4,36 @@ declare module 'lunar-javascript' {
getSolar(): Solar;
getMonth(): number;
getDay(): number;
getYear(): number;
getMonthInChinese(): string;
getDayInChinese(): string;
isLeap(): boolean;
getYearInChinese(): string;
getZodiac(): string;
getYearInGanZhi(): string;
getMonthInGanZhi(): string;
getDayInGanZhi(): string;
getFestivals(): string[];
getPrevJieQi(): JieQi | null;
getLunar(): Lunar;
}
export class Solar {
static fromYmd(year: number, month: number, day: number): Solar;
getYear(): number;
getMonth(): number;
getDay(): number;
getFestivals(): string[];
}
export class JieQi {
getName(): string;
getSolar(): Solar;
}
export class Holiday {
static fromYear(year: number): Holiday[];
getName(): string;
getSolar(): Solar;
}
}

View File

@ -1,4 +1,5 @@
import { Lunar } from 'lunar-javascript';
import { getLunarFromSolar } from './lunar';
export interface CountdownResult {
days: number;
@ -75,10 +76,17 @@ export function calculateCountdown(
const originalHours = parseInt(timeParts[0]) || 0;
const originalMinutes = parseInt(timeParts[1]) || 0;
// 保存农历月份和日期(用于年度重复计算)
let lunarMonth: number | null = null;
let lunarDay: number | null = null;
if (isLunar) {
// 农历日期:使用安全方法创建,处理月末边界
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
if (result) {
// 保存农历日期
lunarMonth = result.lunar.getMonth();
lunarDay = result.lunar.getDay();
targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay(), originalHours, originalMinutes);
} else {
// 无法解析农历日期,使用原始公历日期作为后备
@ -94,12 +102,27 @@ export function calculateCountdown(
// 计算下一个 occurrence
if (repeatType === 'yearly') {
// 年度重复:找到今年或明年的对应日期
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < today) {
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
if (isLunar && lunarMonth !== null && lunarDay !== null) {
// 农历年度重复:根据农历日期查找今年或明年的对应公历日期
const thisYearResult = safeCreateLunarDate(today.getFullYear(), lunarMonth, lunarDay);
if (thisYearResult) {
targetDate = new Date(thisYearResult.solar.getYear(), thisYearResult.solar.getMonth() - 1, thisYearResult.solar.getDay(), originalHours, originalMinutes);
}
// 如果今年的农历日期已过,查找明年
if (!targetDate || targetDate < today) {
const nextYearResult = safeCreateLunarDate(today.getFullYear() + 1, lunarMonth, lunarDay);
if (nextYearResult) {
targetDate = new Date(nextYearResult.solar.getYear(), nextYearResult.solar.getMonth() - 1, nextYearResult.solar.getDay(), originalHours, originalMinutes);
}
}
} else {
// 公历年度重复:找到今年或明年的对应日期
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < today) {
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
targetDate.setHours(originalHours, originalMinutes, 0, 0);
}
}
} else if (repeatType === 'monthly') {
// 月度重复:找到本月或之后月份的对应日期
@ -245,12 +268,9 @@ export function getFriendlyDateDescription(
const day = targetDate.getDate();
if (isLunar) {
// 显示农历
const result = safeCreateLunarDate(targetDate.getFullYear(), month, day);
if (result) {
return `${result.lunar.getMonthInChinese()}${result.lunar.getDayInChinese()}`;
}
return `${month}${day}`;
// 显示农历:使用 getLunarFromSolar 将公历日期正确转换为农历
const lunarInfo = getLunarFromSolar(targetDate);
return `${lunarInfo.monthInChinese}${lunarInfo.dayInChinese}`;
}
return `${month}${day}`;
@ -266,15 +286,3 @@ export function getSolarFromLunar(lunarMonth: number, lunarDay: number, year?: n
return new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay());
}
/**
*
*/
export function getLunarFromSolar(solarDate: Date): { month: number; day: number; monthInChinese: string; dayInChinese: string } {
const lunar = Lunar.fromYmd(solarDate.getFullYear(), solarDate.getMonth() + 1, solarDate.getDate());
return {
month: lunar.getMonth(),
day: lunar.getDay(),
monthInChinese: lunar.getMonthInChinese(),
dayInChinese: lunar.getDayInChinese(),
};
}

231
src/utils/lunar.ts Normal file
View File

@ -0,0 +1,231 @@
/**
*
*
*/
import { Lunar, Solar, Holiday as LunarHoliday } from 'lunar-javascript';
/**
*
*/
export interface LunarInfo {
year: number; // 农历年份
month: number; // 农历月份
day: number; // 农历日期
monthInChinese: string; // 农历月份中文
dayInChinese: string; // 农历日期中文
isLeapMonth: boolean; // 是否闰月
yearInChinese: string; // 农历年(甲子年等)
zodiac: string; // 生肖
}
/**
*
*/
export interface JieQiInfo {
name: string; // 节气名称
nameInChinese: string; // 节气中文名
date: Date; // 节气日期
}
/**
*
*/
export function getLunarInfo(date: Date): LunarInfo {
const lunar = Lunar.fromYmd(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
);
return {
year: lunar.getYear(),
month: lunar.getMonth(),
day: lunar.getDay(),
monthInChinese: lunar.getMonthInChinese(),
dayInChinese: lunar.getDayInChinese(),
isLeapMonth: lunar.isLeap(),
yearInChinese: lunar.getYearInChinese(),
zodiac: lunar.getZodiac(),
};
}
/**
*
*/
export function getJieQiForYear(year: number): JieQiInfo[] {
const result: JieQiInfo[] = [];
try {
// 遍历获取每个节气
const holidays = LunarHoliday.fromYear(year);
for (const holiday of holidays) {
if (holiday.getName()) {
const solarDate = holiday.getSolar();
result.push({
name: holiday.getName(),
nameInChinese: holiday.getName(),
date: new Date(solarDate.getYear(), solarDate.getMonth() - 1, solarDate.getDay()),
});
}
}
} catch {
// 忽略错误
}
// 按日期排序
result.sort((a, b) => a.date.getTime() - b.date.getTime());
return result;
}
/**
*
*/
export function getJieQiForDate(date: Date): string | null {
try {
const lunar = Lunar.fromYmd(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
);
const jieQi = lunar.getPrevJieQi();
if (jieQi) {
return jieQi.getName();
}
} catch {
// 忽略错误
}
return null;
}
/**
*
*/
export function getYearGanZhi(year: number): string {
try {
const lunar = Lunar.fromYmd(year, 1, 1);
return lunar.getYearInGanZhi();
} catch {
return '';
}
}
/**
*
*/
export function getMonthGanZhi(year: number, month: number): string {
try {
const lunar = Lunar.fromYmd(year, month, 1);
return lunar.getMonthInGanZhi();
} catch {
return '';
}
}
/**
*
*/
export function getDayGanZhi(date: Date): string {
try {
const lunar = Lunar.fromYmd(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
);
return lunar.getDayInGanZhi();
} catch {
return '';
}
}
/**
*
*/
export function getLunarFromSolar(solarDate: Date): { month: number; day: number; monthInChinese: string; dayInChinese: string } {
const lunar = Lunar.fromYmd(solarDate.getFullYear(), solarDate.getMonth() + 1, solarDate.getDate());
return {
month: lunar.getMonth(),
day: lunar.getDay(),
monthInChinese: lunar.getMonthInChinese(),
dayInChinese: lunar.getDayInChinese(),
};
}
/**
*
*/
export function isLunarFestival(date: Date): string | null {
try {
const lunar = Lunar.fromYmd(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
);
const festival = lunar.getFestivals();
if (festival && festival.length > 0) {
return festival[0];
}
} catch {
// 忽略错误
}
return null;
}
/**
*
*/
export function isSolarFestival(date: Date): string | null {
try {
const solar = Solar.fromYmd(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
);
const festival = solar.getFestivals();
if (festival && festival.length > 0) {
return festival[0];
}
} catch {
// 忽略错误
}
return null;
}
/**
*
*
*/
export interface TraditionalFestival {
id: string;
name: string;
date: Date; // 该年的公历日期
isLunar: boolean; // 是否为农历节日
description?: string; // 节日描述
}
/**
*
*/
export function getTraditionalFestivals(year: number): TraditionalFestival[] {
const festivals: TraditionalFestival[] = [];
// 使用 lunar-javascript 的节假日功能
try {
const holidays = LunarHoliday.fromYear(year);
for (const holiday of holidays) {
const solar = holiday.getSolar();
festivals.push({
id: holiday.getName(),
name: holiday.getName(),
date: new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay()),
isLunar: true,
description: holiday.getName(),
});
}
} catch {
// 忽略错误
}
// 按日期排序
festivals.sort((a, b) => a.date.getTime() - b.date.getTime());
return festivals;
}