feat: 禅意设计风格重构与体验优化

- LandingPage: 全新水墨晕染算法背景,循环墨迹动画
- 登录/注册页: 禅意黑白极简风格
- HomePage: 三栏布局优化,标题颜色语义化
- 纪念日组件: 分类逻辑优化,颜色语义统一
- 提醒组件: 分组标题颜色优化,逾期提示更醒目
- 修复农历日期边界问题(29/30天月份)
- 添加 lunar-javascript 类型声明
- 清理未使用的导入和代码
This commit is contained in:
ddshi 2026-02-02 15:26:47 +08:00
parent a118346238
commit 250c05e85e
37 changed files with 3426 additions and 617 deletions

1507
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,10 @@
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.10" "zustand": "^5.0.10"
}, },
"devDependencies": { "devDependencies": {

BIN
src/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@ -8,9 +8,7 @@ import {
ActionIcon, ActionIcon,
Loader, Loader,
Box, Box,
Button,
Card, Card,
Badge,
} from '@mantine/core'; } from '@mantine/core';
import { IconSend, IconSparkles } from '@tabler/icons-react'; import { IconSend, IconSparkles } from '@tabler/icons-react';
import { api } from '../../services/api'; import { api } from '../../services/api';
@ -24,7 +22,6 @@ interface AIChatBoxProps {
export function AIChatBox({ onEventCreated }: AIChatBoxProps) { export function AIChatBox({ onEventCreated }: AIChatBoxProps) {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [conversation, setConversation] = useState<AIConversation | null>(null);
const [history, setHistory] = useState<AIConversation[]>([]); const [history, setHistory] = useState<AIConversation[]>([]);
const addConversation = useAppStore((state) => state.addConversation); const addConversation = useAppStore((state) => state.addConversation);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -55,7 +52,6 @@ export function AIChatBox({ onEventCreated }: AIChatBoxProps) {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
setConversation(newConversation);
setHistory((prev) => [...prev, newConversation]); setHistory((prev) => [...prev, newConversation]);
addConversation(newConversation); addConversation(newConversation);

View File

@ -0,0 +1,262 @@
import { useState, useRef, useEffect } from 'react';
import {
Paper,
Text,
Stack,
Group,
TextInput,
ActionIcon,
Loader,
Box,
Transition,
} from '@mantine/core';
import { IconSparkles, IconX, IconSend, IconMessage } from '@tabler/icons-react';
import { api } from '../../services/api';
import { useAppStore } from '../../stores';
import type { AIConversation } from '../../types';
interface FloatingAIChatProps {
onEventCreated?: () => void;
}
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
const [isOpen, setIsOpen] = useState(false);
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
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(),
};
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 (
<>
{/* Floating button */}
<ActionIcon
size="xl"
radius="xl"
variant="filled"
onClick={() => setIsOpen(!isOpen)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 1000,
background: '#1a1a1a',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
}}
>
{isOpen ? <IconX size={24} /> : <IconMessage size={24} />}
</ActionIcon>
{/* Chat window */}
<Transition mounted={isOpen} transition="slide-up" duration={300}>
{(styles) => (
<Paper
shadow="lg"
radius={4}
style={{
...styles,
position: 'fixed',
bottom: 90,
right: 24,
width: 360,
maxHeight: 'calc(100vh - 150px)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
background: '#faf9f7',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}
>
{/* Header */}
<Group
p="md"
style={{
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
background: '#1a1a1a',
borderRadius: '4px 4px 0 0',
}}
>
<Group gap={8}>
<IconSparkles size={16} color="#faf9f7" />
<Text
fw={400}
size="sm"
c="#faf9f7"
style={{ letterSpacing: '0.1em' }}
>
AI
</Text>
</Group>
</Group>
{/* Chat history */}
<Box
p="md"
style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '12px',
minHeight: 280,
maxHeight: 380,
}}
>
{history.length === 0 ? (
<Stack align="center" justify="center" h="100%" gap="sm">
<IconSparkles size={32} color="rgba(0, 0, 0, 0.2)" />
<Text size="xs" c="#888" ta="center" style={{ letterSpacing: '0.05em' }}>
<br />
<Text span size="xs" c="#aaa">
15
</Text>
</Text>
</Stack>
) : (
history.map((conv) => (
<Box key={conv.id}>
<Group gap={8} mb={6}>
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text>
</Group>
<Paper
withBorder
p="sm"
radius={2}
mb="sm"
style={{
background: 'rgba(0, 0, 0, 0.02)',
borderColor: 'rgba(0, 0, 0, 0.08)',
}}
>
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{conv.message}
</Text>
</Paper>
<Group gap={8} mb={6}>
<IconSparkles size={10} color="#1a1a1a" />
<Text size="xs" c="#1a1a1a" fw={400} style={{ letterSpacing: '0.05em' }}>
AI
</Text>
</Group>
<Paper
withBorder
p="sm"
radius={2}
style={{
background: 'rgba(0, 0, 0, 0.03)',
borderColor: 'rgba(0, 0, 0, 0.08)',
}}
>
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{conv.response}
</Text>
</Paper>
</Box>
))
)}
{loading && (
<Group gap={8}>
<Loader size="xs" color="#1a1a1a" />
<Text size="xs" c="#888" style={{ letterSpacing: '0.05em' }}>
AI ...
</Text>
</Group>
)}
<div ref={scrollRef} />
</Box>
{/* Input */}
<Group
p="md"
style={{
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
borderRadius: '0 0 4px 4px',
}}
>
<TextInput
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的提醒事项..."
size="sm"
style={{ flex: 1 }}
disabled={loading}
styles={{
input: {
borderRadius: 2,
borderColor: 'rgba(0, 0, 0, 0.1)',
background: '#faf9f7',
},
}}
/>
<ActionIcon
size="lg"
radius={2}
variant="filled"
onClick={handleSend}
disabled={!message.trim() || loading}
style={{
background: '#1a1a1a',
}}
>
<IconSend size={14} />
</ActionIcon>
</Group>
</Paper>
)}
</Transition>
</>
);
}

View File

@ -1,5 +1,4 @@
import { Card, Text, Badge, Group, Stack, ThemeIcon } from '@mantine/core'; import { Paper, Text, Group, Stack } from '@mantine/core';
import { IconHeart, IconRepeat, IconCalendar } from '@tabler/icons-react';
import type { Event } from '../../types'; import type { Event } from '../../types';
import { calculateCountdown, formatCountdown } from '../../utils/countdown'; import { calculateCountdown, formatCountdown } from '../../utils/countdown';
import { getHolidayById } from '../../constants/holidays'; import { getHolidayById } from '../../constants/holidays';
@ -17,37 +16,40 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false; const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
return ( return (
<Card <Paper
shadow="sm" p="sm"
padding="sm" radius={2}
radius="md"
onClick={onClick} onClick={onClick}
style={{ cursor: 'pointer', transition: 'transform 0.2s' }} style={{
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')} cursor: 'pointer',
transition: 'transform 0.2s',
background: 'rgba(0, 0, 0, 0.02)',
border: '1px solid rgba(0, 0, 0, 0.04)',
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-1px)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')} onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Stack gap={4} style={{ flex: 1 }}> <Stack gap={4} style={{ flex: 1 }}>
<Group gap={6}> <Group gap={6}>
<IconHeart size={14} color="pink" /> <Text size="xs" c="#c41c1c">{event.title}</Text>
<Text fw={500} size="sm" lineClamp={1} style={{ flex: 1 }}>
{event.title}
</Text>
</Group> </Group>
<Group gap="xs"> <Group gap="xs">
{/* Countdown badge */} {/* Countdown */}
<Badge <Text
size="sm" size="xs"
variant="filled" fw={400}
color={countdown.isPast ? 'gray' : countdown.isToday ? 'red' : 'blue'} style={{
leftSection={<IconCalendar size={10} />} letterSpacing: '0.05em',
color: countdown.isToday ? '#c41c1c' : '#666',
}}
> >
{formattedCountdown} {formattedCountdown}
</Badge> </Text>
{/* Date display */} {/* Date display */}
<Text size="xs" c="dimmed"> <Text size="xs" c="#999">
{countdown.isPast {countdown.isPast
? '已过' ? '已过'
: `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}`} : `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}`}
@ -58,22 +60,17 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
<Group gap={6}> <Group gap={6}>
{repeatType !== 'none' && ( {repeatType !== 'none' && (
<Badge <Text size="xs" c="#888">
size="xs"
variant="light"
color="blue"
leftSection={<IconRepeat size={10} />}
>
{repeatType === 'yearly' ? '每年' : '每月'} {repeatType === 'yearly' ? '每年' : '每月'}
</Badge> </Text>
)} )}
{(event.is_holiday || holiday) && ( {(event.is_holiday || holiday) && (
<Badge size="xs" variant="light" color="green"> <Text size="xs" c="#888">
</Badge> </Text>
)} )}
</Group> </Group>
</Group> </Group>
</Card> </Paper>
); );
} }

View File

@ -1,11 +1,10 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Stack, Text, Paper, Group, Button, Badge, ThemeIcon, Card } from '@mantine/core'; import { Stack, Text, Paper, Group, Button } from '@mantine/core';
import { IconPlus, IconHeart, IconCalendar } from '@tabler/icons-react'; import { IconPlus } from '@tabler/icons-react';
import { AnniversaryCard } from './AnniversaryCard'; import { AnniversaryCard } from './AnniversaryCard';
import { getHolidaysForYear, getUpcomingHolidays } from '../../constants/holidays'; import { getHolidaysForYear } from '../../constants/holidays';
import { calculateCountdown, formatCountdown } from '../../utils/countdown'; import { calculateCountdown, formatCountdown } from '../../utils/countdown';
import type { Event } from '../../types'; import type { Event } from '../../types';
import { Lunar, Solar } from 'lunar-javascript';
interface AnniversaryListProps { interface AnniversaryListProps {
events: Event[]; events: Event[];
@ -85,16 +84,21 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
// 空状态 // 空状态
if (allAnniversaries.total === 0) { if (allAnniversaries.total === 0) {
return ( return (
<Paper p="md" withBorder radius="md" h="100%"> <Paper p="md" withBorder radius={4} h="100%">
<Stack align="center" justify="center" h="100%"> <Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm"> <Text c="#999" size="sm" style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
<Button <Button
variant="light" variant="outline"
size="xs" size="xs"
leftSection={<IconPlus size={14} />} leftSection={<IconPlus size={12} />}
onClick={onAddClick} onClick={onAddClick}
style={{
borderColor: '#ccc',
color: '#1a1a1a',
borderRadius: 2,
}}
> >
</Button> </Button>
@ -104,21 +108,25 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
} }
return ( return (
<Paper p="md" withBorder radius="md" h="100%"> <Paper p="md" withBorder radius={4} h="100%">
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Group gap={8}> <Group gap={8}>
<Text fw={500} size="sm"> <Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
</Text> </Text>
<Badge size="xs" variant="light" color="gray"> <Text size="xs" c="#999">
{anniversaries.length} {anniversaries.length}
</Badge> </Text>
</Group> </Group>
<Button <Button
variant="subtle" variant="subtle"
size="xs" size="xs"
leftSection={<IconPlus size={14} />} leftSection={<IconPlus size={12} />}
onClick={onAddClick} onClick={onAddClick}
style={{
color: '#666',
borderRadius: 2,
}}
> >
</Button> </Button>
@ -128,37 +136,37 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
{/* 内置节假日 */} {/* 内置节假日 */}
{allAnniversaries.builtIn.length > 0 && ( {allAnniversaries.builtIn.length > 0 && (
<> <>
<Text size="xs" c="dimmed" fw={500} tt="uppercase"> <Text size="xs" c="#888" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
{allAnniversaries.builtIn.map((holiday) => { {allAnniversaries.builtIn.map((holiday) => {
const countdown = calculateCountdown(holiday.date, holiday.repeat_type, holiday.is_lunar); const countdown = calculateCountdown(holiday.date, holiday.repeat_type, holiday.is_lunar);
return ( return (
<Card <Paper
key={holiday.id} key={holiday.id}
shadow="sm" p="sm"
padding="sm" radius={2}
radius="md" style={{
style={{ cursor: 'pointer', opacity: 0.8, backgroundColor: '#f8f9fa' }} cursor: 'pointer',
opacity: 0.7,
background: 'rgba(0, 0, 0, 0.02)',
border: '1px solid rgba(0, 0, 0, 0.04)',
}}
onClick={() => onEventClick(holiday as unknown as Event)}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap={6}> <Group gap={6}>
<ThemeIcon size="xs" variant="light" color="green"> <Text size="xs" c="#666">{holiday.title}</Text>
<IconCalendar size={10} />
</ThemeIcon>
<Text fw={500} size="sm">
{holiday.title}
</Text>
</Group> </Group>
<Badge <Text
size="xs" size="xs"
variant="filled" c={countdown.isToday ? '#c41c1c' : '#888'}
color={countdown.isToday ? 'red' : 'green'} style={{ letterSpacing: '0.05em' }}
> >
{formatCountdown(countdown)} {formatCountdown(countdown)}
</Badge> </Text>
</Group> </Group>
</Card> </Paper>
); );
})} })}
</> </>
@ -168,7 +176,7 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
{allAnniversaries.user.length > 0 && ( {allAnniversaries.user.length > 0 && (
<> <>
{allAnniversaries.builtIn.length > 0 && ( {allAnniversaries.builtIn.length > 0 && (
<Text size="xs" c="dimmed" fw={500} tt="uppercase" mt="xs"> <Text size="xs" c="#666" fw={500} style={{ letterSpacing: '0.05em' }} mt="xs">
</Text> </Text>
)} )}

View File

@ -1,12 +1,26 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Paper, Textarea, Group, Text, Stack } from '@mantine/core'; import {
Paper,
Textarea,
Group,
Text,
Stack,
Button,
Box,
} 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';
export function NoteEditor({ onSave }: NoteEditorProps) { export function NoteEditor({ onSave }: NoteEditorProps) {
const notes = useAppStore((state) => state.notes); const notes = useAppStore((state) => state.notes);
const updateNotesContent = useAppStore((state) => state.updateNotesContent); const updateNotesContent = useAppStore((state) => state.updateNotesContent);
@ -16,6 +30,7 @@ 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<Date | null>(null);
const [viewMode] = useState<ViewMode>('edit');
// Initialize content from notes // Initialize content from notes
useEffect(() => { useEffect(() => {
@ -29,11 +44,11 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
fetchNotes(); fetchNotes();
}, [fetchNotes]); }, [fetchNotes]);
// Auto-save with debounce // Auto-save with 3 second debounce
const [debouncedContent] = useDebouncedValue(content, 1000); const [debouncedContent] = useDebouncedValue(content, 3000);
useEffect(() => { useEffect(() => {
if (debouncedContent !== undefined && notes) { if (debouncedContent !== undefined && notes && debouncedContent !== content) {
handleSave(debouncedContent); handleSave(debouncedContent);
} }
}, [debouncedContent]); }, [debouncedContent]);
@ -47,13 +62,14 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
updateNotesContent(value); updateNotesContent(value);
await saveNotes(value); await saveNotes(value);
setLastSaved(new Date()); setLastSaved(new Date());
onSave?.();
} catch (error) { } catch (error) {
console.error('Failed to save note:', error); console.error('Failed to save note:', error);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, },
[notes, updateNotesContent, saveNotes] [notes, updateNotesContent, saveNotes, onSave]
); );
const formatLastSaved = () => { const formatLastSaved = () => {
@ -65,34 +81,140 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
return lastSaved.toLocaleTimeString('zh-CN'); return lastSaved.toLocaleTimeString('zh-CN');
}; };
const handleManualSave = () => {
if (content && notes) {
handleSave(content);
}
};
return ( return (
<Paper p="md" withBorder radius="md" h="100%"> <Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Stack gap="sm" h="100%"> <Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Group justify="space-between"> {/* Header */}
<Text fw={500} size="sm"> <Group justify="space-between" style={{ flexShrink: 0 }}>
便 <Group gap="sm">
</Text> <Text fw={400} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
<Text size="xs" c={saving ? 'yellow' : lastSaved ? 'dimmed' : 'gray'}> 便
{saving ? '保存中...' : 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' : lastSaved ? '#999' : '#bbb'}>
{saving ? '保存中...' : formatLastSaved()}
</Text>
</Group>
</Group> </Group>
<Textarea {/* Editor/Preview Area */}
value={content} <Box style={{ flex: 1, minHeight: 0, display: 'flex' }}>
onChange={(e) => setContent(e.target.value)} {viewMode === 'edit' && (
placeholder="在这里记录你的想法..." <Textarea
autosize value={content}
minRows={8} onChange={(e) => setContent(e.target.value)}
maxRows={20} placeholder="在这里记录你的想法...&#10;&#10;支持 Markdown 语法:&#10;- # 一级标题&#10;- **粗体**&#10;- *斜体*&#10;- [链接](url)"
styles={{ autosize
input: { minRows={8}
border: 'none', styles={{
resize: 'none', input: {
fontFamily: 'inherit', border: 'none',
'&:focus': { outline: 'none' }, resize: 'none',
}, fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
}} fontSize: '13px',
/> lineHeight: '1.6',
background: 'transparent',
'&:focus': { outline: 'none' },
},
}}
style={{ flex: 1, width: '100%' }}
/>
)}
{viewMode === 'preview' && (
<Box
style={{
flex: 1,
overflowY: 'auto',
padding: '8px',
fontSize: '13px',
lineHeight: '1.6',
}}
>
{content ? (
<Box
style={{
'& 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="#999" ta="center" py="xl" size="sm" style={{ letterSpacing: '0.05em' }}>
<br />
<Text size="xs" c="#bbb" mt={4}>
使 Markdown
</Text>
</Text>
)}
</Box>
)}
</Box>
</Stack> </Stack>
</Paper> </Paper>
); );

View File

@ -0,0 +1,140 @@
import { useMemo } from 'react';
import {
Modal,
Stack,
Text,
Group,
Button,
Paper,
ActionIcon,
} from '@mantine/core';
import { IconRotateClockwise, IconTrash } from '@tabler/icons-react';
import type { Event } from '../../types';
interface ArchiveReminderModalProps {
opened: boolean;
onClose: () => void;
completedReminders: Event[];
onRestore: (event: Event) => void;
onDelete: (event: Event) => void;
}
export function ArchiveReminderModal({
opened,
onClose,
completedReminders,
onRestore,
onDelete,
}: ArchiveReminderModalProps) {
const sortedReminders = useMemo(() => {
return [...completedReminders].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}, [completedReminders]);
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text
fw={400}
style={{
letterSpacing: '0.1em',
fontSize: '1rem',
}}
>
</Text>
}
size="md"
styles={{
header: {
borderBottom: '1px solid rgba(0,0,0,0.06)',
},
}}
>
{completedReminders.length === 0 ? (
<Paper p="xl" withBorder radius={4} ta="center">
<Text c="#999" size="sm" style={{ letterSpacing: '0.05em' }}>
</Text>
<Text size="xs" c="#bbb" mt={4}>
</Text>
</Paper>
) : (
<Stack gap="sm">
{sortedReminders.map((event) => (
<Paper
key={event.id}
p="sm"
radius={2}
withBorder
style={{
background: 'rgba(0, 0, 0, 0.02)',
borderColor: 'rgba(0, 0, 0, 0.06)',
}}
>
<Group justify="space-between" wrap="nowrap">
<Stack gap={4} style={{ flex: 1 }}>
<Text
size="sm"
fw={400}
lineClamp={1}
style={{
textDecoration: 'line-through',
color: '#999',
letterSpacing: '0.03em',
}}
>
{event.title}
</Text>
<Text size="xs" c="#bbb">
{new Date(event.date).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Stack>
<Group gap={4}>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => onRestore(event)}
style={{ color: '#666' }}
>
<IconRotateClockwise size={12} />
</ActionIcon>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => onDelete(event)}
style={{ color: '#999' }}
>
<IconTrash size={12} />
</ActionIcon>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
onClick={onClose}
style={{
color: '#666',
borderRadius: 2,
}}
>
</Button>
</Group>
</Modal>
);
}

View File

@ -1,29 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import { Paper, Text, Checkbox, Group, Stack, ActionIcon } from '@mantine/core';
Card, import { IconCheck, IconDots } from '@tabler/icons-react';
Text,
Checkbox,
Group,
Stack,
ThemeIcon,
Tooltip,
ActionIcon,
Menu,
} from '@mantine/core';
import {
IconBell,
IconCheck,
IconDots,
IconTrash,
IconEdit,
IconClock,
} from '@tabler/icons-react';
import type { Event } from '../../types'; import type { Event } from '../../types';
interface ReminderCardProps { interface ReminderCardProps {
event: Event; event: Event;
onToggle: () => void; onToggle: () => void;
onClick: () => void; onClick: () => void;
onDelete?: () => void;
} }
export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) { export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
@ -40,7 +24,7 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
// 格式化时间显示 // 格式化时间显示
const timeStr = eventDate.toLocaleString('zh-CN', { const timeStr = eventDate.toLocaleString('zh-CN', {
month: 'short', month: 'numeric',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
@ -51,31 +35,32 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
// 颜色主题 // 颜色主题
const getThemeColor = () => { const getThemeColor = () => {
if (isCompleted) return 'gray'; if (isCompleted) return '#999';
if (timeInfo.isPast) return 'red'; if (timeInfo.isPast) return '#c41c1c';
if (timeInfo.isToday) return 'orange'; if (timeInfo.isToday) return '#666';
return 'blue'; return '#888';
}; };
return ( return (
<Card <Paper
shadow="sm" p="sm"
padding="sm" radius={2}
radius="md"
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
opacity: isCompleted ? 0.5 : 1, opacity: isCompleted ? 0.4 : 1,
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
transform: isHovered ? 'translateY(-2px)' : 'translateY(0)', transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
borderLeft: `3px solid var(--mantine-color-${getThemeColor()}-6)`, borderLeft: `2px solid ${getThemeColor()}`,
background: 'rgba(0, 0, 0, 0.02)',
border: '1px solid rgba(0, 0, 0, 0.04)',
}} }}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}> <Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
{/* Checkbox */} {/* Checkbox - 点击切换完成状态 */}
<Checkbox <Checkbox
checked={isCompleted} checked={isCompleted}
onChange={(e) => { onChange={(e) => {
@ -83,19 +68,20 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
onToggle(); onToggle();
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
color="green" size="xs"
size="sm" color="#1a1a1a"
/> />
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}> <Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{/* Title */} {/* Title */}
<Text <Text
fw={500} fw={400}
size="sm" size="xs"
lineClamp={1} lineClamp={1}
style={{ style={{
textDecoration: isCompleted ? 'line-through' : 'none', textDecoration: isCompleted ? 'line-through' : 'none',
color: isCompleted ? 'dimmed' : undefined, color: isCompleted ? '#bbb' : '#1a1a1a',
letterSpacing: '0.03em',
}} }}
> >
{event.title} {event.title}
@ -103,9 +89,6 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
{/* Time and content */} {/* Time and content */}
<Group gap="xs"> <Group gap="xs">
<ThemeIcon size="xs" variant="light" color={getThemeColor()}>
<IconClock size={10} />
</ThemeIcon>
<Text size="xs" c={getThemeColor()}> <Text size="xs" c={getThemeColor()}>
{timeInfo.timeStr} {timeInfo.timeStr}
</Text> </Text>
@ -113,7 +96,7 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
{/* Content preview */} {/* Content preview */}
{event.content && !isCompleted && ( {event.content && !isCompleted && (
<Text size="xs" c="dimmed" lineClamp={1}> <Text size="xs" c="#999" lineClamp={1}>
{event.content} {event.content}
</Text> </Text>
)} )}
@ -123,51 +106,30 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
{/* Quick actions */} {/* Quick actions */}
<Group gap={4}> <Group gap={4}>
{isHovered && !isCompleted && ( {isHovered && !isCompleted && (
<Tooltip label="完成"> <ActionIcon
<ActionIcon size="sm"
size="sm" variant="subtle"
variant="light" onClick={(e) => {
color="green" e.stopPropagation();
onClick={(e) => { onToggle();
e.stopPropagation(); }}
onToggle(); style={{ color: '#666' }}
}} >
> <IconCheck size={12} />
<IconCheck size={14} /> </ActionIcon>
</ActionIcon>
</Tooltip>
)} )}
<Menu shadow="md" width={120}> <ActionIcon
<Menu.Target> size="sm"
<ActionIcon variant="subtle"
size="sm" color="gray"
variant="subtle" onClick={(e) => e.stopPropagation()}
color="gray" style={{ color: '#999' }}
onClick={(e) => e.stopPropagation()} >
> <IconDots size={12} />
<IconDots size={14} /> </ActionIcon>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={onClick}
>
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={onToggle}
>
{isCompleted ? '恢复' : '删除'}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group> </Group>
</Group> </Group>
</Card> </Paper>
); );
} }

View File

@ -1,7 +1,21 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Stack, Text, Paper, Group, Button, Badge, ThemeIcon, Alert } from '@mantine/core'; import {
import { IconPlus, IconBell, IconAlertCircle } from '@tabler/icons-react'; Stack,
Text,
Paper,
Group,
Button,
Alert,
ActionIcon,
Tooltip,
} from '@mantine/core';
import {
IconPlus,
IconAlertCircle,
IconArchive,
} from '@tabler/icons-react';
import { ReminderCard } from './ReminderCard'; import { ReminderCard } from './ReminderCard';
import { ArchiveReminderModal } from './ArchiveReminderModal';
import type { Event } from '../../types'; import type { Event } from '../../types';
interface ReminderListProps { interface ReminderListProps {
@ -9,6 +23,8 @@ interface ReminderListProps {
onEventClick: (event: Event) => void; onEventClick: (event: Event) => void;
onToggleComplete: (event: Event) => void; onToggleComplete: (event: Event) => void;
onAddClick: () => void; onAddClick: () => void;
onDelete?: (event: Event) => void;
onRestore?: (event: Event) => void;
} }
export function ReminderList({ export function ReminderList({
@ -16,7 +32,11 @@ export function ReminderList({
onEventClick, onEventClick,
onToggleComplete, onToggleComplete,
onAddClick, onAddClick,
onDelete,
onRestore,
}: ReminderListProps) { }: ReminderListProps) {
const [archiveOpened, setArchiveOpened] = useState(false);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const now = new Date(); const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@ -81,23 +101,25 @@ export function ReminderList({
// 空状态 // 空状态
if (!hasActiveReminders) { if (!hasActiveReminders) {
return ( return (
<Paper p="md" withBorder radius="md" h="100%"> <Paper p="md" withBorder radius={4} h="100%">
<Stack align="center" justify="center" h="100%"> <Stack align="center" justify="center" h="100%">
<ThemeIcon size={40} variant="light" color="gray" radius="xl"> <Text c="#999" size="sm" ta="center" style={{ letterSpacing: '0.05em' }}>
<IconBell size={20} />
</ThemeIcon>
<Text c="dimmed" size="sm" ta="center">
<br /> </Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="#bbb" ta="center" mt={4}>
</Text>
</Text> </Text>
<Button <Button
variant="light" variant="outline"
size="xs" size="xs"
leftSection={<IconPlus size={14} />} leftSection={<IconPlus size={12} />}
onClick={onAddClick} onClick={onAddClick}
style={{
borderColor: '#ccc',
color: '#1a1a1a',
borderRadius: 2,
marginTop: 8,
}}
> >
</Button> </Button>
@ -110,149 +132,169 @@ export function ReminderList({
const missedCount = grouped.missed.length; const missedCount = grouped.missed.length;
return ( return (
<Paper p="md" withBorder radius="md" h="100%" style={{ display: 'flex', flexDirection: 'column' }}> <>
{/* Header */} <Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}> {/* Header */}
<Group gap={8}> <Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
<ThemeIcon size="sm" variant="light" color="orange"> <Group gap={8}>
<IconBell size={12} /> <Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
</ThemeIcon>
<Text fw={500} size="sm"> </Text>
{missedCount > 0 && (
</Text> <Text size="xs" c="#c41c1c" fw={500}>
{missedCount > 0 && ( {missedCount}
<Badge size="xs" variant="filled" color="red"> </Text>
{missedCount} )}
</Badge> </Group>
)} <Group gap={4}>
{grouped.completed.length > 0 && (
<Tooltip label="查看已归档">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={() => setArchiveOpened(true)}
style={{ color: '#999' }}
>
<IconArchive size={12} />
</ActionIcon>
</Tooltip>
)}
<Button
variant="subtle"
size="xs"
leftSection={<IconPlus size={12} />}
onClick={onAddClick}
style={{
color: '#666',
borderRadius: 2,
}}
>
</Button>
</Group>
</Group> </Group>
<Button
variant="subtle"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={onAddClick}
>
</Button>
</Group>
{/* Content */} {/* Content */}
<Stack gap="xs" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}> <Stack gap="xs" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{/* 逾期提醒 */} {/* 逾期提醒 */}
{grouped.missed.length > 0 && ( {grouped.missed.length > 0 && (
<Alert <Alert
icon={<IconAlertCircle size={14} />} icon={<IconAlertCircle size={14} />}
color="red" color="dark"
variant="light" variant="light"
p="xs" p="xs"
title={<Text size="xs" fw={500}> {grouped.missed.length}</Text>} title={<Text size="xs" fw={500} c="#c41c1c"> {grouped.missed.length}</Text>}
> >
<Stack gap={4}> <Stack gap={4}>
{grouped.missed.slice(0, 3).map((event) => ( {grouped.missed.slice(0, 3).map((event) => (
<ReminderCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
/>
))}
{grouped.missed.length > 3 && (
<Text size="xs" c="#999" ta="center">
{grouped.missed.length - 3} ...
</Text>
)}
</Stack>
</Alert>
)}
{/* 今天 */}
{grouped.today.length > 0 && (
<>
<Group gap={4}>
<Text size="xs" c="#c41c1c" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text>
</Group>
{grouped.today.map((event) => (
<ReminderCard <ReminderCard
key={event.id} key={event.id}
event={event} event={event}
onClick={() => onEventClick(event)} onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)} onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
/> />
))} ))}
{grouped.missed.length > 3 && ( </>
<Text size="xs" c="dimmed" ta="center"> )}
{grouped.missed.length - 3} ...
{/* 明天 */}
{grouped.tomorrow.length > 0 && (
<>
<Group gap={4}>
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
)} </Group>
</Stack> {grouped.tomorrow.map((event) => (
</Alert> <ReminderCard
)} key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
onDelete={onDelete ? () => onDelete(event) : undefined}
/>
))}
</>
)}
{/* 今天 */} {/* 本周 */}
{grouped.today.length > 0 && ( {grouped.thisWeek.length > 0 && (
<> <>
<Group gap={4}> <Group gap={4}>
<Text size="xs" c="red" fw={600}> <Text size="xs" c="#888" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
<Badge size="xs" variant="light" color="red"> </Group>
{grouped.today.length} {grouped.thisWeek.map((event) => (
</Badge> <ReminderCard
</Group> key={event.id}
{grouped.today.map((event) => ( event={event}
<ReminderCard onClick={() => onEventClick(event)}
key={event.id} onToggle={() => onToggleComplete(event)}
event={event} onDelete={onDelete ? () => onDelete(event) : undefined}
onClick={() => onEventClick(event)} />
onToggle={() => onToggleComplete(event)} ))}
/> </>
))} )}
</>
)}
{/* 明天 */} {/* 更久 */}
{grouped.tomorrow.length > 0 && ( {grouped.later.length > 0 && (
<> <>
<Group gap={4}> <Group gap={4}>
<Text size="xs" c="teal" fw={600}> <Text size="xs" c="#999" fw={400} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
<Badge size="xs" variant="light" color="teal"> </Group>
{grouped.tomorrow.length} {grouped.later.map((event) => (
</Badge> <ReminderCard
</Group> key={event.id}
{grouped.tomorrow.map((event) => ( event={event}
<ReminderCard onClick={() => onEventClick(event)}
key={event.id} onToggle={() => onToggleComplete(event)}
event={event} onDelete={onDelete ? () => onDelete(event) : undefined}
onClick={() => onEventClick(event)} />
onToggle={() => onToggleComplete(event)} ))}
/> </>
))} )}
</> </Stack>
)} </Paper>
{/* 本周 */} {/* Archive Modal */}
{grouped.thisWeek.length > 0 && ( <ArchiveReminderModal
<> opened={archiveOpened}
<Group gap={4}> onClose={() => setArchiveOpened(false)}
<Text size="xs" c="blue" fw={600}> completedReminders={grouped.completed}
onRestore={onRestore || (() => {})}
</Text> onDelete={onDelete || (() => {})}
<Badge size="xs" variant="light" color="blue"> />
{grouped.thisWeek.length} </>
</Badge>
</Group>
{grouped.thisWeek.map((event) => (
<ReminderCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
/>
))}
</>
)}
{/* 更久 */}
{grouped.later.length > 0 && (
<>
<Group gap={4}>
<Text size="xs" c="gray" fw={600}>
</Text>
<Badge size="xs" variant="light" color="gray">
{grouped.later.length}
</Badge>
</Group>
{grouped.later.map((event) => (
<ReminderCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
onToggle={() => onToggleComplete(event)}
/>
))}
</>
)}
</Stack>
</Paper>
); );
} }

View File

@ -3,12 +3,13 @@
* 2024 * 2024
* / * /
*/ */
import { Lunar } from 'lunar-javascript';
export interface Holiday { export interface Holiday {
id: string; id: string;
name: string; name: string;
month: number; // 公历月份 month?: number; // 公历月份(非农历时使用)
day: number; // 公历日期 day?: number; // 公历日期(非农历时使用)
isLunar: boolean; // 是否为农历日期 isLunar: boolean; // 是否为农历日期
lunarMonth?: number; // 农历月份(农历日期时使用) lunarMonth?: number; // 农历月份(农历日期时使用)
lunarDay?: number; // 农历日期(农历日期时使用) lunarDay?: number; // 农历日期(农历日期时使用)
@ -168,9 +169,10 @@ export function isHoliday(
// 农历日期需要转换 // 农历日期需要转换
try { try {
const lunar = Lunar.fromYmd(year, month, day); const lunar = Lunar.fromYmd(year, month, day);
const solar = lunar.getSolar();
if ( if (
lunar.getMonth() === holiday.lunarMonth && solar.getMonth() === holiday.lunarMonth &&
lunar.getDay() === holiday.lunarDay solar.getDay() === holiday.lunarDay
) { ) {
return holiday; return holiday;
} }

View File

@ -13,14 +13,14 @@ import {
Select, Select,
Stack, Stack,
} from '@mantine/core'; } from '@mantine/core';
import { DateInput, TimeInput } from '@mantine/dates'; import { DatePickerInput, TimeInput } from '@mantine/dates';
import { IconLogout, IconPlus } from '@tabler/icons-react'; import { IconLogout } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useAppStore } from '../stores'; import { useAppStore } from '../stores';
import { AnniversaryList } from '../components/anniversary/AnniversaryList'; import { AnniversaryList } from '../components/anniversary/AnniversaryList';
import { ReminderList } from '../components/reminder/ReminderList'; import { ReminderList } from '../components/reminder/ReminderList';
import { NoteEditor } from '../components/note/NoteEditor'; import { NoteEditor } from '../components/note/NoteEditor';
import { AIChatBox } from '../components/ai/AIChatBox'; import { FloatingAIChat } from '../components/ai/FloatingAIChat';
import type { Event, EventType, RepeatType } from '../types'; import type { Event, EventType, RepeatType } from '../types';
export function HomePage() { export function HomePage() {
@ -115,7 +115,7 @@ export function HomePage() {
fetchEvents(); fetchEvents();
}; };
const handleDelete = async () => { const handleDeleteFromModal = async () => {
if (!selectedEvent) return; if (!selectedEvent) return;
await deleteEventById(selectedEvent.id); await deleteEventById(selectedEvent.id);
close(); close();
@ -130,6 +130,20 @@ export function HomePage() {
fetchEvents(); fetchEvents();
}; };
const handleDelete = async (event: Event) => {
if (event.type !== 'reminder') return;
await deleteEventById(event.id);
fetchEvents();
};
const handleRestore = async (event: Event) => {
if (event.type !== 'reminder') return;
await updateEventById(event.id, {
is_completed: false,
});
fetchEvents();
};
const resetForm = () => { const resetForm = () => {
setFormType('anniversary'); setFormType('anniversary');
setFormTitle(''); setFormTitle('');
@ -148,180 +162,302 @@ export function HomePage() {
}; };
return ( return (
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}> <div
{/* Header */} style={{
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}> minHeight: '100vh',
<Title order={2} c="blue"> background: '#faf9f7',
}}
</Title> >
<Group> <Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}>
<Text size="sm" c="dimmed"> {/* Header */}
{user?.nickname || user?.email} <Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
</Text> <Title
<Button order={2}
variant="subtle" style={{
color="gray" fontWeight: 300,
size="xs" fontSize: '1.25rem',
leftSection={<IconLogout size={14} />} letterSpacing: '0.15em',
onClick={handleLogout} color: '#1a1a1a',
}}
> >
退
</Button> </Title>
<Group>
<Text
size="xs"
c="#888"
style={{ letterSpacing: '0.05em' }}
>
{user?.nickname || user?.email}
</Text>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconLogout size={12} />}
onClick={handleLogout}
style={{
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
退
</Button>
</Group>
</Group> </Group>
</Group>
{/* Main Content - 4 panel layout */} {/* Main Content - 3 column horizontal layout */}
<Grid grow style={{ flex: 1, minHeight: 0 }}> <Grid grow style={{ flex: 1, minHeight: 0 }} gutter="md">
{/* Left column - 40% */} {/* Left column - Anniversary */}
<Grid.Col span={4}> <Grid.Col span={4}>
<Stack gap="md" h="100%"> <div style={{ height: '100%', minHeight: 0 }}>
{/* Anniversary list */}
<div style={{ flex: 1, minHeight: 0 }}>
<AnniversaryList <AnniversaryList
events={events} events={events}
onEventClick={handleEventClick} onEventClick={handleEventClick}
onAddClick={() => handleAddClick('anniversary')} onAddClick={() => handleAddClick('anniversary')}
/> />
</div> </div>
</Grid.Col>
{/* Reminder list */} {/* Middle column - Reminder */}
<div style={{ flex: 1, minHeight: 0 }}> <Grid.Col span={4}>
<div style={{ height: '100%', minHeight: 0 }}>
<ReminderList <ReminderList
events={events} events={events}
onEventClick={handleEventClick} onEventClick={handleEventClick}
onToggleComplete={handleToggleComplete} onToggleComplete={handleToggleComplete}
onAddClick={() => handleAddClick('reminder')} onAddClick={() => handleAddClick('reminder')}
onDelete={handleDelete}
onRestore={handleRestore}
/> />
</div> </div>
</Stack> </Grid.Col>
</Grid.Col>
{/* Right column - 60% */} {/* Right column - Note */}
<Grid.Col span={8}> <Grid.Col span={4}>
<Stack gap="md" h="100%"> <div style={{ height: '100%', minHeight: 0 }}>
{/* Note editor */}
<div style={{ flex: 1, minHeight: 0 }}>
<NoteEditor /> <NoteEditor />
</div> </div>
</Grid.Col>
</Grid>
{/* AI Chat */} {/* AI Chat - Floating */}
<div style={{ flex: 1, minHeight: 0 }}> <FloatingAIChat onEventCreated={handleAIEventCreated} />
<AIChatBox onEventCreated={handleAIEventCreated} />
</div>
</Stack>
</Grid.Col>
</Grid>
{/* Add/Edit Event Modal */} {/* Add/Edit Event Modal */}
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={close}
title={ title={
<Group gap={8}> <Text
<IconPlus size={18} /> fw={400}
<Text fw={500}>{isEdit ? '编辑事件' : '添加事件'}</Text> style={{
</Group> letterSpacing: '0.1em',
} fontSize: '1rem',
size="md" }}
> >
<Stack gap="md"> {isEdit ? '编辑事件' : '添加事件'}
{/* Event type */} </Text>
<Select }
label="类型" size="md"
data={[ styles={{
{ value: 'anniversary', label: '纪念日' }, header: {
{ value: 'reminder', label: '提醒' }, borderBottom: '1px solid rgba(0,0,0,0.06)',
]} },
value={formType} body: {
onChange={(value) => value && setFormType(value as EventType)} paddingTop: 20,
disabled={isEdit} },
/> }}
>
{/* Title */} <Stack gap="md">
<TextInput {/* Event type */}
label="标题" <Select
placeholder="输入标题" label={
value={formTitle} <Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
onChange={(e) => setFormTitle(e.target.value)}
required </Text>
/> }
data={[
{/* Content (only for reminders) */} { value: 'anniversary', label: '纪念日' },
{formType === 'reminder' && ( { value: 'reminder', label: '提醒' },
<Textarea ]}
label="内容" value={formType}
placeholder="输入详细内容" onChange={(value) => value && setFormType(value as EventType)}
value={formContent} disabled={isEdit}
onChange={(e) => setFormContent(e.target.value)} styles={{
rows={3} input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/> />
)}
{/* Date */} {/* Title */}
<DateInput <TextInput
label="日期" label={
placeholder="选择日期" <Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
value={formDate}
onChange={setFormDate} </Text>
required }
/> placeholder="输入标题"
value={formTitle}
{/* Time (only for reminders) */} onChange={(e) => setFormTitle(e.target.value)}
{formType === 'reminder' && ( required
<TimeInput styles={{
label="时间" input: {
placeholder="选择时间" borderRadius: 2,
value={formTime} background: '#faf9f7',
onChange={(e) => setFormTime(e.target.value)} },
}}
/> />
)}
{/* Lunar switch */} {/* Content (only for reminders) */}
<Switch {formType === 'reminder' && (
label="农历日期" <Textarea
checked={formIsLunar} label={
onChange={(e) => setFormIsLunar(e.currentTarget.checked)} <Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
/>
</Text>
{/* Repeat type */} }
<Select placeholder="输入详细内容"
label="重复" value={formContent}
data={[ onChange={(e) => setFormContent(e.target.value)}
{ value: 'none', label: '不重复' }, rows={3}
{ value: 'yearly', label: '每年' }, styles={{
{ value: 'monthly', label: '每月' }, input: {
]} borderRadius: 2,
value={formRepeatType} background: '#faf9f7',
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}> {/* Date */}
<DatePickerInput
</Button> label={
<Button onClick={handleSubmit} disabled={!formTitle.trim() || !formDate}> <Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
{isEdit ? '保存' : '添加'}
</Button> </Text>
}
placeholder="选择日期"
value={formDate}
onChange={(value) => setFormDate(value as Date | null)}
required
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
{/* Time (only for reminders) */}
{formType === 'reminder' && (
<TimeInput
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text>
}
placeholder="选择时间"
value={formTime}
onChange={(e) => setFormTime(e.target.value)}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
)}
{/* Lunar switch */}
<Switch
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
}
checked={formIsLunar}
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
/>
{/* Repeat type */}
<Select
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text>
}
data={[
{ value: 'none', label: '不重复' },
{ value: 'yearly', label: '每年' },
{ value: 'monthly', label: '每月' },
]}
value={formRepeatType}
onChange={(value) => value && setFormRepeatType(value as RepeatType)}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
{/* Holiday switch (only for anniversaries) */}
{formType === 'anniversary' && (
<Switch
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
}
checked={formIsHoliday}
onChange={(e) => setFormIsHoliday(e.currentTarget.checked)}
/>
)}
{/* Actions */}
<Group justify="space-between" mt="md">
{isEdit && (
<Button
color="dark"
variant="light"
onClick={handleDeleteFromModal}
style={{
borderRadius: 2,
}}
>
</Button>
)}
<Group ml="auto">
<Button
variant="subtle"
onClick={close}
style={{
borderRadius: 2,
color: '#666',
}}
>
</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> </Modal>
</Modal> </Container>
</Container> </div>
); );
} }

View File

@ -1,25 +1,418 @@
import { Button, Container, Title, Text, Paper } from '@mantine/core'; import { useEffect, useRef } from 'react';
import { Button, Container, Title, Text, Group, Stack } from '@mantine/core';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import iconUrl from '../assets/icon.png';
// 禅意算法背景 - 手印静寂(循环墨晕效果)
function ZenBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener('resize', resize);
// 简化的伪随机
const random = (min = 0, max = 1) => {
return min + Math.random() * (max - min);
};
// 简化的噪声函数
const noise = (x: number, y: number, t: number) => {
return (Math.sin(x * 0.01 + t) * Math.cos(y * 0.01 + t * 0.7) + 1) * 0.5;
};
interface InkStroke {
points: { x: number; y: number; weight: number }[];
x: number;
y: number;
angle: number;
speed: number;
inkAlpha: number;
baseWeight: number;
maxLength: number;
currentLength: number;
complete: boolean;
}
let strokes: InkStroke[] = [];
let time = 0;
let lastStrokeTime = 0;
const strokeInterval = 30; // 每隔一段时间生成新笔触
// 生成随机笔触
const createStroke = (): InkStroke | null => {
const angle = random(0, Math.PI * 2);
const radius = random(canvas.width * 0.1, canvas.width * 0.4);
const x = canvas.width / 2 + Math.cos(angle) * radius;
const y = canvas.height / 2 + Math.sin(angle) * radius;
return {
points: [{ x, y, weight: random(0.3, 1.5) }],
x,
y,
angle: random(0, Math.PI * 2),
speed: random(0.4, 1.0),
inkAlpha: random(10, 30),
baseWeight: random(0.3, 1.5),
maxLength: random(30, 90), // 缩短笔触长度,加快消失
currentLength: 0,
complete: false,
};
};
// 初始化一些笔触
const initStrokes = () => {
strokes = [];
for (let i = 0; i < 8; i++) {
const stroke = createStroke();
if (stroke) {
// 随机偏移起始位置
stroke.currentLength = random(0, stroke.maxLength * 0.5);
strokes.push(stroke);
}
}
};
initStrokes();
// 清空画布
ctx.fillStyle = '#faf9f7';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const animate = () => {
time += 0.008;
// 快速淡出背景 - 让水墨痕迹更快消失
if (Math.floor(time * 125) % 3 === 0) {
ctx.fillStyle = 'rgba(250, 249, 247, 0.12)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// 定期生成新笔触
if (time - lastStrokeTime > strokeInterval * 0.01) {
lastStrokeTime = time;
// 移除已完成太久的笔触
strokes = strokes.filter(s => !s.complete || s.currentLength < s.maxLength);
// 添加新笔触(随机数量)
const newCount = Math.floor(random(0, 2));
for (let i = 0; i < newCount; i++) {
const stroke = createStroke();
if (stroke) strokes.push(stroke!);
}
}
// 更新和绘制笔触
for (const stroke of strokes) {
if (stroke.complete) continue;
// 噪声驱动
const n = noise(stroke.x, stroke.y, time);
stroke.angle += (n - 0.5) * 0.12;
// 呼吸感 - 更柔和
const breath = Math.sin(time * 1.5 + stroke.x * 0.01) * 0.25;
const currentSpeed = stroke.speed * (1 + breath * 0.2);
stroke.x += Math.cos(stroke.angle) * currentSpeed;
stroke.y += Math.sin(stroke.angle) * currentSpeed;
// 笔触粗细 - 模拟提按
const progress = stroke.currentLength / stroke.maxLength;
const weightVar = Math.sin(progress * Math.PI) * 1.0;
const weight = Math.max(0.2, stroke.baseWeight + weightVar);
stroke.points.push({ x: stroke.x, y: stroke.y, weight });
stroke.currentLength++;
if (
stroke.currentLength >= stroke.maxLength ||
stroke.x < -50 || stroke.x > canvas.width + 50 ||
stroke.y < -50 || stroke.y > canvas.height + 50
) {
stroke.complete = true;
}
// 绘制 - 水墨晕染效果
if (stroke.points.length > 1) {
for (let i = 1; i < stroke.points.length; i++) {
const p1 = stroke.points[i - 1];
const p2 = stroke.points[i];
// 渐变透明度
const alpha = stroke.inkAlpha * (1 - i / stroke.points.length * 0.4) / 100;
const size = p2.weight * random(0.8, 1.2);
// 绘制柔和的笔触
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `rgba(25, 25, 25, ${alpha})`;
ctx.lineWidth = size;
ctx.lineCap = 'round';
ctx.stroke();
// 添加淡淡的墨点晕染
if (random(0, 1) < 0.3) {
ctx.beginPath();
ctx.arc(p2.x, p2.y, size * random(0.5, 1.5), 0, Math.PI * 2);
ctx.fillStyle = `rgba(25, 25, 25, ${alpha * 0.3})`;
ctx.fill();
}
}
}
}
// 绘制圆相Ensō- 缓慢呼吸
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const breathScale = 1 + Math.sin(time * 0.3) * 0.015;
const radius = Math.min(canvas.width, canvas.height) * 0.18 * breathScale;
const gap = Math.PI * 0.18; // 开口
const startAngle = -Math.PI / 2 + time * 0.05;
ctx.beginPath();
for (let a = startAngle; a < startAngle + Math.PI * 2 - gap; a += 0.04) {
const noiseOffset = noise(Math.cos(a) * 2, Math.sin(a) * 2, time * 0.2) * 5 - 2.5;
const r = radius + noiseOffset;
const x = centerX + Math.cos(a) * r;
const y = centerY + Math.sin(a) * r;
if (a === startAngle) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = 'rgba(25, 25, 25, 0.06)';
ctx.lineWidth = 0.8;
ctx.stroke();
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
window.removeEventListener('resize', resize);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 0,
}}
/>
);
}
export function LandingPage() { export function LandingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <div
<Paper p="xl" style={{ textAlign: 'center' }}> style={{
<Title order={1} mb="md" c="blue"> minHeight: '100vh',
background: '#faf9f7',
</Title> position: 'relative',
<Text size="lg" c="dimmed" mb="xl"> overflow: 'hidden',
AI · }}
</Text> >
<Text size="sm" c="gray.6" mb="xl"> <ZenBackground />
便App
</Text> {/* 装饰性圆相 */}
<Button size="lg" variant="filled" onClick={() => navigate('/login')}> <svg
使 style={{
</Button> position: 'fixed',
</Paper> top: '8%',
</Container> right: '12%',
width: 200,
height: 200,
opacity: 0.05,
pointerEvents: 'none',
}}
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="#1a1a1a"
strokeWidth="1"
strokeDasharray="250 30"
/>
</svg>
<svg
style={{
position: 'fixed',
bottom: '12%',
left: '10%',
width: 160,
height: 160,
opacity: 0.04,
pointerEvents: 'none',
}}
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="#1a1a1a"
strokeWidth="0.8"
strokeDasharray="200 50"
/>
</svg>
<Container
size="sm"
style={{
position: 'relative',
zIndex: 1,
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
padding: '2rem',
}}
>
<Stack align="center" gap="lg">
{/* 产品图标 */}
<div
style={{
width: 90,
height: 90,
borderRadius: '50%',
background: 'rgba(26, 26, 26, 0.03)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '0.5rem',
}}
>
<img
src={iconUrl}
alt="掐日子"
style={{
width: 60,
height: 60,
borderRadius: '50%',
}}
/>
</div>
<Title
order={1}
style={{
fontWeight: 300,
fontSize: 'clamp(2.2rem, 7vw, 3.2rem)',
letterSpacing: '0.25em',
color: '#1a1a1a',
fontFamily: 'Noto Serif SC, serif',
}}
>
</Title>
<Text
size="sm"
style={{
letterSpacing: '0.35em',
color: '#888',
fontWeight: 300,
}}
>
AI ·
</Text>
<Text
size="xs"
style={{
color: '#999',
maxWidth: 300,
lineHeight: 1.9,
fontWeight: 300,
}}
>
便
<br />
</Text>
<Group gap="md" mt="lg">
<Button
size="sm"
onClick={() => navigate('/login')}
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
padding: '0 2rem',
fontWeight: 400,
letterSpacing: '0.15em',
borderRadius: 2,
}}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => navigate('/register')}
style={{
borderColor: '#ccc',
color: '#1a1a1a',
padding: '0 2rem',
fontWeight: 400,
letterSpacing: '0.15em',
borderRadius: 2,
}}
>
</Button>
</Group>
<Group gap={40} mt={50} style={{ opacity: 0.7 }}>
<Stack gap={3} align="center">
<Text size="xs" fw={300} c="#444"></Text>
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}></Text>
</Stack>
<Stack gap={3} align="center">
<Text size="xs" fw={300} c="#444"></Text>
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}></Text>
</Stack>
<Stack gap={3} align="center">
<Text size="xs" fw={300} c="#444"></Text>
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>AI</Text>
</Stack>
<Stack gap={3} align="center">
<Text size="xs" fw={300} c="#444"></Text>
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>便</Text>
</Stack>
</Group>
</Stack>
</Container>
</div>
); );
} }

View File

@ -26,43 +26,122 @@ export function LoginPage() {
}; };
return ( return (
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <div
<Paper p="xl" shadow="md" radius="lg"> style={{
<Title order={2} ta="center" mb="xl"> minHeight: '100vh',
background: '#faf9f7',
</Title> display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Container size="sm" style={{ width: '100%', maxWidth: 360 }}>
<Paper
p={40}
radius={4}
style={{
background: '#fff',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}
>
<Title
order={2}
ta="center"
mb={32}
style={{
fontWeight: 300,
fontSize: '1.5rem',
letterSpacing: '0.2em',
color: '#1a1a1a',
}}
>
</Title>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label="邮箱" label={
placeholder="your@email.com" <Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
value={email}
onChange={(e) => setEmail(e.target.value)} </Text>
required }
/> placeholder="your@email.com"
<TextInput value={email}
label="密码" onChange={(e) => setEmail(e.target.value)}
type="password" required
placeholder="Your password" styles={{
value={password} input: {
onChange={(e) => setPassword(e.target.value)} borderRadius: 2,
required borderColor: '#e0e0e0',
/> background: '#faf9f7',
{error && <Text c="red" size="sm">{error}</Text>} },
<Button type="submit" loading={loading} fullWidth> }}
/>
</Button> <TextInput
</Stack> label={
</form> <Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
</Text>
}
type="password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
styles={{
input: {
borderRadius: 2,
borderColor: '#e0e0e0',
background: '#faf9f7',
},
}}
/>
{error && (
<Text c="#c41c1c" size="xs" style={{ letterSpacing: '0.05em' }}>
{error}
</Text>
)}
<Button
type="submit"
loading={loading}
fullWidth
mt="sm"
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
borderRadius: 2,
fontWeight: 400,
letterSpacing: '0.15em',
}}
>
</Button>
</Stack>
</form>
<Text ta="center" mt="md" size="sm" c="dimmed"> <Text
{' '} ta="center"
<Anchor component="button" onClick={() => navigate('/register')}> mt="lg"
size="xs"
</Anchor> c="#888"
</Text> style={{ letterSpacing: '0.05em' }}
</Paper> >
</Container> {' '}
<Anchor
component="button"
onClick={() => navigate('/register')}
style={{
color: '#1a1a1a',
textDecoration: 'underline',
textUnderlineOffset: 3,
}}
>
</Anchor>
</Text>
</Paper>
</Container>
</div>
); );
} }

View File

@ -37,71 +37,175 @@ export function RegisterPage() {
if (error) { if (error) {
setError(error); setError(error);
} else { } else {
// 注册成功后直接跳转到首页(已登录状态)
navigate('/'); navigate('/');
} }
setLoading(false); setLoading(false);
}; };
return ( return (
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <div
<Paper p="xl" shadow="md" radius="lg"> style={{
<Title order={2} ta="center" mb="xl"> minHeight: '100vh',
background: '#faf9f7',
</Title> display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Container size="sm" style={{ width: '100%', maxWidth: 360 }}>
<Paper
p={40}
radius={4}
style={{
background: '#fff',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}
>
<Title
order={2}
ta="center"
mb={32}
style={{
fontWeight: 300,
fontSize: '1.5rem',
letterSpacing: '0.2em',
color: '#1a1a1a',
}}
>
</Title>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack gap="md"> <Stack gap="md">
<TextInput
label="昵称"
placeholder="Your nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
<TextInput
label="邮箱"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<div>
<TextInput <TextInput
label="密码" label={
type="password" <Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
placeholder="Create password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{/* 密码要求提示 */}
<Stack gap={4} mt={6}>
{passwordRequirements.map((req, index) => (
<Text
key={index}
size="xs"
c={req.met ? 'green' : 'dimmed'}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
{req.met ? '✓' : '○'} {req.label}
</Text> </Text>
))} }
</Stack> placeholder="Your nickname"
</div> value={nickname}
{error && <Text c="red" size="sm">{error}</Text>} onChange={(e) => setNickname(e.target.value)}
<Button type="submit" loading={loading} fullWidth disabled={!meetsAllRequirements && password.length > 0}> styles={{
input: {
</Button> borderRadius: 2,
</Stack> borderColor: '#e0e0e0',
</form> background: '#faf9f7',
},
}}
/>
<TextInput
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
</Text>
}
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
styles={{
input: {
borderRadius: 2,
borderColor: '#e0e0e0',
background: '#faf9f7',
},
}}
/>
<div>
<TextInput
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
</Text>
}
type="password"
placeholder="Create password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
styles={{
input: {
borderRadius: 2,
borderColor: '#e0e0e0',
background: '#faf9f7',
},
}}
/>
{/* 密码要求提示 */}
<Stack gap={4} mt={8}>
{passwordRequirements.map((req, index) => (
<Text
key={index}
size="xs"
c={req.met ? '#4a9c6d' : '#999'}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
letterSpacing: '0.05em',
}}
>
<span
style={{
width: 10,
display: 'inline-block',
textAlign: 'center',
}}
>
{req.met ? '●' : '○'}
</span>
{req.label}
</Text>
))}
</Stack>
</div>
{error && (
<Text c="#c41c1c" size="xs" style={{ letterSpacing: '0.05em' }}>
{error}
</Text>
)}
<Button
type="submit"
loading={loading}
fullWidth
mt="sm"
disabled={!meetsAllRequirements && password.length > 0}
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
borderRadius: 2,
fontWeight: 400,
letterSpacing: '0.15em',
}}
>
</Button>
</Stack>
</form>
<Text ta="center" mt="md" size="sm" c="dimmed"> <Text
{' '} ta="center"
<Anchor component="button" onClick={() => navigate('/login')}> mt="lg"
size="xs"
</Anchor> c="#888"
</Text> style={{ letterSpacing: '0.05em' }}
</Paper> >
</Container> {' '}
<Anchor
component="button"
onClick={() => navigate('/login')}
style={{
color: '#1a1a1a',
textDecoration: 'underline',
textUnderlineOffset: 3,
}}
>
</Anchor>
</Text>
</Paper>
</Container>
</div>
); );
} }

View File

@ -4,7 +4,7 @@ import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage'; import { RegisterPage } from './pages/RegisterPage';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { useAppStore } from './stores'; import { useAppStore } from './stores';
import { useState, useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
// Loading spinner component // Loading spinner component
function AuthLoading() { function AuthLoading() {
@ -91,10 +91,25 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
// Root redirect - 根据登录状态重定向
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthLoader();
if (isLoading) {
return <AuthLoading />;
}
if (isAuthenticated) {
return <Navigate to="/home" replace />;
}
return <Navigate to="/landing" replace />;
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: '/', path: '/',
element: <Navigate to="/home" replace />, element: <RootRedirect />,
}, },
{ {
path: '/landing', path: '/landing',

View File

@ -1,4 +1,4 @@
import type { User, Event, Note, AIConversation, EventType } from '../types'; import type { User, Event, Note, EventType } from '../types';
// API Base URL - from environment variable // API Base URL - from environment variable
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
@ -33,6 +33,7 @@ export const api = {
const response = await fetch(`${API_URL}${endpoint}`, { const response = await fetch(`${API_URL}${endpoint}`, {
...options, ...options,
headers, headers,
credentials: 'include', // 确保跨域请求时发送凭证cookie
}); });
const data = await response.json(); const data = await response.json();

View File

@ -80,11 +80,3 @@ export interface ApiResponse<T> {
// Loading state types // Loading state types
export type LoadingState = 'idle' | 'loading' | 'success' | 'error'; export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
// Grouped reminder types
export interface GroupedReminders {
today: Reminder[];
tomorrow: Reminder[];
later: Reminder[];
missed: Reminder[];
}

16
src/types/lunar-javascript.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
declare module 'lunar-javascript' {
export class Lunar {
static fromYmd(year: number, month: number, day: number): Lunar;
getSolar(): Solar;
getMonth(): number;
getDay(): number;
getMonthInChinese(): string;
getDayInChinese(): string;
}
export class Solar {
getYear(): number;
getMonth(): number;
getDay(): number;
}
}

View File

@ -1,4 +1,4 @@
import { Lunar, Solar } from 'lunar-javascript'; import { Lunar } from 'lunar-javascript';
export interface CountdownResult { export interface CountdownResult {
days: number; days: number;
@ -12,6 +12,7 @@ export interface CountdownResult {
/** /**
* *
* 28/29/30/31
*/ */
function safeCreateDate(year: number, month: number, day: number): Date { function safeCreateDate(year: number, month: number, day: number): Date {
const date = new Date(year, month, day); const date = new Date(year, month, day);
@ -22,6 +23,32 @@ function safeCreateDate(year: number, month: number, day: number): Date {
return date; return date;
} }
/**
*
* 2930
*/
function safeCreateLunarDate(year: number, month: number, day: number): { lunar: Lunar; solar: Solar } | null {
try {
const lunar = Lunar.fromYmd(year, month, day);
return { lunar, solar: lunar.getSolar() };
} catch {
// 如果日期不存在如农历12月30日在只有29天的月份
// 尝试获取该月的最后一天
try {
// 获取下个月的农历日期,然后往前推一天
const nextMonthLunar = Lunar.fromYmd(year, month + 1, 1);
const lastDayOfMonth = nextMonthLunar.getLunar().getDay() - 1;
if (lastDayOfMonth > 0) {
const lunar = Lunar.fromYmd(year, month, lastDayOfMonth);
return { lunar, solar: lunar.getSolar() };
}
} catch {
// 仍然失败返回null
}
return null;
}
}
/** /**
* *
* occurrence * occurrence
@ -44,10 +71,14 @@ export function calculateCountdown(
const originalDay = dateParts[2]; const originalDay = dateParts[2];
if (isLunar) { if (isLunar) {
// 农历日期:直接用原始农历年/月/日创建农历对象 // 农历日期:使用安全方法创建,处理月末边界
const lunarDate = Lunar.fromYmd(originalYear, originalMonth + 1, originalDay); const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
const solar = lunarDate.getSolar(); if (result) {
targetDate = new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay()); targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay());
} else {
// 无法解析农历日期,使用原始公历日期作为后备
targetDate = new Date(originalYear, originalMonth, originalDay);
}
} else { } else {
// 公历日期 // 公历日期
targetDate = new Date(originalYear, originalMonth, originalDay); targetDate = new Date(originalYear, originalMonth, originalDay);
@ -158,13 +189,11 @@ export function getFriendlyDateDescription(
if (isLunar) { if (isLunar) {
// 显示农历 // 显示农历
try { const result = safeCreateLunarDate(targetDate.getFullYear(), month, day);
const solar = Solar.fromYmd(targetDate.getFullYear(), month, day); if (result) {
const lunar = solar.getLunar(); return `${result.lunar.getMonthInChinese()}${result.lunar.getDayInChinese()}`;
return `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`;
} catch {
return `${month}${day}`;
} }
return `${month}${day}`;
} }
return `${month}${day}`; return `${month}${day}`;
@ -184,8 +213,7 @@ export function getSolarFromLunar(lunarMonth: number, lunarDay: number, year?: n
* *
*/ */
export function getLunarFromSolar(solarDate: Date): { month: number; day: number; monthInChinese: string; dayInChinese: string } { export function getLunarFromSolar(solarDate: Date): { month: number; day: number; monthInChinese: string; dayInChinese: string } {
const solar = Solar.fromYmd(solarDate.getFullYear(), solarDate.getMonth() + 1, solarDate.getDate()); const lunar = Lunar.fromYmd(solarDate.getFullYear(), solarDate.getMonth() + 1, solarDate.getDate());
const lunar = solar.getLunar();
return { return {
month: lunar.getMonth(), month: lunar.getMonth(),
day: lunar.getDay(), day: lunar.getDay(),

1
tmpclaude-1682-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-51ef-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-53b7-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-5843-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-7db7-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-8b3b-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-90be-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-90fc-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-c0a2-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-ca71-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-d261-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-d732-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-ea09-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-f30f-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-f858-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-fec8-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client