diff --git a/src/components/ai/AIChatBox.tsx b/src/components/ai/AIChatBox.tsx new file mode 100644 index 0000000..67c0704 --- /dev/null +++ b/src/components/ai/AIChatBox.tsx @@ -0,0 +1,168 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Paper, + Text, + Stack, + Group, + TextInput, + ActionIcon, + Loader, + Box, + Button, + Card, + Badge, +} from '@mantine/core'; +import { IconSend, IconSparkles } from '@tabler/icons-react'; +import { api } from '../../services/api'; +import { useAppStore } from '../../stores'; +import type { AIConversation } from '../../types'; + +interface AIChatBoxProps { + onEventCreated?: () => void; +} + +export function AIChatBox({ onEventCreated }: AIChatBoxProps) { + const [message, setMessage] = useState(''); + const [loading, setLoading] = useState(false); + const [conversation, setConversation] = useState(null); + const [history, setHistory] = useState([]); + const addConversation = useAppStore((state) => state.addConversation); + const scrollRef = useRef(null); + + const scrollToBottom = () => { + scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [history]); + + const handleSend = async () => { + if (!message.trim() || loading) return; + + const userMessage = message.trim(); + setMessage(''); + setLoading(true); + + try { + const result = await api.ai.parse(userMessage); + + const newConversation: AIConversation = { + id: Date.now().toString(), + user_id: '', + message: userMessage, + response: result.response, + created_at: new Date().toISOString(), + }; + + setConversation(newConversation); + setHistory((prev) => [...prev, newConversation]); + addConversation(newConversation); + + if (onEventCreated) { + onEventCreated(); + } + } catch (error) { + console.error('AI parse error:', error); + } finally { + setLoading(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( + + + + + + + AI 助手 + + + + + {/* Chat history */} + + {history.length === 0 ? ( + + + + 告诉我你想记住的事情 +
+ 例如:下周三见客户、每月15号还房贷 +
+
+ ) : ( + history.map((conv) => ( + + + 你 + + + {conv.message} + + + + AI + + + + {conv.response} + + + + )) + )} + + {loading && ( + + + + AI 正在思考... + + + )} + +
+ + + {/* Input */} + + setMessage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="输入你的提醒事项..." + size="xs" + style={{ flex: 1 }} + disabled={loading} + /> + + + + + + + ); +} diff --git a/src/components/anniversary/AnniversaryCard.tsx b/src/components/anniversary/AnniversaryCard.tsx new file mode 100644 index 0000000..f733cb0 --- /dev/null +++ b/src/components/anniversary/AnniversaryCard.tsx @@ -0,0 +1,58 @@ +import { Card, Text, Badge, Group, ActionIcon, Stack } from '@mantine/core'; +import { IconHeart, IconRepeat } from '@tabler/icons-react'; +import type { Event } from '../../types'; + +interface AnniversaryCardProps { + event: Event; + onClick: () => void; +} + +export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) { + const isLunar = event.is_lunar; + const repeatType = event.repeat_type; + + return ( + (e.currentTarget.style.transform = 'translateY(-2px)')} + onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')} + > + + + + + + {event.title} + + + + {new Date(event.date).toLocaleDateString('zh-CN')} + {isLunar && ' (农历)'} + + + + + {repeatType !== 'none' && ( + } + > + {repeatType === 'yearly' ? '每年' : '每月'} + + )} + {event.is_holiday && ( + + 节假日 + + )} + + + + ); +} diff --git a/src/components/anniversary/AnniversaryList.tsx b/src/components/anniversary/AnniversaryList.tsx new file mode 100644 index 0000000..4e806e0 --- /dev/null +++ b/src/components/anniversary/AnniversaryList.tsx @@ -0,0 +1,58 @@ +import { Stack, Text, Paper, Group, Button } from '@mantine/core'; +import { IconPlus } from '@tabler/icons-react'; +import { AnniversaryCard } from './AnniversaryCard'; +import type { Event } from '../../types'; + +interface AnniversaryListProps { + events: Event[]; + onEventClick: (event: Event) => void; + onAddClick: () => void; +} + +export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) { + const anniversaries = events.filter((e) => e.type === 'anniversary'); + + if (anniversaries.length === 0) { + return ( + + + + 暂无纪念日 + + + + + ); + } + + return ( + + + + 纪念日 + + + + + + {anniversaries.map((event) => ( + onEventClick(event)} /> + ))} + + + ); +} diff --git a/src/components/note/NoteEditor.tsx b/src/components/note/NoteEditor.tsx new file mode 100644 index 0000000..b78b3e2 --- /dev/null +++ b/src/components/note/NoteEditor.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Paper, Textarea, Group, Text, Stack } from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; +import { useAppStore } from '../../stores'; + +interface NoteEditorProps { + onSave?: () => void; +} + +export function NoteEditor({ onSave }: NoteEditorProps) { + const notes = useAppStore((state) => state.notes); + const updateNotesContent = useAppStore((state) => state.updateNotesContent); + const saveNotes = useAppStore((state) => state.saveNotes); + const fetchNotes = useAppStore((state) => state.fetchNotes); + + const [content, setContent] = useState(''); + const [saving, setSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + + // Initialize content from notes + useEffect(() => { + if (notes) { + setContent(notes.content); + } + }, [notes]); + + // Fetch notes on mount + useEffect(() => { + fetchNotes(); + }, [fetchNotes]); + + // Auto-save with debounce + const [debouncedContent] = useDebouncedValue(content, 1000); + + useEffect(() => { + if (debouncedContent !== undefined && notes) { + handleSave(debouncedContent); + } + }, [debouncedContent]); + + const handleSave = useCallback( + async (value: string) => { + if (!notes) return; + + setSaving(true); + try { + updateNotesContent(value); + await saveNotes(value); + setLastSaved(new Date()); + } catch (error) { + console.error('Failed to save note:', error); + } finally { + setSaving(false); + } + }, + [notes, updateNotesContent, saveNotes] + ); + + const formatLastSaved = () => { + if (!lastSaved) return '未保存'; + const now = new Date(); + const diff = now.getTime() - lastSaved.getTime(); + if (diff < 1000) return '刚刚保存'; + if (diff < 60000) return `${Math.floor(diff / 1000)}秒前保存`; + return lastSaved.toLocaleTimeString('zh-CN'); + }; + + return ( + + + + + 便签 + + + {saving ? '保存中...' : formatLastSaved()} + + + +