feat: 实现Home页四区布局
- 实现纪念日列表组件 (AnniversaryCard, AnniversaryList) - 实现提醒列表组件 (ReminderCard, ReminderList) - 实现便签编辑区 (NoteEditor) 带自动保存 - 实现AI对话框 (AIChatBox) 支持自然语言解析 - 更新HomePage实现四区布局和添加/编辑弹窗 - 更新类型定义和数据Store Co-Authored-By: Claude (MiniMax-M2.1) <noreply@anthropic.com>
This commit is contained in:
parent
d3de5d8598
commit
4dbf9b0bbc
168
src/components/ai/AIChatBox.tsx
Normal file
168
src/components/ai/AIChatBox.tsx
Normal file
@ -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<AIConversation | null>(null);
|
||||
const [history, setHistory] = useState<AIConversation[]>([]);
|
||||
const addConversation = useAppStore((state) => state.addConversation);
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between">
|
||||
<Group gap={6}>
|
||||
<IconSparkles size={16} color="orange" />
|
||||
<Text fw={500} size="sm">
|
||||
AI 助手
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Chat history */}
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{history.length === 0 ? (
|
||||
<Stack align="center" justify="center" h="100%" gap="xs">
|
||||
<IconSparkles size={32} color="gray" />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
告诉我你想记住的事情
|
||||
<br />
|
||||
例如:下周三见客户、每月15号还房贷
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
history.map((conv) => (
|
||||
<Box key={conv.id}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
你
|
||||
</Text>
|
||||
<Card withBorder padding="sm" radius="sm" mb="xs">
|
||||
<Text size="sm">{conv.message}</Text>
|
||||
</Card>
|
||||
|
||||
<Text size="xs" c="blue" mb={4}>
|
||||
AI
|
||||
</Text>
|
||||
<Card withBorder padding="sm" radius="sm" bg="blue.0">
|
||||
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{conv.response}
|
||||
</Text>
|
||||
</Card>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Group gap={8}>
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
AI 正在思考...
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} />
|
||||
</Box>
|
||||
|
||||
{/* Input */}
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="输入你的提醒事项..."
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || loading}
|
||||
>
|
||||
<IconSend size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
58
src/components/anniversary/AnniversaryCard.tsx
Normal file
58
src/components/anniversary/AnniversaryCard.tsx
Normal file
@ -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 (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
onClick={onClick}
|
||||
style={{ cursor: 'pointer', transition: 'transform 0.2s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={4}>
|
||||
<Group gap={6}>
|
||||
<IconHeart size={14} color="pink" />
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(event.date).toLocaleDateString('zh-CN')}
|
||||
{isLunar && ' (农历)'}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap={6}>
|
||||
{repeatType !== 'none' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<IconRepeat size={10} />}
|
||||
>
|
||||
{repeatType === 'yearly' ? '每年' : '每月'}
|
||||
</Badge>
|
||||
)}
|
||||
{event.is_holiday && (
|
||||
<Badge size="xs" variant="light" color="green">
|
||||
节假日
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
58
src/components/anniversary/AnniversaryList.tsx
Normal file
58
src/components/anniversary/AnniversaryList.tsx
Normal file
@ -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 (
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
暂无纪念日
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
>
|
||||
添加纪念日
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={500} size="sm">
|
||||
纪念日
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" style={{ maxHeight: 'calc(100% - 40px)', overflowY: 'auto' }}>
|
||||
{anniversaries.map((event) => (
|
||||
<AnniversaryCard key={event.id} event={event} onClick={() => onEventClick(event)} />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
99
src/components/note/NoteEditor.tsx
Normal file
99
src/components/note/NoteEditor.tsx
Normal file
@ -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<Date | null>(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 (
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} size="sm">
|
||||
便签
|
||||
</Text>
|
||||
<Text size="xs" c={saving ? 'yellow' : lastSaved ? 'dimmed' : 'gray'}>
|
||||
{saving ? '保存中...' : formatLastSaved()}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="在这里记录你的想法..."
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={20}
|
||||
styles={{
|
||||
input: {
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
'&:focus': { outline: 'none' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
54
src/components/reminder/ReminderCard.tsx
Normal file
54
src/components/reminder/ReminderCard.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { Card, Text, Checkbox, Group, Stack } from '@mantine/core';
|
||||
import type { Event } from '../../types';
|
||||
|
||||
interface ReminderCardProps {
|
||||
event: Event;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
||||
const isCompleted = event.is_completed ?? false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
opacity: isCompleted ? 0.6 : 1,
|
||||
transition: 'transform 0.2s',
|
||||
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={4}>
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{event.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(event.date).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
{event.content && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{event.content}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
172
src/components/reminder/ReminderList.tsx
Normal file
172
src/components/reminder/ReminderList.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Stack, Text, Paper, Group, Button } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { ReminderCard } from './ReminderCard';
|
||||
import type { Event } from '../../types';
|
||||
|
||||
interface ReminderListProps {
|
||||
events: Event[];
|
||||
onEventClick: (event: Event) => void;
|
||||
onToggleComplete: (event: Event) => void;
|
||||
onAddClick: () => void;
|
||||
}
|
||||
|
||||
export function ReminderList({
|
||||
events,
|
||||
onEventClick,
|
||||
onToggleComplete,
|
||||
onAddClick,
|
||||
}: ReminderListProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const reminders = events.filter((e) => e.type === 'reminder');
|
||||
|
||||
const result = {
|
||||
today: [] as Event[],
|
||||
tomorrow: [] as Event[],
|
||||
later: [] as Event[],
|
||||
missed: [] as Event[],
|
||||
};
|
||||
|
||||
reminders.forEach((event) => {
|
||||
const eventDate = new Date(event.date);
|
||||
|
||||
if (event.is_completed) return;
|
||||
|
||||
if (eventDate < today) {
|
||||
result.missed.push(event);
|
||||
} else if (eventDate < tomorrow) {
|
||||
result.today.push(event);
|
||||
} else {
|
||||
result.later.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by date
|
||||
Object.keys(result).forEach((key) => {
|
||||
result[key as keyof typeof result].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [events]);
|
||||
|
||||
const hasReminders =
|
||||
grouped.today.length > 0 ||
|
||||
grouped.tomorrow.length > 0 ||
|
||||
grouped.later.length > 0 ||
|
||||
grouped.missed.length > 0;
|
||||
|
||||
if (!hasReminders) {
|
||||
return (
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
暂无提醒
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
>
|
||||
添加提醒
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={500} size="sm">
|
||||
提醒
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" style={{ maxHeight: 'calc(100% - 40px)', overflowY: 'auto' }}>
|
||||
{/* Missed reminders */}
|
||||
{grouped.missed.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" c="red" fw={500}>
|
||||
已错过
|
||||
</Text>
|
||||
{grouped.missed.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Today's reminders */}
|
||||
{grouped.today.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" c="blue" fw={500}>
|
||||
今天
|
||||
</Text>
|
||||
{grouped.today.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tomorrow's reminders */}
|
||||
{grouped.tomorrow.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" c="teal" fw={500}>
|
||||
明天
|
||||
</Text>
|
||||
{grouped.tomorrow.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Later reminders */}
|
||||
{grouped.later.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" c="gray" fw={500}>
|
||||
更久之后
|
||||
</Text>
|
||||
{grouped.later.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@ -1,36 +1,164 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Container, Grid, Title, Button, Group, Text } from '@mantine/core';
|
||||
import { IconLogout } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Grid,
|
||||
Title,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Modal,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { DateInput, TimeInput } from '@mantine/dates';
|
||||
import { IconLogout, IconPlus } from '@tabler/icons-react';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useAppStore } from '../stores';
|
||||
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
||||
import { ReminderList } from '../components/reminder/ReminderList';
|
||||
import { NoteEditor } from '../components/note/NoteEditor';
|
||||
import { AIChatBox } from '../components/ai/AIChatBox';
|
||||
import type { Event, EventType, RepeatType } from '../types';
|
||||
|
||||
export function HomePage() {
|
||||
const user = useAppStore((state) => state.user);
|
||||
const logout = useAppStore((state) => state.logout);
|
||||
const checkAuth = useAppStore((state) => state.checkAuth);
|
||||
const events = useAppStore((state) => state.events);
|
||||
const fetchEvents = useAppStore((state) => state.fetchEvents);
|
||||
const createEvent = useAppStore((state) => state.createEvent);
|
||||
const updateEventById = useAppStore((state) => state.updateEventById);
|
||||
const deleteEventById = useAppStore((state) => state.deleteEventById);
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formType, setFormType] = useState<EventType>('anniversary');
|
||||
const [formTitle, setFormTitle] = useState('');
|
||||
const [formContent, setFormContent] = useState('');
|
||||
const [formDate, setFormDate] = useState<Date | null>(null);
|
||||
const [formTime, setFormTime] = useState('');
|
||||
const [formIsLunar, setFormIsLunar] = useState(false);
|
||||
const [formRepeatType, setFormRepeatType] = useState<RepeatType>('none');
|
||||
const [formIsHoliday, setFormIsHoliday] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
fetchEvents();
|
||||
}, [checkAuth, fetchEvents]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const handleEventClick = (event: Event) => {
|
||||
setSelectedEvent(event);
|
||||
setIsEdit(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);
|
||||
open();
|
||||
};
|
||||
|
||||
const handleAddClick = (type: EventType) => {
|
||||
setSelectedEvent(null);
|
||||
setIsEdit(false);
|
||||
setFormType(type);
|
||||
setFormTitle('');
|
||||
setFormContent('');
|
||||
setFormDate(null);
|
||||
setFormTime('');
|
||||
setFormIsLunar(false);
|
||||
setFormRepeatType('none');
|
||||
setFormIsHoliday(type === 'anniversary');
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formTitle.trim() || !formDate) return;
|
||||
|
||||
const dateStr = formTime
|
||||
? new Date(formDate.setHours(parseInt(formTime.split(':')[0]), parseInt(formTime.split(':')[1])))
|
||||
: formDate;
|
||||
|
||||
const eventData = {
|
||||
type: formType,
|
||||
title: formTitle,
|
||||
content: formContent || undefined,
|
||||
date: dateStr.toISOString(),
|
||||
is_lunar: formIsLunar,
|
||||
repeat_type: formRepeatType,
|
||||
is_holiday: formIsHoliday || undefined,
|
||||
};
|
||||
|
||||
if (isEdit && selectedEvent) {
|
||||
await updateEventById(selectedEvent.id, eventData);
|
||||
} else {
|
||||
await createEvent(eventData);
|
||||
}
|
||||
|
||||
close();
|
||||
resetForm();
|
||||
fetchEvents();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedEvent) return;
|
||||
await deleteEventById(selectedEvent.id);
|
||||
close();
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleToggleComplete = async (event: Event) => {
|
||||
if (event.type !== 'reminder') return;
|
||||
await updateEventById(event.id, {
|
||||
is_completed: !event.is_completed,
|
||||
});
|
||||
fetchEvents();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormType('anniversary');
|
||||
setFormTitle('');
|
||||
setFormContent('');
|
||||
setFormDate(null);
|
||||
setFormTime('');
|
||||
setFormIsLunar(false);
|
||||
setFormRepeatType('none');
|
||||
setFormIsHoliday(false);
|
||||
setSelectedEvent(null);
|
||||
setIsEdit(false);
|
||||
};
|
||||
|
||||
const handleAIEventCreated = () => {
|
||||
fetchEvents();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
|
||||
<Title order={2} c="blue">
|
||||
掐日子
|
||||
</Title>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{user?.email}
|
||||
{user?.nickname || user?.email}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
size="xs"
|
||||
leftSection={<IconLogout size={14} />}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
退出
|
||||
@ -38,12 +166,158 @@ export function HomePage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Main Content - Placeholder for now */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text c="dimmed">
|
||||
Home Page - 待开发
|
||||
</Text>
|
||||
</div>
|
||||
{/* Main Content - 4 panel layout */}
|
||||
<Grid grow style={{ flex: 1, minHeight: 0 }}>
|
||||
{/* Left column - 40% */}
|
||||
<Grid.Col span={4}>
|
||||
<Stack gap="md" h="100%">
|
||||
{/* Anniversary list */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<AnniversaryList
|
||||
events={events}
|
||||
onEventClick={handleEventClick}
|
||||
onAddClick={() => handleAddClick('anniversary')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reminder list */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<ReminderList
|
||||
events={events}
|
||||
onEventClick={handleEventClick}
|
||||
onToggleComplete={handleToggleComplete}
|
||||
onAddClick={() => handleAddClick('reminder')}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Right column - 60% */}
|
||||
<Grid.Col span={8}>
|
||||
<Stack gap="md" h="100%">
|
||||
{/* Note editor */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<NoteEditor />
|
||||
</div>
|
||||
|
||||
{/* AI Chat */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<AIChatBox onEventCreated={handleAIEventCreated} />
|
||||
</div>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Add/Edit Event Modal */}
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<IconPlus size={18} />
|
||||
<Text fw={500}>{isEdit ? '编辑事件' : '添加事件'}</Text>
|
||||
</Group>
|
||||
}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Event type */}
|
||||
<Select
|
||||
label="类型"
|
||||
data={[
|
||||
{ value: 'anniversary', label: '纪念日' },
|
||||
{ value: 'reminder', label: '提醒' },
|
||||
]}
|
||||
value={formType}
|
||||
onChange={(value) => value && setFormType(value as EventType)}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<TextInput
|
||||
label="标题"
|
||||
placeholder="输入标题"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Content (only for reminders) */}
|
||||
{formType === 'reminder' && (
|
||||
<Textarea
|
||||
label="内容"
|
||||
placeholder="输入详细内容"
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<DateInput
|
||||
label="日期"
|
||||
placeholder="选择日期"
|
||||
value={formDate}
|
||||
onChange={setFormDate}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Time (only for reminders) */}
|
||||
{formType === 'reminder' && (
|
||||
<TimeInput
|
||||
label="时间"
|
||||
placeholder="选择时间"
|
||||
value={formTime}
|
||||
onChange={(e) => setFormTime(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Lunar switch */}
|
||||
<Switch
|
||||
label="农历日期"
|
||||
checked={formIsLunar}
|
||||
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
{/* Repeat type */}
|
||||
<Select
|
||||
label="重复"
|
||||
data={[
|
||||
{ value: 'none', label: '不重复' },
|
||||
{ value: 'yearly', label: '每年' },
|
||||
{ value: 'monthly', label: '每月' },
|
||||
]}
|
||||
value={formRepeatType}
|
||||
onChange={(value) => value && setFormRepeatType(value as RepeatType)}
|
||||
/>
|
||||
|
||||
{/* Holiday switch (only for anniversaries) */}
|
||||
{formType === 'anniversary' && (
|
||||
<Switch
|
||||
label="节假日"
|
||||
checked={formIsHoliday}
|
||||
onChange={(e) => setFormIsHoliday(e.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Group justify="space-between" mt="md">
|
||||
{isEdit && (
|
||||
<Button color="red" variant="light" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Group ml="auto">
|
||||
<Button variant="subtle" onClick={close}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!formTitle.trim() || !formDate}>
|
||||
{isEdit ? '保存' : '添加'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User, Event, Note, AIConversation } from '../types';
|
||||
import type { User, Event, Note, AIConversation, EventType } from '../types';
|
||||
import { api } from '../services/api';
|
||||
|
||||
interface AppState {
|
||||
@ -24,6 +24,15 @@ interface AppState {
|
||||
setNotes: (notes: Note | null) => void;
|
||||
updateNotesContent: (content: string) => void;
|
||||
setConversations: (conversations: AIConversation[]) => void;
|
||||
addConversation: (conversation: AIConversation) => void;
|
||||
|
||||
// Data fetch actions
|
||||
fetchEvents: (type?: EventType) => Promise<void>;
|
||||
fetchNotes: () => Promise<void>;
|
||||
saveNotes: (content: string) => Promise<void>;
|
||||
createEvent: (event: Partial<Event>) => Promise<{ error: any }>;
|
||||
updateEventById: (id: string, event: Partial<Event>) => Promise<{ error: any }>;
|
||||
deleteEventById: (id: string) => Promise<{ error: any }>;
|
||||
|
||||
// Auth actions
|
||||
login: (email: string, password: string) => Promise<{ error: any }>;
|
||||
@ -59,6 +68,72 @@ export const useAppStore = create<AppState>()(
|
||||
notes: state.notes ? { ...state.notes, content } : null,
|
||||
})),
|
||||
setConversations: (conversations) => set({ conversations }),
|
||||
addConversation: (conversation) => set((state) => ({
|
||||
conversations: [conversation, ...state.conversations],
|
||||
})),
|
||||
|
||||
// Data fetch actions
|
||||
fetchEvents: async (type) => {
|
||||
try {
|
||||
const events = await api.events.list(type);
|
||||
set({ events });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch events:', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchNotes: async () => {
|
||||
try {
|
||||
const notes = await api.notes.get();
|
||||
set({ notes });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notes:', error);
|
||||
}
|
||||
},
|
||||
|
||||
saveNotes: async (content) => {
|
||||
try {
|
||||
const notes = await api.notes.save(content);
|
||||
set({ notes });
|
||||
} catch (error) {
|
||||
console.error('Failed to save notes:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createEvent: async (event) => {
|
||||
try {
|
||||
const newEvent = await api.events.create(event);
|
||||
set((state) => ({ events: [...state.events, newEvent] }));
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
return { error: error.message || '创建失败' };
|
||||
}
|
||||
},
|
||||
|
||||
updateEventById: async (id, event) => {
|
||||
try {
|
||||
const updated = await api.events.update(id, event);
|
||||
set((state) => ({
|
||||
events: state.events.map((e) => (e.id === id ? updated : e)),
|
||||
}));
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
return { error: error.message || '更新失败' };
|
||||
}
|
||||
},
|
||||
|
||||
deleteEventById: async (id) => {
|
||||
try {
|
||||
await api.events.delete(id);
|
||||
set((state) => ({
|
||||
events: state.events.filter((e) => e.id !== id),
|
||||
}));
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
return { error: error.message || '删除失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// Auth actions
|
||||
login: async (email, password) => {
|
||||
|
||||
@ -12,32 +12,22 @@ export type EventType = 'anniversary' | 'reminder';
|
||||
|
||||
export type RepeatType = 'yearly' | 'monthly' | 'none';
|
||||
|
||||
export interface BaseEvent {
|
||||
// Unified event type (matches backend API)
|
||||
export interface Event {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: EventType;
|
||||
title: string;
|
||||
date: string; // ISO date string
|
||||
content?: string; // Only for reminders
|
||||
date: string; // For anniversaries: date, For reminders: reminder time
|
||||
is_lunar: boolean;
|
||||
repeat_type: RepeatType;
|
||||
is_holiday?: boolean; // Only for anniversaries
|
||||
is_completed?: boolean; // Only for reminders
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Anniversary extends BaseEvent {
|
||||
type: 'anniversary';
|
||||
is_holiday: boolean;
|
||||
}
|
||||
|
||||
export interface Reminder extends BaseEvent {
|
||||
type: 'reminder';
|
||||
content: string;
|
||||
reminder_time: string; // ISO datetime string
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
// Combined event type for lists
|
||||
export type Event = Anniversary | Reminder;
|
||||
|
||||
// Note types
|
||||
export interface Note {
|
||||
id: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user