Compare commits
9 Commits
8725108195
...
9e3bd5aba4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3bd5aba4 | ||
|
|
5a3ef7cde4 | ||
|
|
e27bb64c7a | ||
|
|
11a7e2a06c | ||
|
|
7dd8d15c28 | ||
|
|
183c88a6ac | ||
|
|
eb7aeb586b | ||
|
|
e7b6864b42 | ||
|
|
d73a87709c |
@ -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颜色
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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(/^>\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 为 null,saveNotes 也会自动创建便签
|
||||
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="在这里记录你的想法... 支持 Markdown 语法: - # 一级标题 - **粗体** - *斜体* - [链接](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>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
|
||||
108
src/index.css
108
src/index.css
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
23
src/types/lunar-javascript.d.ts
vendored
23
src/types/lunar-javascript.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
231
src/utils/lunar.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user