Compare commits
No commits in common. "a8b4f17043328f8a5e23f9b4fcfca5bd5e2d3b4f" and "4dbf9b0bbceb1e6c9cba048baf24217edd02c211" have entirely different histories.
a8b4f17043
...
4dbf9b0bbc
1507
package-lock.json
generated
1507
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.3.0-alpha",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -23,10 +23,7 @@
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB |
@ -8,7 +8,9 @@ import {
|
||||
ActionIcon,
|
||||
Loader,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Badge,
|
||||
} from '@mantine/core';
|
||||
import { IconSend, IconSparkles } from '@tabler/icons-react';
|
||||
import { api } from '../../services/api';
|
||||
@ -22,6 +24,7 @@ interface AIChatBoxProps {
|
||||
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);
|
||||
@ -52,6 +55,7 @@ export function AIChatBox({ onEventCreated }: AIChatBoxProps) {
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setConversation(newConversation);
|
||||
setHistory((prev) => [...prev, newConversation]);
|
||||
addConversation(newConversation);
|
||||
|
||||
|
||||
@ -1,262 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import { Paper, Text, Group, Stack } from '@mantine/core';
|
||||
import { Card, Text, Badge, Group, ActionIcon, Stack } from '@mantine/core';
|
||||
import { IconHeart, IconRepeat } from '@tabler/icons-react';
|
||||
import type { Event } from '../../types';
|
||||
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
||||
import { getHolidayById } from '../../constants/holidays';
|
||||
|
||||
interface AnniversaryCardProps {
|
||||
event: Event;
|
||||
@ -11,66 +10,49 @@ interface AnniversaryCardProps {
|
||||
export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
|
||||
const isLunar = event.is_lunar;
|
||||
const repeatType = event.repeat_type;
|
||||
const countdown = calculateCountdown(event.date, repeatType, isLunar);
|
||||
const formattedCountdown = formatCountdown(countdown);
|
||||
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
radius={2}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
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)')}
|
||||
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} style={{ flex: 1 }}>
|
||||
<Stack gap={4}>
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="#c41c1c">{event.title}</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{/* Countdown */}
|
||||
<Text
|
||||
size="xs"
|
||||
fw={400}
|
||||
style={{
|
||||
letterSpacing: '0.05em',
|
||||
color: countdown.isToday ? '#c41c1c' : '#666',
|
||||
}}
|
||||
>
|
||||
{formattedCountdown}
|
||||
<IconHeart size={14} color="pink" />
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{event.title}
|
||||
</Text>
|
||||
|
||||
{/* Date display */}
|
||||
<Text size="xs" c="#999">
|
||||
{countdown.isPast
|
||||
? '已过'
|
||||
: `${countdown.nextDate.getMonth() + 1}月${countdown.nextDate.getDate()}日`}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(event.date).toLocaleDateString('zh-CN')}
|
||||
{isLunar && ' (农历)'}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap={6}>
|
||||
{repeatType !== 'none' && (
|
||||
<Text size="xs" c="#888">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<IconRepeat size={10} />}
|
||||
>
|
||||
{repeatType === 'yearly' ? '每年' : '每月'}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{(event.is_holiday || holiday) && (
|
||||
<Text size="xs" c="#888">
|
||||
{event.is_holiday && (
|
||||
<Badge size="xs" variant="light" color="green">
|
||||
节假日
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Stack, Text, Paper, Group, Button } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { AnniversaryCard } from './AnniversaryCard';
|
||||
import { getHolidaysForYear } from '../../constants/holidays';
|
||||
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
||||
import { useAppStore } from '../../stores';
|
||||
import type { Event } from '../../types';
|
||||
|
||||
interface AnniversaryListProps {
|
||||
@ -13,96 +9,21 @@ interface AnniversaryListProps {
|
||||
onAddClick: () => void;
|
||||
}
|
||||
|
||||
// 内置节假日事件类型
|
||||
interface BuiltInHolidayEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
is_holiday: boolean;
|
||||
is_lunar: boolean;
|
||||
repeat_type: 'yearly';
|
||||
type: 'anniversary';
|
||||
is_builtin: true;
|
||||
}
|
||||
|
||||
export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) {
|
||||
const anniversaries = events.filter((e) => e.type === 'anniversary');
|
||||
const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true);
|
||||
|
||||
// 获取内置节假日
|
||||
const builtInHolidays = useMemo(() => {
|
||||
if (!showHolidays) return [];
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const holidays = getHolidaysForYear(year);
|
||||
const nextYear = getHolidaysForYear(year + 1);
|
||||
|
||||
// 合并今年和明年的节假日,按日期排序
|
||||
const allHolidays = [...holidays, ...nextYear].sort(
|
||||
(a, b) => a.date.getTime() - b.date.getTime()
|
||||
);
|
||||
|
||||
// 只取未来90天内的节假日,显示最近3个
|
||||
const cutoffDate = new Date(now);
|
||||
cutoffDate.setDate(cutoffDate.getDate() + 90);
|
||||
|
||||
return allHolidays
|
||||
.filter((h) => h.date >= now && h.date <= cutoffDate)
|
||||
.slice(0, 3)
|
||||
.map((h): BuiltInHolidayEvent => ({
|
||||
id: `builtin-${h.id}`,
|
||||
title: h.name,
|
||||
date: h.date.toISOString(),
|
||||
is_holiday: true,
|
||||
is_lunar: h.isLunar,
|
||||
repeat_type: 'yearly',
|
||||
type: 'anniversary',
|
||||
is_builtin: true,
|
||||
}));
|
||||
}, [showHolidays]);
|
||||
|
||||
// 合并用户纪念日和内置节假日
|
||||
const allAnniversaries = useMemo(() => {
|
||||
// 用户创建的节假日按倒计时排序
|
||||
const sortedUser = [...anniversaries].sort((a, b) => {
|
||||
const countdownA = calculateCountdown(a.date, a.repeat_type, a.is_lunar);
|
||||
const countdownB = calculateCountdown(b.date, b.repeat_type, b.is_lunar);
|
||||
return countdownA.nextDate.getTime() - countdownB.nextDate.getTime();
|
||||
});
|
||||
|
||||
// 内置节假日按日期排序
|
||||
const sortedBuiltIn = [...builtInHolidays].sort((a, b) => {
|
||||
const countdownA = calculateCountdown(a.date, a.repeat_type, a.is_lunar);
|
||||
const countdownB = calculateCountdown(b.date, b.repeat_type, b.is_lunar);
|
||||
return countdownA.nextDate.getTime() - countdownB.nextDate.getTime();
|
||||
});
|
||||
|
||||
return {
|
||||
user: sortedUser,
|
||||
builtIn: sortedBuiltIn,
|
||||
total: sortedUser.length + sortedBuiltIn.length,
|
||||
};
|
||||
}, [anniversaries, builtInHolidays]);
|
||||
|
||||
// 空状态
|
||||
if (allAnniversaries.total === 0) {
|
||||
if (anniversaries.length === 0) {
|
||||
return (
|
||||
<Paper p="md" withBorder radius={4} h="100%">
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="#999" size="sm" style={{ letterSpacing: '0.05em' }}>
|
||||
<Text c="dimmed" size="sm">
|
||||
暂无纪念日
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={12} />}
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
style={{
|
||||
borderColor: '#ccc',
|
||||
color: '#1a1a1a',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
添加纪念日
|
||||
</Button>
|
||||
@ -112,87 +33,25 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder radius={4} h="100%">
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap={8}>
|
||||
<Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
|
||||
<Text fw={500} size="sm">
|
||||
纪念日
|
||||
</Text>
|
||||
<Text size="xs" c="#999">
|
||||
{anniversaries.length}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={12} />}
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
style={{
|
||||
color: '#666',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" style={{ maxHeight: 'calc(100% - 40px)', overflowY: 'auto' }}>
|
||||
{/* 内置节假日 */}
|
||||
{allAnniversaries.builtIn.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" c="#888" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
即将到来
|
||||
</Text>
|
||||
{allAnniversaries.builtIn.map((holiday) => {
|
||||
const countdown = calculateCountdown(holiday.date, holiday.repeat_type, holiday.is_lunar);
|
||||
return (
|
||||
<Paper
|
||||
key={holiday.id}
|
||||
p="sm"
|
||||
radius={2}
|
||||
style={{
|
||||
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 gap={6}>
|
||||
<Text size="xs" c="#666">{holiday.title}</Text>
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c={countdown.isToday ? '#c41c1c' : '#888'}
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
{formatCountdown(countdown)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 用户纪念日 */}
|
||||
{allAnniversaries.user.length > 0 && (
|
||||
<>
|
||||
{allAnniversaries.builtIn.length > 0 && (
|
||||
<Text size="xs" c="#666" fw={500} style={{ letterSpacing: '0.05em' }} mt="xs">
|
||||
我的纪念日
|
||||
</Text>
|
||||
)}
|
||||
{allAnniversaries.user.map((event) => (
|
||||
<AnniversaryCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
/>
|
||||
{anniversaries.map((event) => (
|
||||
<AnniversaryCard key={event.id} event={event} onClick={() => onEventClick(event)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@ -1,26 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Textarea,
|
||||
Group,
|
||||
Text,
|
||||
Stack,
|
||||
Button,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import { Paper, Textarea, Group, Text, Stack } from '@mantine/core';
|
||||
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';
|
||||
|
||||
interface NoteEditorProps {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'edit' | 'preview';
|
||||
|
||||
export function NoteEditor({ onSave }: NoteEditorProps) {
|
||||
const notes = useAppStore((state) => state.notes);
|
||||
const updateNotesContent = useAppStore((state) => state.updateNotesContent);
|
||||
@ -30,7 +16,6 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [viewMode] = useState<ViewMode>('edit');
|
||||
|
||||
// Initialize content from notes
|
||||
useEffect(() => {
|
||||
@ -44,11 +29,11 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
||||
fetchNotes();
|
||||
}, [fetchNotes]);
|
||||
|
||||
// Auto-save with 3 second debounce
|
||||
const [debouncedContent] = useDebouncedValue(content, 3000);
|
||||
// Auto-save with debounce
|
||||
const [debouncedContent] = useDebouncedValue(content, 1000);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedContent !== undefined && notes && debouncedContent !== content) {
|
||||
if (debouncedContent !== undefined && notes) {
|
||||
handleSave(debouncedContent);
|
||||
}
|
||||
}, [debouncedContent]);
|
||||
@ -62,14 +47,13 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
||||
updateNotesContent(value);
|
||||
await saveNotes(value);
|
||||
setLastSaved(new Date());
|
||||
onSave?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to save note:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[notes, updateNotesContent, saveNotes, onSave]
|
||||
[notes, updateNotesContent, saveNotes]
|
||||
);
|
||||
|
||||
const formatLastSaved = () => {
|
||||
@ -81,140 +65,34 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
||||
return lastSaved.toLocaleTimeString('zh-CN');
|
||||
};
|
||||
|
||||
const handleManualSave = () => {
|
||||
if (content && notes) {
|
||||
handleSave(content);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" style={{ flexShrink: 0 }}>
|
||||
<Group gap="sm">
|
||||
<Text fw={400} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500} size="sm">
|
||||
便签
|
||||
</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'}>
|
||||
<Text size="xs" c={saving ? 'yellow' : lastSaved ? 'dimmed' : 'gray'}>
|
||||
{saving ? '保存中...' : formatLastSaved()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Editor/Preview Area */}
|
||||
<Box style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||
{viewMode === 'edit' && (
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="在这里记录你的想法... 支持 Markdown 语法: - # 一级标题 - **粗体** - *斜体* - [链接](url)"
|
||||
placeholder="在这里记录你的想法..."
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={20}
|
||||
styles={{
|
||||
input: {
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit',
|
||||
'&: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>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@ -1,140 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,188 +1,54 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { Paper, Text, Checkbox, Group, Stack, ActionIcon } from '@mantine/core';
|
||||
import { IconDots } from '@tabler/icons-react';
|
||||
import { Card, Text, Checkbox, Group, Stack } from '@mantine/core';
|
||||
import type { Event } from '../../types';
|
||||
|
||||
interface ReminderCardProps {
|
||||
event: Event;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
onDelete?: () => void;
|
||||
isMissed?: boolean;
|
||||
}
|
||||
|
||||
export function ReminderCard({ event, onToggle, onClick, isMissed = false }: ReminderCardProps) {
|
||||
export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
||||
const isCompleted = event.is_completed ?? false;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// 计算时间信息
|
||||
const timeInfo = useMemo(() => {
|
||||
const now = new Date();
|
||||
const eventDate = new Date(event.date);
|
||||
const diff = eventDate.getTime() - now.getTime();
|
||||
const isPast = diff < 0;
|
||||
const isToday = eventDate.toDateString() === now.toDateString();
|
||||
|
||||
const timeStr = eventDate.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return { isPast, isToday, timeStr, diff };
|
||||
}, [event.date]);
|
||||
|
||||
// 获取文字颜色
|
||||
const getTextColor = () => {
|
||||
if (isCompleted) return '#999';
|
||||
if (timeInfo.isPast) return '#666';
|
||||
return '#1a1a1a';
|
||||
};
|
||||
|
||||
// 获取时间颜色
|
||||
const getTimeColor = () => {
|
||||
if (isCompleted) return '#bbb';
|
||||
if (timeInfo.isPast) return '#c41c1c';
|
||||
return '#666';
|
||||
};
|
||||
|
||||
// 获取背景色
|
||||
const getBackground = () => {
|
||||
if (isCompleted) return 'rgba(0, 0, 0, 0.02)';
|
||||
if (isMissed) return 'rgba(196, 28, 28, 0.03)';
|
||||
return 'rgba(0, 0, 0, 0.02)';
|
||||
};
|
||||
|
||||
// 获取边框颜色
|
||||
const getBorderColor = () => {
|
||||
if (isCompleted) return 'rgba(0, 0, 0, 0.06)';
|
||||
if (isMissed) return 'rgba(196, 28, 28, 0.15)';
|
||||
return 'rgba(0, 0, 0, 0.06)';
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
radius={2}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
opacity: isAnimating ? 0 : isCompleted ? 0.4 : 1,
|
||||
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||
background: getBackground(),
|
||||
border: `1px solid ${getBorderColor()}`,
|
||||
borderLeft: isMissed && !isCompleted ? '3px solid #c41c1c' : undefined,
|
||||
transition: 'all 0.3s ease',
|
||||
animation: isAnimating ? 'reminder-card-fadeOut 0.3s ease-out forwards' : 'none',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes reminder-card-pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes reminder-card-fadeOut {
|
||||
0% { opacity: 1; transform: translateX(0); }
|
||||
100% { opacity: 0; transform: translateX(20px); }
|
||||
}
|
||||
`}</style>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Checkbox */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
animation: isAnimating ? 'reminder-card-pulse 0.3s ease-out' : 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isMissed && !isCompleted) {
|
||||
// 已过期提醒:先播放动画,动画结束后再触发 toggle
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
onToggle();
|
||||
}, 300);
|
||||
} else {
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
color="#1a1a1a"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} onClick={onClick}>
|
||||
{/* Title */}
|
||||
<Text
|
||||
fw={400}
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
style={{
|
||||
opacity: isCompleted ? 0.6 : 1,
|
||||
transition: 'transform 0.2s',
|
||||
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||
color: getTextColor(),
|
||||
letterSpacing: '0.03em',
|
||||
transition: 'color 0.2s ease',
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* Time */}
|
||||
<Text
|
||||
size="xs"
|
||||
c={getTimeColor()}
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
{timeInfo.timeStr}
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(event.date).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
|
||||
{/* Content preview - 始终显示 */}
|
||||
{event.content && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={isCompleted ? '#bbb' : '#999'}
|
||||
lineClamp={1}
|
||||
style={{
|
||||
opacity: isCompleted ? 0.6 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{event.content}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Quick actions */}
|
||||
<Group gap={4}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
onToggle();
|
||||
}}
|
||||
style={{
|
||||
color: '#999',
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<IconDots size={12} />
|
||||
</ActionIcon>
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Paper,
|
||||
Group,
|
||||
Button,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-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';
|
||||
|
||||
@ -19,8 +9,6 @@ interface ReminderListProps {
|
||||
onEventClick: (event: Event) => void;
|
||||
onToggleComplete: (event: Event) => void;
|
||||
onAddClick: () => void;
|
||||
onDelete?: (event: Event) => void;
|
||||
onRestore?: (event: Event) => void;
|
||||
}
|
||||
|
||||
export function ReminderList({
|
||||
@ -28,20 +16,12 @@ export function ReminderList({
|
||||
onEventClick,
|
||||
onToggleComplete,
|
||||
onAddClick,
|
||||
onDelete,
|
||||
onRestore,
|
||||
}: ReminderListProps) {
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
const dayAfterTomorrow = new Date(tomorrow);
|
||||
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);
|
||||
dayAfterTomorrow.setHours(0, 0, 0, 0);
|
||||
|
||||
const reminders = events.filter((e) => e.type === 'reminder');
|
||||
|
||||
@ -55,68 +35,45 @@ export function ReminderList({
|
||||
reminders.forEach((event) => {
|
||||
const eventDate = new Date(event.date);
|
||||
|
||||
// 已过期且已完成的去归档页
|
||||
if (event.is_completed && eventDate < now) {
|
||||
return;
|
||||
}
|
||||
if (event.is_completed) return;
|
||||
|
||||
// 未过期或已完成未过期的,按时间分组
|
||||
if (eventDate < now) {
|
||||
// 已过期未完成
|
||||
if (eventDate < today) {
|
||||
result.missed.push(event);
|
||||
} else if (eventDate < tomorrow) {
|
||||
// 今天
|
||||
result.today.push(event);
|
||||
} else if (eventDate < dayAfterTomorrow) {
|
||||
// 明天
|
||||
result.tomorrow.push(event);
|
||||
} else {
|
||||
// 更久之后
|
||||
result.later.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
// 按时间排序
|
||||
const sortByDate = (a: Event, b: Event) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
|
||||
result.today.sort(sortByDate);
|
||||
result.tomorrow.sort(sortByDate);
|
||||
result.later.sort(sortByDate);
|
||||
// 已过期按时间正序(最早的在前)
|
||||
result.missed.sort(sortByDate);
|
||||
// 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 hasActiveReminders =
|
||||
const hasReminders =
|
||||
grouped.today.length > 0 ||
|
||||
grouped.tomorrow.length > 0 ||
|
||||
grouped.later.length > 0 ||
|
||||
grouped.missed.length > 0;
|
||||
|
||||
// 空状态
|
||||
if (!hasActiveReminders) {
|
||||
if (!hasReminders) {
|
||||
return (
|
||||
<Paper p="md" withBorder radius={4} h="100%">
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="#999" size="sm" ta="center" style={{ letterSpacing: '0.05em' }}>
|
||||
<Text c="dimmed" size="sm">
|
||||
暂无提醒
|
||||
</Text>
|
||||
<Text size="xs" c="#bbb" ta="center" mt={4}>
|
||||
记得添加提醒事项哦
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={12} />}
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
style={{
|
||||
borderColor: '#ccc',
|
||||
color: '#1a1a1a',
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
添加提醒
|
||||
</Button>
|
||||
@ -125,130 +82,91 @@ export function ReminderList({
|
||||
);
|
||||
}
|
||||
|
||||
// 已过提醒数量提示
|
||||
const missedCount = grouped.missed.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
|
||||
<Group gap={8}>
|
||||
<Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
|
||||
<Paper p="md" withBorder radius="md" h="100%">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={500} size="sm">
|
||||
提醒
|
||||
</Text>
|
||||
{missedCount > 0 && (
|
||||
<Text size="xs" c="#c41c1c" fw={500}>
|
||||
{missedCount}个逾期
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={12} />}
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={onAddClick}
|
||||
style={{
|
||||
color: '#666',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Content */}
|
||||
<Stack gap="xs" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{/* 逾期提醒 */}
|
||||
<Stack gap="xs" style={{ maxHeight: 'calc(100% - 40px)', overflowY: 'auto' }}>
|
||||
{/* Missed reminders */}
|
||||
{grouped.missed.length > 0 && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={14} />}
|
||||
color="dark"
|
||||
variant="light"
|
||||
p="xs"
|
||||
title={<Text size="xs" fw={500} c="#c41c1c">已错过 {grouped.missed.length}个</Text>}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{grouped.missed.slice(0, 3).map((event) => (
|
||||
<>
|
||||
<Text size="xs" c="red" fw={500}>
|
||||
已错过
|
||||
</Text>
|
||||
{grouped.missed.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||
isMissed={true}
|
||||
/>
|
||||
))}
|
||||
{grouped.missed.length > 3 && (
|
||||
<Text size="xs" c="#999" ta="center">
|
||||
还有 {grouped.missed.length - 3} 个逾期提醒...
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 今天 */}
|
||||
{/* Today's reminders */}
|
||||
{grouped.today.length > 0 && (
|
||||
<>
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="#c41c1c" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
<Text size="xs" c="blue" fw={500}>
|
||||
今天
|
||||
</Text>
|
||||
</Group>
|
||||
{grouped.today.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 明天 */}
|
||||
{/* Tomorrow's reminders */}
|
||||
{grouped.tomorrow.length > 0 && (
|
||||
<>
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
<Text size="xs" c="teal" fw={500}>
|
||||
明天
|
||||
</Text>
|
||||
</Group>
|
||||
{grouped.tomorrow.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 更久之后 */}
|
||||
{/* Later reminders */}
|
||||
{grouped.later.length > 0 && (
|
||||
<>
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="#999" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
<Text size="xs" c="gray" fw={500}>
|
||||
更久之后
|
||||
</Text>
|
||||
</Group>
|
||||
{grouped.later.map((event) => (
|
||||
<ReminderCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick(event)}
|
||||
onToggle={() => onToggleComplete(event)}
|
||||
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,252 +0,0 @@
|
||||
/**
|
||||
* 中国法定节假日数据
|
||||
* 基于2024年节假日安排
|
||||
* 每个节日包含:名称、日期(公历/农历)、是否放假日
|
||||
*/
|
||||
import { Lunar } from 'lunar-javascript';
|
||||
|
||||
export interface Holiday {
|
||||
id: string;
|
||||
name: string;
|
||||
month?: number; // 公历月份(非农历时使用)
|
||||
day?: number; // 公历日期(非农历时使用)
|
||||
isLunar: boolean; // 是否为农历日期
|
||||
lunarMonth?: number; // 农历月份(农历日期时使用)
|
||||
lunarDay?: number; // 农历日期(农历日期时使用)
|
||||
isStatutory: boolean; // 是否法定节假日
|
||||
repeatYearly: boolean; // 是否每年重复
|
||||
}
|
||||
|
||||
export const HOLIDAYS: Holiday[] = [
|
||||
// 元旦
|
||||
{
|
||||
id: 'new-year',
|
||||
name: '元旦',
|
||||
month: 1,
|
||||
day: 1,
|
||||
isLunar: false,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 春节
|
||||
{
|
||||
id: 'spring-festival',
|
||||
name: '春节',
|
||||
isLunar: true,
|
||||
lunarMonth: 1,
|
||||
lunarDay: 1,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 元宵节
|
||||
{
|
||||
id: 'lantern',
|
||||
name: '元宵节',
|
||||
isLunar: true,
|
||||
lunarMonth: 1,
|
||||
lunarDay: 15,
|
||||
isStatutory: false,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 清明节
|
||||
{
|
||||
id: 'qingming',
|
||||
name: '清明节',
|
||||
month: 4,
|
||||
day: 4,
|
||||
isLunar: false,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 劳动节
|
||||
{
|
||||
id: 'labor-day',
|
||||
name: '劳动节',
|
||||
month: 5,
|
||||
day: 1,
|
||||
isLunar: false,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 端午节
|
||||
{
|
||||
id: 'dragon-boat',
|
||||
name: '端午节',
|
||||
isLunar: true,
|
||||
lunarMonth: 5,
|
||||
lunarDay: 5,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 中秋节
|
||||
{
|
||||
id: 'mid-autumn',
|
||||
name: '中秋节',
|
||||
isLunar: true,
|
||||
lunarMonth: 8,
|
||||
lunarDay: 15,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 国庆节
|
||||
{
|
||||
id: 'national-day',
|
||||
name: '国庆节',
|
||||
month: 10,
|
||||
day: 1,
|
||||
isLunar: false,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 重阳节
|
||||
{
|
||||
id: 'double-ninth',
|
||||
name: '重阳节',
|
||||
isLunar: true,
|
||||
lunarMonth: 9,
|
||||
lunarDay: 9,
|
||||
isStatutory: false,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 冬至
|
||||
{
|
||||
id: 'winter-solstice',
|
||||
name: '冬至',
|
||||
month: 12,
|
||||
day: 21,
|
||||
isLunar: false,
|
||||
isStatutory: false,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 腊八节
|
||||
{
|
||||
id: 'laba',
|
||||
name: '腊八节',
|
||||
isLunar: true,
|
||||
lunarMonth: 12,
|
||||
lunarDay: 8,
|
||||
isStatutory: false,
|
||||
repeatYearly: true,
|
||||
},
|
||||
// 除夕
|
||||
{
|
||||
id: 'chinese-new-years-eve',
|
||||
name: '除夕',
|
||||
isLunar: true,
|
||||
lunarMonth: 12,
|
||||
lunarDay: 30,
|
||||
isStatutory: true,
|
||||
repeatYearly: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取所有内置节假日
|
||||
*/
|
||||
export function getBuiltInHolidays(): Holiday[] {
|
||||
return [...HOLIDAYS];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找节假日
|
||||
*/
|
||||
export function getHolidayById(id: string): Holiday | undefined {
|
||||
return HOLIDAYS.find((h) => h.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查给定日期是否为节假日
|
||||
*/
|
||||
export function isHoliday(
|
||||
date: Date,
|
||||
year: number = date.getFullYear()
|
||||
): Holiday | undefined {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
for (const holiday of HOLIDAYS) {
|
||||
if (holiday.isLunar && holiday.lunarMonth && holiday.lunarDay) {
|
||||
// 农历日期需要转换
|
||||
try {
|
||||
const lunar = Lunar.fromYmd(year, month, day);
|
||||
const solar = lunar.getSolar();
|
||||
if (
|
||||
solar.getMonth() === holiday.lunarMonth &&
|
||||
solar.getDay() === holiday.lunarDay
|
||||
) {
|
||||
return holiday;
|
||||
}
|
||||
} catch {
|
||||
// 农历转换失败,忽略
|
||||
}
|
||||
} else {
|
||||
// 公历日期直接比较
|
||||
if (holiday.month === month && holiday.day === day) {
|
||||
return holiday;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定年份的所有节假日
|
||||
*/
|
||||
export function getHolidaysForYear(year: number): Array<Holiday & { date: Date }> {
|
||||
const result: Array<Holiday & { date: Date }> = [];
|
||||
|
||||
for (const holiday of HOLIDAYS) {
|
||||
if (holiday.isLunar && holiday.lunarMonth && holiday.lunarDay) {
|
||||
try {
|
||||
// 使用 Lunar 构造函数创建农历对象,再获取对应的公历日期
|
||||
const lunar = new Lunar(year, holiday.lunarMonth, holiday.lunarDay);
|
||||
const solar = lunar.getSolar();
|
||||
result.push({
|
||||
...holiday,
|
||||
date: new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay()),
|
||||
});
|
||||
} catch {
|
||||
// 农历转换失败,跳过
|
||||
}
|
||||
} else if (holiday.month && holiday.day) {
|
||||
result.push({
|
||||
...holiday,
|
||||
date: new Date(year, holiday.month - 1, holiday.day),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期排序
|
||||
result.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取即将到来的节假日
|
||||
*/
|
||||
export function getUpcomingHolidays(
|
||||
fromDate: Date = new Date(),
|
||||
limit: number = 5
|
||||
): Array<Holiday & { date: Date; daysUntil: number }> {
|
||||
const year = fromDate.getFullYear();
|
||||
const nextYear = year + 1;
|
||||
const holidays = [
|
||||
...getHolidaysForYear(year),
|
||||
...getHolidaysForYear(nextYear),
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
const upcoming = holidays
|
||||
.filter((h) => h.date >= now)
|
||||
.map((h) => ({
|
||||
...h,
|
||||
daysUntil: Math.ceil((h.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
}))
|
||||
.slice(0, limit);
|
||||
|
||||
return upcoming;
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Stack,
|
||||
Paper,
|
||||
Group,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowLeft, IconRotateClockwise, IconTrash, IconArchive } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores';
|
||||
import type { Event } from '../types';
|
||||
|
||||
export function ArchivePage() {
|
||||
const navigate = useNavigate();
|
||||
const events = useAppStore((state) => state.events);
|
||||
const fetchEvents = useAppStore((state) => state.fetchEvents);
|
||||
const updateEventById = useAppStore((state) => state.updateEventById);
|
||||
const deleteEventById = useAppStore((state) => state.deleteEventById);
|
||||
|
||||
// 页面加载时获取数据
|
||||
useEffect(() => {
|
||||
const isAuthenticated = useAppStore.getState().isAuthenticated;
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
} else {
|
||||
fetchEvents();
|
||||
}
|
||||
}, [navigate, fetchEvents]);
|
||||
|
||||
// 获取已归档的提醒(已过期且已勾选的)
|
||||
const archivedReminders = events.filter((e) => {
|
||||
if (e.type !== 'reminder' || !e.is_completed) return false;
|
||||
const eventDate = new Date(e.date);
|
||||
const now = new Date();
|
||||
return eventDate < now; // 仅已过期的
|
||||
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
const handleRestore = async (event: Event) => {
|
||||
await updateEventById(event.id, { is_completed: false });
|
||||
};
|
||||
|
||||
const handleDelete = async (event: Event) => {
|
||||
await deleteEventById(event.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#faf9f7',
|
||||
paddingTop: 80,
|
||||
paddingBottom: 40,
|
||||
}}
|
||||
>
|
||||
<Container size="xs">
|
||||
{/* Header */}
|
||||
<Group mb="lg">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<IconArchive size={20} color="#666" />
|
||||
<Title
|
||||
order={2}
|
||||
style={{
|
||||
fontWeight: 300,
|
||||
fontSize: '1.25rem',
|
||||
letterSpacing: '0.15em',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
归档
|
||||
</Title>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Content */}
|
||||
<Paper p="md" withBorder radius={4}>
|
||||
{archivedReminders.length === 0 ? (
|
||||
<Stack align="center" justify="center" py="xl">
|
||||
<Text c="#999" size="sm" ta="center" style={{ letterSpacing: '0.05em' }}>
|
||||
暂无已归档的提醒
|
||||
</Text>
|
||||
<Text size="xs" c="#bbb" ta="center" mt={4}>
|
||||
完成的提醒会自动归档到这里
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{archivedReminders.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>
|
||||
{event.content && (
|
||||
<Text size="xs" c="#bbb" lineClamp={1}>
|
||||
{event.content}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap={4}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => handleRestore(event)}
|
||||
style={{ color: '#666' }}
|
||||
title="恢复"
|
||||
>
|
||||
<IconRotateClockwise size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => handleDelete(event)}
|
||||
style={{ color: '#999' }}
|
||||
title="删除"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -13,19 +13,17 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { DatePickerInput, TimeInput } from '@mantine/dates';
|
||||
import { IconLogout, IconSettings, IconArchive } from '@tabler/icons-react';
|
||||
import { DateInput, TimeInput } from '@mantine/dates';
|
||||
import { IconLogout, IconPlus } from '@tabler/icons-react';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores';
|
||||
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
||||
import { ReminderList } from '../components/reminder/ReminderList';
|
||||
import { NoteEditor } from '../components/note/NoteEditor';
|
||||
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
|
||||
import { AIChatBox } from '../components/ai/AIChatBox';
|
||||
import type { Event, EventType, RepeatType } from '../types';
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAppStore((state) => state.user);
|
||||
const logout = useAppStore((state) => state.logout);
|
||||
const checkAuth = useAppStore((state) => state.checkAuth);
|
||||
@ -49,11 +47,10 @@ export function HomePage() {
|
||||
const [formRepeatType, setFormRepeatType] = useState<RepeatType>('none');
|
||||
const [formIsHoliday, setFormIsHoliday] = useState(false);
|
||||
|
||||
// Initialize auth and data on mount
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
fetchEvents().catch(console.error);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
fetchEvents();
|
||||
}, [checkAuth, fetchEvents]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
@ -89,12 +86,9 @@ export function HomePage() {
|
||||
const handleSubmit = async () => {
|
||||
if (!formTitle.trim() || !formDate) return;
|
||||
|
||||
// 确保 date 是 Date 对象
|
||||
const dateObj = formDate instanceof Date ? formDate : new Date(formDate as unknown as string);
|
||||
|
||||
const dateStr = formTime
|
||||
? new Date(dateObj.setHours(parseInt(formTime.split(':')[0]), parseInt(formTime.split(':')[1])))
|
||||
: dateObj;
|
||||
? new Date(formDate.setHours(parseInt(formTime.split(':')[0]), parseInt(formTime.split(':')[1])))
|
||||
: formDate;
|
||||
|
||||
const eventData = {
|
||||
type: formType,
|
||||
@ -117,7 +111,7 @@ export function HomePage() {
|
||||
fetchEvents();
|
||||
};
|
||||
|
||||
const handleDeleteFromModal = async () => {
|
||||
const handleDelete = async () => {
|
||||
if (!selectedEvent) return;
|
||||
await deleteEventById(selectedEvent.id);
|
||||
close();
|
||||
@ -125,28 +119,9 @@ export function HomePage() {
|
||||
};
|
||||
|
||||
const handleToggleComplete = async (event: Event) => {
|
||||
if (event.type !== 'reminder') return;
|
||||
// 使用当前期望的状态(取反)
|
||||
const newCompleted = !event.is_completed;
|
||||
const result = await updateEventById(event.id, {
|
||||
is_completed: newCompleted,
|
||||
});
|
||||
if (result.error) {
|
||||
console.error('更新失败:', result.error);
|
||||
}
|
||||
// 乐观更新已处理 UI 响应,无需 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,
|
||||
is_completed: !event.is_completed,
|
||||
});
|
||||
fetchEvents();
|
||||
};
|
||||
@ -169,149 +144,86 @@ export function HomePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#faf9f7',
|
||||
}}
|
||||
>
|
||||
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
|
||||
<Title
|
||||
order={2}
|
||||
style={{
|
||||
fontWeight: 300,
|
||||
fontSize: '1.25rem',
|
||||
letterSpacing: '0.15em',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
<Title order={2} c="blue">
|
||||
掐日子
|
||||
</Title>
|
||||
<Group>
|
||||
{/* 归档入口 - 一直显示 */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconArchive size={12} />}
|
||||
onClick={() => navigate('/archive')}
|
||||
style={{
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
{/* 设置入口 */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconSettings size={12} />}
|
||||
onClick={() => navigate('/settings')}
|
||||
style={{
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
设置
|
||||
</Button>
|
||||
<Text
|
||||
size="xs"
|
||||
c="#888"
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{user?.nickname || user?.email}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconLogout size={12} />}
|
||||
leftSection={<IconLogout size={14} />}
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Main Content - 3 column horizontal layout */}
|
||||
<Grid grow style={{ flex: 1, minHeight: 0 }} gutter="md">
|
||||
{/* Left column - Anniversary */}
|
||||
{/* Main Content - 4 panel layout */}
|
||||
<Grid grow style={{ flex: 1, minHeight: 0 }}>
|
||||
{/* Left column - 40% */}
|
||||
<Grid.Col span={4}>
|
||||
<div style={{ height: '100%', minHeight: 0 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
{/* Anniversary list */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<AnniversaryList
|
||||
events={events}
|
||||
onEventClick={handleEventClick}
|
||||
onAddClick={() => handleAddClick('anniversary')}
|
||||
/>
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Middle column - Reminder */}
|
||||
<Grid.Col span={4}>
|
||||
<div style={{ height: '100%', minHeight: 0 }}>
|
||||
{/* Reminder list */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<ReminderList
|
||||
events={events}
|
||||
onEventClick={handleEventClick}
|
||||
onToggleComplete={handleToggleComplete}
|
||||
onAddClick={() => handleAddClick('reminder')}
|
||||
onDelete={handleDelete}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Right column - Note */}
|
||||
<Grid.Col span={4}>
|
||||
<div style={{ height: '100%', minHeight: 0 }}>
|
||||
{/* 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>
|
||||
|
||||
{/* AI Chat - Floating */}
|
||||
<FloatingAIChat onEventCreated={handleAIEventCreated} />
|
||||
|
||||
{/* Add/Edit Event Modal */}
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={
|
||||
<Text
|
||||
fw={400}
|
||||
style={{
|
||||
letterSpacing: '0.1em',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
{isEdit ? '编辑事件' : '添加事件'}
|
||||
</Text>
|
||||
<Group gap={8}>
|
||||
<IconPlus size={18} />
|
||||
<Text fw={500}>{isEdit ? '编辑事件' : '添加事件'}</Text>
|
||||
</Group>
|
||||
}
|
||||
size="md"
|
||||
styles={{
|
||||
header: {
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
},
|
||||
body: {
|
||||
paddingTop: 20,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Event type */}
|
||||
<Select
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
类型
|
||||
</Text>
|
||||
}
|
||||
label="类型"
|
||||
data={[
|
||||
{ value: 'anniversary', label: '纪念日' },
|
||||
{ value: 'reminder', label: '提醒' },
|
||||
@ -319,111 +231,57 @@ export function HomePage() {
|
||||
value={formType}
|
||||
onChange={(value) => value && setFormType(value as EventType)}
|
||||
disabled={isEdit}
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 2,
|
||||
background: '#faf9f7',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<TextInput
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
标题
|
||||
</Text>
|
||||
}
|
||||
label="标题"
|
||||
placeholder="输入标题"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
required
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 2,
|
||||
background: '#faf9f7',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content (only for reminders) */}
|
||||
{formType === 'reminder' && (
|
||||
<Textarea
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
内容
|
||||
</Text>
|
||||
}
|
||||
label="内容"
|
||||
placeholder="输入详细内容"
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={3}
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 2,
|
||||
background: '#faf9f7',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<DatePickerInput
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
日期
|
||||
</Text>
|
||||
}
|
||||
<DateInput
|
||||
label="日期"
|
||||
placeholder="选择日期"
|
||||
value={formDate}
|
||||
onChange={(value) => setFormDate(value as Date | null)}
|
||||
onChange={setFormDate}
|
||||
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>
|
||||
}
|
||||
label="时间"
|
||||
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>
|
||||
}
|
||||
label="农历日期"
|
||||
checked={formIsLunar}
|
||||
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
{/* Repeat type */}
|
||||
<Select
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
重复
|
||||
</Text>
|
||||
}
|
||||
label="重复"
|
||||
data={[
|
||||
{ value: 'none', label: '不重复' },
|
||||
{ value: 'yearly', label: '每年' },
|
||||
@ -431,22 +289,12 @@ export function HomePage() {
|
||||
]}
|
||||
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>
|
||||
}
|
||||
label="节假日"
|
||||
checked={formIsHoliday}
|
||||
onChange={(e) => setFormIsHoliday(e.currentTarget.checked)}
|
||||
/>
|
||||
@ -455,37 +303,15 @@ export function HomePage() {
|
||||
{/* Actions */}
|
||||
<Group justify="space-between" mt="md">
|
||||
{isEdit && (
|
||||
<Button
|
||||
color="dark"
|
||||
variant="light"
|
||||
onClick={handleDeleteFromModal}
|
||||
style={{
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Button color="red" variant="light" onClick={handleDelete}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Group ml="auto">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={close}
|
||||
style={{
|
||||
borderRadius: 2,
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<Button variant="subtle" onClick={close}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!formTitle.trim() || !formDate}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleSubmit} disabled={!formTitle.trim() || !formDate}>
|
||||
{isEdit ? '保存' : '添加'}
|
||||
</Button>
|
||||
</Group>
|
||||
@ -493,6 +319,5 @@ export function HomePage() {
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,418 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Button, Container, Title, Text, Group, Stack } from '@mantine/core';
|
||||
import { Button, Container, Title, Text, Paper } from '@mantine/core';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#faf9f7',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ZenBackground />
|
||||
|
||||
{/* 装饰性圆相 */}
|
||||
<svg
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '8%',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Paper p="xl" style={{ textAlign: 'center' }}>
|
||||
<Title order={1} mb="md" c="blue">
|
||||
掐日子
|
||||
</Title>
|
||||
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
letterSpacing: '0.35em',
|
||||
color: '#888',
|
||||
fontWeight: 300,
|
||||
}}
|
||||
>
|
||||
<Text size="lg" c="dimmed" mb="xl">
|
||||
AI 纪念日 · 提醒
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
style={{
|
||||
color: '#999',
|
||||
maxWidth: 300,
|
||||
lineHeight: 1.9,
|
||||
fontWeight: 300,
|
||||
}}
|
||||
>
|
||||
轻便、灵活的倒数日和提醒应用
|
||||
<br />
|
||||
让每一个重要的日子都被铭记
|
||||
<Text size="sm" c="gray.6" mb="xl">
|
||||
轻便、灵活的倒数日和提醒App,专注提醒功能
|
||||
</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 size="lg" variant="filled" onClick={() => navigate('/login')}>
|
||||
开始使用
|
||||
</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>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ export function LoginPage() {
|
||||
|
||||
const { error } = await login(email, password);
|
||||
if (error) {
|
||||
setError(error);
|
||||
setError(error.message || '登录失败');
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
@ -26,122 +26,43 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#faf9f7',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Paper p="xl" shadow="md" radius="lg">
|
||||
<Title order={2} ta="center" mb="xl">
|
||||
登录
|
||||
</Title>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||
邮箱
|
||||
</Text>
|
||||
}
|
||||
label="邮箱"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 2,
|
||||
borderColor: '#e0e0e0',
|
||||
background: '#faf9f7',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||
密码
|
||||
</Text>
|
||||
}
|
||||
label="密码"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{error && <Text c="red" size="sm">{error}</Text>}
|
||||
<Button type="submit" loading={loading} fullWidth>
|
||||
登录
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text
|
||||
ta="center"
|
||||
mt="lg"
|
||||
size="xs"
|
||||
c="#888"
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
<Text ta="center" mt="md" size="sm" c="dimmed">
|
||||
还没有账号?{' '}
|
||||
<Anchor
|
||||
component="button"
|
||||
onClick={() => navigate('/register')}
|
||||
style={{
|
||||
color: '#1a1a1a',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: 3,
|
||||
}}
|
||||
>
|
||||
<Anchor component="button" onClick={() => navigate('/register')}>
|
||||
立即注册
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,200 +12,64 @@ export function RegisterPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 密码强度验证
|
||||
const passwordRequirements = [
|
||||
{ label: '至少8个字符', met: password.length >= 8 },
|
||||
{ label: '包含大写字母', met: /[A-Z]/.test(password) },
|
||||
{ label: '包含小写字母', met: /[a-z]/.test(password) },
|
||||
{ label: '包含数字', met: /\d/.test(password) },
|
||||
];
|
||||
|
||||
const meetsAllRequirements = passwordRequirements.every((req) => req.met);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
if (!meetsAllRequirements) {
|
||||
setError('密码不符合要求,请检查密码格式');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await register(email, password, nickname);
|
||||
if (error) {
|
||||
setError(error);
|
||||
setError(error.message || '注册失败');
|
||||
} else {
|
||||
navigate('/');
|
||||
navigate('/login');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#faf9f7',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Paper p="xl" shadow="md" radius="lg">
|
||||
<Title order={2} ta="center" mb="xl">
|
||||
注册
|
||||
</Title>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||
昵称
|
||||
</Text>
|
||||
}
|
||||
label="昵称"
|
||||
placeholder="Your nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 2,
|
||||
borderColor: '#e0e0e0',
|
||||
background: '#faf9f7',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||
邮箱
|
||||
</Text>
|
||||
}
|
||||
label="邮箱"
|
||||
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>
|
||||
}
|
||||
label="密码"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{error && <Text c="red" size="sm">{error}</Text>}
|
||||
<Button type="submit" loading={loading} fullWidth>
|
||||
注册
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text
|
||||
ta="center"
|
||||
mt="lg"
|
||||
size="xs"
|
||||
c="#888"
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
<Text ta="center" mt="md" size="sm" c="dimmed">
|
||||
已有账号?{' '}
|
||||
<Anchor
|
||||
component="button"
|
||||
onClick={() => navigate('/login')}
|
||||
style={{
|
||||
color: '#1a1a1a',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: 3,
|
||||
}}
|
||||
>
|
||||
<Anchor component="button" onClick={() => navigate('/login')}>
|
||||
立即登录
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Switch,
|
||||
Stack,
|
||||
Paper,
|
||||
Group,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowLeft, IconSettings } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores';
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const settings = useAppStore((state) => state.settings);
|
||||
const updateSettings = useAppStore((state) => state.updateSettings);
|
||||
|
||||
// 页面加载时检查登录状态
|
||||
useEffect(() => {
|
||||
const isAuthenticated = useAppStore.getState().isAuthenticated;
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#faf9f7',
|
||||
}}
|
||||
>
|
||||
<Container size="xl" py="md">
|
||||
{/* Header */}
|
||||
<Group mb="lg" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title
|
||||
order={2}
|
||||
style={{
|
||||
fontWeight: 300,
|
||||
fontSize: '1.25rem',
|
||||
letterSpacing: '0.15em',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
设置
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<Paper p="lg" withBorder radius={4} style={{ maxWidth: 500 }}>
|
||||
<Stack gap="lg">
|
||||
{/* 节假日设置 */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<IconSettings size={18} color="#666" />
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={400} style={{ letterSpacing: '0.05em', color: '#1a1a1a' }}>
|
||||
显示节假日
|
||||
</Text>
|
||||
<Text size="xs" c="#999" style={{ letterSpacing: '0.03em' }}>
|
||||
在纪念日列表中显示即将到来的节假日(最近3个)
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={settings.showHolidays}
|
||||
onChange={(e) => updateSettings({ showHolidays: e.currentTarget.checked })}
|
||||
size="sm"
|
||||
color="#1a1a1a"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/routes.tsx
113
src/routes.tsx
@ -1,78 +1,23 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { LandingPage } from './pages/LandingPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { ArchivePage } from './pages/ArchivePage';
|
||||
import { useAppStore } from './stores';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Loading spinner component
|
||||
function AuthLoading() {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #4A90D9 0%, #7AB8F5 100%)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: '3px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Auth loader - handles checkAuth and loading state
|
||||
function useAuthLoader() {
|
||||
// Protected Route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAppStore((state) => state.isAuthenticated);
|
||||
const checkAuth = useAppStore((state) => state.checkAuth);
|
||||
const isLoading = useAppStore((state) => state.isLoading);
|
||||
|
||||
// 使用 ref 跟踪是否已检查过,避免重复检查
|
||||
const checkedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 标记已检查,但只在未登录时调用 checkAuth
|
||||
if (!checkedRef.current) {
|
||||
checkedRef.current = true;
|
||||
// 如果未登录,才需要检查
|
||||
if (!isAuthenticated) {
|
||||
checkAuth();
|
||||
}
|
||||
}
|
||||
}, [checkAuth, isAuthenticated]);
|
||||
|
||||
// 如果已登录,直接返回,不再显示 Loading
|
||||
if (isAuthenticated) {
|
||||
return { isAuthenticated: true, isLoading: false };
|
||||
}
|
||||
|
||||
return { isAuthenticated: false, isLoading };
|
||||
}
|
||||
|
||||
// Protected Route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthLoader();
|
||||
}, [checkAuth]);
|
||||
|
||||
if (isLoading) {
|
||||
return <AuthLoading />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return null; // Or a loading spinner
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
@ -80,41 +25,23 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Public Route wrapper (redirects to home if authenticated)
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthLoader();
|
||||
const isAuthenticated = useAppStore((state) => state.isAuthenticated);
|
||||
const checkAuth = useAppStore((state) => state.checkAuth);
|
||||
|
||||
if (isLoading) {
|
||||
return <AuthLoading />;
|
||||
}
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/home" replace />;
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
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([
|
||||
{
|
||||
path: '/',
|
||||
element: <RootRedirect />,
|
||||
},
|
||||
{
|
||||
path: '/landing',
|
||||
element: (
|
||||
<PublicRoute>
|
||||
<LandingPage />
|
||||
@ -145,20 +72,4 @@ export const router = createBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/archive',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<ArchivePage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { User, Event, Note, EventType } from '../types';
|
||||
import type { User, Event, Note, AIConversation, EventType } from '../types';
|
||||
|
||||
// API Base URL - from environment variable
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||
@ -33,7 +33,6 @@ export const api = {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // 确保跨域请求时发送凭证(cookie)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@ -88,7 +87,7 @@ export const api = {
|
||||
|
||||
// Events API
|
||||
events: {
|
||||
list: async (type?: EventType) => {
|
||||
getAll: async (type?: EventType) => {
|
||||
const query = type ? `?type=${type}` : '';
|
||||
return api.request<Event[]>(`/events${query}`);
|
||||
},
|
||||
|
||||
@ -1,28 +1,14 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User, Event, Note, AIConversation, EventType } from '../types';
|
||||
import { api } from '../services/api';
|
||||
|
||||
// 应用设置类型
|
||||
interface AppSettings {
|
||||
showHolidays: boolean; // 是否显示节假日
|
||||
}
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings: AppSettings = {
|
||||
showHolidays: true,
|
||||
};
|
||||
|
||||
interface AppState {
|
||||
// Auth state
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
// Settings state
|
||||
settings: AppSettings;
|
||||
updateSettings: (settings: Partial<AppSettings>) => void;
|
||||
|
||||
// Data state
|
||||
events: Event[];
|
||||
notes: Note | null;
|
||||
@ -62,17 +48,10 @@ export const useAppStore = create<AppState>()(
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
settings: defaultSettings,
|
||||
events: [],
|
||||
notes: null,
|
||||
conversations: [],
|
||||
|
||||
// Settings
|
||||
updateSettings: (newSettings) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings },
|
||||
})),
|
||||
|
||||
// Setters
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
@ -114,7 +93,7 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
saveNotes: async (content) => {
|
||||
try {
|
||||
const notes = await api.notes.update(content);
|
||||
const notes = await api.notes.save(content);
|
||||
set({ notes });
|
||||
} catch (error) {
|
||||
console.error('Failed to save notes:', error);
|
||||
@ -134,21 +113,12 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
updateEventById: async (id, event) => {
|
||||
try {
|
||||
// 乐观更新:立即更新本地状态
|
||||
const updated = await api.events.update(id, event);
|
||||
set((state) => ({
|
||||
events: state.events.map((e) =>
|
||||
e.id === id ? { ...e, ...event } : e
|
||||
),
|
||||
events: state.events.map((e) => (e.id === id ? updated : e)),
|
||||
}));
|
||||
// 发送 API 请求
|
||||
await api.events.update(id, event);
|
||||
// 乐观更新已生效,不需要用 API 返回数据覆盖
|
||||
// 避免 API 返回数据不完整导致状态丢失
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
// 失败时回滚,重新获取数据
|
||||
const events = await api.events.list();
|
||||
set({ events });
|
||||
return { error: error.message || '更新失败' };
|
||||
}
|
||||
},
|
||||
@ -173,8 +143,7 @@ export const useAppStore = create<AppState>()(
|
||||
set({ user, isAuthenticated: true });
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || '登录失败,请检查邮箱和密码';
|
||||
return { error: errorMessage };
|
||||
return { error: error.message || '登录失败' };
|
||||
}
|
||||
},
|
||||
|
||||
@ -185,8 +154,7 @@ export const useAppStore = create<AppState>()(
|
||||
set({ user, isAuthenticated: true });
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || '注册失败,请稍后重试';
|
||||
return { error: errorMessage };
|
||||
return { error: error.message || '注册失败' };
|
||||
}
|
||||
},
|
||||
|
||||
@ -229,11 +197,9 @@ export const useAppStore = create<AppState>()(
|
||||
}),
|
||||
{
|
||||
name: 'qia-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
settings: state.settings,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@ -80,3 +80,11 @@ export interface ApiResponse<T> {
|
||||
|
||||
// Loading state types
|
||||
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
16
src/types/lunar-javascript.d.ts
vendored
@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,223 +0,0 @@
|
||||
import { Lunar } from 'lunar-javascript';
|
||||
|
||||
export interface CountdownResult {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
nextDate: Date;
|
||||
isToday: boolean;
|
||||
isPast: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地创建日期,处理月末边界情况
|
||||
* 某些月份只有28/29/30/31天
|
||||
*/
|
||||
function safeCreateDate(year: number, month: number, day: number): Date {
|
||||
const date = new Date(year, month, day);
|
||||
// 如果日期溢出(如下个月),自动调整到月末
|
||||
if (date.getMonth() !== month % 12) {
|
||||
date.setMonth(month + 1, 0);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地创建农历日期,处理月末边界情况
|
||||
* 某些农历月份只有29或30天,需要检查并调整
|
||||
*/
|
||||
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 的倒计时
|
||||
*/
|
||||
export function calculateCountdown(
|
||||
dateStr: string,
|
||||
repeatType: 'yearly' | 'monthly' | 'none',
|
||||
isLunar: boolean
|
||||
): CountdownResult {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
let targetDate: Date;
|
||||
let isPast = false;
|
||||
|
||||
// 解析日期字符串
|
||||
// 格式假设为 ISO 格式 "YYYY-MM-DD" 或完整的 ISO datetime
|
||||
const dateParts = dateStr.split('T')[0].split('-').map(Number);
|
||||
const originalYear = dateParts[0];
|
||||
const originalMonth = dateParts[1] - 1; // JavaScript月份从0开始
|
||||
const originalDay = dateParts[2];
|
||||
|
||||
if (isLunar) {
|
||||
// 农历日期:使用安全方法创建,处理月末边界
|
||||
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
|
||||
if (result) {
|
||||
targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay());
|
||||
} else {
|
||||
// 无法解析农历日期,使用原始公历日期作为后备
|
||||
targetDate = new Date(originalYear, originalMonth, originalDay);
|
||||
}
|
||||
} else {
|
||||
// 公历日期
|
||||
targetDate = new Date(originalYear, originalMonth, originalDay);
|
||||
}
|
||||
|
||||
// 计算下一个 occurrence
|
||||
if (repeatType === 'yearly') {
|
||||
// 年度重复:找到今年或明年的对应日期
|
||||
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||
if (targetDate < today) {
|
||||
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
|
||||
}
|
||||
} else if (repeatType === 'monthly') {
|
||||
// 月度重复:找到本月或下月的对应日期
|
||||
targetDate = safeCreateDate(today.getFullYear(), today.getMonth(), targetDate.getDate());
|
||||
if (targetDate < today) {
|
||||
targetDate = safeCreateDate(today.getFullYear(), today.getMonth() + 1, targetDate.getDate());
|
||||
}
|
||||
} else {
|
||||
// 不重复
|
||||
if (targetDate < today) {
|
||||
isPast = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算时间差
|
||||
const diff = targetDate.getTime() - now.getTime();
|
||||
const isToday = targetDate.toDateString() === today.toDateString();
|
||||
|
||||
if (diff < 0 && !isPast) {
|
||||
isPast = true;
|
||||
}
|
||||
|
||||
const absDiff = Math.abs(diff);
|
||||
const days = Math.floor(absDiff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((absDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((absDiff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((absDiff % (1000 * 60)) / 1000);
|
||||
|
||||
return {
|
||||
days: isPast ? -days : days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
nextDate: targetDate,
|
||||
isToday,
|
||||
isPast,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时显示
|
||||
*/
|
||||
export function formatCountdown(countdown: CountdownResult): string {
|
||||
if (countdown.isPast) {
|
||||
return '已过';
|
||||
}
|
||||
|
||||
if (countdown.isToday) {
|
||||
return '今天';
|
||||
}
|
||||
|
||||
if (countdown.days > 0) {
|
||||
return `${countdown.days}天`;
|
||||
}
|
||||
|
||||
if (countdown.hours > 0) {
|
||||
return `${countdown.hours}时${countdown.minutes}分`;
|
||||
}
|
||||
|
||||
return `${countdown.minutes}分${countdown.seconds}秒`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取友好的日期描述
|
||||
*/
|
||||
export function getFriendlyDateDescription(
|
||||
dateStr: string,
|
||||
repeatType: 'yearly' | 'monthly' | 'none',
|
||||
isLunar: boolean
|
||||
): string {
|
||||
const countdown = calculateCountdown(dateStr, repeatType, isLunar);
|
||||
const targetDate = countdown.nextDate;
|
||||
|
||||
if (countdown.isPast) {
|
||||
return '已过';
|
||||
}
|
||||
|
||||
if (countdown.isToday) {
|
||||
return '今天';
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
if (targetDate.toDateString() === tomorrow.toDateString()) {
|
||||
return '明天';
|
||||
}
|
||||
|
||||
if (countdown.days < 7) {
|
||||
return `${countdown.days}天后`;
|
||||
}
|
||||
|
||||
// 返回格式化日期
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
|
||||
if (isLunar) {
|
||||
// 显示农历
|
||||
const result = safeCreateLunarDate(targetDate.getFullYear(), month, day);
|
||||
if (result) {
|
||||
return `${result.lunar.getMonthInChinese()}月${result.lunar.getDayInChinese()}`;
|
||||
}
|
||||
return `${month}月${day}日`;
|
||||
}
|
||||
|
||||
return `${month}月${day}日`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取农历日期的公历日期
|
||||
*/
|
||||
export function getSolarFromLunar(lunarMonth: number, lunarDay: number, year?: number): Date {
|
||||
const targetYear = year || new Date().getFullYear();
|
||||
const lunar = Lunar.fromYmd(targetYear, lunarMonth, lunarDay);
|
||||
const solar = lunar.getSolar();
|
||||
return new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公历日期的农历日期
|
||||
*/
|
||||
export function getLunarFromSolar(solarDate: Date): { month: number; day: number; monthInChinese: string; dayInChinese: string } {
|
||||
const lunar = Lunar.fromYmd(solarDate.getFullYear(), solarDate.getMonth() + 1, solarDate.getDate());
|
||||
return {
|
||||
month: lunar.getMonth(),
|
||||
day: lunar.getDay(),
|
||||
monthInChinese: lunar.getMonthInChinese(),
|
||||
dayInChinese: lunar.getDayInChinese(),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user