chore: 更新子模块(农历和AI优化)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-02-28 11:39:40 +08:00
parent 11a7e2a06c
commit e27bb64c7a
2 changed files with 264 additions and 210 deletions

View File

@ -1,25 +1,85 @@
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;
} }
type ViewMode = 'edit' | 'preview'; // Simple Markdown to HTML converter
function parseMarkdown(text: string): string {
if (!text) return '';
let html = text;
// Escape HTML first (but preserve our line breaks)
html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Code blocks (must be before inline code)
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Headers
html = html.replace(/^######\s+(.*)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.*)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.*)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.*)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.*)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.*)$/gm, '<h1>$1</h1>');
// Bold (**text** or __text__)
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic (*text* or _text_)
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/_([^_]+)_/g, '<em>$1</em>');
// Strikethrough (~~text~~)
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Unordered lists (- item or * item)
html = html.replace(/^[-*]\s+(.*)$/gm, '<li>$1</li>');
// Wrap consecutive <li> elements in <ul>
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
// Ordered lists (1. item)
html = html.replace(/^\d+\.\s+(.*)$/gm, '<li>$1</li>');
// Wrap consecutive <li> elements in <ol> (but not if already in <ul>)
html = html.replace(/(<\/ul>\n?)(<li>\d+\.)/g, '$1$2');
html = html.replace(/(<li>\d+\..*<\/li>\n?)+/g, (match) => {
// Only wrap if not already inside a ul
if (!match.includes('<ul>')) {
return `<ol>${match}</ol>`;
}
return match;
});
// Blockquotes (> quote)
html = html.replace(/^&gt;\s+(.*)$/gm, '<blockquote>$1</blockquote>');
// Wrap consecutive blockquotes
html = html.replace(/(<blockquote>.*<\/blockquote>\n?)+/g, (match) => match);
// Horizontal rule
html = html.replace(/^---$/gm, '<hr>');
// Line breaks (convert \n to <br>)
html = html.replace(/\n/g, '<br>');
return html;
}
export function NoteEditor({ onSave }: NoteEditorProps) { export function NoteEditor({ onSave }: NoteEditorProps) {
const notes = useAppStore((state) => state.notes); const notes = useAppStore((state) => state.notes);
@ -28,255 +88,141 @@ 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<Date | null>(null); const [lastSaved, setLastSaved] = useState<number | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [viewMode] = useState<ViewMode>('edit'); const [now, setNow] = useState(Date.now());
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize content from notes // Initialize content from notes
useEffect(() => { useEffect(() => {
if (notes) { if (notes?.content) {
setContent(notes.content); setContent(notes.content);
setHasUnsavedChanges(false);
} }
}, [notes]); }, [notes]);
// Track unsaved changes // Timer to update time display every second
useEffect(() => { useEffect(() => {
if (notes && content !== notes.content) { const interval = setInterval(() => {
setHasUnsavedChanges(true); setNow(Date.now());
} }, 1000);
}, [content, notes]); return () => clearInterval(interval);
}, []);
// Fetch notes on mount // Fetch notes on mount
useEffect(() => { useEffect(() => {
fetchNotes(); fetchNotes();
}, [fetchNotes]); }, [fetchNotes]);
// Auto-save with 3 second debounce // Debounce content for auto-save
const [debouncedContent] = useDebouncedValue(content, 3000); const [debouncedContent] = useDebouncedValue(content, 2000);
// 滚动条样式 - 仅在悬停时显示 // Save function
const scrollbarStyle = ` const handleSave = useCallback(async (value: string) => {
.note-scroll::-webkit-scrollbar { if (!value) return;
width: 4px; if (saving) return;
height: 4px;
}
.note-scroll::-webkit-scrollbar-track {
background: transparent;
}
.note-scroll::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 2px;
}
.note-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
`;
const handleSave = useCallback( setSaving(true);
async (value: string) => { try {
// 即使 notes 为 nullsaveNotes 也会自动创建便签 await saveNotes(value);
if (!value) return; setLastSaved(Date.now());
// 防止重复保存 onSave?.();
if (saving) return; } catch (error) {
console.error('Failed to save note:', error);
} finally {
setSaving(false);
}
}, [saving, saveNotes, onSave]);
setSaving(true); // Auto-save when debounced content changes
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(() => {
// 即使 notes 为 null 也可以自动保存(会创建新便签) if (debouncedContent && debouncedContent !== notes?.content) {
if (debouncedContent !== undefined && debouncedContent !== content) {
handleSave(debouncedContent); handleSave(debouncedContent);
} }
}, [debouncedContent, content, handleSave]); }, [debouncedContent, handleSave, notes?.content]);
// Format saved time
const formatLastSaved = () => { const formatLastSaved = () => {
if (saving) return '保存中...'; if (saving) return '保存中';
if (hasUnsavedChanges && !lastSaved) return '未保存';
if (!lastSaved) return ''; if (!lastSaved) return '';
const now = new Date(); const diff = now - lastSaved;
const diff = now.getTime() - lastSaved.getTime(); if (diff < 60000) return `${Math.floor(diff / 1000)}秒前`;
if (diff < 1000) return '已保存'; return new Date(lastSaved).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前保存`;
return lastSaved.toLocaleTimeString('zh-CN');
}; };
const handleManualSave = () => { // Enter edit mode
if (content) { const handleClick = () => {
setIsEditing(true);
setTimeout(() => textareaRef.current?.focus(), 50);
};
// Exit edit mode and save
const handleBlur = () => {
setIsEditing(false);
// Blur 后立即保存
if (content !== notes?.content) {
handleSave(content); 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="md" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <Paper p="sm" withBorder radius={4} style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fafafa' }}>
<style>{scrollbarStyle}</style> <Stack gap="xs" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<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 }}>
<Group gap="sm"> <Text fw={500} size="sm" c="#333">便</Text>
<Text fw={400} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}> <Text size="xs" c="#999">{formatLastSaved()}</Text>
便
</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>
{/* Editor/Preview Area */} {/* Content Area */}
<Box <Box
ref={scrollContainerRef} style={{ flex: 1, minHeight: 0, position: 'relative' }}
onWheel={handleWheel} onClick={handleClick}
data-scroll-container="note"
className="note-scroll"
style={{ flex: 1, minHeight: 0, display: 'flex', overflowY: 'auto' }}
> >
{viewMode === 'edit' && ( {isEditing ? (
<Textarea <textarea
ref={textareaRef}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder="在这里记录你的想法...&#10;&#10;支持 Markdown 语法:&#10;- # 一级标题&#10;- **粗体**&#10;- *斜体*&#10;- [链接](url)" onBlur={handleBlur}
autosize placeholder="随手记录...(支持换行)"
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%',
overflowY: 'auto', height: '100%',
}} border: 'none',
/> resize: 'none',
)} fontFamily: 'Monaco, Menlo, monospace',
{viewMode === 'preview' && (
<Box
style={{
flex: 1,
overflowY: 'auto',
padding: '8px',
fontSize: '13px', fontSize: '13px',
lineHeight: '1.6', lineHeight: '1.6',
background: '#fff',
padding: '8px',
borderRadius: '4px',
outline: 'none',
}} }}
/>
) : (
<Box
style={{
width: '100%',
height: '100%',
overflowY: 'auto',
padding: '8px',
background: '#fff',
borderRadius: '4px',
fontSize: '13px',
lineHeight: '1.6',
cursor: 'text',
fontFamily: 'Monaco, Menlo, monospace',
}}
className="markdown-preview"
onClick={handleClick}
> >
{content ? ( {content ? (
<Box <span
style={{ dangerouslySetInnerHTML={{ __html: parseMarkdown(content) }}
'& h1, & h2, & h3': { style={{ display: 'block' }}
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="#999" ta="center" py="xl" size="sm" style={{ letterSpacing: '0.05em' }}> <Text c="#aaa" size="sm">...</Text>
<br />
<Text size="xs" c="#bbb" mt={4}>
使 Markdown
</Text>
</Text>
)} )}
</Box> </Box>
)} )}

View File

@ -66,3 +66,111 @@
.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;
}