Compare commits

..

No commits in common. "9e3bd5aba4a5b4f71ec6d9eb57d56e262aeb9981" and "8725108195067836749c58728b60133f35a49497" have entirely different histories.

12 changed files with 303 additions and 1092 deletions

View File

@ -1,9 +1,8 @@
import { Paper, Text, Group, Stack, Badge } from '@mantine/core'; import { Paper, Text, Group, Stack, Badge } from '@mantine/core';
import { IconRepeat } from '@tabler/icons-react'; import { IconRepeat, IconCalendar, IconFlag } from '@tabler/icons-react';
import type { Event } from '../../types'; import type { Event } from '../../types';
import { calculateCountdown } from '../../utils/countdown'; import { calculateCountdown } from '../../utils/countdown';
import { getHolidayById } from '../../constants/holidays'; import { getHolidayById } from '../../constants/holidays';
import { getLunarInfo } from '../../utils/lunar';
interface AnniversaryCardProps { interface AnniversaryCardProps {
event: Event; event: Event;
@ -20,17 +19,7 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
const getNextDateText = () => { const getNextDateText = () => {
if (countdown.isPast) return '已过'; if (countdown.isPast) return '已过';
if (countdown.isToday) 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颜色 // 获取循环icon颜色

View File

@ -27,10 +27,7 @@ interface BuiltInHolidayEvent {
export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) { export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) {
const anniversaries = events.filter((e) => e.type === 'anniversary'); const anniversaries = events.filter((e) => e.type === 'anniversary');
const settings = useAppStore((state) => state.settings); const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true);
const showHolidays = settings?.showHolidays ?? true;
const holidayDisplayCount = settings?.holidayDisplayCount ?? 3;
const showStatutoryOnly = settings?.showStatutoryOnly ?? false;
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [bottomPadding, setBottomPadding] = useState(0); const [bottomPadding, setBottomPadding] = useState(0);
@ -111,42 +108,28 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
const nextYear = getHolidaysForYear(year + 1); const nextYear = getHolidaysForYear(year + 1);
// 合并今年和明年的节假日,按日期排序 // 合并今年和明年的节假日,按日期排序
let allHolidays = [...holidays, ...nextYear].sort( const allHolidays = [...holidays, ...nextYear].sort(
(a, b) => a.date.getTime() - b.date.getTime() (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); const cutoffDate = new Date(now);
cutoffDate.setDate(cutoffDate.getDate() + 90); cutoffDate.setDate(cutoffDate.getDate() + 90);
return allHolidays return allHolidays
.filter((h) => h.date >= now && h.date <= cutoffDate) .filter((h) => h.date >= now && h.date <= cutoffDate)
.slice(0, holidayDisplayCount) .slice(0, 3)
.map((h): BuiltInHolidayEvent => ({ .map((h): BuiltInHolidayEvent => ({
id: `builtin-${h.id}`, id: `builtin-${h.id}`,
title: h.name, title: h.name,
date: h.date.toISOString(), date: h.date.toISOString(),
is_holiday: true, is_holiday: true,
// 注意:内置节假日的日期已经是计算后的公历日期, is_lunar: h.isLunar,
// 不需要再按农历处理,直接使用公历即可
is_lunar: false,
repeat_type: 'yearly', repeat_type: 'yearly',
type: 'anniversary', type: 'anniversary',
is_builtin: true, is_builtin: true,
})); }));
}, [showHolidays, holidayDisplayCount, showStatutoryOnly, settings]); }, [showHolidays]);
// 合并用户纪念日和内置节假日 // 合并用户纪念日和内置节假日
const allAnniversaries = useMemo(() => { const allAnniversaries = useMemo(() => {

View File

@ -9,7 +9,6 @@ import {
IconChevronLeft, IconChevronLeft,
IconChevronRight, IconChevronRight,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Lunar } from 'lunar-javascript';
interface FixedCalendarProps { interface FixedCalendarProps {
value: Date | null; value: Date | null;
@ -46,32 +45,6 @@ function isDateInRange(date: Date, min?: Date, max?: Date): boolean {
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
const MONTH_NAMES = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; 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) { export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalendarProps) {
const [currentMonth, setCurrentMonth] = useState(value || new Date()); const [currentMonth, setCurrentMonth] = useState(value || new Date());
@ -89,35 +62,30 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
isToday: boolean; isToday: boolean;
isSelected: boolean; isSelected: boolean;
isDisabled: boolean; isDisabled: boolean;
lunarText?: string; // 农历日期简写
}> = []; }> = [];
// 上月剩余的天数 // 上月剩余的天数
const prevMonthDays = getDaysInMonth(new Date(year, month - 1, 1)); const prevMonthDays = getDaysInMonth(new Date(year, month - 1, 1));
for (let i = firstDay - 1; i >= 0; i--) { for (let i = firstDay - 1; i >= 0; i--) {
const date = new Date(year, month - 1, prevMonthDays - i); const date = new Date(year, month - 1, prevMonthDays - i);
const lunarText = getLunarDayText(date);
days.push({ days.push({
date, date,
isCurrentMonth: false, isCurrentMonth: false,
isToday: isSameDay(date, new Date()), isToday: isSameDay(date, new Date()),
isSelected: isSameDay(date, value), isSelected: isSameDay(date, value),
isDisabled: !isDateInRange(date, minDate, maxDate), isDisabled: !isDateInRange(date, minDate, maxDate),
lunarText,
}); });
} }
// 当月的天数 // 当月的天数
for (let i = 1; i <= daysInMonth; i++) { for (let i = 1; i <= daysInMonth; i++) {
const date = new Date(year, month, i); const date = new Date(year, month, i);
const lunarText = getLunarDayText(date);
days.push({ days.push({
date, date,
isCurrentMonth: true, isCurrentMonth: true,
isToday: isSameDay(date, new Date()), isToday: isSameDay(date, new Date()),
isSelected: isSameDay(date, value), isSelected: isSameDay(date, value),
isDisabled: !isDateInRange(date, minDate, maxDate), isDisabled: !isDateInRange(date, minDate, maxDate),
lunarText,
}); });
} }
@ -125,14 +93,12 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
const remainingDays = 42 - days.length; const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) { for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i); const date = new Date(year, month + 1, i);
const lunarText = getLunarDayText(date);
days.push({ days.push({
date, date,
isCurrentMonth: false, isCurrentMonth: false,
isToday: isSameDay(date, new Date()), isToday: isSameDay(date, new Date()),
isSelected: isSameDay(date, value), isSelected: isSameDay(date, value),
isDisabled: !isDateInRange(date, minDate, maxDate), isDisabled: !isDateInRange(date, minDate, maxDate),
lunarText,
}); });
} }
@ -234,7 +200,6 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
flex: 1, flex: 1,
aspectRatio: '1', aspectRatio: '1',
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: 13, fontSize: 13,
@ -268,19 +233,7 @@ export function FixedCalendar({ value, onChange, minDate, maxDate }: FixedCalend
} }
}} }}
> >
<span>{day.date.getDate()}</span> {day.date.getDate()}
{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> </Box>
); );
})} })}

View File

@ -1,85 +1,25 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Paper, Paper,
Textarea,
Group, Group,
Text, Text,
Stack, Stack,
Button,
Box, Box,
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks'; 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'; import { useAppStore } from '../../stores';
interface NoteEditorProps { interface NoteEditorProps {
onSave?: () => void; onSave?: () => void;
} }
// Simple Markdown to HTML converter type ViewMode = 'edit' | 'preview';
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) { export function NoteEditor({ onSave }: NoteEditorProps) {
const notes = useAppStore((state) => state.notes); const notes = useAppStore((state) => state.notes);
@ -88,141 +28,255 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<number | null>(null); const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isEditing, setIsEditing] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [now, setNow] = useState(Date.now()); const [viewMode] = useState<ViewMode>('edit');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize content from notes // Initialize content from notes
useEffect(() => { useEffect(() => {
if (notes?.content) { if (notes) {
setContent(notes.content); setContent(notes.content);
setHasUnsavedChanges(false);
} }
}, [notes]); }, [notes]);
// Timer to update time display every second // Track unsaved changes
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { if (notes && content !== notes.content) {
setNow(Date.now()); setHasUnsavedChanges(true);
}, 1000); }
return () => clearInterval(interval); }, [content, notes]);
}, []);
// Fetch notes on mount // Fetch notes on mount
useEffect(() => { useEffect(() => {
fetchNotes(); fetchNotes();
}, [fetchNotes]); }, [fetchNotes]);
// Debounce content for auto-save // Auto-save with 3 second debounce
const [debouncedContent] = useDebouncedValue(content, 2000); const [debouncedContent] = useDebouncedValue(content, 3000);
// Save function // 滚动条样式 - 仅在悬停时显示
const handleSave = useCallback(async (value: string) => { const scrollbarStyle = `
if (!value) return; .note-scroll::-webkit-scrollbar {
if (saving) return; width: 4px;
height: 4px;
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]); .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);
}
`;
// Auto-save when debounced content changes const handleSave = useCallback(
async (value: string) => {
// 即使 notes 为 nullsaveNotes 也会自动创建便签
if (!value) return;
// 防止重复保存
if (saving) return;
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
useEffect(() => { useEffect(() => {
if (debouncedContent && debouncedContent !== notes?.content) { // 即使 notes 为 null 也可以自动保存(会创建新便签)
if (debouncedContent !== undefined && debouncedContent !== content) {
handleSave(debouncedContent); handleSave(debouncedContent);
} }
}, [debouncedContent, handleSave, notes?.content]); }, [debouncedContent, content, handleSave]);
// Format saved time
const formatLastSaved = () => { const formatLastSaved = () => {
if (saving) return '保存中'; if (saving) return '保存中...';
if (hasUnsavedChanges && !lastSaved) return '未保存';
if (!lastSaved) return ''; if (!lastSaved) return '';
const diff = now - lastSaved; const now = new Date();
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前`; const diff = now.getTime() - lastSaved.getTime();
return new Date(lastSaved).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); if (diff < 1000) return '已保存';
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前保存`;
return lastSaved.toLocaleTimeString('zh-CN');
}; };
// Enter edit mode const handleManualSave = () => {
const handleClick = () => { if (content) {
setIsEditing(true);
setTimeout(() => textareaRef.current?.focus(), 50);
};
// Exit edit mode and save
const handleBlur = () => {
setIsEditing(false);
// Blur 后立即保存
if (content !== notes?.content) {
handleSave(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 ( return (
<Paper p="sm" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fafafa' }}> <Paper p="md" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Stack gap="xs" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}> <style>{scrollbarStyle}</style>
<Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Header */} {/* Header */}
<Group justify="space-between" style={{ flexShrink: 0 }}> <Group justify="space-between" style={{ flexShrink: 0 }}>
<Text fw={500} size="sm" c="#333">便</Text> <Group gap="sm">
<Text size="xs" c="#999">{formatLastSaved()}</Text> <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>
</Group> </Group>
{/* Content Area */} {/* Editor/Preview Area */}
<Box <Box
style={{ flex: 1, minHeight: 0, position: 'relative' }} ref={scrollContainerRef}
onClick={handleClick} onWheel={handleWheel}
data-scroll-container="note"
className="note-scroll"
style={{ flex: 1, minHeight: 0, display: 'flex', overflowY: 'auto' }}
> >
{isEditing ? ( {viewMode === 'edit' && (
<textarea <Textarea
ref={textareaRef}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
onBlur={handleBlur} placeholder="在这里记录你的想法...&#10;&#10;支持 Markdown 语法:&#10;- # 一级标题&#10;- **粗体**&#10;- *斜体*&#10;- [链接](url)"
placeholder="随手记录...(支持换行)" 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' },
},
}}
style={{ style={{
flex: 1,
width: '100%', width: '100%',
height: '100%', overflowY: 'auto',
border: 'none',
resize: 'none',
fontFamily: 'Monaco, Menlo, monospace',
fontSize: '13px',
lineHeight: '1.6',
background: '#fff',
padding: '8px',
borderRadius: '4px',
outline: 'none',
}} }}
/> />
) : ( )}
{viewMode === 'preview' && (
<Box <Box
style={{ style={{
width: '100%', flex: 1,
height: '100%',
overflowY: 'auto', overflowY: 'auto',
padding: '8px', padding: '8px',
background: '#fff',
borderRadius: '4px',
fontSize: '13px', fontSize: '13px',
lineHeight: '1.6', lineHeight: '1.6',
cursor: 'text',
fontFamily: 'Monaco, Menlo, monospace',
}} }}
className="markdown-preview"
onClick={handleClick}
> >
{content ? ( {content ? (
<span <Box
dangerouslySetInnerHTML={{ __html: parseMarkdown(content) }} style={{
style={{ display: 'block' }} '& 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>
) : ( ) : (
<Text c="#aaa" size="sm">...</Text> <Text c="#999" ta="center" py="xl" size="sm" style={{ letterSpacing: '0.05em' }}>
<br />
<Text size="xs" c="#bbb" mt={4}>
使 Markdown
</Text>
</Text>
)} )}
</Box> </Box>
)} )}

View File

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

View File

@ -66,111 +66,3 @@
.mantine-DatePicker-calendarHeader { .mantine-DatePicker-calendarHeader {
min-height: 36px; 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,7 +46,6 @@ export function HomePage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null); const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const [isBuiltinHoliday, setIsBuiltinHoliday] = useState(false); // 内置节假日只读模式
// Form state // Form state
const [formType, setFormType] = useState<EventType>('anniversary'); const [formType, setFormType] = useState<EventType>('anniversary');
@ -72,33 +71,7 @@ export function HomePage() {
}; };
const handleEventClick = (event: Event) => { 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); setSelectedEvent(event);
setIsBuiltinHoliday(false);
setIsEdit(true); setIsEdit(true);
setFormType(event.type); setFormType(event.type);
setFormTitle(event.title); setFormTitle(event.title);
@ -122,7 +95,6 @@ export function HomePage() {
const handleAddClick = (type: EventType) => { const handleAddClick = (type: EventType) => {
setSelectedEvent(null); setSelectedEvent(null);
setIsEdit(false); setIsEdit(false);
setIsBuiltinHoliday(false);
setFormType(type); setFormType(type);
setFormTitle(''); setFormTitle('');
setFormContent(''); setFormContent('');
@ -446,7 +418,7 @@ export function HomePage() {
fontSize: '1rem', fontSize: '1rem',
}} }}
> >
{isBuiltinHoliday ? '节假日详情' : (isEdit ? '编辑事件' : '添加事件')} {isEdit ? '编辑事件' : '添加事件'}
</Text> </Text>
} }
size="md" size="md"
@ -604,19 +576,20 @@ export function HomePage() {
</Group> </Group>
</Box> </Box>
{/* Lunar switch (only for anniversaries) */} {/* Lunar switch (only for anniversaries, disabled in edit mode) */}
{formType === 'anniversary' && ( {formType === 'anniversary' && (
<Switch <Switch
label={ label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}> <Text size="xs" c={isEdit ? '#ccc' : '#666'} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
} }
checked={formIsLunar} checked={formIsLunar}
onChange={(e) => setFormIsLunar(e.currentTarget.checked)} onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
disabled={isEdit}
styles={{ styles={{
track: { track: {
opacity: 1, opacity: isEdit ? 0.5 : 1,
}, },
}} }}
/> />
@ -728,7 +701,7 @@ export function HomePage() {
{/* Actions */} {/* Actions */}
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
{isEdit && !isBuiltinHoliday && ( {isEdit && (
<Button <Button
color="dark" color="dark"
variant="light" variant="light"
@ -740,44 +713,28 @@ export function HomePage() {
</Button> </Button>
)} )}
{isBuiltinHoliday && <div />}
<Group ml="auto"> <Group ml="auto">
{isBuiltinHoliday ? ( <Button
<Button variant="subtle"
onClick={close} onClick={close}
style={{ style={{
background: '#1a1a1a', borderRadius: 2,
border: '1px solid #1a1a1a', color: '#666',
borderRadius: 2, }}
}} >
>
</Button>
</Button> <Button
) : ( onClick={handleSubmit}
<> disabled={!formTitle.trim() || !formDate}
<Button style={{
variant="subtle" background: '#1a1a1a',
onClick={close} border: '1px solid #1a1a1a',
style={{ borderRadius: 2,
borderRadius: 2, }}
color: '#666', >
}} {isEdit ? '保存' : '添加'}
> </Button>
</Button>
<Button
onClick={handleSubmit}
disabled={!formTitle.trim() || !formDate}
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
borderRadius: 2,
}}
>
{isEdit ? '保存' : '添加'}
</Button>
</>
)}
</Group> </Group>
</Group> </Group>
</Stack> </Stack>

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState } from 'react';
import { import {
Container, Container,
Title, Title,
@ -9,18 +9,13 @@ import {
Group, Group,
Button, Button,
Loader, Loader,
Select,
Divider,
Checkbox,
ScrollArea,
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowLeft, IconSettings, IconBell, IconRefresh, IconBellCheck, IconChevronDown } from '@tabler/icons-react'; import { IconArrowLeft, IconSettings, IconBell, IconRefresh, IconBellCheck } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores'; import { useAppStore } from '../stores';
import { requestNotificationPermission, getNotificationPermission, isNotificationSupported } from '../services/notification'; import { requestNotificationPermission, getNotificationPermission, isNotificationSupported } from '../services/notification';
import { syncRemindersToSW, triggerSWCheck } from '../services/swSync'; import { syncRemindersToSW, triggerSWCheck } from '../services/swSync';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { getBuiltInHolidays } from '../constants/holidays';
export function SettingsPage() { export function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -28,88 +23,6 @@ export function SettingsPage() {
const updateSettings = useAppStore((state) => state.updateSettings); const updateSettings = useAppStore((state) => state.updateSettings);
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'default'>('default'); const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'default'>('default');
const [isRequesting, setIsRequesting] = useState(false); 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(() => { useEffect(() => {
@ -257,145 +170,18 @@ export function SettingsPage() {
</Text> </Text>
<Text size="xs" c="#999" style={{ letterSpacing: '0.03em' }}> <Text size="xs" c="#999" style={{ letterSpacing: '0.03em' }}>
3
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
<Switch <Switch
checked={settings.showHolidays} checked={settings.showHolidays}
onChange={(e) => handleHolidayToggle(e.currentTarget.checked)} onChange={(e) => updateSettings({ showHolidays: e.currentTarget.checked })}
size="sm" size="sm"
color="#1a1a1a" color="#1a1a1a"
/> />
</Group> </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() && ( {isNotificationSupported() && (
<Group justify="space-between" style={{ marginTop: 16 }}> <Group justify="space-between" style={{ marginTop: 16 }}>

View File

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

View File

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

View File

@ -1,231 +0,0 @@
/**
*
*
*/
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;
}