feat: 优化 AI 对话框交互体验
- 重构 AI 输入框为底部悬浮式,聚焦展开 - 添加预览卡片支持编辑重复、颜色等选项 - 优化时区显示和日期格式化 - 添加 loading 状态和 Toast 提示 - 支持确认后自动关闭并显示成功通知 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc627544d8
commit
5f1c6208df
@ -9,23 +9,46 @@ import {
|
||||
Loader,
|
||||
Box,
|
||||
Transition,
|
||||
Button,
|
||||
Badge,
|
||||
Divider,
|
||||
} 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 { useAppStore } from '../../stores';
|
||||
import type { AIConversation } from '../../types';
|
||||
import type { AIConversation, AIParsedEvent, RepeatType, PriorityType } from '../../types';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
interface FloatingAIChatProps {
|
||||
onEventCreated?: () => void;
|
||||
}
|
||||
|
||||
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [history, setHistory] = useState<AIConversation[]>([]);
|
||||
const [parsedEvent, setParsedEvent] = useState<AIParsedEvent | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const addConversation = useAppStore((state) => state.addConversation);
|
||||
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 = () => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
@ -33,7 +56,19 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [history]);
|
||||
}, [history, showPreview, loading]);
|
||||
|
||||
// 聚焦时展开
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
// 点击遮罩层关闭
|
||||
const handleClose = () => {
|
||||
setIsFocused(false);
|
||||
setShowPreview(false);
|
||||
setParsedEvent(null);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim() || loading) return;
|
||||
@ -41,15 +76,28 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
||||
const userMessage = message.trim();
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
setShowPreview(false);
|
||||
|
||||
try {
|
||||
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 = {
|
||||
id: Date.now().toString(),
|
||||
user_id: '',
|
||||
message: userMessage,
|
||||
response: result.response,
|
||||
response: responseText,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@ -61,202 +109,451 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI parse error:', error);
|
||||
notifications.show({
|
||||
title: '解析失败',
|
||||
message: 'AI 解析出错,请重试',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
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) {
|
||||
e.preventDefault();
|
||||
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 (
|
||||
<>
|
||||
{/* 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}>
|
||||
{/* 遮罩层 */}
|
||||
<Transition mounted={isFocused} transition="fade" duration={200}>
|
||||
{(styles) => (
|
||||
<Paper
|
||||
shadow="lg"
|
||||
radius={4}
|
||||
<Box
|
||||
style={{
|
||||
...styles,
|
||||
position: 'fixed',
|
||||
bottom: 90,
|
||||
right: 24,
|
||||
width: 360,
|
||||
maxHeight: 'calc(100vh - 150px)',
|
||||
zIndex: 1000,
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
zIndex: 1999,
|
||||
}}
|
||||
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',
|
||||
flexDirection: 'column',
|
||||
background: '#faf9f7',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Group
|
||||
p="md"
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-1)',
|
||||
background: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
>
|
||||
<Group gap={8}>
|
||||
<IconSparkles size={16} color="#faf9f7" />
|
||||
<Text
|
||||
fw={400}
|
||||
size="sm"
|
||||
c="#faf9f7"
|
||||
style={{ letterSpacing: '0.1em' }}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap={6}>
|
||||
<IconSparkles size={16} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={600} size="sm" c="dark.7">
|
||||
AI 助手
|
||||
</Text>
|
||||
</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>
|
||||
</Box>
|
||||
|
||||
{/* Chat history */}
|
||||
{/* Chat area */}
|
||||
<Box
|
||||
p="md"
|
||||
p="sm"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
minHeight: 280,
|
||||
maxHeight: 380,
|
||||
gap: '8px',
|
||||
minHeight: 160,
|
||||
maxHeight: 320,
|
||||
}}
|
||||
>
|
||||
{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号还房贷
|
||||
{history.length === 0 && !showPreview && !loading ? (
|
||||
<Stack align="center" justify="center" h="100%" gap="xs">
|
||||
<IconSparkles size={24} color="var(--mantine-color-gray-4)" />
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
告诉 AI 帮你添加纪念日或提醒
|
||||
</Text>
|
||||
<Text size="xxs" c="var(--mantine-color-gray-3)" ta="center">
|
||||
例如:「下周三见客户」「每月15号还房贷」「明年5月20日结婚纪念日」
|
||||
</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' }}>
|
||||
<>
|
||||
{history.map((conv) => (
|
||||
<Box key={conv.id} style={{ marginBottom: 4 }}>
|
||||
<Group gap={6} mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
你
|
||||
</Text>
|
||||
</Group>
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius={2}
|
||||
mb="sm"
|
||||
p="xs"
|
||||
radius={8}
|
||||
mb="xs"
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.02)',
|
||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||
background: 'var(--mantine-color-blue-0)',
|
||||
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}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Group gap={8} mb={6}>
|
||||
<IconSparkles size={10} color="#1a1a1a" />
|
||||
<Text size="xs" c="#1a1a1a" fw={400} style={{ letterSpacing: '0.05em' }}>
|
||||
<Group gap={6} mb={4}>
|
||||
<IconSparkles size={10} color="var(--mantine-color-blue-5)" />
|
||||
<Text size="xs" c="blue.6" fw={600}>
|
||||
AI
|
||||
</Text>
|
||||
</Group>
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius={2}
|
||||
p="xs"
|
||||
radius={8}
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.03)',
|
||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||
background: 'var(--mantine-color-gray-0)',
|
||||
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}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<Group gap={8}>
|
||||
<Loader size="xs" color="#1a1a1a" />
|
||||
<Text size="xs" c="#888" style={{ letterSpacing: '0.05em' }}>
|
||||
<Group gap={6}>
|
||||
<Loader size="sm" color="blue" />
|
||||
<Text size="xs" c="dimmed">
|
||||
AI 正在思考...
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} />
|
||||
</Box>
|
||||
|
||||
{/* Input */}
|
||||
<Group
|
||||
p="md"
|
||||
{showPreview && parsedEvent && (
|
||||
<Box style={{ marginTop: 4 }}>
|
||||
<Paper
|
||||
p="sm"
|
||||
radius={12}
|
||||
style={{
|
||||
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
borderRadius: '0 0 4px 4px',
|
||||
background: 'var(--mantine-color-body)',
|
||||
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
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="输入你的提醒事项..."
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder="输入你想记住的事情..."
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
disabled={loading}
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 2,
|
||||
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||
background: '#faf9f7',
|
||||
borderRadius: 12,
|
||||
borderColor: 'var(--mantine-color-gray-2)',
|
||||
background: isFocused ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.6)',
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
transition: 'all 0.3s ease',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius={2}
|
||||
radius={10}
|
||||
variant="filled"
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || loading}
|
||||
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>
|
||||
</Group>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Transition>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
||||
import { calculateCountdown } from '../../utils/countdown';
|
||||
import { getHolidayById } from '../../constants/holidays';
|
||||
|
||||
interface AnniversaryCardProps {
|
||||
@ -12,9 +13,32 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
|
||||
const isLunar = event.is_lunar;
|
||||
const repeatType = event.repeat_type;
|
||||
const countdown = calculateCountdown(event.date, repeatType, isLunar);
|
||||
const formattedCountdown = formatCountdown(countdown);
|
||||
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
|
||||
|
||||
// 获取下一次纪念日日期描述
|
||||
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 (
|
||||
<Paper
|
||||
p="sm"
|
||||
@ -30,46 +54,61 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
|
||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
{/* 左侧:纪念日名称、循环icon、下一次日期 */}
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="#c41c1c">{event.title}</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{/* Countdown */}
|
||||
<Group gap={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={400}
|
||||
fw={500}
|
||||
style={{ color: getNameColor() }}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
{/* 节假日标签 - 浅浅显示 */}
|
||||
{(event.is_holiday || holiday) && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
style={{
|
||||
letterSpacing: '0.05em',
|
||||
color: countdown.isToday ? '#c41c1c' : '#666',
|
||||
height: 16,
|
||||
padding: '0 4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{formattedCountdown}
|
||||
</Text>
|
||||
|
||||
{/* Date display */}
|
||||
节假日
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{/* 循环icon */}
|
||||
{repeatType !== 'none' && (
|
||||
<IconRepeat
|
||||
size={10}
|
||||
color={getRepeatIconColor()}
|
||||
style={{ marginTop: 1 }}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="#999">
|
||||
{countdown.isPast
|
||||
? '已过'
|
||||
: `${countdown.nextDate.getMonth() + 1}月${countdown.nextDate.getDate()}日`}
|
||||
{isLunar && ' (农历)'}
|
||||
{getNextDateText()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap={6}>
|
||||
{repeatType !== 'none' && (
|
||||
<Text size="xs" c="#888">
|
||||
{repeatType === 'yearly' ? '每年' : '每月'}
|
||||
{/* 右侧:倒数剩余天数 */}
|
||||
<Text
|
||||
size="xs"
|
||||
fw={400}
|
||||
style={{
|
||||
color: countdown.isToday ? '#c41c1c' : countdown.isPast ? '#999' : '#666',
|
||||
letterSpacing: '0.02em',
|
||||
minWidth: '3.5em',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{countdown.isPast ? '已过' : `${countdown.days}天`}
|
||||
</Text>
|
||||
)}
|
||||
{(event.is_holiday || holiday) && (
|
||||
<Text size="xs" c="#888">
|
||||
节假日
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@ -391,6 +391,8 @@ export function HomePage() {
|
||||
onDateChange={handleDateChange}
|
||||
onPriorityChange={handlePriorityChange}
|
||||
/>
|
||||
{/* 底部空白区域 - 避免被 AI 输入框遮挡 */}
|
||||
<Box style={{ height: 120 }} />
|
||||
</div>
|
||||
|
||||
{/* Right column - Note */}
|
||||
@ -441,14 +443,25 @@ export function HomePage() {
|
||||
]}
|
||||
value={formType}
|
||||
onChange={(value) => value && setFormType(value as EventType)}
|
||||
disabled={isEdit}
|
||||
readOnly={isEdit}
|
||||
rightSection={isEdit ? undefined : undefined}
|
||||
rightSectionPointerEvents={isEdit ? 'none' : 'auto'}
|
||||
styles={{
|
||||
input: {
|
||||
borderRadius: 2,
|
||||
background: '#faf9f7',
|
||||
background: isEdit ? '#eee' : '#faf9f7',
|
||||
color: isEdit ? '#999' : undefined,
|
||||
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>
|
||||
</Box>
|
||||
|
||||
{/* Lunar switch (only for anniversaries) */}
|
||||
{/* Lunar switch (only for anniversaries, disabled in edit mode) */}
|
||||
{formType === 'anniversary' && (
|
||||
<Switch
|
||||
label={
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
|
||||
<Text size="xs" c={isEdit ? '#ccc' : '#666'} style={{ letterSpacing: '0.05em' }}>
|
||||
农历日期
|
||||
</Text>
|
||||
}
|
||||
checked={formIsLunar}
|
||||
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
||||
disabled={isEdit}
|
||||
styles={{
|
||||
track: {
|
||||
opacity: isEdit ? 0.5 : 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -45,14 +45,18 @@ export interface Note {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// AI Parsing types
|
||||
// AI Parsing types - 扩展支持所有字段
|
||||
export interface AIParsedEvent {
|
||||
title: string;
|
||||
date: string;
|
||||
timezone: string; // 时区信息
|
||||
is_lunar: boolean;
|
||||
repeat_type: RepeatType;
|
||||
reminder_time?: string;
|
||||
type: EventType;
|
||||
content?: string; // 详细内容(仅提醒)
|
||||
is_holiday?: boolean; // 节假日(仅纪念日)
|
||||
priority?: PriorityType; // 颜色(仅提醒)
|
||||
reminder_times?: string[]; // 提前提醒时间点
|
||||
}
|
||||
|
||||
export interface AIConversation {
|
||||
|
||||
@ -55,7 +55,7 @@ function safeCreateLunarDate(year: number, month: number, day: number): { lunar:
|
||||
*/
|
||||
export function calculateCountdown(
|
||||
dateStr: string,
|
||||
repeatType: 'yearly' | 'monthly' | 'none',
|
||||
repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
|
||||
isLunar: boolean
|
||||
): CountdownResult {
|
||||
const now = new Date();
|
||||
@ -70,32 +70,68 @@ export function calculateCountdown(
|
||||
const originalMonth = dateParts[1] - 1; // JavaScript月份从0开始
|
||||
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) {
|
||||
// 农历日期:使用安全方法创建,处理月末边界
|
||||
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
|
||||
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 {
|
||||
// 无法解析农历日期,使用原始公历日期作为后备
|
||||
targetDate = new Date(originalYear, originalMonth, originalDay);
|
||||
targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
|
||||
}
|
||||
} else {
|
||||
// 公历日期
|
||||
targetDate = new Date(originalYear, originalMonth, originalDay);
|
||||
targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
|
||||
}
|
||||
|
||||
// 先判断是否今天(日期部分相等)
|
||||
const isToday = targetDate.toDateString() === today.toDateString();
|
||||
|
||||
// 计算下一个 occurrence
|
||||
if (repeatType === 'yearly') {
|
||||
// 年度重复:找到今年或明年的对应日期
|
||||
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||
targetDate.setHours(originalHours, originalMinutes, 0, 0);
|
||||
if (targetDate < today) {
|
||||
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
|
||||
targetDate.setHours(originalHours, originalMinutes, 0, 0);
|
||||
}
|
||||
} else if (repeatType === 'monthly') {
|
||||
// 月度重复:找到本月或下月的对应日期
|
||||
targetDate = safeCreateDate(today.getFullYear(), today.getMonth(), targetDate.getDate());
|
||||
if (targetDate < today) {
|
||||
targetDate = safeCreateDate(today.getFullYear(), today.getMonth() + 1, targetDate.getDate());
|
||||
// 月度重复:找到本月或之后月份的对应日期
|
||||
const originalDay = targetDate.getDate();
|
||||
let currentMonth = today.getMonth(); // 从当前月份开始
|
||||
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 {
|
||||
// 不重复
|
||||
@ -106,17 +142,38 @@ export function calculateCountdown(
|
||||
|
||||
// 计算时间差
|
||||
const diff = targetDate.getTime() - now.getTime();
|
||||
const isToday = targetDate.toDateString() === today.toDateString();
|
||||
|
||||
// 如果是今天,即使是负数(已过),也显示为 0 而不是"已过"
|
||||
// 只要 isToday 为 true,就不算"已过",显示为"今天"
|
||||
if (diff < 0 && !isPast) {
|
||||
if (!isToday) {
|
||||
isPast = true;
|
||||
}
|
||||
}
|
||||
|
||||
const absDiff = Math.abs(diff);
|
||||
const days = Math.floor(absDiff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((absDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((absDiff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((absDiff % (1000 * 60)) / 1000);
|
||||
// 如果是今天,直接返回0天
|
||||
let days: number;
|
||||
let hours = 0;
|
||||
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 {
|
||||
days: isPast ? -days : days,
|
||||
@ -157,7 +214,7 @@ export function formatCountdown(countdown: CountdownResult): string {
|
||||
*/
|
||||
export function getFriendlyDateDescription(
|
||||
dateStr: string,
|
||||
repeatType: 'yearly' | 'monthly' | 'none',
|
||||
repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
|
||||
isLunar: boolean
|
||||
): string {
|
||||
const countdown = calculateCountdown(dateStr, repeatType, isLunar);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user