feat: 禅意设计风格重构与体验优化
- LandingPage: 全新水墨晕染算法背景,循环墨迹动画 - 登录/注册页: 禅意黑白极简风格 - HomePage: 三栏布局优化,标题颜色语义化 - 纪念日组件: 分类逻辑优化,颜色语义统一 - 提醒组件: 分组标题颜色优化,逾期提示更醒目 - 修复农历日期边界问题(29/30天月份) - 添加 lunar-javascript 类型声明 - 清理未使用的导入和代码
This commit is contained in:
parent
a118346238
commit
250c05e85e
1507
package-lock.json
generated
1507
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -23,7 +23,10 @@
|
|||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
src/assets/icon.png
Normal file
BIN
src/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@ -8,9 +8,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Loader,
|
Loader,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Badge,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconSend, IconSparkles } from '@tabler/icons-react';
|
import { IconSend, IconSparkles } from '@tabler/icons-react';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
@ -24,7 +22,6 @@ interface AIChatBoxProps {
|
|||||||
export function AIChatBox({ onEventCreated }: AIChatBoxProps) {
|
export function AIChatBox({ onEventCreated }: AIChatBoxProps) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [conversation, setConversation] = useState<AIConversation | null>(null);
|
|
||||||
const [history, setHistory] = useState<AIConversation[]>([]);
|
const [history, setHistory] = useState<AIConversation[]>([]);
|
||||||
const addConversation = useAppStore((state) => state.addConversation);
|
const addConversation = useAppStore((state) => state.addConversation);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@ -55,7 +52,6 @@ export function AIChatBox({ onEventCreated }: AIChatBoxProps) {
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setConversation(newConversation);
|
|
||||||
setHistory((prev) => [...prev, newConversation]);
|
setHistory((prev) => [...prev, newConversation]);
|
||||||
addConversation(newConversation);
|
addConversation(newConversation);
|
||||||
|
|
||||||
|
|||||||
262
src/components/ai/FloatingAIChat.tsx
Normal file
262
src/components/ai/FloatingAIChat.tsx
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
TextInput,
|
||||||
|
ActionIcon,
|
||||||
|
Loader,
|
||||||
|
Box,
|
||||||
|
Transition,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconSparkles, IconX, IconSend, IconMessage } from '@tabler/icons-react';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import { useAppStore } from '../../stores';
|
||||||
|
import type { AIConversation } from '../../types';
|
||||||
|
|
||||||
|
interface FloatingAIChatProps {
|
||||||
|
onEventCreated?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [history, setHistory] = useState<AIConversation[]>([]);
|
||||||
|
const addConversation = useAppStore((state) => state.addConversation);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim() || loading) return;
|
||||||
|
|
||||||
|
const userMessage = message.trim();
|
||||||
|
setMessage('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.ai.parse(userMessage);
|
||||||
|
|
||||||
|
const newConversation: AIConversation = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
user_id: '',
|
||||||
|
message: userMessage,
|
||||||
|
response: result.response,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setHistory((prev) => [...prev, newConversation]);
|
||||||
|
addConversation(newConversation);
|
||||||
|
|
||||||
|
if (onEventCreated) {
|
||||||
|
onEventCreated();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI parse error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating button */}
|
||||||
|
<ActionIcon
|
||||||
|
size="xl"
|
||||||
|
radius="xl"
|
||||||
|
variant="filled"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 24,
|
||||||
|
right: 24,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#1a1a1a',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? <IconX size={24} /> : <IconMessage size={24} />}
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
{/* Chat window */}
|
||||||
|
<Transition mounted={isOpen} transition="slide-up" duration={300}>
|
||||||
|
{(styles) => (
|
||||||
|
<Paper
|
||||||
|
shadow="lg"
|
||||||
|
radius={4}
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 90,
|
||||||
|
right: 24,
|
||||||
|
width: 360,
|
||||||
|
maxHeight: 'calc(100vh - 150px)',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: '#faf9f7',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Group
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
borderRadius: '4px 4px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={8}>
|
||||||
|
<IconSparkles size={16} color="#faf9f7" />
|
||||||
|
<Text
|
||||||
|
fw={400}
|
||||||
|
size="sm"
|
||||||
|
c="#faf9f7"
|
||||||
|
style={{ letterSpacing: '0.1em' }}
|
||||||
|
>
|
||||||
|
AI 助手
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Chat history */}
|
||||||
|
<Box
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
minHeight: 280,
|
||||||
|
maxHeight: 380,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<Stack align="center" justify="center" h="100%" gap="sm">
|
||||||
|
<IconSparkles size={32} color="rgba(0, 0, 0, 0.2)" />
|
||||||
|
<Text size="xs" c="#888" ta="center" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
告诉我你想记住的事情
|
||||||
|
<br />
|
||||||
|
<Text span size="xs" c="#aaa">
|
||||||
|
例如:下周三见客户、每月15号还房贷
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
history.map((conv) => (
|
||||||
|
<Box key={conv.id}>
|
||||||
|
<Group gap={8} mb={6}>
|
||||||
|
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||||
|
你
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
p="sm"
|
||||||
|
radius={2}
|
||||||
|
mb="sm"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0, 0, 0, 0.02)',
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||||
|
{conv.message}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Group gap={8} mb={6}>
|
||||||
|
<IconSparkles size={10} color="#1a1a1a" />
|
||||||
|
<Text size="xs" c="#1a1a1a" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||||
|
AI
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
p="sm"
|
||||||
|
radius={2}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0, 0, 0, 0.03)',
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||||
|
{conv.response}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Group gap={8}>
|
||||||
|
<Loader size="xs" color="#1a1a1a" />
|
||||||
|
<Text size="xs" c="#888" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
AI 正在思考...
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<Group
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
|
||||||
|
borderRadius: '0 0 4px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="输入你的提醒事项..."
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={loading}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
radius={2}
|
||||||
|
variant="filled"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!message.trim() || loading}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconSend size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { Card, Text, Badge, Group, Stack, ThemeIcon } from '@mantine/core';
|
import { Paper, Text, Group, Stack } from '@mantine/core';
|
||||||
import { IconHeart, IconRepeat, IconCalendar } from '@tabler/icons-react';
|
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
||||||
import { getHolidayById } from '../../constants/holidays';
|
import { getHolidayById } from '../../constants/holidays';
|
||||||
@ -17,37 +16,40 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
|
|||||||
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
|
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Paper
|
||||||
shadow="sm"
|
p="sm"
|
||||||
padding="sm"
|
radius={2}
|
||||||
radius="md"
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{ cursor: 'pointer', transition: 'transform 0.2s' }}
|
style={{
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')}
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
background: 'rgba(0, 0, 0, 0.02)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.04)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-1px)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<IconHeart size={14} color="pink" />
|
<Text size="xs" c="#c41c1c">{event.title}</Text>
|
||||||
<Text fw={500} size="sm" lineClamp={1} style={{ flex: 1 }}>
|
|
||||||
{event.title}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{/* Countdown badge */}
|
{/* Countdown */}
|
||||||
<Badge
|
<Text
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="filled"
|
fw={400}
|
||||||
color={countdown.isPast ? 'gray' : countdown.isToday ? 'red' : 'blue'}
|
style={{
|
||||||
leftSection={<IconCalendar size={10} />}
|
letterSpacing: '0.05em',
|
||||||
|
color: countdown.isToday ? '#c41c1c' : '#666',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{formattedCountdown}
|
{formattedCountdown}
|
||||||
</Badge>
|
</Text>
|
||||||
|
|
||||||
{/* Date display */}
|
{/* Date display */}
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="#999">
|
||||||
{countdown.isPast
|
{countdown.isPast
|
||||||
? '已过'
|
? '已过'
|
||||||
: `${countdown.nextDate.getMonth() + 1}月${countdown.nextDate.getDate()}日`}
|
: `${countdown.nextDate.getMonth() + 1}月${countdown.nextDate.getDate()}日`}
|
||||||
@ -58,22 +60,17 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
|
|||||||
|
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
{repeatType !== 'none' && (
|
{repeatType !== 'none' && (
|
||||||
<Badge
|
<Text size="xs" c="#888">
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color="blue"
|
|
||||||
leftSection={<IconRepeat size={10} />}
|
|
||||||
>
|
|
||||||
{repeatType === 'yearly' ? '每年' : '每月'}
|
{repeatType === 'yearly' ? '每年' : '每月'}
|
||||||
</Badge>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{(event.is_holiday || holiday) && (
|
{(event.is_holiday || holiday) && (
|
||||||
<Badge size="xs" variant="light" color="green">
|
<Text size="xs" c="#888">
|
||||||
节假日
|
节假日
|
||||||
</Badge>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Stack, Text, Paper, Group, Button, Badge, ThemeIcon, Card } from '@mantine/core';
|
import { Stack, Text, Paper, Group, Button } from '@mantine/core';
|
||||||
import { IconPlus, IconHeart, IconCalendar } from '@tabler/icons-react';
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
import { AnniversaryCard } from './AnniversaryCard';
|
import { AnniversaryCard } from './AnniversaryCard';
|
||||||
import { getHolidaysForYear, getUpcomingHolidays } from '../../constants/holidays';
|
import { getHolidaysForYear } from '../../constants/holidays';
|
||||||
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
import { Lunar, Solar } from 'lunar-javascript';
|
|
||||||
|
|
||||||
interface AnniversaryListProps {
|
interface AnniversaryListProps {
|
||||||
events: Event[];
|
events: Event[];
|
||||||
@ -85,16 +84,21 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
|
|||||||
// 空状态
|
// 空状态
|
||||||
if (allAnniversaries.total === 0) {
|
if (allAnniversaries.total === 0) {
|
||||||
return (
|
return (
|
||||||
<Paper p="md" withBorder radius="md" h="100%">
|
<Paper p="md" withBorder radius={4} h="100%">
|
||||||
<Stack align="center" justify="center" h="100%">
|
<Stack align="center" justify="center" h="100%">
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="#999" size="sm" style={{ letterSpacing: '0.05em' }}>
|
||||||
暂无纪念日
|
暂无纪念日
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconPlus size={14} />}
|
leftSection={<IconPlus size={12} />}
|
||||||
onClick={onAddClick}
|
onClick={onAddClick}
|
||||||
|
style={{
|
||||||
|
borderColor: '#ccc',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
添加纪念日
|
添加纪念日
|
||||||
</Button>
|
</Button>
|
||||||
@ -104,21 +108,25 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p="md" withBorder radius="md" h="100%">
|
<Paper p="md" withBorder radius={4} h="100%">
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
<Text fw={500} size="sm">
|
<Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
|
||||||
纪念日
|
纪念日
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="gray">
|
<Text size="xs" c="#999">
|
||||||
{anniversaries.length}
|
{anniversaries.length}
|
||||||
</Badge>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconPlus size={14} />}
|
leftSection={<IconPlus size={12} />}
|
||||||
onClick={onAddClick}
|
onClick={onAddClick}
|
||||||
|
style={{
|
||||||
|
color: '#666',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
添加
|
添加
|
||||||
</Button>
|
</Button>
|
||||||
@ -128,37 +136,37 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
|
|||||||
{/* 内置节假日 */}
|
{/* 内置节假日 */}
|
||||||
{allAnniversaries.builtIn.length > 0 && (
|
{allAnniversaries.builtIn.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
|
<Text size="xs" c="#888" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||||
即将到来
|
即将到来
|
||||||
</Text>
|
</Text>
|
||||||
{allAnniversaries.builtIn.map((holiday) => {
|
{allAnniversaries.builtIn.map((holiday) => {
|
||||||
const countdown = calculateCountdown(holiday.date, holiday.repeat_type, holiday.is_lunar);
|
const countdown = calculateCountdown(holiday.date, holiday.repeat_type, holiday.is_lunar);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Paper
|
||||||
key={holiday.id}
|
key={holiday.id}
|
||||||
shadow="sm"
|
p="sm"
|
||||||
padding="sm"
|
radius={2}
|
||||||
radius="md"
|
style={{
|
||||||
style={{ cursor: 'pointer', opacity: 0.8, backgroundColor: '#f8f9fa' }}
|
cursor: 'pointer',
|
||||||
|
opacity: 0.7,
|
||||||
|
background: 'rgba(0, 0, 0, 0.02)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.04)',
|
||||||
|
}}
|
||||||
|
onClick={() => onEventClick(holiday as unknown as Event)}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<ThemeIcon size="xs" variant="light" color="green">
|
<Text size="xs" c="#666">{holiday.title}</Text>
|
||||||
<IconCalendar size={10} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={500} size="sm">
|
|
||||||
{holiday.title}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Badge
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="filled"
|
c={countdown.isToday ? '#c41c1c' : '#888'}
|
||||||
color={countdown.isToday ? 'red' : 'green'}
|
style={{ letterSpacing: '0.05em' }}
|
||||||
>
|
>
|
||||||
{formatCountdown(countdown)}
|
{formatCountdown(countdown)}
|
||||||
</Badge>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Paper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@ -168,7 +176,7 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
|
|||||||
{allAnniversaries.user.length > 0 && (
|
{allAnniversaries.user.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{allAnniversaries.builtIn.length > 0 && (
|
{allAnniversaries.builtIn.length > 0 && (
|
||||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" mt="xs">
|
<Text size="xs" c="#666" fw={500} style={{ letterSpacing: '0.05em' }} mt="xs">
|
||||||
我的纪念日
|
我的纪念日
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,12 +1,26 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Paper, Textarea, Group, Text, Stack } from '@mantine/core';
|
import {
|
||||||
|
Paper,
|
||||||
|
Textarea,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
} from '@mantine/core';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
import { useAppStore } from '../../stores';
|
import { useAppStore } from '../../stores';
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'edit' | 'preview';
|
||||||
|
|
||||||
export function NoteEditor({ onSave }: NoteEditorProps) {
|
export function NoteEditor({ onSave }: NoteEditorProps) {
|
||||||
const notes = useAppStore((state) => state.notes);
|
const notes = useAppStore((state) => state.notes);
|
||||||
const updateNotesContent = useAppStore((state) => state.updateNotesContent);
|
const updateNotesContent = useAppStore((state) => state.updateNotesContent);
|
||||||
@ -16,6 +30,7 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
|||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const [viewMode] = useState<ViewMode>('edit');
|
||||||
|
|
||||||
// Initialize content from notes
|
// Initialize content from notes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -29,11 +44,11 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
|||||||
fetchNotes();
|
fetchNotes();
|
||||||
}, [fetchNotes]);
|
}, [fetchNotes]);
|
||||||
|
|
||||||
// Auto-save with debounce
|
// Auto-save with 3 second debounce
|
||||||
const [debouncedContent] = useDebouncedValue(content, 1000);
|
const [debouncedContent] = useDebouncedValue(content, 3000);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedContent !== undefined && notes) {
|
if (debouncedContent !== undefined && notes && debouncedContent !== content) {
|
||||||
handleSave(debouncedContent);
|
handleSave(debouncedContent);
|
||||||
}
|
}
|
||||||
}, [debouncedContent]);
|
}, [debouncedContent]);
|
||||||
@ -47,13 +62,14 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
|||||||
updateNotesContent(value);
|
updateNotesContent(value);
|
||||||
await saveNotes(value);
|
await saveNotes(value);
|
||||||
setLastSaved(new Date());
|
setLastSaved(new Date());
|
||||||
|
onSave?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save note:', error);
|
console.error('Failed to save note:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notes, updateNotesContent, saveNotes]
|
[notes, updateNotesContent, saveNotes, onSave]
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatLastSaved = () => {
|
const formatLastSaved = () => {
|
||||||
@ -65,34 +81,140 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
|||||||
return lastSaved.toLocaleTimeString('zh-CN');
|
return lastSaved.toLocaleTimeString('zh-CN');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleManualSave = () => {
|
||||||
|
if (content && notes) {
|
||||||
|
handleSave(content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p="md" withBorder radius="md" h="100%">
|
<Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Stack gap="sm" h="100%">
|
<Stack gap="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Group justify="space-between">
|
{/* Header */}
|
||||||
<Text fw={500} size="sm">
|
<Group justify="space-between" style={{ flexShrink: 0 }}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text fw={400} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
|
||||||
便签
|
便签
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c={saving ? 'yellow' : lastSaved ? 'dimmed' : 'gray'}>
|
</Group>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconDeviceFloppy size={12} />}
|
||||||
|
onClick={handleManualSave}
|
||||||
|
loading={saving}
|
||||||
|
style={{
|
||||||
|
borderColor: '#ccc',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Text size="xs" c={saving ? '#666' : lastSaved ? '#999' : '#bbb'}>
|
||||||
{saving ? '保存中...' : formatLastSaved()}
|
{saving ? '保存中...' : formatLastSaved()}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Editor/Preview Area */}
|
||||||
|
<Box style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||||
|
{viewMode === 'edit' && (
|
||||||
<Textarea
|
<Textarea
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
placeholder="在这里记录你的想法..."
|
placeholder="在这里记录你的想法... 支持 Markdown 语法: - # 一级标题 - **粗体** - *斜体* - [链接](url)"
|
||||||
autosize
|
autosize
|
||||||
minRows={8}
|
minRows={8}
|
||||||
maxRows={20}
|
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
border: 'none',
|
border: 'none',
|
||||||
resize: 'none',
|
resize: 'none',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
background: 'transparent',
|
||||||
'&:focus': { outline: 'none' },
|
'&:focus': { outline: 'none' },
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
style={{ flex: 1, width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'preview' && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content ? (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
'& h1, & h2, & h3': {
|
||||||
|
marginTop: '1em',
|
||||||
|
marginBottom: '0.5em',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
'& h1': { fontSize: '1.25em', borderBottom: '1px solid rgba(0,0,0,0.08)', paddingBottom: '0.3em' },
|
||||||
|
'& h2': { fontSize: '1.1em', borderBottom: '1px solid rgba(0,0,0,0.06)', paddingBottom: '0.3em' },
|
||||||
|
'& p': { marginBottom: '0.8em', color: '#333' },
|
||||||
|
'& ul, & ol': { paddingLeft: '1.5em', marginBottom: '0.8em' },
|
||||||
|
'& li': { marginBottom: '0.3em', color: '#333' },
|
||||||
|
'& blockquote': {
|
||||||
|
borderLeft: '3px solid rgba(0,0,0,0.1)',
|
||||||
|
paddingLeft: '1em',
|
||||||
|
marginLeft: 0,
|
||||||
|
color: '#888',
|
||||||
|
},
|
||||||
|
'& code': {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||||
|
padding: '0.2em 0.4em',
|
||||||
|
borderRadius: '2px',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
},
|
||||||
|
'& pre': {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.03)',
|
||||||
|
padding: '1em',
|
||||||
|
borderRadius: '2px',
|
||||||
|
overflow: 'auto',
|
||||||
|
},
|
||||||
|
'& pre code': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
'& a': {
|
||||||
|
color: '#1a1a1a',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: 2,
|
||||||
|
},
|
||||||
|
'& hr': {
|
||||||
|
border: 'none',
|
||||||
|
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||||
|
margin: '1em 0',
|
||||||
|
},
|
||||||
|
} as any}
|
||||||
|
>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text c="#999" ta="center" py="xl" size="sm" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
暂无内容
|
||||||
|
<br />
|
||||||
|
<Text size="xs" c="#bbb" mt={4}>
|
||||||
|
使用 Markdown 格式记录你的想法
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
140
src/components/reminder/ArchiveReminderModal.tsx
Normal file
140
src/components/reminder/ArchiveReminderModal.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
ActionIcon,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconRotateClockwise, IconTrash } from '@tabler/icons-react';
|
||||||
|
import type { Event } from '../../types';
|
||||||
|
|
||||||
|
interface ArchiveReminderModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
completedReminders: Event[];
|
||||||
|
onRestore: (event: Event) => void;
|
||||||
|
onDelete: (event: Event) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchiveReminderModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
completedReminders,
|
||||||
|
onRestore,
|
||||||
|
onDelete,
|
||||||
|
}: ArchiveReminderModalProps) {
|
||||||
|
const sortedReminders = useMemo(() => {
|
||||||
|
return [...completedReminders].sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
);
|
||||||
|
}, [completedReminders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<Text
|
||||||
|
fw={400}
|
||||||
|
style={{
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已归档提醒
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
|
styles={{
|
||||||
|
header: {
|
||||||
|
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{completedReminders.length === 0 ? (
|
||||||
|
<Paper p="xl" withBorder radius={4} ta="center">
|
||||||
|
<Text c="#999" size="sm" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
暂无已归档的提醒
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="#bbb" mt={4}>
|
||||||
|
完成的提醒会自动归档到这里
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{sortedReminders.map((event) => (
|
||||||
|
<Paper
|
||||||
|
key={event.id}
|
||||||
|
p="sm"
|
||||||
|
radius={2}
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0, 0, 0, 0.02)',
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={400}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
color: '#999',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="#bbb">
|
||||||
|
{new Date(event.date).toLocaleString('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap={4}>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => onRestore(event)}
|
||||||
|
style={{ color: '#666' }}
|
||||||
|
>
|
||||||
|
<IconRotateClockwise size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => onDelete(event)}
|
||||||
|
style={{ color: '#999' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
color: '#666',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,29 +1,13 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import { Paper, Text, Checkbox, Group, Stack, ActionIcon } from '@mantine/core';
|
||||||
Card,
|
import { IconCheck, IconDots } from '@tabler/icons-react';
|
||||||
Text,
|
|
||||||
Checkbox,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
ThemeIcon,
|
|
||||||
Tooltip,
|
|
||||||
ActionIcon,
|
|
||||||
Menu,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconBell,
|
|
||||||
IconCheck,
|
|
||||||
IconDots,
|
|
||||||
IconTrash,
|
|
||||||
IconEdit,
|
|
||||||
IconClock,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
|
|
||||||
interface ReminderCardProps {
|
interface ReminderCardProps {
|
||||||
event: Event;
|
event: Event;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
||||||
@ -40,7 +24,7 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
|||||||
|
|
||||||
// 格式化时间显示
|
// 格式化时间显示
|
||||||
const timeStr = eventDate.toLocaleString('zh-CN', {
|
const timeStr = eventDate.toLocaleString('zh-CN', {
|
||||||
month: 'short',
|
month: 'numeric',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
@ -51,31 +35,32 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
|||||||
|
|
||||||
// 颜色主题
|
// 颜色主题
|
||||||
const getThemeColor = () => {
|
const getThemeColor = () => {
|
||||||
if (isCompleted) return 'gray';
|
if (isCompleted) return '#999';
|
||||||
if (timeInfo.isPast) return 'red';
|
if (timeInfo.isPast) return '#c41c1c';
|
||||||
if (timeInfo.isToday) return 'orange';
|
if (timeInfo.isToday) return '#666';
|
||||||
return 'blue';
|
return '#888';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Paper
|
||||||
shadow="sm"
|
p="sm"
|
||||||
padding="sm"
|
radius={2}
|
||||||
radius="md"
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
opacity: isCompleted ? 0.5 : 1,
|
opacity: isCompleted ? 0.4 : 1,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
transform: isHovered ? 'translateY(-2px)' : 'translateY(0)',
|
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||||
borderLeft: `3px solid var(--mantine-color-${getThemeColor()}-6)`,
|
borderLeft: `2px solid ${getThemeColor()}`,
|
||||||
|
background: 'rgba(0, 0, 0, 0.02)',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.04)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
{/* Checkbox */}
|
{/* Checkbox - 点击切换完成状态 */}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isCompleted}
|
checked={isCompleted}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -83,19 +68,20 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
|||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
color="green"
|
size="xs"
|
||||||
size="sm"
|
color="#1a1a1a"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
fw={500}
|
fw={400}
|
||||||
size="sm"
|
size="xs"
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
style={{
|
style={{
|
||||||
textDecoration: isCompleted ? 'line-through' : 'none',
|
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||||
color: isCompleted ? 'dimmed' : undefined,
|
color: isCompleted ? '#bbb' : '#1a1a1a',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{event.title}
|
{event.title}
|
||||||
@ -103,9 +89,6 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
|||||||
|
|
||||||
{/* Time and content */}
|
{/* Time and content */}
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon size="xs" variant="light" color={getThemeColor()}>
|
|
||||||
<IconClock size={10} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c={getThemeColor()}>
|
<Text size="xs" c={getThemeColor()}>
|
||||||
{timeInfo.timeStr}
|
{timeInfo.timeStr}
|
||||||
</Text>
|
</Text>
|
||||||
@ -113,7 +96,7 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
|||||||
|
|
||||||
{/* Content preview */}
|
{/* Content preview */}
|
||||||
{event.content && !isCompleted && (
|
{event.content && !isCompleted && (
|
||||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
<Text size="xs" c="#999" lineClamp={1}>
|
||||||
{event.content}
|
{event.content}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -123,51 +106,30 @@ export function ReminderCard({ event, onToggle, onClick }: ReminderCardProps) {
|
|||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
{isHovered && !isCompleted && (
|
{isHovered && !isCompleted && (
|
||||||
<Tooltip label="完成">
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="subtle"
|
||||||
color="green"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
|
style={{ color: '#666' }}
|
||||||
>
|
>
|
||||||
<IconCheck size={14} />
|
<IconCheck size={12} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Menu shadow="md" width={120}>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ color: '#999' }}
|
||||||
>
|
>
|
||||||
<IconDots size={14} />
|
<IconDots size={12} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconEdit size={14} />}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconTrash size={14} />}
|
|
||||||
color="red"
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
{isCompleted ? '恢复' : '删除'}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,21 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Stack, Text, Paper, Group, Button, Badge, ThemeIcon, Alert } from '@mantine/core';
|
import {
|
||||||
import { IconPlus, IconBell, IconAlertCircle } from '@tabler/icons-react';
|
Stack,
|
||||||
|
Text,
|
||||||
|
Paper,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconArchive,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
import { ReminderCard } from './ReminderCard';
|
import { ReminderCard } from './ReminderCard';
|
||||||
|
import { ArchiveReminderModal } from './ArchiveReminderModal';
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
|
|
||||||
interface ReminderListProps {
|
interface ReminderListProps {
|
||||||
@ -9,6 +23,8 @@ interface ReminderListProps {
|
|||||||
onEventClick: (event: Event) => void;
|
onEventClick: (event: Event) => void;
|
||||||
onToggleComplete: (event: Event) => void;
|
onToggleComplete: (event: Event) => void;
|
||||||
onAddClick: () => void;
|
onAddClick: () => void;
|
||||||
|
onDelete?: (event: Event) => void;
|
||||||
|
onRestore?: (event: Event) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReminderList({
|
export function ReminderList({
|
||||||
@ -16,7 +32,11 @@ export function ReminderList({
|
|||||||
onEventClick,
|
onEventClick,
|
||||||
onToggleComplete,
|
onToggleComplete,
|
||||||
onAddClick,
|
onAddClick,
|
||||||
|
onDelete,
|
||||||
|
onRestore,
|
||||||
}: ReminderListProps) {
|
}: ReminderListProps) {
|
||||||
|
const [archiveOpened, setArchiveOpened] = useState(false);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
@ -81,23 +101,25 @@ export function ReminderList({
|
|||||||
// 空状态
|
// 空状态
|
||||||
if (!hasActiveReminders) {
|
if (!hasActiveReminders) {
|
||||||
return (
|
return (
|
||||||
<Paper p="md" withBorder radius="md" h="100%">
|
<Paper p="md" withBorder radius={4} h="100%">
|
||||||
<Stack align="center" justify="center" h="100%">
|
<Stack align="center" justify="center" h="100%">
|
||||||
<ThemeIcon size={40} variant="light" color="gray" radius="xl">
|
<Text c="#999" size="sm" ta="center" style={{ letterSpacing: '0.05em' }}>
|
||||||
<IconBell size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text c="dimmed" size="sm" ta="center">
|
|
||||||
暂无提醒
|
暂无提醒
|
||||||
<br />
|
</Text>
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
<Text size="xs" c="#bbb" ta="center" mt={4}>
|
||||||
记得添加提醒事项哦
|
记得添加提醒事项哦
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconPlus size={14} />}
|
leftSection={<IconPlus size={12} />}
|
||||||
onClick={onAddClick}
|
onClick={onAddClick}
|
||||||
|
style={{
|
||||||
|
borderColor: '#ccc',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
borderRadius: 2,
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
添加提醒
|
添加提醒
|
||||||
</Button>
|
</Button>
|
||||||
@ -110,31 +132,48 @@ export function ReminderList({
|
|||||||
const missedCount = grouped.missed.length;
|
const missedCount = grouped.missed.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p="md" withBorder radius="md" h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
<>
|
||||||
|
<Paper p="md" withBorder radius={4} h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
|
<Group justify="space-between" mb="sm" style={{ flexShrink: 0 }}>
|
||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
<ThemeIcon size="sm" variant="light" color="orange">
|
<Text fw={500} size="sm" style={{ letterSpacing: '0.1em', color: '#1a1a1a' }}>
|
||||||
<IconBell size={12} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={500} size="sm">
|
|
||||||
提醒
|
提醒
|
||||||
</Text>
|
</Text>
|
||||||
{missedCount > 0 && (
|
{missedCount > 0 && (
|
||||||
<Badge size="xs" variant="filled" color="red">
|
<Text size="xs" c="#c41c1c" fw={500}>
|
||||||
{missedCount}个逾期
|
{missedCount}个逾期
|
||||||
</Badge>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
{grouped.completed.length > 0 && (
|
||||||
|
<Tooltip label="查看已归档">
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setArchiveOpened(true)}
|
||||||
|
style={{ color: '#999' }}
|
||||||
|
>
|
||||||
|
<IconArchive size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconPlus size={14} />}
|
leftSection={<IconPlus size={12} />}
|
||||||
onClick={onAddClick}
|
onClick={onAddClick}
|
||||||
|
style={{
|
||||||
|
color: '#666',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
添加
|
添加
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Stack gap="xs" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<Stack gap="xs" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
@ -142,10 +181,10 @@ export function ReminderList({
|
|||||||
{grouped.missed.length > 0 && (
|
{grouped.missed.length > 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
icon={<IconAlertCircle size={14} />}
|
icon={<IconAlertCircle size={14} />}
|
||||||
color="red"
|
color="dark"
|
||||||
variant="light"
|
variant="light"
|
||||||
p="xs"
|
p="xs"
|
||||||
title={<Text size="xs" fw={500}>已错过 {grouped.missed.length}个</Text>}
|
title={<Text size="xs" fw={500} c="#c41c1c">已错过 {grouped.missed.length}个</Text>}
|
||||||
>
|
>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{grouped.missed.slice(0, 3).map((event) => (
|
{grouped.missed.slice(0, 3).map((event) => (
|
||||||
@ -154,10 +193,11 @@ export function ReminderList({
|
|||||||
event={event}
|
event={event}
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
onToggle={() => onToggleComplete(event)}
|
onToggle={() => onToggleComplete(event)}
|
||||||
|
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{grouped.missed.length > 3 && (
|
{grouped.missed.length > 3 && (
|
||||||
<Text size="xs" c="dimmed" ta="center">
|
<Text size="xs" c="#999" ta="center">
|
||||||
还有 {grouped.missed.length - 3} 个逾期提醒...
|
还有 {grouped.missed.length - 3} 个逾期提醒...
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -169,12 +209,9 @@ export function ReminderList({
|
|||||||
{grouped.today.length > 0 && (
|
{grouped.today.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Text size="xs" c="red" fw={600}>
|
<Text size="xs" c="#c41c1c" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||||
今天
|
今天
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="red">
|
|
||||||
{grouped.today.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
{grouped.today.map((event) => (
|
{grouped.today.map((event) => (
|
||||||
<ReminderCard
|
<ReminderCard
|
||||||
@ -182,6 +219,7 @@ export function ReminderList({
|
|||||||
event={event}
|
event={event}
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
onToggle={() => onToggleComplete(event)}
|
onToggle={() => onToggleComplete(event)}
|
||||||
|
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@ -191,12 +229,9 @@ export function ReminderList({
|
|||||||
{grouped.tomorrow.length > 0 && (
|
{grouped.tomorrow.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Text size="xs" c="teal" fw={600}>
|
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||||
明天
|
明天
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="teal">
|
|
||||||
{grouped.tomorrow.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
{grouped.tomorrow.map((event) => (
|
{grouped.tomorrow.map((event) => (
|
||||||
<ReminderCard
|
<ReminderCard
|
||||||
@ -204,6 +239,7 @@ export function ReminderList({
|
|||||||
event={event}
|
event={event}
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
onToggle={() => onToggleComplete(event)}
|
onToggle={() => onToggleComplete(event)}
|
||||||
|
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@ -213,12 +249,9 @@ export function ReminderList({
|
|||||||
{grouped.thisWeek.length > 0 && (
|
{grouped.thisWeek.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Text size="xs" c="blue" fw={600}>
|
<Text size="xs" c="#888" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||||
本周
|
本周
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="blue">
|
|
||||||
{grouped.thisWeek.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
{grouped.thisWeek.map((event) => (
|
{grouped.thisWeek.map((event) => (
|
||||||
<ReminderCard
|
<ReminderCard
|
||||||
@ -226,6 +259,7 @@ export function ReminderList({
|
|||||||
event={event}
|
event={event}
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
onToggle={() => onToggleComplete(event)}
|
onToggle={() => onToggleComplete(event)}
|
||||||
|
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@ -235,12 +269,9 @@ export function ReminderList({
|
|||||||
{grouped.later.length > 0 && (
|
{grouped.later.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Text size="xs" c="gray" fw={600}>
|
<Text size="xs" c="#999" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||||
以后
|
以后
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="gray">
|
|
||||||
{grouped.later.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
{grouped.later.map((event) => (
|
{grouped.later.map((event) => (
|
||||||
<ReminderCard
|
<ReminderCard
|
||||||
@ -248,11 +279,22 @@ export function ReminderList({
|
|||||||
event={event}
|
event={event}
|
||||||
onClick={() => onEventClick(event)}
|
onClick={() => onEventClick(event)}
|
||||||
onToggle={() => onToggleComplete(event)}
|
onToggle={() => onToggleComplete(event)}
|
||||||
|
onDelete={onDelete ? () => onDelete(event) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* Archive Modal */}
|
||||||
|
<ArchiveReminderModal
|
||||||
|
opened={archiveOpened}
|
||||||
|
onClose={() => setArchiveOpened(false)}
|
||||||
|
completedReminders={grouped.completed}
|
||||||
|
onRestore={onRestore || (() => {})}
|
||||||
|
onDelete={onDelete || (() => {})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,13 @@
|
|||||||
* 基于2024年节假日安排
|
* 基于2024年节假日安排
|
||||||
* 每个节日包含:名称、日期(公历/农历)、是否放假日
|
* 每个节日包含:名称、日期(公历/农历)、是否放假日
|
||||||
*/
|
*/
|
||||||
|
import { Lunar } from 'lunar-javascript';
|
||||||
|
|
||||||
export interface Holiday {
|
export interface Holiday {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
month: number; // 公历月份
|
month?: number; // 公历月份(非农历时使用)
|
||||||
day: number; // 公历日期
|
day?: number; // 公历日期(非农历时使用)
|
||||||
isLunar: boolean; // 是否为农历日期
|
isLunar: boolean; // 是否为农历日期
|
||||||
lunarMonth?: number; // 农历月份(农历日期时使用)
|
lunarMonth?: number; // 农历月份(农历日期时使用)
|
||||||
lunarDay?: number; // 农历日期(农历日期时使用)
|
lunarDay?: number; // 农历日期(农历日期时使用)
|
||||||
@ -168,9 +169,10 @@ export function isHoliday(
|
|||||||
// 农历日期需要转换
|
// 农历日期需要转换
|
||||||
try {
|
try {
|
||||||
const lunar = Lunar.fromYmd(year, month, day);
|
const lunar = Lunar.fromYmd(year, month, day);
|
||||||
|
const solar = lunar.getSolar();
|
||||||
if (
|
if (
|
||||||
lunar.getMonth() === holiday.lunarMonth &&
|
solar.getMonth() === holiday.lunarMonth &&
|
||||||
lunar.getDay() === holiday.lunarDay
|
solar.getDay() === holiday.lunarDay
|
||||||
) {
|
) {
|
||||||
return holiday;
|
return holiday;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,14 +13,14 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { DateInput, TimeInput } from '@mantine/dates';
|
import { DatePickerInput, TimeInput } from '@mantine/dates';
|
||||||
import { IconLogout, IconPlus } from '@tabler/icons-react';
|
import { IconLogout } from '@tabler/icons-react';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useAppStore } from '../stores';
|
import { useAppStore } from '../stores';
|
||||||
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
||||||
import { ReminderList } from '../components/reminder/ReminderList';
|
import { ReminderList } from '../components/reminder/ReminderList';
|
||||||
import { NoteEditor } from '../components/note/NoteEditor';
|
import { NoteEditor } from '../components/note/NoteEditor';
|
||||||
import { AIChatBox } from '../components/ai/AIChatBox';
|
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
|
||||||
import type { Event, EventType, RepeatType } from '../types';
|
import type { Event, EventType, RepeatType } from '../types';
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
@ -115,7 +115,7 @@ export function HomePage() {
|
|||||||
fetchEvents();
|
fetchEvents();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDeleteFromModal = async () => {
|
||||||
if (!selectedEvent) return;
|
if (!selectedEvent) return;
|
||||||
await deleteEventById(selectedEvent.id);
|
await deleteEventById(selectedEvent.id);
|
||||||
close();
|
close();
|
||||||
@ -130,6 +130,20 @@ export function HomePage() {
|
|||||||
fetchEvents();
|
fetchEvents();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (event: Event) => {
|
||||||
|
if (event.type !== 'reminder') return;
|
||||||
|
await deleteEventById(event.id);
|
||||||
|
fetchEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (event: Event) => {
|
||||||
|
if (event.type !== 'reminder') return;
|
||||||
|
await updateEventById(event.id, {
|
||||||
|
is_completed: false,
|
||||||
|
});
|
||||||
|
fetchEvents();
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormType('anniversary');
|
setFormType('anniversary');
|
||||||
setFormTitle('');
|
setFormTitle('');
|
||||||
@ -148,86 +162,121 @@ export function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: '#faf9f7',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}>
|
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
|
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
|
||||||
<Title order={2} c="blue">
|
<Title
|
||||||
|
order={2}
|
||||||
|
style={{
|
||||||
|
fontWeight: 300,
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
掐日子
|
掐日子
|
||||||
</Title>
|
</Title>
|
||||||
<Group>
|
<Group>
|
||||||
<Text size="sm" c="dimmed">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="#888"
|
||||||
|
style={{ letterSpacing: '0.05em' }}
|
||||||
|
>
|
||||||
{user?.nickname || user?.email}
|
{user?.nickname || user?.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconLogout size={14} />}
|
leftSection={<IconLogout size={12} />}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
style={{
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
退出
|
退出
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Main Content - 4 panel layout */}
|
{/* Main Content - 3 column horizontal layout */}
|
||||||
<Grid grow style={{ flex: 1, minHeight: 0 }}>
|
<Grid grow style={{ flex: 1, minHeight: 0 }} gutter="md">
|
||||||
{/* Left column - 40% */}
|
{/* Left column - Anniversary */}
|
||||||
<Grid.Col span={4}>
|
<Grid.Col span={4}>
|
||||||
<Stack gap="md" h="100%">
|
<div style={{ height: '100%', minHeight: 0 }}>
|
||||||
{/* Anniversary list */}
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<AnniversaryList
|
<AnniversaryList
|
||||||
events={events}
|
events={events}
|
||||||
onEventClick={handleEventClick}
|
onEventClick={handleEventClick}
|
||||||
onAddClick={() => handleAddClick('anniversary')}
|
onAddClick={() => handleAddClick('anniversary')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
{/* Reminder list */}
|
{/* Middle column - Reminder */}
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<Grid.Col span={4}>
|
||||||
|
<div style={{ height: '100%', minHeight: 0 }}>
|
||||||
<ReminderList
|
<ReminderList
|
||||||
events={events}
|
events={events}
|
||||||
onEventClick={handleEventClick}
|
onEventClick={handleEventClick}
|
||||||
onToggleComplete={handleToggleComplete}
|
onToggleComplete={handleToggleComplete}
|
||||||
onAddClick={() => handleAddClick('reminder')}
|
onAddClick={() => handleAddClick('reminder')}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRestore={handleRestore}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
{/* Right column - 60% */}
|
{/* Right column - Note */}
|
||||||
<Grid.Col span={8}>
|
<Grid.Col span={4}>
|
||||||
<Stack gap="md" h="100%">
|
<div style={{ height: '100%', minHeight: 0 }}>
|
||||||
{/* Note editor */}
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<NoteEditor />
|
<NoteEditor />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Chat */}
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<AIChatBox onEventCreated={handleAIEventCreated} />
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* AI Chat - Floating */}
|
||||||
|
<FloatingAIChat onEventCreated={handleAIEventCreated} />
|
||||||
|
|
||||||
{/* Add/Edit Event Modal */}
|
{/* Add/Edit Event Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
title={
|
title={
|
||||||
<Group gap={8}>
|
<Text
|
||||||
<IconPlus size={18} />
|
fw={400}
|
||||||
<Text fw={500}>{isEdit ? '编辑事件' : '添加事件'}</Text>
|
style={{
|
||||||
</Group>
|
letterSpacing: '0.1em',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEdit ? '编辑事件' : '添加事件'}
|
||||||
|
</Text>
|
||||||
}
|
}
|
||||||
size="md"
|
size="md"
|
||||||
|
styles={{
|
||||||
|
header: {
|
||||||
|
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Event type */}
|
{/* Event type */}
|
||||||
<Select
|
<Select
|
||||||
label="类型"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||||
|
类型
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'anniversary', label: '纪念日' },
|
{ value: 'anniversary', label: '纪念日' },
|
||||||
{ value: 'reminder', label: '提醒' },
|
{ value: 'reminder', label: '提醒' },
|
||||||
@ -235,57 +284,111 @@ export function HomePage() {
|
|||||||
value={formType}
|
value={formType}
|
||||||
onChange={(value) => value && setFormType(value as EventType)}
|
onChange={(value) => value && setFormType(value as EventType)}
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="标题"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||||
|
标题
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
placeholder="输入标题"
|
placeholder="输入标题"
|
||||||
value={formTitle}
|
value={formTitle}
|
||||||
onChange={(e) => setFormTitle(e.target.value)}
|
onChange={(e) => setFormTitle(e.target.value)}
|
||||||
required
|
required
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content (only for reminders) */}
|
{/* Content (only for reminders) */}
|
||||||
{formType === 'reminder' && (
|
{formType === 'reminder' && (
|
||||||
<Textarea
|
<Textarea
|
||||||
label="内容"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||||
|
内容
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
placeholder="输入详细内容"
|
placeholder="输入详细内容"
|
||||||
value={formContent}
|
value={formContent}
|
||||||
onChange={(e) => setFormContent(e.target.value)}
|
onChange={(e) => setFormContent(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<DateInput
|
<DatePickerInput
|
||||||
label="日期"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||||
|
日期
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
placeholder="选择日期"
|
placeholder="选择日期"
|
||||||
value={formDate}
|
value={formDate}
|
||||||
onChange={setFormDate}
|
onChange={(value) => setFormDate(value as Date | null)}
|
||||||
required
|
required
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Time (only for reminders) */}
|
{/* Time (only for reminders) */}
|
||||||
{formType === 'reminder' && (
|
{formType === 'reminder' && (
|
||||||
<TimeInput
|
<TimeInput
|
||||||
label="时间"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||||
|
时间
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
placeholder="选择时间"
|
placeholder="选择时间"
|
||||||
value={formTime}
|
value={formTime}
|
||||||
onChange={(e) => setFormTime(e.target.value)}
|
onChange={(e) => setFormTime(e.target.value)}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lunar switch */}
|
{/* Lunar switch */}
|
||||||
<Switch
|
<Switch
|
||||||
label="农历日期"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
农历日期
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
checked={formIsLunar}
|
checked={formIsLunar}
|
||||||
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Repeat type */}
|
{/* Repeat type */}
|
||||||
<Select
|
<Select
|
||||||
label="重复"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||||
|
重复
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'none', label: '不重复' },
|
{ value: 'none', label: '不重复' },
|
||||||
{ value: 'yearly', label: '每年' },
|
{ value: 'yearly', label: '每年' },
|
||||||
@ -293,12 +396,22 @@ export function HomePage() {
|
|||||||
]}
|
]}
|
||||||
value={formRepeatType}
|
value={formRepeatType}
|
||||||
onChange={(value) => value && setFormRepeatType(value as RepeatType)}
|
onChange={(value) => value && setFormRepeatType(value as RepeatType)}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Holiday switch (only for anniversaries) */}
|
{/* Holiday switch (only for anniversaries) */}
|
||||||
{formType === 'anniversary' && (
|
{formType === 'anniversary' && (
|
||||||
<Switch
|
<Switch
|
||||||
label="节假日"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
节假日
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
checked={formIsHoliday}
|
checked={formIsHoliday}
|
||||||
onChange={(e) => setFormIsHoliday(e.currentTarget.checked)}
|
onChange={(e) => setFormIsHoliday(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
@ -307,15 +420,37 @@ export function HomePage() {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Group justify="space-between" mt="md">
|
<Group justify="space-between" mt="md">
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<Button color="red" variant="light" onClick={handleDelete}>
|
<Button
|
||||||
|
color="dark"
|
||||||
|
variant="light"
|
||||||
|
onClick={handleDeleteFromModal}
|
||||||
|
style={{
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Group ml="auto">
|
<Group ml="auto">
|
||||||
<Button variant="subtle" onClick={close}>
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={close}
|
||||||
|
style={{
|
||||||
|
borderRadius: 2,
|
||||||
|
color: '#666',
|
||||||
|
}}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={!formTitle.trim() || !formDate}>
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!formTitle.trim() || !formDate}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
border: '1px solid #1a1a1a',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isEdit ? '保存' : '添加'}
|
{isEdit ? '保存' : '添加'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@ -323,5 +458,6 @@ export function HomePage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,418 @@
|
|||||||
import { Button, Container, Title, Text, Paper } from '@mantine/core';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Button, Container, Title, Text, Group, Stack } from '@mantine/core';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import iconUrl from '../assets/icon.png';
|
||||||
|
|
||||||
|
// 禅意算法背景 - 手印静寂(循环墨晕效果)
|
||||||
|
function ZenBackground() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
// 简化的伪随机
|
||||||
|
const random = (min = 0, max = 1) => {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化的噪声函数
|
||||||
|
const noise = (x: number, y: number, t: number) => {
|
||||||
|
return (Math.sin(x * 0.01 + t) * Math.cos(y * 0.01 + t * 0.7) + 1) * 0.5;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InkStroke {
|
||||||
|
points: { x: number; y: number; weight: number }[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
speed: number;
|
||||||
|
inkAlpha: number;
|
||||||
|
baseWeight: number;
|
||||||
|
maxLength: number;
|
||||||
|
currentLength: number;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let strokes: InkStroke[] = [];
|
||||||
|
let time = 0;
|
||||||
|
let lastStrokeTime = 0;
|
||||||
|
const strokeInterval = 30; // 每隔一段时间生成新笔触
|
||||||
|
|
||||||
|
// 生成随机笔触
|
||||||
|
const createStroke = (): InkStroke | null => {
|
||||||
|
const angle = random(0, Math.PI * 2);
|
||||||
|
const radius = random(canvas.width * 0.1, canvas.width * 0.4);
|
||||||
|
const x = canvas.width / 2 + Math.cos(angle) * radius;
|
||||||
|
const y = canvas.height / 2 + Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: [{ x, y, weight: random(0.3, 1.5) }],
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
angle: random(0, Math.PI * 2),
|
||||||
|
speed: random(0.4, 1.0),
|
||||||
|
inkAlpha: random(10, 30),
|
||||||
|
baseWeight: random(0.3, 1.5),
|
||||||
|
maxLength: random(30, 90), // 缩短笔触长度,加快消失
|
||||||
|
currentLength: 0,
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化一些笔触
|
||||||
|
const initStrokes = () => {
|
||||||
|
strokes = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const stroke = createStroke();
|
||||||
|
if (stroke) {
|
||||||
|
// 随机偏移起始位置
|
||||||
|
stroke.currentLength = random(0, stroke.maxLength * 0.5);
|
||||||
|
strokes.push(stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initStrokes();
|
||||||
|
|
||||||
|
// 清空画布
|
||||||
|
ctx.fillStyle = '#faf9f7';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
time += 0.008;
|
||||||
|
|
||||||
|
// 快速淡出背景 - 让水墨痕迹更快消失
|
||||||
|
if (Math.floor(time * 125) % 3 === 0) {
|
||||||
|
ctx.fillStyle = 'rgba(250, 249, 247, 0.12)';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定期生成新笔触
|
||||||
|
if (time - lastStrokeTime > strokeInterval * 0.01) {
|
||||||
|
lastStrokeTime = time;
|
||||||
|
// 移除已完成太久的笔触
|
||||||
|
strokes = strokes.filter(s => !s.complete || s.currentLength < s.maxLength);
|
||||||
|
// 添加新笔触(随机数量)
|
||||||
|
const newCount = Math.floor(random(0, 2));
|
||||||
|
for (let i = 0; i < newCount; i++) {
|
||||||
|
const stroke = createStroke();
|
||||||
|
if (stroke) strokes.push(stroke!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新和绘制笔触
|
||||||
|
for (const stroke of strokes) {
|
||||||
|
if (stroke.complete) continue;
|
||||||
|
|
||||||
|
// 噪声驱动
|
||||||
|
const n = noise(stroke.x, stroke.y, time);
|
||||||
|
stroke.angle += (n - 0.5) * 0.12;
|
||||||
|
|
||||||
|
// 呼吸感 - 更柔和
|
||||||
|
const breath = Math.sin(time * 1.5 + stroke.x * 0.01) * 0.25;
|
||||||
|
const currentSpeed = stroke.speed * (1 + breath * 0.2);
|
||||||
|
|
||||||
|
stroke.x += Math.cos(stroke.angle) * currentSpeed;
|
||||||
|
stroke.y += Math.sin(stroke.angle) * currentSpeed;
|
||||||
|
|
||||||
|
// 笔触粗细 - 模拟提按
|
||||||
|
const progress = stroke.currentLength / stroke.maxLength;
|
||||||
|
const weightVar = Math.sin(progress * Math.PI) * 1.0;
|
||||||
|
const weight = Math.max(0.2, stroke.baseWeight + weightVar);
|
||||||
|
|
||||||
|
stroke.points.push({ x: stroke.x, y: stroke.y, weight });
|
||||||
|
stroke.currentLength++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
stroke.currentLength >= stroke.maxLength ||
|
||||||
|
stroke.x < -50 || stroke.x > canvas.width + 50 ||
|
||||||
|
stroke.y < -50 || stroke.y > canvas.height + 50
|
||||||
|
) {
|
||||||
|
stroke.complete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制 - 水墨晕染效果
|
||||||
|
if (stroke.points.length > 1) {
|
||||||
|
for (let i = 1; i < stroke.points.length; i++) {
|
||||||
|
const p1 = stroke.points[i - 1];
|
||||||
|
const p2 = stroke.points[i];
|
||||||
|
// 渐变透明度
|
||||||
|
const alpha = stroke.inkAlpha * (1 - i / stroke.points.length * 0.4) / 100;
|
||||||
|
const size = p2.weight * random(0.8, 1.2);
|
||||||
|
|
||||||
|
// 绘制柔和的笔触
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p1.x, p1.y);
|
||||||
|
ctx.lineTo(p2.x, p2.y);
|
||||||
|
ctx.strokeStyle = `rgba(25, 25, 25, ${alpha})`;
|
||||||
|
ctx.lineWidth = size;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 添加淡淡的墨点晕染
|
||||||
|
if (random(0, 1) < 0.3) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p2.x, p2.y, size * random(0.5, 1.5), 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(25, 25, 25, ${alpha * 0.3})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制圆相(Ensō)- 缓慢呼吸
|
||||||
|
const centerX = canvas.width / 2;
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
const breathScale = 1 + Math.sin(time * 0.3) * 0.015;
|
||||||
|
const radius = Math.min(canvas.width, canvas.height) * 0.18 * breathScale;
|
||||||
|
const gap = Math.PI * 0.18; // 开口
|
||||||
|
const startAngle = -Math.PI / 2 + time * 0.05;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let a = startAngle; a < startAngle + Math.PI * 2 - gap; a += 0.04) {
|
||||||
|
const noiseOffset = noise(Math.cos(a) * 2, Math.sin(a) * 2, time * 0.2) * 5 - 2.5;
|
||||||
|
const r = radius + noiseOffset;
|
||||||
|
const x = centerX + Math.cos(a) * r;
|
||||||
|
const y = centerY + Math.sin(a) * r;
|
||||||
|
if (a === startAngle) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(25, 25, 25, 0.06)';
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LandingPage() {
|
export function LandingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
<div
|
||||||
<Paper p="xl" style={{ textAlign: 'center' }}>
|
style={{
|
||||||
<Title order={1} mb="md" c="blue">
|
minHeight: '100vh',
|
||||||
|
background: '#faf9f7',
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
掐日子
|
掐日子
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="lg" c="dimmed" mb="xl">
|
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
style={{
|
||||||
|
letterSpacing: '0.35em',
|
||||||
|
color: '#888',
|
||||||
|
fontWeight: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
AI 纪念日 · 提醒
|
AI 纪念日 · 提醒
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="gray.6" mb="xl">
|
|
||||||
轻便、灵活的倒数日和提醒App,专注提醒功能
|
<Text
|
||||||
|
size="xs"
|
||||||
|
style={{
|
||||||
|
color: '#999',
|
||||||
|
maxWidth: 300,
|
||||||
|
lineHeight: 1.9,
|
||||||
|
fontWeight: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
轻便、灵活的倒数日和提醒应用
|
||||||
|
<br />
|
||||||
|
让每一个重要的日子都被铭记
|
||||||
</Text>
|
</Text>
|
||||||
<Button size="lg" variant="filled" onClick={() => navigate('/login')}>
|
|
||||||
开始使用
|
<Group gap="md" mt="lg">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
border: '1px solid #1a1a1a',
|
||||||
|
padding: '0 2rem',
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
style={{
|
||||||
|
borderColor: '#ccc',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
padding: '0 2rem',
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={40} mt={50} style={{ opacity: 0.7 }}>
|
||||||
|
<Stack gap={3} align="center">
|
||||||
|
<Text size="xs" fw={300} c="#444">◯</Text>
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>纪念日</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={3} align="center">
|
||||||
|
<Text size="xs" fw={300} c="#444">◎</Text>
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>提醒</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={3} align="center">
|
||||||
|
<Text size="xs" fw={300} c="#444">◇</Text>
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>AI</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={3} align="center">
|
||||||
|
<Text size="xs" fw={300} c="#444">▫</Text>
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>便签</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,43 +26,122 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
<div
|
||||||
<Paper p="xl" shadow="md" radius="lg">
|
style={{
|
||||||
<Title order={2} ta="center" mb="xl">
|
minHeight: '100vh',
|
||||||
|
background: '#faf9f7',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container size="sm" style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<Paper
|
||||||
|
p={40}
|
||||||
|
radius={4}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title
|
||||||
|
order={2}
|
||||||
|
ta="center"
|
||||||
|
mb={32}
|
||||||
|
style={{
|
||||||
|
fontWeight: 300,
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
letterSpacing: '0.2em',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
登录
|
登录
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="邮箱"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||||
|
邮箱
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
borderColor: '#e0e0e0',
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="密码"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||||
|
密码
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
borderColor: '#e0e0e0',
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{error && <Text c="red" size="sm">{error}</Text>}
|
{error && (
|
||||||
<Button type="submit" loading={loading} fullWidth>
|
<Text c="#c41c1c" size="xs" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
border: '1px solid #1a1a1a',
|
||||||
|
borderRadius: 2,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
登录
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Text ta="center" mt="md" size="sm" c="dimmed">
|
<Text
|
||||||
|
ta="center"
|
||||||
|
mt="lg"
|
||||||
|
size="xs"
|
||||||
|
c="#888"
|
||||||
|
style={{ letterSpacing: '0.05em' }}
|
||||||
|
>
|
||||||
还没有账号?{' '}
|
还没有账号?{' '}
|
||||||
<Anchor component="button" onClick={() => navigate('/register')}>
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
style={{
|
||||||
|
color: '#1a1a1a',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
立即注册
|
立即注册
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,71 +37,175 @@ export function RegisterPage() {
|
|||||||
if (error) {
|
if (error) {
|
||||||
setError(error);
|
setError(error);
|
||||||
} else {
|
} else {
|
||||||
// 注册成功后直接跳转到首页(已登录状态)
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
<div
|
||||||
<Paper p="xl" shadow="md" radius="lg">
|
style={{
|
||||||
<Title order={2} ta="center" mb="xl">
|
minHeight: '100vh',
|
||||||
|
background: '#faf9f7',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container size="sm" style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<Paper
|
||||||
|
p={40}
|
||||||
|
radius={4}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title
|
||||||
|
order={2}
|
||||||
|
ta="center"
|
||||||
|
mb={32}
|
||||||
|
style={{
|
||||||
|
fontWeight: 300,
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
letterSpacing: '0.2em',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
注册
|
注册
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="昵称"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||||
|
昵称
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
placeholder="Your nickname"
|
placeholder="Your nickname"
|
||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
borderColor: '#e0e0e0',
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="邮箱"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||||
|
邮箱
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
borderColor: '#e0e0e0',
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="密码"
|
label={
|
||||||
|
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em', marginBottom: 4 }}>
|
||||||
|
密码
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Create password"
|
placeholder="Create password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderRadius: 2,
|
||||||
|
borderColor: '#e0e0e0',
|
||||||
|
background: '#faf9f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* 密码要求提示 */}
|
{/* 密码要求提示 */}
|
||||||
<Stack gap={4} mt={6}>
|
<Stack gap={4} mt={8}>
|
||||||
{passwordRequirements.map((req, index) => (
|
{passwordRequirements.map((req, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={index}
|
key={index}
|
||||||
size="xs"
|
size="xs"
|
||||||
c={req.met ? 'green' : 'dimmed'}
|
c={req.met ? '#4a9c6d' : '#999'}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{req.met ? '✓' : '○'} {req.label}
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
display: 'inline-block',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{req.met ? '●' : '○'}
|
||||||
|
</span>
|
||||||
|
{req.label}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
{error && <Text c="red" size="sm">{error}</Text>}
|
{error && (
|
||||||
<Button type="submit" loading={loading} fullWidth disabled={!meetsAllRequirements && password.length > 0}>
|
<Text c="#c41c1c" size="xs" style={{ letterSpacing: '0.05em' }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
disabled={!meetsAllRequirements && password.length > 0}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
border: '1px solid #1a1a1a',
|
||||||
|
borderRadius: 2,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
注册
|
注册
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Text ta="center" mt="md" size="sm" c="dimmed">
|
<Text
|
||||||
|
ta="center"
|
||||||
|
mt="lg"
|
||||||
|
size="xs"
|
||||||
|
c="#888"
|
||||||
|
style={{ letterSpacing: '0.05em' }}
|
||||||
|
>
|
||||||
已有账号?{' '}
|
已有账号?{' '}
|
||||||
<Anchor component="button" onClick={() => navigate('/login')}>
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{
|
||||||
|
color: '#1a1a1a',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
立即登录
|
立即登录
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { LoginPage } from './pages/LoginPage';
|
|||||||
import { RegisterPage } from './pages/RegisterPage';
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
import { HomePage } from './pages/HomePage';
|
import { HomePage } from './pages/HomePage';
|
||||||
import { useAppStore } from './stores';
|
import { useAppStore } from './stores';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
// Loading spinner component
|
// Loading spinner component
|
||||||
function AuthLoading() {
|
function AuthLoading() {
|
||||||
@ -91,10 +91,25 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Root redirect - 根据登录状态重定向
|
||||||
|
function RootRedirect() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthLoader();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <AuthLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/home" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to="/landing" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <Navigate to="/home" replace />,
|
element: <RootRedirect />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/landing',
|
path: '/landing',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { User, Event, Note, AIConversation, EventType } from '../types';
|
import type { User, Event, Note, EventType } from '../types';
|
||||||
|
|
||||||
// API Base URL - from environment variable
|
// API Base URL - from environment variable
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||||
@ -33,6 +33,7 @@ export const api = {
|
|||||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'include', // 确保跨域请求时发送凭证(cookie)
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@ -80,11 +80,3 @@ export interface ApiResponse<T> {
|
|||||||
|
|
||||||
// Loading state types
|
// Loading state types
|
||||||
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
|
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
// Grouped reminder types
|
|
||||||
export interface GroupedReminders {
|
|
||||||
today: Reminder[];
|
|
||||||
tomorrow: Reminder[];
|
|
||||||
later: Reminder[];
|
|
||||||
missed: Reminder[];
|
|
||||||
}
|
|
||||||
|
|||||||
16
src/types/lunar-javascript.d.ts
vendored
Normal file
16
src/types/lunar-javascript.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
declare module 'lunar-javascript' {
|
||||||
|
export class Lunar {
|
||||||
|
static fromYmd(year: number, month: number, day: number): Lunar;
|
||||||
|
getSolar(): Solar;
|
||||||
|
getMonth(): number;
|
||||||
|
getDay(): number;
|
||||||
|
getMonthInChinese(): string;
|
||||||
|
getDayInChinese(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Solar {
|
||||||
|
getYear(): number;
|
||||||
|
getMonth(): number;
|
||||||
|
getDay(): number;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Lunar, Solar } from 'lunar-javascript';
|
import { Lunar } from 'lunar-javascript';
|
||||||
|
|
||||||
export interface CountdownResult {
|
export interface CountdownResult {
|
||||||
days: number;
|
days: number;
|
||||||
@ -12,6 +12,7 @@ export interface CountdownResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 安全地创建日期,处理月末边界情况
|
* 安全地创建日期,处理月末边界情况
|
||||||
|
* 某些月份只有28/29/30/31天
|
||||||
*/
|
*/
|
||||||
function safeCreateDate(year: number, month: number, day: number): Date {
|
function safeCreateDate(year: number, month: number, day: number): Date {
|
||||||
const date = new Date(year, month, day);
|
const date = new Date(year, month, day);
|
||||||
@ -22,6 +23,32 @@ function safeCreateDate(year: number, month: number, day: number): Date {
|
|||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地创建农历日期,处理月末边界情况
|
||||||
|
* 某些农历月份只有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 的倒计时
|
* 考虑重复规则,计算下一个 occurrence 的倒计时
|
||||||
@ -44,10 +71,14 @@ export function calculateCountdown(
|
|||||||
const originalDay = dateParts[2];
|
const originalDay = dateParts[2];
|
||||||
|
|
||||||
if (isLunar) {
|
if (isLunar) {
|
||||||
// 农历日期:直接用原始农历年/月/日创建农历对象
|
// 农历日期:使用安全方法创建,处理月末边界
|
||||||
const lunarDate = Lunar.fromYmd(originalYear, originalMonth + 1, originalDay);
|
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
|
||||||
const solar = lunarDate.getSolar();
|
if (result) {
|
||||||
targetDate = new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay());
|
targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay());
|
||||||
|
} else {
|
||||||
|
// 无法解析农历日期,使用原始公历日期作为后备
|
||||||
|
targetDate = new Date(originalYear, originalMonth, originalDay);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 公历日期
|
// 公历日期
|
||||||
targetDate = new Date(originalYear, originalMonth, originalDay);
|
targetDate = new Date(originalYear, originalMonth, originalDay);
|
||||||
@ -158,13 +189,11 @@ export function getFriendlyDateDescription(
|
|||||||
|
|
||||||
if (isLunar) {
|
if (isLunar) {
|
||||||
// 显示农历
|
// 显示农历
|
||||||
try {
|
const result = safeCreateLunarDate(targetDate.getFullYear(), month, day);
|
||||||
const solar = Solar.fromYmd(targetDate.getFullYear(), month, day);
|
if (result) {
|
||||||
const lunar = solar.getLunar();
|
return `${result.lunar.getMonthInChinese()}月${result.lunar.getDayInChinese()}`;
|
||||||
return `${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`;
|
|
||||||
} catch {
|
|
||||||
return `${month}月${day}日`;
|
|
||||||
}
|
}
|
||||||
|
return `${month}月${day}日`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${month}月${day}日`;
|
return `${month}月${day}日`;
|
||||||
@ -184,8 +213,7 @@ export function getSolarFromLunar(lunarMonth: number, lunarDay: number, year?: n
|
|||||||
* 获取公历日期的农历日期
|
* 获取公历日期的农历日期
|
||||||
*/
|
*/
|
||||||
export function getLunarFromSolar(solarDate: Date): { month: number; day: number; monthInChinese: string; dayInChinese: string } {
|
export function getLunarFromSolar(solarDate: Date): { month: number; day: number; monthInChinese: string; dayInChinese: string } {
|
||||||
const solar = Solar.fromYmd(solarDate.getFullYear(), solarDate.getMonth() + 1, solarDate.getDate());
|
const lunar = Lunar.fromYmd(solarDate.getFullYear(), solarDate.getMonth() + 1, solarDate.getDate());
|
||||||
const lunar = solar.getLunar();
|
|
||||||
return {
|
return {
|
||||||
month: lunar.getMonth(),
|
month: lunar.getMonth(),
|
||||||
day: lunar.getDay(),
|
day: lunar.getDay(),
|
||||||
|
|||||||
1
tmpclaude-1682-cwd
Normal file
1
tmpclaude-1682-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-51ef-cwd
Normal file
1
tmpclaude-51ef-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-53b7-cwd
Normal file
1
tmpclaude-53b7-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-5843-cwd
Normal file
1
tmpclaude-5843-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-7db7-cwd
Normal file
1
tmpclaude-7db7-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-8b3b-cwd
Normal file
1
tmpclaude-8b3b-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-90be-cwd
Normal file
1
tmpclaude-90be-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-90fc-cwd
Normal file
1
tmpclaude-90fc-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-c0a2-cwd
Normal file
1
tmpclaude-c0a2-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-ca71-cwd
Normal file
1
tmpclaude-ca71-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-d261-cwd
Normal file
1
tmpclaude-d261-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-d732-cwd
Normal file
1
tmpclaude-d732-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-ea09-cwd
Normal file
1
tmpclaude-ea09-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-f30f-cwd
Normal file
1
tmpclaude-f30f-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-f858-cwd
Normal file
1
tmpclaude-f858-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
1
tmpclaude-fec8-cwd
Normal file
1
tmpclaude-fec8-cwd
Normal file
@ -0,0 +1 @@
|
|||||||
|
/e/qia/client
|
||||||
Loading…
x
Reference in New Issue
Block a user