Compare commits
No commits in common. "9e3bd5aba4a5b4f71ec6d9eb57d56e262aeb9981" and "8725108195067836749c58728b60133f35a49497" have entirely different histories.
9e3bd5aba4
...
8725108195
@ -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颜色
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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, '&').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) {
|
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 为 null,saveNotes 也会自动创建便签
|
||||||
|
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="在这里记录你的想法... 支持 Markdown 语法: - # 一级标题 - **粗体** - *斜体* - [链接](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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
108
src/index.css
108
src/index.css
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 }}>
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
23
src/types/lunar-javascript.d.ts
vendored
23
src/types/lunar-javascript.d.ts
vendored
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user