feat: 优化 AI 对话框交互体验

- 重构 AI 输入框为底部悬浮式,聚焦展开
- 添加预览卡片支持编辑重复、颜色等选项
- 优化时区显示和日期格式化
- 添加 loading 状态和 Toast 提示
- 支持确认后自动关闭并显示成功通知

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-02-13 11:53:16 +08:00
parent bc627544d8
commit 5f1c6208df
5 changed files with 630 additions and 214 deletions

View File

@ -9,23 +9,46 @@ import {
Loader, Loader,
Box, Box,
Transition, Transition,
Button,
Badge,
Divider,
} from '@mantine/core'; } from '@mantine/core';
import { IconSparkles, IconX, IconSend, IconMessage } from '@tabler/icons-react'; import {
IconSparkles,
IconSend,
IconCalendar,
IconRepeat,
IconFlag,
} from '@tabler/icons-react';
import { api } from '../../services/api'; import { api } from '../../services/api';
import { useAppStore } from '../../stores'; import { useAppStore } from '../../stores';
import type { AIConversation } from '../../types'; import type { AIConversation, AIParsedEvent, RepeatType, PriorityType } from '../../types';
import { notifications } from '@mantine/notifications';
interface FloatingAIChatProps { interface FloatingAIChatProps {
onEventCreated?: () => void; onEventCreated?: () => void;
} }
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) { export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
const [isOpen, setIsOpen] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [history, setHistory] = useState<AIConversation[]>([]); const [history, setHistory] = useState<AIConversation[]>([]);
const [parsedEvent, setParsedEvent] = useState<AIParsedEvent | null>(null);
const [showPreview, setShowPreview] = useState(false);
const addConversation = useAppStore((state) => state.addConversation); const addConversation = useAppStore((state) => state.addConversation);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 编辑状态
const [editForm, setEditForm] = useState<AIParsedEvent>({
title: '',
date: '',
timezone: 'Asia/Shanghai',
is_lunar: false,
repeat_type: 'none',
type: 'reminder',
});
const scrollToBottom = () => { const scrollToBottom = () => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -33,7 +56,19 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [history]); }, [history, showPreview, loading]);
// 聚焦时展开
const handleFocus = () => {
setIsFocused(true);
};
// 点击遮罩层关闭
const handleClose = () => {
setIsFocused(false);
setShowPreview(false);
setParsedEvent(null);
};
const handleSend = async () => { const handleSend = async () => {
if (!message.trim() || loading) return; if (!message.trim() || loading) return;
@ -41,15 +76,28 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
const userMessage = message.trim(); const userMessage = message.trim();
setMessage(''); setMessage('');
setLoading(true); setLoading(true);
setShowPreview(false);
try { try {
const result = await api.ai.parse(userMessage); const result = await api.ai.parse(userMessage);
// 保存解析结果用于预览
if (result.parsed) {
setParsedEvent(result.parsed as AIParsedEvent);
setEditForm(result.parsed as AIParsedEvent);
setShowPreview(true);
}
// 确保只保存response文本
const responseText = typeof result.response === 'string'
? result.response
: '已为你创建提醒';
const newConversation: AIConversation = { const newConversation: AIConversation = {
id: Date.now().toString(), id: Date.now().toString(),
user_id: '', user_id: '',
message: userMessage, message: userMessage,
response: result.response, response: responseText,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
@ -61,202 +109,451 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
} }
} catch (error) { } catch (error) {
console.error('AI parse error:', error); console.error('AI parse error:', error);
notifications.show({
title: '解析失败',
message: 'AI 解析出错,请重试',
color: 'red',
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleKeyPress = (e: React.KeyboardEvent) => { const handleConfirm = async () => {
if (!parsedEvent) return;
try {
const eventData = {
type: editForm.type,
title: editForm.title,
date: editForm.date,
is_lunar: editForm.is_lunar,
repeat_type: editForm.repeat_type,
content: editForm.content,
is_holiday: editForm.is_holiday,
priority: editForm.priority,
reminder_times: editForm.reminder_times,
};
await api.events.create(eventData);
notifications.show({
title: editForm.type === 'anniversary' ? '纪念日已创建' : '提醒已创建',
message: editForm.title,
color: 'green',
});
setShowPreview(false);
setParsedEvent(null);
setIsFocused(false);
setHistory([]);
setMessage('');
if (onEventCreated) {
onEventCreated();
}
} catch (error) {
console.error('Create event error:', error);
notifications.show({
title: '创建失败',
message: '请重试',
color: 'red',
});
}
};
const handleCancel = () => {
setShowPreview(false);
setParsedEvent(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
if (e.key === 'Escape' && isFocused) {
handleClose();
}
};
// 获取日期显示文本
const getDateDisplay = () => {
if (!editForm.date) return '未设置';
const timezone = editForm.timezone || 'Asia/Shanghai';
const dateStr = editForm.date;
const utcDate = new Date(dateStr);
if (isNaN(utcDate.getTime())) return '日期格式错误';
const localDateStr = utcDate.toLocaleString('en-US', { timeZone: timezone });
const localDate = new Date(localDateStr);
const hours = localDate.getHours();
const minutes = localDate.getMinutes();
const hasExplicitTime = hours !== 0 || minutes !== 0;
if (hasExplicitTime) {
return localDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone,
});
}
return `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, '0')}-${String(localDate.getDate()).padStart(2, '0')}`;
};
const getTypeDisplay = () => {
return editForm.type === 'anniversary' ? '纪念日' : '提醒';
}; };
return ( return (
<> <>
{/* Floating button */} {/* 遮罩层 */}
<ActionIcon <Transition mounted={isFocused} transition="fade" duration={200}>
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) => ( {(styles) => (
<Paper <Box
shadow="lg"
radius={4}
style={{ style={{
...styles, ...styles,
position: 'fixed', position: 'fixed',
bottom: 90, inset: 0,
right: 24, background: 'rgba(0, 0, 0, 0.2)',
width: 360, zIndex: 1999,
maxHeight: 'calc(100vh - 150px)', }}
zIndex: 1000, onClick={handleClose}
/>
)}
</Transition>
{/* 悬浮容器 - 聚焦时放大 */}
<Box
style={{
position: 'fixed',
bottom: 'min(6vh, 48px)',
left: '50%',
zIndex: 2000,
width: 'min(100% - 32px, 640px)',
transition: 'transform 0.3s ease',
transformOrigin: 'bottom center',
transform: isFocused ? 'translateX(-50%) scale(1.02)' : 'translateX(-50%) scale(1)',
}}
>
<Paper
style={{
width: '100%',
background: isFocused ? 'var(--mantine-color-body)' : 'rgba(255, 255, 255, 0.4)',
backdropFilter: isFocused ? 'none' : 'blur(8px)',
borderRadius: 16,
boxShadow: isFocused
? '0 8px 32px rgba(0, 0, 0, 0.12)'
: '0 2px 12px rgba(0, 0, 0, 0.06)',
border: '1px solid var(--mantine-color-gray-2)',
transition: 'all 0.3s ease',
overflow: 'visible',
}}
>
{/* 展开的聊天面板 */}
<Transition mounted={isFocused} transition="slide-up" duration={250}>
{(panelStyles) => (
<Box
style={{
...panelStyles,
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
marginBottom: 8,
width: '100%',
background: 'var(--mantine-color-body)',
borderRadius: 16,
maxHeight: 'min(60vh, 480px)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
background: '#faf9f7', overflow: 'hidden',
border: '1px solid rgba(0, 0, 0, 0.06)', boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.1)',
}} }}
> >
{/* Header */} {/* Header */}
<Group <Box
p="md" p="sm"
style={{ style={{
borderBottom: '1px solid rgba(0, 0, 0, 0.06)', borderBottom: '1px solid var(--mantine-color-gray-1)',
background: '#1a1a1a', background: 'var(--mantine-color-gray-0)',
borderRadius: '4px 4px 0 0',
}} }}
> >
<Group gap={8}> <Group justify="space-between" align="center">
<IconSparkles size={16} color="#faf9f7" /> <Group gap={6}>
<Text <IconSparkles size={16} color="var(--mantine-color-blue-6)" />
fw={400} <Text fw={600} size="sm" c="dark.7">
size="sm"
c="#faf9f7"
style={{ letterSpacing: '0.1em' }}
>
AI AI
</Text> </Text>
</Group> </Group>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleClose}
color="gray"
style={{ borderRadius: 8 }}
>
<IconSend size={12} style={{ transform: 'rotate(45deg)', opacity: 0.6 }} />
</ActionIcon>
</Group> </Group>
</Box>
{/* Chat history */} {/* Chat area */}
<Box <Box
p="md" p="sm"
style={{ style={{
flex: 1, flex: 1,
overflowY: 'auto', overflowY: 'auto',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '12px', gap: '8px',
minHeight: 280, minHeight: 160,
maxHeight: 380, maxHeight: 320,
}} }}
> >
{history.length === 0 ? ( {history.length === 0 && !showPreview && !loading ? (
<Stack align="center" justify="center" h="100%" gap="sm"> <Stack align="center" justify="center" h="100%" gap="xs">
<IconSparkles size={32} color="rgba(0, 0, 0, 0.2)" /> <IconSparkles size={24} color="var(--mantine-color-gray-4)" />
<Text size="xs" c="#888" ta="center" style={{ letterSpacing: '0.05em' }}> <Text size="xs" c="dimmed" ta="center">
AI
<br />
<Text span size="xs" c="#aaa">
15
</Text> </Text>
<Text size="xxs" c="var(--mantine-color-gray-3)" ta="center">
15520
</Text> </Text>
</Stack> </Stack>
) : ( ) : (
history.map((conv) => ( <>
<Box key={conv.id}> {history.map((conv) => (
<Group gap={8} mb={6}> <Box key={conv.id} style={{ marginBottom: 4 }}>
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}> <Group gap={6} mb={4}>
<Text size="xs" c="dimmed" fw={500}>
</Text> </Text>
</Group> </Group>
<Paper <Paper
withBorder p="xs"
p="sm" radius={8}
radius={2} mb="xs"
mb="sm"
style={{ style={{
background: 'rgba(0, 0, 0, 0.02)', background: 'var(--mantine-color-blue-0)',
borderColor: 'rgba(0, 0, 0, 0.08)', border: '1px solid var(--mantine-color-blue-1)',
maxWidth: 280,
}} }}
> >
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}> <Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{conv.message} {conv.message}
</Text> </Text>
</Paper> </Paper>
<Group gap={8} mb={6}> <Group gap={6} mb={4}>
<IconSparkles size={10} color="#1a1a1a" /> <IconSparkles size={10} color="var(--mantine-color-blue-5)" />
<Text size="xs" c="#1a1a1a" fw={400} style={{ letterSpacing: '0.05em' }}> <Text size="xs" c="blue.6" fw={600}>
AI AI
</Text> </Text>
</Group> </Group>
<Paper <Paper
withBorder p="xs"
p="sm" radius={8}
radius={2}
style={{ style={{
background: 'rgba(0, 0, 0, 0.03)', background: 'var(--mantine-color-gray-0)',
borderColor: 'rgba(0, 0, 0, 0.08)', border: '1px solid var(--mantine-color-gray-1)',
maxWidth: 280,
}} }}
> >
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}> <Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{conv.response} {conv.response}
</Text> </Text>
</Paper> </Paper>
</Box> </Box>
)) ))}
)}
{loading && ( {loading && (
<Group gap={8}> <Group gap={6}>
<Loader size="xs" color="#1a1a1a" /> <Loader size="sm" color="blue" />
<Text size="xs" c="#888" style={{ letterSpacing: '0.05em' }}> <Text size="xs" c="dimmed">
AI ... AI ...
</Text> </Text>
</Group> </Group>
)} )}
<div ref={scrollRef} /> {showPreview && parsedEvent && (
</Box> <Box style={{ marginTop: 4 }}>
<Paper
{/* Input */} p="sm"
<Group radius={12}
p="md"
style={{ style={{
borderTop: '1px solid rgba(0, 0, 0, 0.06)', background: 'var(--mantine-color-body)',
borderRadius: '0 0 4px 4px', border: '2px solid var(--mantine-color-blue-2)',
}} }}
> >
<Stack gap="xs">
<Group justify="space-between" align="center">
<Badge size="xs" color={editForm.type === 'anniversary' ? 'blue' : 'orange'}>
{getTypeDisplay()}
</Badge>
<Text size="sm" fw={500} style={{ flex: 1, marginLeft: 8 }}>
{editForm.title || '未设置'}
</Text>
</Group>
<Divider />
<Group justify="space-between" align="center">
<Group gap={4}>
<IconCalendar size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"></Text>
</Group>
<Text size="sm" fw={500}>{getDateDisplay()}</Text>
</Group>
<Group justify="space-between" align="center">
<Group gap={4}>
<IconRepeat size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"></Text>
</Group>
<Group gap={2}>
{['none', 'daily', 'weekly', 'monthly', 'yearly'].map((type) => (
<Button
key={type}
variant={editForm.repeat_type === type ? 'filled' : 'subtle'}
size="compact-xs"
onClick={() => setEditForm({ ...editForm, repeat_type: type as RepeatType })}
style={{ borderRadius: 6, padding: '2px 8px' }}
>
{type === 'none' ? '不重复' :
type === 'daily' ? '每天' :
type === 'weekly' ? '每周' :
type === 'monthly' ? '每月' : '每年'}
</Button>
))}
</Group>
</Group>
{editForm.type === 'reminder' && (
<Group justify="space-between" align="center">
<Group gap={4}>
<IconFlag size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"></Text>
</Group>
<Group gap={4}>
{[
{ value: 'none', color: 'var(--mantine-color-gray-3)' },
{ value: 'red', color: '#ef4444' },
{ value: 'green', color: '#22c55e' },
{ value: 'yellow', color: '#eab308' },
].map((item) => (
<Box
key={item.value}
onClick={() => setEditForm({ ...editForm, priority: item.value as PriorityType })}
style={{
width: 16,
height: 16,
borderRadius: '50%',
background: item.color,
border: editForm.priority === item.value
? '2px solid var(--mantine-color-dark-6)'
: '1px solid var(--mantine-color-gray-2)',
cursor: 'pointer',
}}
/>
))}
</Group>
</Group>
)}
<Group justify="flex-end" gap="xs" mt={4}>
<Button
variant="subtle"
size="compact-xs"
onClick={handleCancel}
style={{ borderRadius: 6 }}
>
</Button>
<Button
size="compact-xs"
onClick={handleConfirm}
disabled={!editForm.title.trim() || !editForm.date}
style={{
background: 'var(--mantine-color-blue-6)',
borderRadius: 6,
}}
>
</Button>
</Group>
</Stack>
</Paper>
</Box>
)}
</>
)}
<div ref={scrollRef} />
</Box>
</Box>
)}
</Transition>
{/* 输入框区域 */}
<Box
p="sm"
style={{
background: 'transparent',
}}
>
<Group gap={6}>
<TextInput <TextInput
ref={inputRef}
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress} onKeyDown={handleKeyDown}
placeholder="输入你的提醒事项..." onFocus={handleFocus}
placeholder="输入你想记住的事情..."
size="sm" size="sm"
style={{ flex: 1 }} style={{ flex: 1 }}
disabled={loading} disabled={loading}
styles={{ styles={{
input: { input: {
borderRadius: 2, borderRadius: 12,
borderColor: 'rgba(0, 0, 0, 0.1)', borderColor: 'var(--mantine-color-gray-2)',
background: '#faf9f7', background: isFocused ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.6)',
paddingLeft: 12,
paddingRight: 12,
transition: 'all 0.3s ease',
}, },
}} }}
/> />
<ActionIcon <ActionIcon
size="lg" size="lg"
radius={2} radius={10}
variant="filled" variant="filled"
onClick={handleSend} onClick={handleSend}
disabled={!message.trim() || loading} disabled={!message.trim() || loading}
style={{ style={{
background: '#1a1a1a', background: (!message.trim() || loading)
? 'var(--mantine-color-gray-4)'
: isFocused
? 'var(--mantine-color-blue-6)'
: 'rgba(0, 122, 255, 0.5)',
width: 40,
height: 40,
transition: 'all 0.3s ease',
}} }}
> >
<IconSend size={14} /> <IconSend size={16} color={(!message.trim() || loading) ? 'var(--mantine-color-gray-6)' : 'white'} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Box>
</Paper> </Paper>
)} </Box>
</Transition>
</> </>
); );
} }

View File

@ -1,6 +1,7 @@
import { Paper, Text, Group, Stack } from '@mantine/core'; import { Paper, Text, Group, Stack, Badge } from '@mantine/core';
import { IconRepeat, IconCalendar, IconFlag } from '@tabler/icons-react';
import type { Event } from '../../types'; import type { Event } from '../../types';
import { calculateCountdown, formatCountdown } from '../../utils/countdown'; import { calculateCountdown } from '../../utils/countdown';
import { getHolidayById } from '../../constants/holidays'; import { getHolidayById } from '../../constants/holidays';
interface AnniversaryCardProps { interface AnniversaryCardProps {
@ -12,9 +13,32 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
const isLunar = event.is_lunar; const isLunar = event.is_lunar;
const repeatType = event.repeat_type; const repeatType = event.repeat_type;
const countdown = calculateCountdown(event.date, repeatType, isLunar); const countdown = calculateCountdown(event.date, repeatType, isLunar);
const formattedCountdown = formatCountdown(countdown);
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false; const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
// 获取下一次纪念日日期描述
const getNextDateText = () => {
if (countdown.isPast) return '已过';
if (countdown.isToday) return '今天';
return `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}${isLunar ? ' (农历)' : ''}`;
};
// 获取循环icon颜色
const getRepeatIconColor = () => {
if (repeatType === 'yearly') return '#1a1a1a'; // 年度 - 黑色
if (repeatType === 'monthly') return '#666666'; // 月度 - 灰色
if (repeatType === 'weekly') return '#999999'; // 每周 - 浅灰色
if (repeatType === 'daily') return '#bbbbbb'; // 每天 - 更浅灰色
return '';
};
// 根据节日类型设置名称颜色
const getNameColor = () => {
if (holiday) return '#c41c1c'; // 节假日红色
if (repeatType === 'yearly') return '#1a1a1a'; // 年度纪念日黑色
if (repeatType === 'monthly') return '#666666'; // 月度纪念日灰色
return '#1a1a1a'; // 默认黑色
};
return ( return (
<Paper <Paper
p="sm" p="sm"
@ -30,46 +54,61 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
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">
{/* 左侧纪念日名称、循环icon、下一次日期 */}
<Stack gap={4} style={{ flex: 1 }}> <Stack gap={4} style={{ flex: 1 }}>
<Group gap={6}> <Group gap={4}>
<Text size="xs" c="#c41c1c">{event.title}</Text>
</Group>
<Group gap="xs">
{/* Countdown */}
<Text <Text
size="xs" size="xs"
fw={400} fw={500}
style={{ color: getNameColor() }}
>
{event.title}
</Text>
{/* 节假日标签 - 浅浅显示 */}
{(event.is_holiday || holiday) && (
<Badge
size="xs"
variant="light"
color="red"
style={{ style={{
letterSpacing: '0.05em', height: 16,
color: countdown.isToday ? '#c41c1c' : '#666', padding: '0 4px',
fontSize: '10px',
fontWeight: 400,
}} }}
> >
{formattedCountdown}
</Text> </Badge>
)}
{/* Date display */} </Group>
<Group gap="xs">
{/* 循环icon */}
{repeatType !== 'none' && (
<IconRepeat
size={10}
color={getRepeatIconColor()}
style={{ marginTop: 1 }}
/>
)}
<Text size="xs" c="#999"> <Text size="xs" c="#999">
{countdown.isPast {getNextDateText()}
? '已过'
: `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}`}
{isLunar && ' (农历)'}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>
<Group gap={6}> {/* 右侧:倒数剩余天数 */}
{repeatType !== 'none' && ( <Text
<Text size="xs" c="#888"> size="xs"
{repeatType === 'yearly' ? '每年' : '每月'} fw={400}
style={{
color: countdown.isToday ? '#c41c1c' : countdown.isPast ? '#999' : '#666',
letterSpacing: '0.02em',
minWidth: '3.5em',
textAlign: 'right',
}}
>
{countdown.isPast ? '已过' : `${countdown.days}`}
</Text> </Text>
)}
{(event.is_holiday || holiday) && (
<Text size="xs" c="#888">
</Text>
)}
</Group>
</Group> </Group>
</Paper> </Paper>
); );

View File

@ -391,6 +391,8 @@ export function HomePage() {
onDateChange={handleDateChange} onDateChange={handleDateChange}
onPriorityChange={handlePriorityChange} onPriorityChange={handlePriorityChange}
/> />
{/* 底部空白区域 - 避免被 AI 输入框遮挡 */}
<Box style={{ height: 120 }} />
</div> </div>
{/* Right column - Note */} {/* Right column - Note */}
@ -441,14 +443,25 @@ export function HomePage() {
]} ]}
value={formType} value={formType}
onChange={(value) => value && setFormType(value as EventType)} onChange={(value) => value && setFormType(value as EventType)}
disabled={isEdit}
readOnly={isEdit} readOnly={isEdit}
rightSection={isEdit ? undefined : undefined}
rightSectionPointerEvents={isEdit ? 'none' : 'auto'} rightSectionPointerEvents={isEdit ? 'none' : 'auto'}
styles={{ styles={{
input: { input: {
borderRadius: 2, borderRadius: 2,
background: '#faf9f7', background: isEdit ? '#eee' : '#faf9f7',
color: isEdit ? '#999' : undefined,
pointerEvents: isEdit ? 'none' : 'auto', pointerEvents: isEdit ? 'none' : 'auto',
cursor: isEdit ? 'not-allowed' : 'pointer',
borderColor: isEdit ? '#ddd' : undefined,
}, },
dropdown: {
cursor: 'pointer',
},
}}
classNames={{
input: isEdit ? 'disabled-select' : undefined,
}} }}
/> />
@ -561,16 +574,22 @@ export function HomePage() {
</Group> </Group>
</Box> </Box>
{/* Lunar switch (only for anniversaries) */} {/* Lunar switch (only for anniversaries, disabled in edit mode) */}
{formType === 'anniversary' && ( {formType === 'anniversary' && (
<Switch <Switch
label={ label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}> <Text size="xs" c={isEdit ? '#ccc' : '#666'} style={{ letterSpacing: '0.05em' }}>
</Text> </Text>
} }
checked={formIsLunar} checked={formIsLunar}
onChange={(e) => setFormIsLunar(e.currentTarget.checked)} onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
disabled={isEdit}
styles={{
track: {
opacity: isEdit ? 0.5 : 1,
},
}}
/> />
)} )}

View File

@ -45,14 +45,18 @@ export interface Note {
updated_at: string; updated_at: string;
} }
// AI Parsing types // AI Parsing types - 扩展支持所有字段
export interface AIParsedEvent { export interface AIParsedEvent {
title: string; title: string;
date: string; date: string;
timezone: string; // 时区信息
is_lunar: boolean; is_lunar: boolean;
repeat_type: RepeatType; repeat_type: RepeatType;
reminder_time?: string;
type: EventType; type: EventType;
content?: string; // 详细内容(仅提醒)
is_holiday?: boolean; // 节假日(仅纪念日)
priority?: PriorityType; // 颜色(仅提醒)
reminder_times?: string[]; // 提前提醒时间点
} }
export interface AIConversation { export interface AIConversation {

View File

@ -55,7 +55,7 @@ function safeCreateLunarDate(year: number, month: number, day: number): { lunar:
*/ */
export function calculateCountdown( export function calculateCountdown(
dateStr: string, dateStr: string,
repeatType: 'yearly' | 'monthly' | 'none', repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
isLunar: boolean isLunar: boolean
): CountdownResult { ): CountdownResult {
const now = new Date(); const now = new Date();
@ -70,32 +70,68 @@ export function calculateCountdown(
const originalMonth = dateParts[1] - 1; // JavaScript月份从0开始 const originalMonth = dateParts[1] - 1; // JavaScript月份从0开始
const originalDay = dateParts[2]; const originalDay = dateParts[2];
// 获取原始时间(小时和分钟)
const timeParts = dateStr.includes('T') ? dateStr.split('T')[1].split(':') : ['00', '00'];
const originalHours = parseInt(timeParts[0]) || 0;
const originalMinutes = parseInt(timeParts[1]) || 0;
if (isLunar) { if (isLunar) {
// 农历日期:使用安全方法创建,处理月末边界 // 农历日期:使用安全方法创建,处理月末边界
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay); const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
if (result) { if (result) {
targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay()); targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay(), originalHours, originalMinutes);
} else { } else {
// 无法解析农历日期,使用原始公历日期作为后备 // 无法解析农历日期,使用原始公历日期作为后备
targetDate = new Date(originalYear, originalMonth, originalDay); targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
} }
} else { } else {
// 公历日期 // 公历日期
targetDate = new Date(originalYear, originalMonth, originalDay); targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
} }
// 先判断是否今天(日期部分相等)
const isToday = targetDate.toDateString() === today.toDateString();
// 计算下一个 occurrence // 计算下一个 occurrence
if (repeatType === 'yearly') { if (repeatType === 'yearly') {
// 年度重复:找到今年或明年的对应日期 // 年度重复:找到今年或明年的对应日期
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate()); targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < today) { if (targetDate < today) {
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate()); targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
targetDate.setHours(originalHours, originalMinutes, 0, 0);
} }
} else if (repeatType === 'monthly') { } else if (repeatType === 'monthly') {
// 月度重复:找到本月或下月的对应日期 // 月度重复:找到本月或之后月份的对应日期
targetDate = safeCreateDate(today.getFullYear(), today.getMonth(), targetDate.getDate()); const originalDay = targetDate.getDate();
if (targetDate < today) { let currentMonth = today.getMonth(); // 从当前月份开始
targetDate = safeCreateDate(today.getFullYear(), today.getMonth() + 1, targetDate.getDate()); targetDate = safeCreateDate(today.getFullYear(), currentMonth, originalDay);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
// 持续递增月份直到找到未来日期
while (targetDate < today) {
currentMonth++;
targetDate = safeCreateDate(today.getFullYear(), currentMonth, originalDay);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
}
} else if (repeatType === 'daily') {
// 每日重复:找到今天或明天的对应时间点
targetDate = new Date(today);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < now) {
targetDate = new Date(today);
targetDate.setDate(targetDate.getDate() + 1);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
}
} else if (repeatType === 'weekly') {
// 每周重复:找到本周或下周对应星期几的时间点
const dayOfWeek = new Date(originalYear, originalMonth, originalDay).getDay();
targetDate = new Date(today);
const daysUntilTarget = (dayOfWeek - today.getDay() + 7) % 7;
targetDate.setDate(targetDate.getDate() + daysUntilTarget);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < now) {
targetDate.setDate(targetDate.getDate() + 7);
} }
} else { } else {
// 不重复 // 不重复
@ -106,17 +142,38 @@ export function calculateCountdown(
// 计算时间差 // 计算时间差
const diff = targetDate.getTime() - now.getTime(); const diff = targetDate.getTime() - now.getTime();
const isToday = targetDate.toDateString() === today.toDateString();
// 如果是今天,即使是负数(已过),也显示为 0 而不是"已过"
// 只要 isToday 为 true就不算"已过",显示为"今天"
if (diff < 0 && !isPast) { if (diff < 0 && !isPast) {
if (!isToday) {
isPast = true; isPast = true;
} }
}
const absDiff = Math.abs(diff); const absDiff = Math.abs(diff);
const days = Math.floor(absDiff / (1000 * 60 * 60 * 24)); // 如果是今天直接返回0天
const hours = Math.floor((absDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); let days: number;
const minutes = Math.floor((absDiff % (1000 * 60 * 60)) / (1000 * 60)); let hours = 0;
const seconds = Math.floor((absDiff % (1000 * 60)) / 1000); let minutes = 0;
let seconds = 0;
if (isToday) {
days = 0;
hours = 0;
minutes = 0;
seconds = 0;
isPast = false; // 今天不算已过
} else {
// 使用 ceil 来计算天数,这样用户看到的是"还有X天"的直观感受
// 例如2/12 18:00 到 2/15 00:00 应该显示"还有3天"而不是"还有2天"
days = Math.ceil(absDiff / (1000 * 60 * 60 * 24));
// 重新计算剩余的小时、分钟、秒(使用 ceil 后剩余时间需要调整)
const remainingAfterDays = absDiff - (days - 1) * (1000 * 60 * 60 * 24);
hours = Math.floor((remainingAfterDays % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
minutes = Math.floor((remainingAfterDays % (1000 * 60 * 60)) / (1000 * 60));
seconds = Math.floor((remainingAfterDays % (1000 * 60)) / 1000);
}
return { return {
days: isPast ? -days : days, days: isPast ? -days : days,
@ -157,7 +214,7 @@ export function formatCountdown(countdown: CountdownResult): string {
*/ */
export function getFriendlyDateDescription( export function getFriendlyDateDescription(
dateStr: string, dateStr: string,
repeatType: 'yearly' | 'monthly' | 'none', repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
isLunar: boolean isLunar: boolean
): string { ): string {
const countdown = calculateCountdown(dateStr, repeatType, isLunar); const countdown = calculateCountdown(dateStr, repeatType, isLunar);